Merge branch 'develop' into latest

This commit is contained in:
Tom Wheeler
2026-01-02 14:00:21 +13:00
152 changed files with 17063 additions and 6548 deletions
+3 -2
View File
@@ -47,14 +47,15 @@ config/posters/*.*
config/temp/*.*
config/plex-base-posters/**
# wallpaper storage
config/wallpapers/*.*
# theme music storage
config/themes/*.*
# fonts storage
config/fonts/*.*
# logs
config/logs/*.log*
config/logs/*.json
+8
View File
@@ -72,6 +72,14 @@ RUN apk add --no-cache \
fc-cache -fv && \
rm -rf /tmp/*
# Configure fontconfig to scan custom fonts directory
RUN mkdir -p /etc/fonts/conf.d && \
echo '<?xml version="1.0"?>' > /etc/fonts/conf.d/99-agregarr-custom-fonts.conf && \
echo '<!DOCTYPE fontconfig SYSTEM "fonts.dtd">' >> /etc/fonts/conf.d/99-agregarr-custom-fonts.conf && \
echo '<fontconfig>' >> /etc/fonts/conf.d/99-agregarr-custom-fonts.conf && \
echo ' <dir>/app/config/fonts</dir>' >> /etc/fonts/conf.d/99-agregarr-custom-fonts.conf && \
echo '</fontconfig>' >> /etc/fonts/conf.d/99-agregarr-custom-fonts.conf
# Install Deno - yt-dlp requires a JS runtime as of 2025-11-12
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories && \
echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
+1 -1
View File
@@ -19,7 +19,7 @@ Agregarr keeps your Plex Home and Recommended fresh by frequently updating it wi
- **Poster Templates**: Create your own Poster Templates which can be dynamically filled with content per-collection
- **Preview Collections**: Preview the collection and its matching/missing items, and add them individually via Radarr/Sonarr or Overseerr, or add items to the global exclusions list.
<img width="1920" height="935" alt="vlcsnap-2025-08-25-21h02m59s912" src="https://github.com/user-attachments/assets/3ff916d1-2172-4f58-9581-362febbfa0eb" />
<img width="1902" height="983" alt="agregarr-promo" src="https://github.com/user-attachments/assets/1b744502-30ce-4988-93fc-4588e1207e69" />
## Installation
+518 -64
View File
@@ -248,6 +248,7 @@ components:
'letterboxd',
'networks',
'originals',
'plex',
'multi-source',
'anilist',
'myanimelist',
@@ -527,30 +528,17 @@ components:
minimum: 0
maximum: 100
nullable: true
excludedGenres:
type: array
items:
type: integer
description: 'TMDB genre IDs to exclude from missing items search'
example: [28, 53]
nullable: true
excludedCountries:
type: array
items:
type: string
description: 'ISO 3166-1 country codes to exclude from missing items search'
example: ['JP', 'KR']
nullable: true
excludedLanguages:
type: array
items:
type: string
description: 'ISO 639-1 language codes to exclude from missing items search'
example: ['ja', 'ko']
minimumRottenTomatoesAudienceRating:
type: number
format: float
description: 'Only process movies/TV shows with Rotten Tomatoes audience score >= this value (0 = no limit)'
example: 75
minimum: 0
maximum: 100
nullable: true
filterSettings:
type: object
description: 'Unified filter settings with include/exclude modes (new format, supersedes excludedGenres/excludedCountries/excludedLanguages)'
description: 'Unified filter settings with include/exclude modes'
properties:
genres:
type: object
@@ -627,6 +615,21 @@ components:
example: 'default'
default: 'default'
nullable: true
personMinimumItems:
type: number
description: 'Unified minimum items required to create actor/director collections'
example: 3
nullable: true
useSeparator:
type: boolean
description: 'Create a separator collection for auto actor/director collections'
example: false
nullable: true
separatorTitle:
type: string
description: 'Title for the separator collection'
example: 'Actors Collections'
nullable: true
excludeFromCollections:
type: array
items:
@@ -805,6 +808,7 @@ components:
'letterboxd',
'networks',
'originals',
'plex',
'multi-source',
'anilist',
'myanimelist',
@@ -1066,30 +1070,17 @@ components:
minimum: 0
maximum: 100
nullable: true
excludedGenres:
type: array
items:
type: integer
description: 'TMDB genre IDs to exclude from missing items search'
example: [28, 53]
nullable: true
excludedCountries:
type: array
items:
type: string
description: 'ISO 3166-1 country codes to exclude from missing items search'
example: ['JP', 'KR']
nullable: true
excludedLanguages:
type: array
items:
type: string
description: 'ISO 639-1 language codes to exclude from missing items search'
example: ['ja', 'ko']
minimumRottenTomatoesAudienceRating:
type: number
format: float
description: 'Only process movies/TV shows with Rotten Tomatoes audience score >= this value (0 = no limit)'
example: 75
minimum: 0
maximum: 100
nullable: true
filterSettings:
type: object
description: 'Unified filter settings with include/exclude modes (new format, supersedes excludedGenres/excludedCountries/excludedLanguages)'
description: 'Unified filter settings with include/exclude modes'
properties:
genres:
type: object
@@ -1166,6 +1157,21 @@ components:
example: 'default'
default: 'default'
nullable: true
personMinimumItems:
type: number
description: 'Unified minimum items required to create actor/director collections'
example: 3
nullable: true
useSeparator:
type: boolean
description: 'Create a separator collection for auto actor/director collections'
example: false
nullable: true
separatorTitle:
type: string
description: 'Title for the separator collection'
example: 'Actors Collections'
nullable: true
excludeFromCollections:
type: array
items:
@@ -1485,6 +1491,7 @@ components:
'letterboxd',
'networks',
'originals',
'plex',
'multi-source',
'anilist',
'myanimelist',
@@ -1758,30 +1765,17 @@ components:
minimum: 0
maximum: 100
nullable: true
excludedGenres:
type: array
items:
type: integer
description: 'TMDB genre IDs to exclude from missing items search'
example: [28, 53]
nullable: true
excludedCountries:
type: array
items:
type: string
description: 'ISO 3166-1 country codes to exclude from missing items search'
example: ['JP', 'KR']
nullable: true
excludedLanguages:
type: array
items:
type: string
description: 'ISO 639-1 language codes to exclude from missing items search'
example: ['ja', 'ko']
minimumRottenTomatoesAudienceRating:
type: number
format: float
description: 'Only process movies/TV shows with Rotten Tomatoes audience score >= this value (0 = no limit)'
example: 75
minimum: 0
maximum: 100
nullable: true
filterSettings:
type: object
description: 'Unified filter settings with include/exclude modes (new format, supersedes excludedGenres/excludedCountries/excludedLanguages)'
description: 'Unified filter settings with include/exclude modes'
properties:
genres:
type: object
@@ -1868,6 +1862,21 @@ components:
example: 'default'
default: 'default'
nullable: true
personMinimumItems:
type: number
description: 'Unified minimum items required to create actor/director collections'
example: 3
nullable: true
useSeparator:
type: boolean
description: 'Create a separator collection for auto actor/director collections'
example: false
nullable: true
separatorTitle:
type: string
description: 'Title for the separator collection'
example: 'Actors Collections'
nullable: true
excludeFromCollections:
type: array
items:
@@ -2197,6 +2206,7 @@ components:
'overseerr',
'networks',
'originals',
'plex',
'anilist',
'myanimelist',
'radarrtag',
@@ -3233,7 +3243,7 @@ components:
example: 'and'
field:
type: string
description: 'Context field to evaluate (e.g., imdbRating, resolution, daysUntilRelease)'
description: 'Context field to evaluate (e.g., imdbRating, resolution, daysUntilRelease, daysUntilAction)'
example: 'imdbRating'
operator:
type: string
@@ -3362,6 +3372,11 @@ components:
type: number
description: 'For rating badges - minimum rating to display'
example: 7.0
tmdbLanguage:
type: string
nullable: true
description: 'ISO language code for TMDB poster metadata (e.g., en, fr, pt-BR). If not set, uses global setting.'
example: 'en'
createdAt:
type: string
format: date-time
@@ -3522,6 +3537,30 @@ components:
externalUrl:
type: string
nullable: true
MaintainerrSettings:
type: object
properties:
hostname:
type: string
nullable: true
example: 'maintainerr.example.com'
port:
type: number
nullable: true
example: 6246
useSsl:
type: boolean
nullable: true
urlBase:
type: string
nullable: true
example: ''
apiKey:
type: string
nullable: true
externalUrl:
type: string
nullable: true
TraktSettings:
type: object
properties:
@@ -6945,6 +6984,7 @@ paths:
sonarrtag,
comingsoon,
filtered_hub,
plex,
multi-source,
]
example: imdb
@@ -8696,6 +8736,89 @@ paths:
description: 'Tautulli API not found at specified URL'
'500':
description: 'Connection test failed - Server error or network issue'
/settings/maintainerr:
get:
summary: Get Maintainerr settings
description: Retrieves current Maintainerr settings.
tags:
- settings-integrations
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MaintainerrSettings'
post:
summary: Update Maintainerr settings
description: Updates Maintainerr settings with the provided values.
tags:
- settings-integrations
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MaintainerrSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/MaintainerrSettings'
/settings/maintainerr/test:
post:
summary: Test Maintainerr connection
description: Tests connection to a Maintainerr instance.
tags:
- settings-integrations
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
hostname:
type: string
example: 'maintainerr.example.com'
port:
type: number
example: 6246
apiKey:
type: string
example: 'your-api-key-here'
useSsl:
type: boolean
example: false
urlBase:
type: string
example: ''
required:
- hostname
- apiKey
responses:
'200':
description: 'Connection test successful'
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
'400':
description: 'Invalid API key - Authentication failed'
'401':
description: 'Invalid API key - Authentication failed'
'403':
description: 'API key lacks required permissions'
'404':
description: 'Maintainerr API not found at specified URL'
'500':
description: 'Connection test failed - Server error or network issue'
/settings/trakt:
get:
summary: Get Trakt settings
@@ -9967,6 +10090,48 @@ paths:
timestamp:
type: string
example: '2020-12-15T16:20:00.069Z'
/settings/export-debug:
post:
summary: Export debugging information
description: Exports selected debugging information (database, settings, logs) as a zip file for troubleshooting purposes.
tags:
- settings-system
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
includeDatabase:
type: boolean
description: Include database file (db.sqlite3) in export
example: true
includeSettings:
type: boolean
description: Include settings.json file in export
example: true
includeLogs:
type: boolean
description: Include logs directory in export
example: true
responses:
'200':
description: Debug export zip file
content:
application/zip:
schema:
type: string
format: binary
headers:
Content-Disposition:
schema:
type: string
example: 'attachment; filename="agregarr-debug-2025-12-19T10-30-45.zip"'
'400':
description: Invalid request (no items selected)
'500':
description: Server error during export
/settings/about:
get:
summary: Get server stats
@@ -13446,6 +13611,10 @@ paths:
runtime:
type: integer
example: 139
daysUntilAction:
type: integer
example: 5
description: 'Days until Maintainerr takes action on this item (negative = overdue)'
'400':
description: Invalid poster ID format
'401':
@@ -13551,6 +13720,11 @@ paths:
minimumRating:
type: number
description: For rating badges - minimum rating to display
tmdbLanguage:
type: string
nullable: true
description: ISO language code for TMDB poster metadata (e.g., en, fr, pt-BR). If not set, uses global setting.
example: 'en'
required:
- libraryName
- mediaType
@@ -13628,9 +13802,86 @@ paths:
example: '1'
'401':
description: Authentication required
'409':
description: Conflict - another overlay operation is already running
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: 'Full overlay sync is currently running. Please wait for it to complete or cancel it before syncing individual libraries.'
conflictType:
type: string
enum: [full-sync-running, library-already-running]
example: 'full-sync-running'
'500':
description: Failed to start overlay application
/overlay-library-configs/{libraryId}/status:
get:
summary: Get overlay application status for library
description: Returns the current status of overlay application for a specific library.
tags:
- overlays
parameters:
- name: libraryId
in: path
required: true
schema:
type: string
description: Plex library ID
responses:
'200':
description: Status retrieved successfully
content:
application/json:
schema:
type: object
properties:
running:
type: boolean
description: Whether overlay application is running for this library
example: true
libraryName:
type: string
description: Name of the library (only present if running)
example: 'Movies'
startTime:
type: number
description: Timestamp when the job started (only present if running)
example: 1640000000000
/overlay-library-configs/status/all:
get:
summary: Get all running library overlay operations
description: Returns a list of all libraries currently running overlay application.
tags:
- overlays
responses:
'200':
description: Running libraries retrieved successfully
content:
application/json:
schema:
type: object
properties:
runningLibraries:
type: array
items:
type: object
properties:
libraryId:
type: string
example: '1'
libraryName:
type: string
example: 'Movies'
startTime:
type: number
example: 1640000000000
/overlay-settings:
get:
summary: Get overlay settings
@@ -14148,6 +14399,209 @@ paths:
'401':
description: Authentication required
/plex/search:
get:
summary: Search Plex libraries
description: Search across all Plex libraries for movies and TV shows.
tags:
- overlays
parameters:
- in: query
name: query
required: true
allowReserved: true
schema:
type: string
description: Search term
example: 'Inception'
- in: query
name: limit
required: false
schema:
type: integer
default: 20
description: Maximum number of results to return
example: 20
responses:
'200':
description: Search results
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
ratingKey:
type: string
example: '12345'
title:
type: string
example: 'Inception'
year:
type: integer
example: 2010
type:
type: string
enum: [movie, show]
example: 'movie'
thumb:
type: string
example: '/library/metadata/12345/thumb/1234567890'
libraryId:
type: string
example: '1'
libraryName:
type: string
example: 'Movies'
totalResults:
type: integer
example: 5
'400':
description: Query parameter is required
'401':
description: Authentication required
'500':
description: Failed to search Plex
/overlay-test:
post:
summary: Test overlay application on a single item
description: Test how overlays would be applied to a specific Plex item, returning the rendered poster with detailed debugging information including template matches, condition evaluation, and context variables.
tags:
- overlays
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- ratingKey
properties:
ratingKey:
type: string
description: Plex rating key of the item to test
example: '12345'
responses:
'200':
description: Overlay test completed successfully
content:
application/json:
schema:
type: object
properties:
poster:
type: string
format: byte
description: Base64-encoded WebP image of the poster with overlays applied
item:
type: object
properties:
ratingKey:
type: string
example: '12345'
title:
type: string
example: 'Inception'
year:
type: integer
example: 2010
type:
type: string
enum: [movie, show]
example: 'movie'
libraryId:
type: string
example: '1'
libraryName:
type: string
example: 'Movies'
templates:
type: array
description: All enabled templates for the library with match results
items:
type: object
properties:
id:
type: integer
example: 1
name:
type: string
example: 'IMDb Rating Badge'
matched:
type: boolean
description: Whether this template's conditions were met
example: true
conditionResults:
type: object
description: Detailed condition evaluation for debugging
properties:
sectionResults:
type: array
items:
type: object
properties:
sectionIndex:
type: integer
sectionOperator:
type: string
enum: [and, or]
matched:
type: boolean
ruleResults:
type: array
items:
type: object
properties:
ruleIndex:
type: integer
ruleOperator:
type: string
enum: [and, or]
field:
type: string
example: 'imdbRating'
operator:
type: string
example: 'gte'
value:
description: Expected value
actualValue:
description: Actual value from context
matched:
type: boolean
context:
type: object
description: Context variables grouped by source
properties:
plex:
type: object
description: Plex metadata
tmdb:
type: object
description: TMDB metadata
ratings:
type: object
description: IMDb and Rotten Tomatoes ratings
monitoring:
type: object
description: Radarr/Sonarr monitoring status
computed:
type: object
description: Computed fields
'400':
description: Invalid request (missing ratingKey, no library config, item is episode/season)
'401':
description: Authentication required
'404':
description: Item not found in Plex
'500':
description: Failed to test overlay
security:
- cookieAuth: []
- apiKey: []
@@ -0,0 +1,8 @@
[
{
"name": "Martin Scorsese",
"tmdbId": 1032,
"filename": "person_1032.jpg",
"profilePath": "/preview-persons/person_1032.jpg"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 242.11"><g fill-rule="nonzero"><path fill="#FFCB00" d="M41.47 0h429.06C493.36 0 512 18.64 512 41.47v159.17c0 22.83-18.64 41.47-41.47 41.47H41.47C18.66 242.11 0 223.46 0 200.64V41.47C0 18.64 18.64 0 41.47 0z"/><path fill="#262626" d="M50.92 23.15h410.16c11.18 0 20.28 9.09 20.28 20.28v155.25c0 11.19-9.1 20.28-20.28 20.28H50.92c-11.18 0-20.28-9.09-20.28-20.28V43.43c0-11.19 9.1-20.28 20.28-20.28z"/><path fill="#FD5" d="M72.5 61.79h36.59v41.39h40.06V61.79h36.75v118.53h-36.75v-48.01h-40.06v48.01H72.5V61.79zm129.96 0h54.44c10.7 0 19.39 1.46 25.99 4.36 6.59 2.92 12.06 7.09 16.39 12.52 4.3 5.46 7.42 11.78 9.35 19.01 3.97 14.65 4.37 38.52-1.4 52.43-2.87 6.98-6.87 12.83-12.01 17.55-5.13 4.72-10.62 7.86-16.5 9.44-8.03 2.15-15.31 3.22-21.82 3.22h-54.44V61.79zm36.59 26.82v64.73h8.99c7.67 0 13.13-.86 16.39-2.54 3.23-1.71 5.77-4.66 7.62-8.89 4.42-10.17 4.13-37.86-3.4-46.35-4.11-4.64-10.92-6.95-20.45-6.95h-9.15zm83.9 91.71V61.79h61.04c11.31 0 19.97.96 25.93 2.92 18.81 6.07 25.4 30.71 15.76 47.07-2.79 4.81-6.65 8.67-11.59 11.65-3.12 1.87-7.42 3.45-12.86 4.69 4.36 1.46 7.53 2.9 9.52 4.36 1.32.96 3.28 3.06 5.85 6.23 2.54 3.18 4.25 5.63 5.1 7.37l17.8 34.24h-41.39l-19.56-36.14c-2.48-4.69-4.69-7.73-6.62-9.14-2.65-1.82-5.66-2.73-9-2.73h-3.23v48.01h-36.75zm36.75-70.36h15.48c1.66 0 4.92-.55 9.72-1.63 2.42-.46 4.41-1.7 5.93-3.72 1.54-2.01 2.31-4.3 2.31-6.9 0-3.83-1.21-6.79-3.64-8.83-2.43-2.07-6.98-3.09-13.68-3.09H359.7v24.17z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="8" viewBox="0 0 240 8">
<rect x="0" y="0" width="240" height="8" rx="4" fill="#f5c266"/>
</svg>

After

Width:  |  Height:  |  Size: 158 B

+33 -5
View File
@@ -98,14 +98,42 @@ class FlixPatrolAPI extends ExternalAPI {
// Parse platform to extract base platform and requested section filter
// Format: "netflix-kids_top_10" -> platform: "netflix", filter: "kids"
// "netflix_top_10" -> platform: "netflix", filter: undefined (all content)
const platformMatch = platform.match(/^([^-]+)(?:-(.+?))?_top_10$/);
// "hbo-max_top_10" -> platform: "hbo-max", filter: undefined (hbo-max is the platform name)
// "apple-tv_top_10" -> platform: "apple-tv", filter: undefined (apple-tv is the platform name)
// List of platforms that have dashes in their names (not filters)
const multiPartPlatforms = [
'hbo-max',
'apple-tv',
'amazon-prime',
'apple-tv-store',
'google-tv',
];
let basePlatform = platform;
let contentFilter: 'kids' | undefined;
if (platformMatch) {
basePlatform = platformMatch[1];
if (platformMatch[2] === 'kids') {
contentFilter = 'kids';
// Check if this is a multi-part platform name
const isMultiPartPlatform = multiPartPlatforms.some((p) =>
platform.startsWith(p)
);
if (isMultiPartPlatform) {
// For multi-part platforms, extract the full platform name
const matchedPlatform = multiPartPlatforms.find((p) =>
platform.startsWith(p)
);
if (matchedPlatform) {
basePlatform = matchedPlatform;
}
} else {
// For single-part platforms, check for content filters
const platformMatch = platform.match(/^([^-]+)(?:-(.+?))?_top_10$/);
if (platformMatch) {
basePlatform = platformMatch[1];
if (platformMatch[2] === 'kids') {
contentFilter = 'kids';
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { MaintainerrSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
export interface MaintainerrMedia {
id: number;
collectionId: number;
plexId: number;
tmdbId: number;
addDate: string; // ISO 8601 date string
image_path: string;
isManual: boolean;
}
export interface MaintainerrCollection {
id: number;
plexId: number;
libraryId: number;
title: string;
description: string;
isActive: boolean;
arrAction: number;
visibleOnRecommended: boolean;
visibleOnHome: boolean;
deleteAfterDays: number;
manualCollection: boolean;
manualCollectionName: string;
listExclusions: boolean;
forceOverseerr: boolean;
type: number;
keepLogsForMonths: number;
addDate: string;
handledMediaAmount: number;
lastDurationInSeconds: number;
tautulliWatchedPercentOverride: number | null;
radarrSettingsId: number | null;
sonarrSettingsId: number | null;
media: MaintainerrMedia[];
}
class MaintainerrAPI {
private axios: AxiosInstance;
constructor(settings: MaintainerrSettings) {
const protocol = settings.useSsl ? 'https' : 'http';
const port = settings.port ? `:${settings.port}` : '';
const urlBase = settings.urlBase ?? '';
this.axios = axios.create({
baseURL: `${protocol}://${settings.hostname}${port}${urlBase}`,
headers: { 'X-Api-Key': settings.apiKey },
timeout: 30000,
});
}
public async getCollections(): Promise<MaintainerrCollection[]> {
try {
const response = await this.axios.get<MaintainerrCollection[]>(
'/api/collections'
);
return response.data;
} catch (e) {
logger.error('Something went wrong fetching Maintainerr collections', {
label: 'Maintainerr API',
errorMessage: e.message,
});
throw e;
}
}
}
export default MaintainerrAPI;
+359 -20
View File
@@ -88,6 +88,13 @@ interface PlexStream {
// Video stream fields
DOVIPresent?: boolean;
DOVIProfile?: number; // Dolby Vision profile (5, 7, 8, etc.)
DOVILevel?: number;
DOVIVersion?: string;
DOVIBLPresent?: boolean;
DOVIELPresent?: boolean;
DOVIRPUPresent?: boolean;
DOVIBLCompatID?: number;
height?: number;
width?: number;
colorPrimaries?: string;
@@ -355,16 +362,14 @@ class PlexAPI {
});
settings.plex.libraries = newLibraries;
settings.save();
} catch (e) {
logger.error('Failed to fetch Plex libraries.', {
logger.error('Failed to sync Plex libraries - keeping existing data', {
label: 'Plex API',
message: e.message,
});
settings.plex.libraries = [];
throw e;
}
settings.save();
}
public async getLibraryContents(
@@ -1252,11 +1257,11 @@ class PlexAPI {
/**
* Update collection mode (visibility of individual items)
* @param collectionRatingKey - Collection rating key
* @param mode - Collection mode: 0 = library default, 1 = hide items show collection, 2 = show collection and items, 3 = hide collection show items
* @param mode - Collection mode: -1 = inherit library default, 0 = library default, 1 = hide items show collection, 2 = show collection and items, 3 = hide collection show items
*/
public async updateCollectionMode(
collectionRatingKey: string,
mode: 0 | 1 | 2 | 3
mode: -1 | 0 | 1 | 2 | 3
): Promise<void> {
try {
// Plex uses /prefs endpoint with collectionMode query parameter
@@ -1628,24 +1633,35 @@ class PlexAPI {
for (let i = 0; i < desiredOrder.length; i++) {
if (currentOrder[i] !== desiredOrder[i]) {
const itemToMove = desiredOrder[i];
const afterItem = i > 0 ? desiredOrder[i - 1] : null;
if (afterItem) {
const success = await this.moveItemInCollection(
let success = false;
if (i === 0) {
// Special case: position 0 - move without 'after' parameter
try {
const moveUrl = `/library/collections/${collectionRatingKey}/items/${itemToMove}/move`;
await this.safePutQuery(moveUrl);
success = true;
} catch (error) {
success = false;
}
} else {
// Normal case: move after the previous item
const afterItem = desiredOrder[i - 1];
success = await this.moveItemInCollection(
collectionRatingKey,
itemToMove,
afterItem
);
}
if (success) {
moveCount++;
// Update in-memory tracking: remove from old position and insert at new position
const oldIndex = currentOrder.indexOf(itemToMove);
currentOrder.splice(oldIndex, 1);
currentOrder.splice(i, 0, itemToMove);
} else {
failCount++;
}
if (success) {
moveCount++;
// Update in-memory tracking: remove from old position and insert at new position
const oldIndex = currentOrder.indexOf(itemToMove);
currentOrder.splice(oldIndex, 1);
currentOrder.splice(i, 0, itemToMove);
} else {
failCount++;
}
}
}
@@ -2280,7 +2296,10 @@ class PlexAPI {
smartCollectionRatingKey: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
subtype: 'recently_added' | 'recently_released',
subtype:
| 'recently_added'
| 'recently_released'
| 'recently_released_episodes',
maxItems?: number
): Promise<void> {
return this.smartCollectionManager.updateFilteredHubUri(
@@ -2303,6 +2322,44 @@ class PlexAPI {
);
}
/**
* Create a smart collection filtered by director name
*/
public async createDirectorCollection(
title: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
directorName: string,
limit?: number
): Promise<string | null> {
return this.smartCollectionManager.createDirectorCollection(
title,
libraryKey,
mediaType,
directorName,
limit
);
}
/**
* Create a smart collection filtered by actor name
*/
public async createActorCollection(
title: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
actorName: string,
limit?: number
): Promise<string | null> {
return this.smartCollectionManager.createActorCollection(
title,
libraryKey,
mediaType,
actorName,
limit
);
}
// POSTER MANAGEMENT METHODS - Delegated to PlexPosterManager
/**
@@ -2433,6 +2490,288 @@ class PlexAPI {
): Promise<void> {
return this.posterManager.updateSummary(ratingKey, summary);
}
/**
* Get top directors from a library section with their item counts
* Excludes placeholder items using the same query filters as smart collections
*/
public async getLibraryDirectors(
libraryId: string,
limit?: number
): Promise<{ name: string; count: number }[]> {
try {
logger.debug(`Fetching directors from library ${libraryId}`, {
label: 'Plex API',
libraryId,
limit,
});
// Fetch library metadata to determine media type
const libraries = await this.getLibraries();
const library = libraries.find((lib) => lib.key === libraryId);
const mediaType = library?.type === 'show' ? 'tv' : 'movie';
const type = mediaType === 'movie' ? 1 : 2;
// Build query with placeholder exclusions (same as smart collections)
let queryUri: string;
if (mediaType === 'tv') {
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
queryUri = `/library/sections/${libraryId}/all?type=${type}&episode.title!=${titleFilter}`;
} else {
const labelFilter = encodeURIComponent('trailer-placeholder');
queryUri = `/library/sections/${libraryId}/all?type=${type}&label!=${labelFilter}`;
}
const response = await this.plexClient.query<{
MediaContainer: {
totalSize: number;
Metadata?: {
Director?: { tag: string }[];
}[];
};
}>({
uri: queryUri,
extraHeaders: {
'X-Plex-Container-Size': '0', // Get all items
},
});
const items = response.MediaContainer.Metadata || [];
const directorCounts = new Map<string, number>();
for (const item of items) {
if (item.Director && Array.isArray(item.Director)) {
for (const director of item.Director) {
if (director.tag) {
const currentCount = directorCounts.get(director.tag) || 0;
directorCounts.set(director.tag, currentCount + 1);
}
}
}
}
let directors = Array.from(directorCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
if (limit && limit > 0) {
directors = directors.slice(0, limit);
}
logger.info(
`Found ${directorCounts.size} unique directors in library ${libraryId}`,
{
label: 'Plex API',
libraryId,
totalDirectors: directorCounts.size,
returned: directors.length,
topDirectors: directors
.slice(0, 5)
.map((d) => `${d.name} (${d.count})`),
}
);
return directors;
} catch (error) {
logger.error(`Failed to fetch directors from library ${libraryId}`, {
label: 'Plex API',
libraryId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Get top actors from a library section with their item counts
* Excludes placeholder items using the same query filters as smart collections
*/
public async getLibraryActors(
libraryId: string,
limit?: number
): Promise<{ name: string; count: number }[]> {
try {
logger.debug(`Fetching actors from library ${libraryId}`, {
label: 'Plex API',
libraryId,
limit,
});
// Fetch library metadata to determine media type
const libraries = await this.getLibraries();
const library = libraries.find((lib) => lib.key === libraryId);
const mediaType = library?.type === 'show' ? 'tv' : 'movie';
const type = mediaType === 'movie' ? 1 : 2;
// Build query with placeholder exclusions (same as smart collections)
let queryUri: string;
if (mediaType === 'tv') {
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
queryUri = `/library/sections/${libraryId}/all?type=${type}&episode.title!=${titleFilter}`;
} else {
const labelFilter = encodeURIComponent('trailer-placeholder');
queryUri = `/library/sections/${libraryId}/all?type=${type}&label!=${labelFilter}`;
}
const response = await this.plexClient.query<{
MediaContainer: {
totalSize: number;
Metadata?: {
Role?: { tag: string }[];
}[];
};
}>({
uri: queryUri,
extraHeaders: {
'X-Plex-Container-Size': '0', // Get all items
},
});
const items = response.MediaContainer.Metadata || [];
const actorCounts = new Map<string, number>();
for (const item of items) {
const roles = (item as { Role?: { tag?: string }[] }).Role;
if (roles && Array.isArray(roles)) {
// Only consider the first few actors per item to avoid noisy long casts
for (const role of roles.slice(0, 5)) {
if (role.tag) {
const currentCount = actorCounts.get(role.tag) || 0;
actorCounts.set(role.tag, currentCount + 1);
}
}
}
}
let actors = Array.from(actorCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
if (limit && limit > 0) {
actors = actors.slice(0, limit);
}
logger.info(
`Found ${actorCounts.size} unique actors in library ${libraryId}`,
{
label: 'Plex API',
libraryId,
totalActors: actorCounts.size,
returned: actors.length,
topActors: actors.slice(0, 5).map((d) => `${d.name} (${d.count})`),
}
);
return actors;
} catch (error) {
logger.error(`Failed to fetch actors from library ${libraryId}`, {
label: 'Plex API',
libraryId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Get library items for a specific director (movies or TV)
*/
public async getItemsByDirector(
libraryId: string,
directorName: string,
mediaType: 'movie' | 'tv',
limit?: number
): Promise<PlexLibraryItem[]> {
const type = mediaType === 'movie' ? 1 : 2;
const directorFilter = encodeURIComponent(directorName);
const filterParams =
mediaType === 'tv'
? `episode.title!=${encodeURIComponent('Trailer (Placeholder)')}`
: `label!=${encodeURIComponent('trailer-placeholder')}`;
let uri = `/library/sections/${libraryId}/all?type=${type}&director=${directorFilter}&${filterParams}&includeGuids=1`;
if (limit && limit > 0) {
uri += `&limit=${limit}`;
}
try {
const response = await this.plexClient.query<{
MediaContainer: { Metadata?: PlexLibraryItem[] };
}>({
uri,
extraHeaders: limit
? {
'X-Plex-Container-Size': `${limit}`,
}
: undefined,
});
return response.MediaContainer.Metadata || [];
} catch (error) {
logger.error(
`Failed to fetch items for director "${directorName}" in library ${libraryId}`,
{
label: 'Plex API',
directorName,
libraryId,
mediaType,
limit,
error: error instanceof Error ? error.message : String(error),
}
);
throw error;
}
}
/**
* Get library items for a specific actor (movies or TV)
*/
public async getItemsByActor(
libraryId: string,
actorName: string,
mediaType: 'movie' | 'tv',
limit?: number
): Promise<PlexLibraryItem[]> {
const type = mediaType === 'movie' ? 1 : 2;
const actorFilter = encodeURIComponent(actorName);
const filterParams =
mediaType === 'tv'
? `episode.title!=${encodeURIComponent('Trailer (Placeholder)')}`
: `label!=${encodeURIComponent('trailer-placeholder')}`;
let uri = `/library/sections/${libraryId}/all?type=${type}&actor=${actorFilter}&${filterParams}&includeGuids=1`;
if (limit && limit > 0) {
uri += `&limit=${limit}`;
}
try {
const response = await this.plexClient.query<{
MediaContainer: { Metadata?: PlexLibraryItem[] };
}>({
uri,
extraHeaders: limit
? {
'X-Plex-Container-Size': `${limit}`,
}
: undefined,
});
return response.MediaContainer.Metadata || [];
} catch (error) {
logger.error(
`Failed to fetch items for actor "${actorName}" in library ${libraryId}`,
{
label: 'Plex API',
actorName,
libraryId,
mediaType,
limit,
error: error instanceof Error ? error.message : String(error),
}
);
throw error;
}
}
}
export default PlexAPI;
+1 -1
View File
@@ -295,7 +295,7 @@ class TheMovieDb extends ExternalAPI {
include_video_language: language + ', en',
},
},
43200
1800 // 30 minutes cache - next_episode_to_air changes frequently when shows are airing
);
return data;
+3
View File
@@ -39,6 +39,9 @@ export class OverlayLibraryConfig {
@Column({ type: 'simple-json' })
public enabledOverlays: EnabledOverlay[];
@Column({ type: 'varchar', nullable: true })
public tmdbLanguage?: string; // ISO language code for TMDB poster metadata (e.g., 'en', 'fr', 'pt-BR')
@CreateDateColumn()
public createdAt: Date;
+2
View File
@@ -44,6 +44,7 @@ export interface OverlayTextElementProps {
color: string;
textAlign: 'left' | 'center' | 'right';
maxLines?: number;
opacity?: number; // 0-100
}
/**
@@ -89,6 +90,7 @@ export interface OverlayVariableElementProps {
fontStyle: 'normal' | 'italic';
color: string;
textAlign: 'left' | 'center' | 'right';
opacity?: number; // 0-100
}
/**
+10 -2
View File
@@ -10,7 +10,7 @@ import {
export interface LayeredElement {
id: string;
layerOrder: number; // 0 = bottom, higher = top
type: 'text' | 'raster' | 'svg' | 'content-grid';
type: 'text' | 'raster' | 'svg' | 'content-grid' | 'person';
// Common properties
x: number;
@@ -24,7 +24,8 @@ export interface LayeredElement {
| TextElementProps
| RasterElementProps
| SVGElementProps
| ContentGridProps;
| ContentGridProps
| PersonElementProps;
}
export interface TextElementProps {
@@ -37,12 +38,19 @@ export interface TextElementProps {
color: string;
textAlign: 'left' | 'center' | 'right';
maxLines?: number;
textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
}
export interface RasterElementProps {
imagePath: string; // Path to uploaded raster image
}
export interface PersonElementProps {
imagePath?: string; // Optional preview/placeholder image
overlayColor?: string; // Optional overlay tint color
overlayOpacity?: number; // 0-1 overlay opacity
}
export interface SVGElementProps {
iconType: 'source-logo' | 'svg-icon' | 'custom-icon';
iconPath?: string; // For custom icons, service logo is dynamic
+30 -2
View File
@@ -24,6 +24,7 @@ import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import fs from 'fs';
import next from 'next';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
@@ -73,8 +74,8 @@ app
// Migrate legacy sort order (reverseOrder/randomizeOrder) to sortOrder enum
settings.migrateSortOrderToEnum();
// Migrate poster templates to unified layering system for v1.3.2
await settings.migratePosterTemplatesV132();
// Migrate overlay-application job schedule from midnight to 3am to prevent conflicts
settings.migrateOverlayJobSchedule();
// Seed default source colors and poster template (one-time setup)
try {
@@ -157,6 +158,17 @@ app
logger.error('Failed to initialize icon storage:', error);
}
// Initialize fonts directory for custom fonts
try {
const fontsDir = path.join(process.cwd(), 'config', 'fonts');
if (!fs.existsSync(fontsDir)) {
fs.mkdirSync(fontsDir, { recursive: true });
logger.info('Created fonts directory successfully');
}
} catch (error) {
logger.error('Failed to initialize fonts directory:', error);
}
// Initialize base poster storage directory
try {
const { plexBasePosterManager } = await import(
@@ -518,6 +530,22 @@ app
})
);
// Serve custom fonts
const customFontsPath = path.join(process.cwd(), 'config', 'fonts');
server.use(
'/custom-fonts',
express.static(customFontsPath, {
setHeaders: (res, path) => {
if (path.endsWith('.ttf')) {
res.setHeader('Content-Type', 'font/ttf');
} else if (path.endsWith('.otf')) {
res.setHeader('Content-Type', 'font/otf');
}
res.setHeader('Access-Control-Allow-Origin', '*');
},
})
);
server.post('/upload-icon', async (req, res) => {
try {
const multer = (await import('multer')).default;
@@ -592,6 +592,60 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
}
}
/**
* Handle placeholder cleanup and process missing items in one step
* This combines the cleanup phase (remove old placeholders) with creation phase (add new ones)
*
* @param items - Items that exist in Plex
* @param missingItems - Items that don't exist in Plex
* @param config - Collection configuration
* @param plexClient - Plex API client
* @param libraryCache - Optional library cache for optimization
* @param autoRequestHandler - Optional function to call for auto-requests
* @returns Collection items created from placeholders
*/
protected async handlePlaceholdersAndMissingItems(
items: CollectionItem[],
missingItems: MissingItem[] | undefined,
config: CollectionConfig,
plexClient: PlexAPI,
libraryCache?: LibraryItemsCache,
autoRequestHandler?: () => Promise<void>
): Promise<CollectionItem[]> {
// Phase 1: Cleanup old placeholders (or delete all if setting disabled)
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
const { handlePlaceholderCleanup } = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
await handlePlaceholderCleanup(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
// Phase 2: Create new placeholders for missing items
if (!missingItems || missingItems.length === 0) {
return [];
}
return this.processMissingItems(
missingItems,
config,
plexClient,
autoRequestHandler
);
}
/**
* Process missing items - create placeholders AND/OR send to auto-requests
* This is the main entry point for handling missing items in any collection type.
@@ -624,9 +678,9 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
// Create placeholders if enabled
if (config.createPlaceholdersForMissing) {
// Import and use the PlaceholderService
// Import and use the PlaceholderCreation service
const { processPlaceholdersForMissingItems } = await import(
'@server/lib/collections/services/PlaceholderService'
'@server/lib/placeholders/services/PlaceholderCreation'
);
logger.info('Creating placeholders for missing items', {
@@ -3139,6 +3193,7 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
options?: {
collectionTypeOverride?: string; // For networks/originals to pass platform name
dynamicLogo?: string; // For networks to pass extracted sprite logo
personImageUrl?: string; // For person collections to pass TMDB profile image
}
): Promise<void> {
try {
@@ -3313,7 +3368,11 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
mediaType,
items: posterItems,
autoPosterTemplate: config.autoPosterTemplate,
libraryId: config.libraryId,
...(options?.dynamicLogo && { dynamicLogo: options.dynamicLogo }),
...(options?.personImageUrl && {
personImageUrl: options.personImageUrl,
}),
},
`Auto-generated: ${collectionName}`,
collectionIdentifier
@@ -397,6 +397,39 @@ export function findCollectionByConfigId(
}
}
// Special handling for Plex Library person collections (directors/actors)
if (
configType === 'plex' &&
(configSubtype === 'directors' || configSubtype === 'actors')
) {
const prefix = `AgregarrAuto${
configSubtype === 'actors' ? 'Actor' : 'Director'
}-${configId}-`.toLowerCase();
const hasPersonCollections = allCollections.some((collection) => {
if (!collection.labels) return false;
return collection.labels.some((label) => {
const labelText = typeof label === 'string' ? label : label.tag;
if (!labelText) return false;
const normalized = labelText.toLowerCase();
return normalized.startsWith(prefix);
});
});
if (hasPersonCollections) {
logger.debug(
`Plex Library ${configSubtype} collections found for config: ${configId}`,
{
label: 'Collection Matching',
configId,
configType,
configSubtype,
}
);
return true;
}
}
// First, try to match by rating key (fastest)
if (ratingKey && allCollections.some((c) => c.ratingKey === ratingKey)) {
return true;
+1
View File
@@ -153,6 +153,7 @@ export type CollectionSource =
| 'originals'
| 'anilist'
| 'myanimelist'
| 'plex'
| 'radarrtag'
| 'sonarrtag'
| 'comingsoon'
+16 -6
View File
@@ -1515,18 +1515,28 @@ export class HubSyncService {
if (config.isLibraryPromoted && config.sortOrderLibrary > 0) {
// Promoted: Set exclamation marks
const sameLibraryConfigs = preExistingConfigs.filter(
const sameLibraryPreExisting = preExistingConfigs.filter(
(c) =>
c.libraryId === config.libraryId &&
c.sortOrderLibrary !== undefined &&
c.isLibraryPromoted === true
);
if (sameLibraryConfigs.length > 0) {
const sortOrders = sameLibraryConfigs
.map((c) => c.sortOrderLibrary)
.filter((order): order is number => order !== undefined);
const maxSortOrder = Math.max(...sortOrders);
const collectionConfigs = settings.plex.collectionConfigs || [];
const sameLibraryCollections = collectionConfigs.filter(
(c) =>
c.libraryId === config.libraryId &&
c.sortOrderLibrary !== undefined &&
c.isLibraryPromoted === true
);
const combinedSortOrders = [
...sameLibraryPreExisting.map((c) => c.sortOrderLibrary),
...sameLibraryCollections.map((c) => c.sortOrderLibrary),
].filter((order): order is number => order !== undefined);
if (combinedSortOrders.length > 0) {
const maxSortOrder = Math.max(...combinedSortOrders);
const exclamationCount = maxSortOrder - config.sortOrderLibrary + 2;
const exclamationPrefix = '!'.repeat(exclamationCount);
sortTitle = `${exclamationPrefix}${config.name}`;
@@ -278,11 +278,11 @@ class PlexSmartCollectionManager {
/**
* Create a filtered hub replacement smart collection that excludes coming soon placeholders
* Supports: recently_added, recently_released
* Supports: recently_added, recently_released, recently_released_episodes
* @param title - Title for the smart collection
* @param libraryKey - Library section key (e.g., "1" for movies)
* @param mediaType - 'movie' or 'tv'
* @param subtype - Hub subtype ('recently_added' or 'recently_released')
* @param subtype - Hub subtype ('recently_added', 'recently_released', or 'recently_released_episodes')
* @param maxItems - Maximum number of items to include in the smart collection
* @returns The rating key of the created smart collection or null if failed
*/
@@ -290,7 +290,10 @@ class PlexSmartCollectionManager {
title: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
subtype: 'recently_added' | 'recently_released',
subtype:
| 'recently_added'
| 'recently_released'
| 'recently_released_episodes',
maxItems?: number
): Promise<string | null> {
try {
@@ -340,6 +343,18 @@ class PlexSmartCollectionManager {
labelFilter
)}`;
}
} else if (subtype === 'recently_released_episodes') {
// Last Episode Added: Sort by most recent episode added date (TV only)
if (mediaType === 'tv') {
// TV Shows: Sort by last episode added date, filter out "Trailer (Placeholder)"
const sortParam = 'episode.addedAt:desc';
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&sort=${sortParam}&episode.title!=${titleFilter}`;
} else {
throw new Error(
`recently_released_episodes subtype is only supported for TV libraries`
);
}
} else {
throw new Error(`Unsupported filtered hub subtype: ${subtype}`);
}
@@ -421,13 +436,226 @@ class PlexSmartCollectionManager {
}
}
/**
* Create a smart collection filtered by director name
*/
public async createDirectorCollection(
title: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
directorName: string,
limit?: number
): Promise<string | null> {
try {
logger.debug(
`Creating director smart collection "${title}" for library ${libraryKey}`,
{
label: 'Plex API',
title,
libraryKey,
mediaType,
directorName,
limit,
}
);
const type = mediaType === 'movie' ? 1 : 2;
// Build filter URI: director filter + exclude placeholders
const directorFilter = encodeURIComponent(directorName);
let filterUri: string;
if (mediaType === 'tv') {
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&director=${directorFilter}&episode.title!=${titleFilter}`;
} else {
const labelFilter = encodeURIComponent('trailer-placeholder');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&director=${directorFilter}&label!=${labelFilter}`;
}
if (limit && limit > 0) {
filterUri += `&limit=${limit}`;
}
const uri = `server://${
getSettings().plex.machineId
}/com.plexapp.plugins.library${filterUri}`;
const createUrl = `/library/collections?type=${type}&title=${encodeURIComponent(
title
)}&smart=1&uri=${encodeURIComponent(uri)}&sectionId=${libraryKey}`;
const createResponse = await this.plexApi['safePostQuery'](createUrl);
if (
!createResponse ||
typeof createResponse !== 'object' ||
!('MediaContainer' in createResponse)
) {
logger.error(
'Invalid response when creating director smart collection',
{
label: 'Plex API',
response: createResponse,
}
);
return null;
}
const mediaContainer = createResponse.MediaContainer as {
Metadata?: { ratingKey: string }[];
};
if (!mediaContainer.Metadata || mediaContainer.Metadata.length === 0) {
logger.error(
'No metadata returned when creating director smart collection',
{
label: 'Plex API',
response: createResponse,
}
);
return null;
}
const smartCollectionRatingKey = mediaContainer.Metadata[0].ratingKey;
// Set the collection to be filtered by user
await this.setCollectionUserFilter(smartCollectionRatingKey);
logger.info(
`Successfully created director smart collection "${title}" with rating key ${smartCollectionRatingKey}`,
{
label: 'Plex API',
title,
smartCollectionRatingKey,
mediaType,
directorName,
limit,
}
);
return smartCollectionRatingKey;
} catch (error) {
logger.error(`Error creating director smart collection "${title}"`, {
label: 'Plex API',
title,
libraryKey,
mediaType,
directorName,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Create a smart collection filtered by actor name
*/
public async createActorCollection(
title: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
actorName: string,
limit?: number
): Promise<string | null> {
try {
logger.debug(
`Creating actor smart collection "${title}" for library ${libraryKey}`,
{
label: 'Plex API',
title,
libraryKey,
mediaType,
actorName,
limit,
}
);
const type = mediaType === 'movie' ? 1 : 2;
// Build filter URI: actor filter + exclude placeholders
const actorFilter = encodeURIComponent(actorName);
let filterUri: string;
if (mediaType === 'tv') {
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&actor=${actorFilter}&episode.title!=${titleFilter}`;
} else {
const labelFilter = encodeURIComponent('trailer-placeholder');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&actor=${actorFilter}&label!=${labelFilter}`;
}
if (limit && limit > 0) {
filterUri += `&limit=${limit}`;
}
const uri = `server://${
getSettings().plex.machineId
}/com.plexapp.plugins.library${filterUri}`;
const createUrl = `/library/collections?type=${type}&title=${encodeURIComponent(
title
)}&smart=1&uri=${encodeURIComponent(uri)}&sectionId=${libraryKey}`;
const createResponse = await this.plexApi['safePostQuery'](createUrl);
if (
!createResponse ||
typeof createResponse !== 'object' ||
!('MediaContainer' in createResponse)
) {
logger.error('Invalid response when creating actor smart collection', {
label: 'Plex API',
response: createResponse,
});
return null;
}
const mediaContainer = createResponse.MediaContainer as {
Metadata?: { ratingKey: string }[];
};
if (!mediaContainer.Metadata || mediaContainer.Metadata.length === 0) {
logger.error(
'No metadata returned when creating actor smart collection',
{
label: 'Plex API',
response: createResponse,
}
);
return null;
}
const smartCollectionRatingKey = mediaContainer.Metadata[0].ratingKey;
// Set the collection to be filtered by user
await this.setCollectionUserFilter(smartCollectionRatingKey);
logger.info(
`Successfully created actor smart collection "${title}" with rating key ${smartCollectionRatingKey}`,
{
label: 'Plex API',
title,
smartCollectionRatingKey,
mediaType,
actorName,
limit,
}
);
return smartCollectionRatingKey;
} catch (error) {
logger.error(`Error creating actor smart collection "${title}"`, {
label: 'Plex API',
title,
libraryKey,
mediaType,
actorName,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Update an existing filtered hub smart collection's URI
* This updates the filter parameters including maxItems limit
* @param smartCollectionRatingKey - The rating key of the smart collection to update
* @param libraryKey - Library section key (e.g., "1" for movies)
* @param mediaType - 'movie' or 'tv'
* @param subtype - Hub subtype ('recently_added' or 'recently_released')
* @param subtype - Hub subtype ('recently_added', 'recently_released', or 'recently_released_episodes')
* @param maxItems - Maximum number of items to include in the smart collection
* @returns Promise<void>
*/
@@ -435,7 +663,10 @@ class PlexSmartCollectionManager {
smartCollectionRatingKey: string,
libraryKey: string,
mediaType: 'movie' | 'tv',
subtype: 'recently_added' | 'recently_released',
subtype:
| 'recently_added'
| 'recently_released'
| 'recently_released_episodes',
maxItems?: number
): Promise<void> {
try {
@@ -486,6 +717,18 @@ class PlexSmartCollectionManager {
labelFilter
)}`;
}
} else if (subtype === 'recently_released_episodes') {
// Last Episode Added: Sort by most recent episode added date (TV only)
if (mediaType === 'tv') {
// TV Shows: Sort by last episode added date, filter out "Trailer (Placeholder)"
const sortParam = 'episode.addedAt:desc';
const titleFilter = encodeURIComponent('Trailer (Placeholder)');
filterUri = `/library/sections/${libraryKey}/all?type=${type}&sort=${sortParam}&episode.title!=${titleFilter}`;
} else {
throw new Error(
`recently_released_episodes subtype is only supported for TV libraries`
);
}
} else {
throw new Error(`Unsupported filtered hub subtype: ${subtype}`);
}
@@ -539,7 +539,7 @@ export async function applyPreSyncUserRestrictions(): Promise<void> {
// Get all potential Overseerr users (those who could have collections)
const { overseerrCollectionService } = await import(
'@server/lib/collections/external/overseerr'
'@server/lib/collections/sources/overseerr'
);
const potentialOverseerrUsers =
await overseerrCollectionService.getUsersWithPlexIds();
@@ -619,7 +619,7 @@ export async function applySelectivePreSyncUserRestrictions(
// Get Overseerr users with Plex IDs for regular user collections
// Exclude admin user from user collections (consistent with overseerr sync logic)
const { overseerrCollectionService } = await import(
'@server/lib/collections/external/overseerr'
'@server/lib/collections/sources/overseerr'
);
const overseerrUsers =
await overseerrCollectionService.getUsersWithPlexIds();
@@ -25,7 +25,6 @@ import { syncCacheService } from './SyncCacheService';
*/
export class AutoRequestService {
private serviceUserManager: ServiceUserManager;
private overseerrAPI: OverseerrAPI | null = null;
private missingItemRepository = getRepository(MissingItemRequest);
private tmdbAPI: TheMovieDb;
@@ -35,20 +34,18 @@ export class AutoRequestService {
}
/**
* Get or initialize Overseerr API client
* Get Overseerr API client with current settings
*/
private getOverseerrAPI(): OverseerrAPI {
if (!this.overseerrAPI) {
const settings = getSettings();
const overseerrSettings = settings.overseerr;
const settings = getSettings();
const overseerrSettings = settings.overseerr;
if (!overseerrSettings?.hostname || !overseerrSettings?.apiKey) {
throw new Error('Overseerr API settings not configured');
}
this.overseerrAPI = new OverseerrAPI(overseerrSettings);
if (!overseerrSettings?.hostname || !overseerrSettings?.apiKey) {
throw new Error('Overseerr API settings not configured');
}
return this.overseerrAPI;
// Create fresh client with current settings
return new OverseerrAPI(overseerrSettings);
}
/**
@@ -4,7 +4,7 @@ import type {
PlexCollection,
PlexLabel,
} from '@server/lib/collections/core/types';
import { overseerrCollectionService } from '@server/lib/collections/external/overseerr';
import { overseerrCollectionService } from '@server/lib/collections/sources/overseerr';
import type { CollectionConfig } from '@server/lib/settings';
import logger from '@server/logger';
@@ -448,7 +448,7 @@ export class CollectionCleanupService {
// For server_owner, get the actual admin user plexId to match the real label format
try {
const { overseerrCollectionService } = await import(
'@server/lib/collections/external/overseerr'
'@server/lib/collections/sources/overseerr'
);
const adminUser = await overseerrCollectionService.getAdminUser();
const adminPlexId = adminUser?.plexId || adminUser?.id;
@@ -3,11 +3,17 @@ import OverseerrAPI, {
} from '@server/api/overseerr';
import type PlexAPI from '@server/api/plexapi';
import type { BaseCollectionSync } from '@server/lib/collections/core/BaseCollectionSync';
import { getCollectionMediaType } from '@server/lib/collections/core/CollectionUtilities';
import type {
CollectionSource,
SyncResult,
} from '@server/lib/collections/core/types';
import type {
DiscoveredMoviePlaceholder,
DiscoveredPlaceholder,
} from '@server/lib/placeholders/services/PlaceholderDiscovery';
import type {
CollectionConfig,
MultiSourceCollectionConfig,
MultiSourceCombineMode,
MultiSourceType,
@@ -75,6 +81,176 @@ export class CollectionSyncService {
}
}
/**
* Pre-fetch placeholder discovery to avoid repeated filesystem scans during sync
* OPTIMIZATION: Run discovery ONCE per library and share results across all collections
*/
private async prefetchPlaceholderDiscovery(
plexClient: PlexAPI,
collectionConfigs: CollectionConfig[]
): Promise<{
tv: DiscoveredPlaceholder[];
movies: DiscoveredMoviePlaceholder[];
}> {
const settings = getSettings();
const tvLibraryPath = settings.main.placeholderTVRootFolder;
const movieLibraryPath = settings.main.placeholderMovieRootFolder;
let tv: DiscoveredPlaceholder[] = [];
let movies: DiscoveredMoviePlaceholder[] = [];
// Only run discovery if there are collections with placeholders enabled
const hasPlaceholderCollections = collectionConfigs.some(
(c) => c.createPlaceholdersForMissing
);
if (!hasPlaceholderCollections) {
logger.debug('No placeholder-enabled collections, skipping discovery', {
label: 'Collection Sync Service',
});
return { tv, movies };
}
// Import discovery functions
const {
discoverPlaceholdersFromMarkers,
discoverMoviePlaceholdersFromFilenames,
} = await import('@server/lib/placeholders/services/PlaceholderDiscovery');
// Find the first TV library ID from placeholder-enabled collections
const tvLibraryId = collectionConfigs.find(
(c) =>
c.createPlaceholdersForMissing && getCollectionMediaType(c) === 'tv'
)?.libraryId;
// Discover TV placeholders
if (tvLibraryPath && tvLibraryId) {
try {
logger.info('Running global TV placeholder discovery', {
label: 'Collection Sync Service',
libraryId: tvLibraryId,
libraryPath: tvLibraryPath,
});
tv = await discoverPlaceholdersFromMarkers(
plexClient,
tvLibraryId,
tvLibraryPath
);
logger.info('Global TV placeholder discovery complete', {
label: 'Collection Sync Service',
discovered: tv.length,
});
// Process discovered placeholders immediately: fix titles, cleanup real content
const { cleanupPlaceholderForRealContent } = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
const { ensurePlaceholderEpisodeTitle } = await import(
'@server/lib/placeholders/services/PlaceholderTitleFixer'
);
let cleanedUp = 0;
let titlesFixes = 0;
for (const { plexItem, needsTitleFix, marker } of tv) {
if (!plexItem) {
continue; // Not found in Plex
}
if (!needsTitleFix && marker.tmdbId) {
// Real content detected - clean up placeholder
await cleanupPlaceholderForRealContent(
marker.tmdbId,
marker.placeholderPath,
'tv'
);
cleanedUp++;
} else if (needsTitleFix) {
// Still a placeholder - fix episode title
await ensurePlaceholderEpisodeTitle(
plexClient,
plexItem.ratingKey,
marker.title
);
titlesFixes++;
}
}
logger.info('Global TV placeholder processing complete', {
label: 'Collection Sync Service',
cleanedUp,
titlesFixes,
});
} catch (error) {
logger.warn('Failed to run global TV placeholder discovery', {
label: 'Collection Sync Service',
error: error instanceof Error ? error.message : String(error),
});
}
}
// Find the first movie library ID from placeholder-enabled collections
const movieLibraryId = collectionConfigs.find(
(c) =>
c.createPlaceholdersForMissing && getCollectionMediaType(c) === 'movie'
)?.libraryId;
// Discover movie placeholders
if (movieLibraryPath && movieLibraryId) {
try {
logger.info('Running global movie placeholder discovery', {
label: 'Collection Sync Service',
libraryId: movieLibraryId,
libraryPath: movieLibraryPath,
});
movies = await discoverMoviePlaceholdersFromFilenames(
plexClient,
movieLibraryId,
movieLibraryPath
);
logger.info('Global movie placeholder discovery complete', {
label: 'Collection Sync Service',
discovered: movies.length,
});
// Process discovered movie placeholders: cleanup real content
const { cleanupPlaceholderForRealContent } = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
let moviesCleanedUp = 0;
for (const { plexItem, needsCleanup, movie } of movies) {
if (plexItem && needsCleanup) {
// Real content detected - clean up placeholder
await cleanupPlaceholderForRealContent(
movie.tmdbId,
movie.placeholderPath,
'movie'
);
moviesCleanedUp++;
}
}
logger.info('Global movie placeholder processing complete', {
label: 'Collection Sync Service',
cleanedUp: moviesCleanedUp,
});
} catch (error) {
logger.warn('Failed to run global movie placeholder discovery', {
label: 'Collection Sync Service',
error: error instanceof Error ? error.message : String(error),
});
}
}
return { tv, movies };
}
/**
* Sync all collection configurations using their respective sync services
* This replaces the 84-line switch statement with clean, maintainable code
@@ -186,13 +362,26 @@ export class CollectionSyncService {
onProgress?.(0, 'Pre-fetching Overseerr requests...');
const overseerrRequestsCache = await this.prefetchOverseerrRequests();
// Pre-fetch placeholder discovery cache
onProgress?.(0, 'Discovering placeholders...');
const placeholderDiscovery = await this.prefetchPlaceholderDiscovery(
plexClient,
collectionConfigs
);
// Initialize the global sync cache service for use across all sync operations
syncCacheService.initialize(overseerrRequestsCache, libraryCache);
syncCacheService.setPlaceholderDiscoveryCache(
placeholderDiscovery.tv,
placeholderDiscovery.movies
);
logger.info('Sync caches ready, starting collection processing', {
label: 'Collection Sync Service',
libraryCache: cachedLibraryCount,
requestsCache: overseerrRequestsCache.length,
placeholderDiscoveryTv: placeholderDiscovery.tv.length,
placeholderDiscoveryMovies: placeholderDiscovery.movies.length,
});
let totalCreated = 0;
@@ -260,7 +449,7 @@ export class CollectionSyncService {
name: config.name,
type: 'multi-source',
visibilityConfig: config.visibilityConfig,
mediaType: 'movie', // Default, should be set properly by caller
mediaType: getCollectionMediaType(config),
libraryId: config.libraryId,
libraryName: config.libraryName,
maxItems: config.maxItems ?? 50, // Provide default for multi-source
@@ -315,9 +504,6 @@ export class CollectionSyncService {
seasonsPerShowLimit: config.seasonsPerShowLimit,
maxPositionToProcess: config.maxPositionToProcess,
minimumYear: config.minimumYear,
excludedGenres: config.excludedGenres,
excludedCountries: config.excludedCountries,
excludedLanguages: config.excludedLanguages,
filterSettings: config.filterSettings,
directDownloadRadarrServerId: config.directDownloadRadarrServerId,
directDownloadRadarrProfileId:
@@ -507,77 +693,83 @@ export class CollectionSyncService {
): Promise<BaseCollectionSync<CollectionSource>> {
switch (type) {
case 'trakt': {
const { TraktCollectionSync } = await import('../external/trakt');
const { TraktCollectionSync } = await import('../sources/trakt');
return new TraktCollectionSync();
}
case 'mdblist': {
const { MDBListCollectionSync } = await import('../external/mdblist');
const { MDBListCollectionSync } = await import('../sources/mdblist');
return new MDBListCollectionSync();
}
case 'tmdb': {
const { TmdbCollectionSync } = await import('../external/tmdb');
const { TmdbCollectionSync } = await import('../sources/tmdb');
return new TmdbCollectionSync();
}
case 'imdb': {
const { ImdbCollectionSync } = await import('../external/imdb');
const { ImdbCollectionSync } = await import('../sources/imdb');
return new ImdbCollectionSync();
}
case 'tautulli': {
const { TautulliCollectionSync } = await import('../external/tautulli');
const { TautulliCollectionSync } = await import('../sources/tautulli');
return new TautulliCollectionSync();
}
case 'letterboxd': {
const { LetterboxdCollectionSync } = await import(
'../external/letterboxd'
'../sources/letterboxd'
);
return new LetterboxdCollectionSync();
}
case 'networks': {
const { NetworksCollectionSync } = await import('../external/networks');
const { NetworksCollectionSync } = await import('../sources/networks');
return new NetworksCollectionSync();
}
case 'originals': {
const { OriginalsCollectionSync } = await import(
'../external/originals'
'../sources/originals'
);
return new OriginalsCollectionSync();
}
case 'anilist': {
const { AnilistCollectionSync } = await import('../external/anilist');
const { AnilistCollectionSync } = await import('../sources/anilist');
return new AnilistCollectionSync();
}
case 'myanimelist': {
const { MyAnimeListCollectionSync } = await import(
'../external/myanimelist'
'../sources/myanimelist'
);
return new MyAnimeListCollectionSync();
}
case 'overseerr': {
const { OverseerrCollectionSync } = await import(
'../external/overseerrSync'
'../sources/overseerrSync'
);
return new OverseerrCollectionSync();
}
case 'radarrtag': {
const { RadarrTagCollectionSync } = await import('../external/radarr');
const { RadarrTagCollectionSync } = await import('../sources/radarr');
return new RadarrTagCollectionSync();
}
case 'sonarrtag': {
const { SonarrTagCollectionSync } = await import('../external/sonarr');
const { SonarrTagCollectionSync } = await import('../sources/sonarr');
return new SonarrTagCollectionSync();
}
case 'comingsoon': {
const { ComingSoonCollectionSync } = await import(
'../external/comingsoon'
'../sources/comingsoon'
);
return new ComingSoonCollectionSync();
}
case 'filtered_hub': {
const { FilteredHubCollectionSync } = await import(
'../external/recentlyadded'
'../sources/recentlyadded'
);
return new FilteredHubCollectionSync();
}
case 'plex': {
const { PlexLibraryCollectionSync } = await import(
'../sources/plexlibrary'
);
return new PlexLibraryCollectionSync();
}
case 'multi-source':
throw new Error(
'Multi-source collections should be handled by MultiSourceOrchestrator, not individual sync services'
@@ -22,8 +22,6 @@ import { missingItemFilterService } from './MissingItemFilterService';
* Radarr/Sonarr without going through request/approval workflow
*/
export class DirectDownloadService {
private radarrAPI: RadarrAPI | null = null;
private sonarrAPI: SonarrAPI | null = null;
private tmdbAPI: TheMovieDb;
private readonly SOURCE_LABELS: Record<
| 'trakt'
@@ -14,14 +14,18 @@ export interface FilteredMissingItemsResult {
filteredItems: MissingItem[];
/** IMDb ratings map for filtered items (tmdbId -> rating) */
imdbRatingsMap: Map<number, number | null>;
/** Rotten Tomatoes ratings map for filtered items (tmdbId -> critics score) */
/** Rotten Tomatoes critics ratings map for filtered items (tmdbId -> critics score) */
rtRatingsMap: Map<number, number | null>;
/** Rotten Tomatoes audience ratings map for filtered items (tmdbId -> audience score) */
rtAudienceRatingsMap: Map<number, number | null>;
/** Items filtered by year */
yearFilteredItems: string[];
/** Items filtered by low IMDb rating */
lowRatedItems: string[];
/** Items filtered by low Rotten Tomatoes rating */
/** Items filtered by low Rotten Tomatoes critics rating */
lowRatedRTItems: string[];
/** Items filtered by low Rotten Tomatoes audience rating */
lowRatedRTAudienceItems: string[];
/** Items filtered by excluded genres */
excludedGenreItems: string[];
/** Items filtered by excluded countries */
@@ -69,6 +73,7 @@ export class MissingItemFilterService {
const yearFilteredItems: string[] = [];
const lowRatedItems: string[] = [];
const lowRatedRTItems: string[] = [];
const lowRatedRTAudienceItems: string[] = [];
const excludedGenreItems: string[] = [];
const excludedCountryItems: string[] = [];
const excludedLanguageItems: string[] = [];
@@ -136,13 +141,17 @@ export class MissingItemFilterService {
// Step 2.5: Fetch Rotten Tomatoes ratings if filter is enabled
const rtRatingsMap = new Map<number, number | null>(); // tmdbId -> critics score
const rtAudienceRatingsMap = new Map<number, number | null>(); // tmdbId -> audience score
if (
config.minimumRottenTomatoesRating &&
config.minimumRottenTomatoesRating > 0
(config.minimumRottenTomatoesRating &&
config.minimumRottenTomatoesRating > 0) ||
(config.minimumRottenTomatoesAudienceRating &&
config.minimumRottenTomatoesAudienceRating > 0)
) {
await this.fetchRTRatings(
yearFilteredMissingItems,
rtRatingsMap,
rtAudienceRatingsMap,
config,
serviceLabel
);
@@ -187,7 +196,7 @@ export class MissingItemFilterService {
// If not in map (no IMDb ID found), allow the item (continue processing)
}
// Check Rotten Tomatoes rating filter using cached ratings
// Check Rotten Tomatoes critics rating filter using cached ratings
if (
config.minimumRottenTomatoesRating &&
config.minimumRottenTomatoesRating > 0
@@ -198,7 +207,7 @@ export class MissingItemFilterService {
// If score is null or undefined (no rating found), allow the item
if (score === null || score === undefined) {
logger.debug(
`No Rotten Tomatoes rating found for ${item.title}, allowing item`,
`No Rotten Tomatoes critics rating found for ${item.title}, allowing item`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
@@ -208,7 +217,7 @@ export class MissingItemFilterService {
} else if (score < config.minimumRottenTomatoesRating) {
// Score exists but below threshold
logger.debug(
`${item.title} RT score ${score} below minimum ${config.minimumRottenTomatoesRating}`,
`${item.title} RT critics score ${score} below minimum ${config.minimumRottenTomatoesRating}`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
@@ -225,6 +234,44 @@ export class MissingItemFilterService {
// If not in map (no RT rating found), allow the item (continue processing)
}
// Check Rotten Tomatoes audience rating filter using cached ratings
if (
config.minimumRottenTomatoesAudienceRating &&
config.minimumRottenTomatoesAudienceRating > 0
) {
if (rtAudienceRatingsMap.has(item.tmdbId)) {
const score = rtAudienceRatingsMap.get(item.tmdbId);
// If score is null or undefined (no rating found), allow the item
if (score === null || score === undefined) {
logger.debug(
`No Rotten Tomatoes audience rating found for ${item.title}, allowing item`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
title: item.title,
}
);
} else if (score < config.minimumRottenTomatoesAudienceRating) {
// Score exists but below threshold
logger.debug(
`${item.title} RT audience score ${score} below minimum ${config.minimumRottenTomatoesAudienceRating}`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
title: item.title,
score,
minimumScore: config.minimumRottenTomatoesAudienceRating,
}
);
lowRatedRTAudienceItems.push(item.title);
continue;
}
// else: score >= minimum, allow the item (continue processing)
}
// If not in map (no RT rating found), allow the item (continue processing)
}
// Check genre filter (supports both include and exclude modes)
const genreFilter = this.getGenreFilter(config);
if (genreFilter && genreFilter.values.length > 0) {
@@ -301,13 +348,80 @@ export class MissingItemFilterService {
fullyFilteredItems.push(item);
}
// Log filtering summary if any items were filtered out
const totalFiltered = missingItems.length - fullyFilteredItems.length;
if (totalFiltered > 0) {
const filterReasons: string[] = [];
if (yearFilteredItems.length > 0) {
filterReasons.push(`${yearFilteredItems.length} due to year`);
}
if (lowRatedItems.length > 0) {
filterReasons.push(`${lowRatedItems.length} due to IMDb rating`);
}
if (lowRatedRTItems.length > 0) {
filterReasons.push(
`${lowRatedRTItems.length} due to RT critics rating`
);
}
if (lowRatedRTAudienceItems.length > 0) {
filterReasons.push(
`${lowRatedRTAudienceItems.length} due to RT audience rating`
);
}
if (excludedGenreItems.length > 0) {
filterReasons.push(
`${excludedGenreItems.length} due to excluded genres`
);
}
if (excludedCountryItems.length > 0) {
filterReasons.push(
`${excludedCountryItems.length} due to excluded countries`
);
}
if (excludedLanguageItems.length > 0) {
filterReasons.push(
`${excludedLanguageItems.length} due to excluded languages`
);
}
if (includedGenreItems.length > 0) {
filterReasons.push(
`${includedGenreItems.length} due to included genres filter`
);
}
if (includedCountryItems.length > 0) {
filterReasons.push(
`${includedCountryItems.length} due to included countries filter`
);
}
if (includedLanguageItems.length > 0) {
filterReasons.push(
`${includedLanguageItems.length} due to included languages filter`
);
}
logger.info(
`Filtered ${totalFiltered}/${
missingItems.length
} items: ${filterReasons.join(', ')}`,
{
label: serviceLabel,
originalCount: missingItems.length,
filteredCount: fullyFilteredItems.length,
removedCount: totalFiltered,
}
);
}
return {
filteredItems: fullyFilteredItems,
imdbRatingsMap,
rtRatingsMap,
rtAudienceRatingsMap,
yearFilteredItems,
lowRatedItems,
lowRatedRTItems,
lowRatedRTAudienceItems,
excludedGenreItems,
excludedCountryItems,
excludedLanguageItems,
@@ -408,6 +522,7 @@ export class MissingItemFilterService {
private async fetchRTRatings(
items: MissingItem[],
ratingsMap: Map<number, number | null>,
audienceRatingsMap: Map<number, number | null>,
config: CollectionConfig,
serviceLabel: string
): Promise<void> {
@@ -426,6 +541,7 @@ export class MissingItemFilterService {
items.map(async (item) => {
try {
let rtRating = null;
let audienceScore = null;
if (item.mediaType === 'movie' && item.year) {
const rating = await this.rtAPI.getMovieRatings(
@@ -433,31 +549,48 @@ export class MissingItemFilterService {
item.year
);
rtRating = rating?.criticsScore ?? null;
audienceScore = rating?.audienceScore ?? null;
audienceRatingsMap.set(item.tmdbId, audienceScore);
} else if (item.mediaType === 'tv' && item.year) {
const rating = await this.rtAPI.getTVRatings(
item.title,
item.year
);
rtRating = rating?.criticsScore ?? null;
audienceScore = rating?.audienceScore ?? null;
audienceRatingsMap.set(item.tmdbId, audienceScore);
}
ratingsMap.set(item.tmdbId, rtRating);
if (rtRating !== null) {
logger.debug(
`Found RT rating ${rtRating} for ${item.title} (${item.year})`,
`Found RT critics score ${rtRating} for ${item.title} (${item.year})`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
title: item.title,
year: item.year,
rating: rtRating,
criticsScore: rtRating,
}
);
}
if (audienceScore !== null) {
logger.debug(
`Found RT audience score ${audienceScore} for ${item.title} (${item.year})`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
title: item.title,
year: item.year,
audienceScore: audienceScore,
}
);
}
} catch (error) {
logger.debug(
`Failed to get RT rating for ${item.title}, will allow item`,
`Failed to get RT ratings for ${item.title}, will allow item`,
{
label: serviceLabel,
tmdbId: item.tmdbId,
@@ -467,23 +600,34 @@ export class MissingItemFilterService {
);
// Set to null to indicate we tried but failed
ratingsMap.set(item.tmdbId, null);
audienceRatingsMap.set(item.tmdbId, null);
}
})
);
const ratingsFound = Array.from(ratingsMap.values()).filter(
const criticsRatingsFound = Array.from(ratingsMap.values()).filter(
(r) => r !== null
).length;
const audienceRatingsFound = Array.from(
audienceRatingsMap.values()
).filter((r) => r !== null).length;
logger.debug(
`Cached ${ratingsMap.size} RT ratings (${ratingsFound} found, ${
ratingsMap.size - ratingsFound
} not found)`,
`Cached ${
ratingsMap.size
} RT ratings - Critics: ${criticsRatingsFound} found, ${
ratingsMap.size - criticsRatingsFound
} not found | Audience: ${audienceRatingsFound} found, ${
audienceRatingsMap.size - audienceRatingsFound
} not found`,
{
label: serviceLabel,
collection: config.name,
totalCached: ratingsMap.size,
ratingsFound,
ratingsNotFound: ratingsMap.size - ratingsFound,
criticsRatingsFound,
criticsRatingsNotFound: ratingsMap.size - criticsRatingsFound,
audienceRatingsFound,
audienceRatingsNotFound:
audienceRatingsMap.size - audienceRatingsFound,
}
);
} catch (error) {
@@ -496,162 +640,30 @@ export class MissingItemFilterService {
}
/**
* Normalize genre filter config (backward compatible with old excludedGenres format)
* Get genre filter config
*/
private getGenreFilter(
config: CollectionConfig
): { mode: 'exclude' | 'include'; values: number[] } | null {
// New format takes precedence
if (config.filterSettings?.genres) {
return config.filterSettings.genres;
}
// Fall back to old format
if (config.excludedGenres && config.excludedGenres.length > 0) {
return { mode: 'exclude', values: config.excludedGenres };
}
return null;
return config.filterSettings?.genres || null;
}
/**
* Normalize country filter config (backward compatible with old excludedCountries format)
* Get country filter config
*/
private getCountryFilter(
config: CollectionConfig
): { mode: 'exclude' | 'include'; values: string[] } | null {
// New format takes precedence
if (config.filterSettings?.countries) {
return config.filterSettings.countries;
}
// Fall back to old format
if (config.excludedCountries && config.excludedCountries.length > 0) {
return { mode: 'exclude', values: config.excludedCountries };
}
return null;
return config.filterSettings?.countries || null;
}
/**
* Normalize language filter config (backward compatible with old excludedLanguages format)
* Get language filter config
*/
private getLanguageFilter(
config: CollectionConfig
): { mode: 'exclude' | 'include'; values: string[] } | null {
// New format takes precedence
if (config.filterSettings?.languages) {
return config.filterSettings.languages;
}
// Fall back to old format
if (config.excludedLanguages && config.excludedLanguages.length > 0) {
return { mode: 'exclude', values: config.excludedLanguages };
}
return null;
}
/**
* Check if an item has any excluded genres
*/
private async hasExcludedGenres(
tmdbId: number,
mediaType: 'movie' | 'tv',
excludedGenres: number[]
): Promise<boolean> {
try {
if (mediaType === 'movie') {
const movie = await this.tmdbAPI.getMovie({ movieId: tmdbId });
return movie.genres.some((genre) => excludedGenres.includes(genre.id));
} else {
const tvShow = await this.tmdbAPI.getTvShow({ tvId: tmdbId });
return tvShow.genres.some((genre) => excludedGenres.includes(genre.id));
}
} catch (error) {
logger.warn(
`Failed to check genres for TMDB ID ${tmdbId}, allowing item`,
{
label: 'Missing Item Filter Service',
error: error instanceof Error ? error.message : 'Unknown error',
}
);
return false; // If we can't check genres, don't exclude the item
}
}
/**
* Check if an item has any excluded origin countries
*/
private async hasExcludedCountries(
tmdbId: number,
mediaType: 'movie' | 'tv',
excludedCountries: string[]
): Promise<boolean> {
try {
if (mediaType === 'movie') {
const movie = await this.tmdbAPI.getMovie({ movieId: tmdbId });
// Movies use production_countries array
if (movie.production_countries) {
return movie.production_countries.some((country) =>
excludedCountries.includes(country.iso_3166_1)
);
}
return false;
} else {
const tvShow = await this.tmdbAPI.getTvShow({ tvId: tmdbId });
// TV shows use origin_country array
if (tvShow.origin_country) {
return tvShow.origin_country.some((country) =>
excludedCountries.includes(country)
);
}
return false;
}
} catch (error) {
logger.warn(
`Failed to check origin countries for TMDB ID ${tmdbId}, allowing item`,
{
label: 'Missing Item Filter Service',
error: error instanceof Error ? error.message : 'Unknown error',
}
);
return false; // If we can't check countries, don't exclude the item
}
}
/**
* Check if an item has any excluded spoken languages
*/
private async hasExcludedLanguages(
tmdbId: number,
mediaType: 'movie' | 'tv',
excludedLanguages: string[]
): Promise<boolean> {
try {
if (mediaType === 'movie') {
const movie = await this.tmdbAPI.getMovie({ movieId: tmdbId });
// Movies use spoken_languages array
if (movie.spoken_languages) {
return movie.spoken_languages.some((language) =>
excludedLanguages.includes(language.iso_639_1)
);
}
return false;
} else {
const tvShow = await this.tmdbAPI.getTvShow({ tvId: tmdbId });
// TV shows use spoken_languages array
if (tvShow.spoken_languages) {
return tvShow.spoken_languages.some((language) =>
excludedLanguages.includes(language.iso_639_1)
);
}
return false;
}
} catch (error) {
logger.warn(
`Failed to check spoken languages for TMDB ID ${tmdbId}, allowing item`,
{
label: 'Missing Item Filter Service',
error: error instanceof Error ? error.message : 'Unknown error',
}
);
return false; // If we can't check languages, don't exclude the item
}
return config.filterSettings?.languages || null;
}
/**
@@ -1000,10 +1012,10 @@ export class MissingItemFilterService {
);
}
// Log summary of items excluded by Rotten Tomatoes rating
// Log summary of items excluded by Rotten Tomatoes critics rating
if (result.lowRatedRTItems.length > 0) {
logger.info(
`Items skipped due to Rotten Tomatoes rating below ${config.minimumRottenTomatoesRating}`,
`Items skipped due to Rotten Tomatoes critics rating below ${config.minimumRottenTomatoesRating}`,
{
label: `${sourceLabel} Collections`,
collection: config.name,
@@ -1016,6 +1028,23 @@ export class MissingItemFilterService {
}
);
}
// Log summary of items excluded by Rotten Tomatoes audience rating
if (result.lowRatedRTAudienceItems.length > 0) {
logger.info(
`Items skipped due to Rotten Tomatoes audience rating below ${config.minimumRottenTomatoesAudienceRating}`,
{
label: `${sourceLabel} Collections`,
collection: config.name,
minimumRating: config.minimumRottenTomatoesAudienceRating,
count: result.lowRatedRTAudienceItems.length,
titles: result.lowRatedRTAudienceItems.slice(0, 10),
...(result.lowRatedRTAudienceItems.length > 10 && {
additionalCount: result.lowRatedRTAudienceItems.length - 10,
}),
}
);
}
}
}
@@ -24,20 +24,20 @@ import {
validateAndSanitizeItems,
validateCollectionItems,
} from '@server/lib/collections/core/CollectionUtilities';
import { AnilistCollectionSync } from '@server/lib/collections/external/anilist';
import { ComingSoonCollectionSync } from '@server/lib/collections/external/comingsoon';
import { ImdbCollectionSync } from '@server/lib/collections/external/imdb';
import { LetterboxdCollectionSync } from '@server/lib/collections/external/letterboxd';
import { MDBListCollectionSync } from '@server/lib/collections/external/mdblist';
import { MyAnimeListCollectionSync } from '@server/lib/collections/external/myanimelist';
import { NetworksCollectionSync } from '@server/lib/collections/external/networks';
import { OriginalsCollectionSync } from '@server/lib/collections/external/originals';
import { OverseerrCollectionSync } from '@server/lib/collections/external/overseerrSync';
import RadarrTagCollectionSync from '@server/lib/collections/external/radarr';
import SonarrTagCollectionSync from '@server/lib/collections/external/sonarr';
import { TautulliCollectionSync } from '@server/lib/collections/external/tautulli';
import { TmdbCollectionSync } from '@server/lib/collections/external/tmdb';
import { TraktCollectionSync } from '@server/lib/collections/external/trakt';
import { AnilistCollectionSync } from '@server/lib/collections/sources/anilist';
import { ComingSoonCollectionSync } from '@server/lib/collections/sources/comingsoon';
import { ImdbCollectionSync } from '@server/lib/collections/sources/imdb';
import { LetterboxdCollectionSync } from '@server/lib/collections/sources/letterboxd';
import { MDBListCollectionSync } from '@server/lib/collections/sources/mdblist';
import { MyAnimeListCollectionSync } from '@server/lib/collections/sources/myanimelist';
import { NetworksCollectionSync } from '@server/lib/collections/sources/networks';
import { OriginalsCollectionSync } from '@server/lib/collections/sources/originals';
import { OverseerrCollectionSync } from '@server/lib/collections/sources/overseerrSync';
import RadarrTagCollectionSync from '@server/lib/collections/sources/radarr';
import SonarrTagCollectionSync from '@server/lib/collections/sources/sonarr';
import { TautulliCollectionSync } from '@server/lib/collections/sources/tautulli';
import { TmdbCollectionSync } from '@server/lib/collections/sources/tmdb';
import { TraktCollectionSync } from '@server/lib/collections/sources/trakt';
import { TimeRestrictionUtils } from '@server/lib/collections/utils/TimeRestrictionUtils';
import type { CollectionItemWithPoster } from '@server/lib/posterGeneration';
import { generatePoster } from '@server/lib/posterStorage';
@@ -384,70 +384,70 @@ export class MultiSourceOrchestrator {
}
);
// Clean up placeholders for multi-source collection
// This handles: released items (real content arrived), orphaned items (no longer in any source), stale items (7+ days old)
if (config.createPlaceholdersForMissing) {
try {
// Collect all tmdbIds from ALL sources (both items that exist in Plex and missing items)
const allSourceTmdbIds = new Set<number>();
// Handle placeholder cleanup for multi-source collection
// If createPlaceholdersForMissing enabled: cleans up released/orphaned/stale items
// If createPlaceholdersForMissing disabled: deletes all placeholder records
try {
// Collect all tmdbIds from ALL sources (both items that exist in Plex and missing items)
const allSourceTmdbIds = new Set<number>();
// Add tmdbIds from items that exist in Plex
for (const item of combinedItems) {
if (item.tmdbId !== undefined) {
allSourceTmdbIds.add(item.tmdbId);
}
// Add tmdbIds from items that exist in Plex
for (const item of combinedItems) {
if (item.tmdbId !== undefined) {
allSourceTmdbIds.add(item.tmdbId);
}
// Add tmdbIds from missing items across all sources
for (const missingGroup of missingItemGroups) {
for (const missingItem of missingGroup) {
allSourceTmdbIds.add(missingItem.tmdbId);
}
}
logger.info(
`Running placeholder cleanup for multi-source collection: ${collectionNameForSync}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
sourceTmdbIdCount: allSourceTmdbIds.size,
}
);
// Import cleanup function
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
// Run cleanup with parent config and combined source IDs
await cleanupPlaceholdersForConfig(
configForSync as unknown as CollectionConfig,
plexClient,
libraryCache,
allSourceTmdbIds
);
logger.debug(
'Placeholder cleanup completed for multi-source collection',
{
label: 'Multi-Source Orchestrator',
configId: config.id,
}
);
} catch (cleanupError) {
logger.error(
`Failed to cleanup placeholders for multi-source collection: ${collectionNameForSync}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
error:
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError),
}
);
// Don't throw - cleanup failure shouldn't break collection sync
}
// Add tmdbIds from missing items across all sources
for (const missingGroup of missingItemGroups) {
for (const missingItem of missingGroup) {
allSourceTmdbIds.add(missingItem.tmdbId);
}
}
logger.debug(
`Running placeholder handling for multi-source collection: ${collectionNameForSync}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
sourceTmdbIdCount: allSourceTmdbIds.size,
createPlaceholdersForMissing: config.createPlaceholdersForMissing,
}
);
// Import helper function that handles both enabled/disabled cases
const { handlePlaceholderCleanup } = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
// Run cleanup or deletion based on setting
await handlePlaceholderCleanup(
configForSync as unknown as CollectionConfig,
plexClient,
libraryCache,
allSourceTmdbIds
);
logger.debug(
'Placeholder handling completed for multi-source collection',
{
label: 'Multi-Source Orchestrator',
configId: config.id,
}
);
} catch (cleanupError) {
logger.error(
`Failed to handle placeholders for multi-source collection: ${collectionNameForSync}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
error:
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError),
}
);
// Don't throw - cleanup failure shouldn't break collection sync
}
// Create/update collection directly in Plex
@@ -618,10 +618,10 @@ export class MultiSourceOrchestrator {
);
try {
// Use PlaceholderService for unified placeholder creation
// Use PlaceholderCreation service for unified placeholder creation
// This works for any source type, not just Coming Soon
const { processPlaceholdersForMissingItems } = await import(
'@server/lib/collections/services/PlaceholderService'
'@server/lib/placeholders/services/PlaceholderCreation'
);
const newPlaceholderItems = await processPlaceholdersForMissingItems(
@@ -151,22 +151,19 @@ export function generateServiceUserConfig(
*/
export class ServiceUserManager {
private userRepository = getRepository(User);
private overseerrAPI: OverseerrAPI | null = null;
/**
* Get or initialize Overseerr API client
* Get Overseerr API client with current settings
*/
private getOverseerrAPI(): OverseerrAPI {
if (!this.overseerrAPI) {
const settings = getSettings();
if (!settings.overseerr.hostname || !settings.overseerr.apiKey) {
throw new Error(
'External Overseerr not configured for service user management'
);
}
this.overseerrAPI = new OverseerrAPI(settings.overseerr);
const settings = getSettings();
if (!settings.overseerr.hostname || !settings.overseerr.apiKey) {
throw new Error(
'External Overseerr not configured for service user management'
);
}
return this.overseerrAPI;
// Create fresh client with current settings
return new OverseerrAPI(settings.overseerr);
}
/**
@@ -1,6 +1,10 @@
import type { OverseerrMediaRequest } from '@server/api/overseerr';
import type { TmdbMovieDetails } from '@server/api/themoviedb/interfaces';
import type { LibraryItemsCache } from '@server/lib/collections/core/CollectionUtilities';
import type {
DiscoveredMoviePlaceholder,
DiscoveredPlaceholder,
} from '@server/lib/placeholders/services/PlaceholderDiscovery';
interface CacheEntry<T> {
data: T;
@@ -18,6 +22,8 @@ export class SyncCacheService {
private libraryItemsCache: LibraryItemsCache = {};
private tmdbFranchiseCache: Map<number, CacheEntry<TmdbMovieDetails>> =
new Map();
private placeholderDiscoveryCacheTv: DiscoveredPlaceholder[] = [];
private placeholderDiscoveryCacheMovies: DiscoveredMoviePlaceholder[] = [];
private isInitialized = false;
public static getInstance(): SyncCacheService {
@@ -39,6 +45,31 @@ export class SyncCacheService {
this.isInitialized = true;
}
/**
* Initialize placeholder discovery cache
*/
public setPlaceholderDiscoveryCache(
tv: DiscoveredPlaceholder[],
movies: DiscoveredMoviePlaceholder[]
): void {
this.placeholderDiscoveryCacheTv = tv;
this.placeholderDiscoveryCacheMovies = movies;
}
/**
* Get cached TV placeholder discoveries
*/
public getPlaceholderDiscoveryCacheTv(): DiscoveredPlaceholder[] {
return this.placeholderDiscoveryCacheTv;
}
/**
* Get cached movie placeholder discoveries
*/
public getPlaceholderDiscoveryCacheMovies(): DiscoveredMoviePlaceholder[] {
return this.placeholderDiscoveryCacheMovies;
}
/**
* Clear all cached data
*/
@@ -46,6 +77,8 @@ export class SyncCacheService {
this.overseerrRequestsCache = [];
this.libraryItemsCache = {};
this.tmdbFranchiseCache.clear();
this.placeholderDiscoveryCacheTv = [];
this.placeholderDiscoveryCacheMovies = [];
this.isInitialized = false;
}
@@ -109,46 +109,44 @@ export class AnilistCollectionSync extends BaseCollectionSync<'anilist'> {
config
);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? async () => {
try {
await processMissingItemsWithMode(
missingItems,
config,
'anilist'
);
} catch (e) {
logger.debug('Failed to process missing items for AniList', {
label: 'AniList Collections',
error: String(e),
});
}
}
: undefined
);
// Handle auto-requests for missing items using the unified download service
if (missingItems && missingItems.length > 0) {
try {
await processMissingItemsWithMode(missingItems, config, 'anilist');
} catch (e) {
logger.debug('Failed to process missing items for AniList', {
label: 'AniList Collections',
error: String(e),
});
}
// Add placeholder items to the collection
let finalItems = items;
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
// If no items were mapped, log a warning and return early
if (!items || items.length === 0) {
if (finalItems.length === 0) {
return { created: 0, updated: 0 };
}
// Process collection using media type strategy
const result = await this.processWithMediaTypeStrategy(
items,
finalItems,
config,
plexClient,
allCollections,
@@ -17,7 +17,6 @@ import type {
SyncResult,
} from '@server/lib/collections/core/types';
import { CollectionSyncErrorType } from '@server/lib/collections/core/types';
import { cleanupPlaceholdersForConfig } from '@server/lib/collections/services/PlaceholderService';
import type { CollectionConfig } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -149,27 +148,18 @@ export class ComingSoonCollectionSync extends BaseCollectionSync<'comingsoon'> {
const { items, missingItems, mappingStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
const sourceTmdbIds = new Set(sourceData.map((item) => item.tmdbId));
await cleanupPlaceholdersForConfig(
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
sourceTmdbIds
undefined // No auto-requests for Coming Soon collections
);
// Handle placeholder creation for missing items using unified flow
// This respects the createPlaceholdersForMissing checkbox (which should be force-enabled for Coming Soon)
if (missingItems && missingItems.length > 0) {
const newlyCreatedItems = await this.processMissingItems(
missingItems,
config,
plexClient
);
// Add newly created placeholder items to the collection
items.push(...newlyCreatedItems);
}
// Add newly created placeholder items to the collection
items.push(...placeholderItems);
// Note: We don't show "released" items anymore
// When real content is detected, placeholder is deleted immediately
@@ -753,7 +743,7 @@ export class ComingSoonCollectionSync extends BaseCollectionSync<'comingsoon'> {
// Attempt to download a real trailer
const { downloadTrailer } = await import(
'@server/lib/comingsoon/trailerDownload'
'@server/lib/placeholders/trailerDownload'
);
const trailerPath = await downloadTrailer(
sourceItem.title,
@@ -67,7 +67,8 @@ export async function fetchMonitoredMovies(
}
const { getFutureDateFromToday } = await import('@server/utils/dateHelpers');
const maxDaysAway = config.comingSoonDays || 360;
const maxDaysAway =
config.placeholderDaysAhead || config.comingSoonDays || 360;
const maxDate = getFutureDateFromToday(maxDaysAway);
for (const radarrInstance of settings.radarr) {
@@ -237,7 +238,8 @@ export async function fetchMonitoredShows(
return items;
}
const maxDaysAway = config.comingSoonDays || 360;
const maxDaysAway =
config.placeholderDaysAhead || config.comingSoonDays || 360;
for (const sonarrInstance of settings.sonarr) {
try {
@@ -664,7 +666,8 @@ export async function fetchTmdbComingSoonMovies(
const tmdbClient = new TmdbAPI();
const perPage = 20; // TMDB returns 20 per page
const maxDaysAway = config.comingSoonDays || 360;
const maxDaysAway =
config.placeholderDaysAhead || config.comingSoonDays || 360;
// Calculate date range for upcoming releases
const { getToday, getFutureDateFromToday, extractReleaseDates } =
@@ -829,7 +832,8 @@ export async function fetchTmdbComingSoonShows(
const tmdbClient = new TmdbAPI();
const perPage = 20; // TMDB returns 20 per page
const maxDaysAway = config.comingSoonDays || 360;
const maxDaysAway =
config.placeholderDaysAhead || config.comingSoonDays || 360;
// Calculate date range for upcoming air dates
const { getToday, getFutureDateFromToday } = await import(
@@ -504,7 +504,6 @@ export class ImdbCollectionSync extends BaseCollectionSync<'imdb'> {
if (
titleTypeId === 'tvSeries' ||
titleTypeId === 'tvMiniSeries' ||
titleTypeId === 'tvMovie' ||
titleTypeId === 'tvShort' ||
titleTypeId === 'tvSpecial'
) {
@@ -513,6 +512,7 @@ export class ImdbCollectionSync extends BaseCollectionSync<'imdb'> {
type = 'tv';
// Handle episodes if needed
}
// Note: tvMovie is NOT included - these are movies in TMDB
}
items.push({
@@ -941,45 +941,22 @@ export class ImdbCollectionSync extends BaseCollectionSync<'imdb'> {
);
// Source data mapped to items
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
// Extract tmdbIds from items and missingItems for orphan detection
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
logger.debug('Processing missing items', {
label: 'IMDb Collections Debug',
configName: config.name,
missingItemsCount: missingItems.length,
});
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -560,26 +560,6 @@ export class LetterboxdCollectionSync extends BaseCollectionSync<'letterboxd'> {
config
);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
logger.debug('Source data mapped to items', {
label: 'Letterboxd Collections Debug',
configName: config.name,
@@ -587,23 +567,22 @@ export class LetterboxdCollectionSync extends BaseCollectionSync<'letterboxd'> {
missingItemsLength: missingItems?.length || 0,
});
// Process missing items - creates placeholders and/or sends to auto-requests
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
logger.debug('Processing missing items', {
label: 'Letterboxd Collections Debug',
configName: config.name,
missingItemsCount: missingItems.length,
});
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -96,39 +96,22 @@ export class MDBListCollectionSync extends BaseCollectionSync<'mdblist'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -106,51 +106,45 @@ export class MyAnimeListCollectionSync extends BaseCollectionSync<'myanimelist'>
config
);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? async () => {
try {
await processMissingItemsWithMode(
missingItems,
config,
'myanimelist'
);
} catch (e) {
logger.debug('Failed to process missing items for MAL', {
label: 'MyAnimeList Collections',
error: String(e),
});
}
}
: undefined
);
// Handle auto-requests for missing items
if (missingItems && missingItems.length > 0) {
try {
await processMissingItemsWithMode(
missingItems,
config,
'myanimelist'
);
} catch (e) {
logger.debug('Failed to process missing items for MAL', {
label: 'MyAnimeList Collections',
error: String(e),
});
}
// Add placeholder items to the collection
let finalItems = items;
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
// If no items were mapped, return early
if (!items || items.length === 0) {
if (finalItems.length === 0) {
return { created: 0, updated: 0 };
}
// Process collection using media type strategy
const result = await this.processWithMediaTypeStrategy(
items,
finalItems,
config,
plexClient,
allCollections,
@@ -89,55 +89,34 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
libraryCache
);
// Get the collection media type for dual search
const collectionMediaType = getCollectionMediaType(config);
// Map to standardized format
const mappedResult = await this.mapSourceDataToItems(
sourceData,
config,
plexClient,
libraryCache,
collectionMediaType
libraryCache
);
// Apply filtering safety net
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -281,13 +260,15 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
sourceData: NetworksSourceData[],
config: CollectionConfig,
plexClient?: PlexAPI,
libraryCache?: LibraryItemsCache,
mediaType?: 'movie' | 'tv' | 'both'
libraryCache?: LibraryItemsCache
): Promise<{
items: NetworksCollectionItem[];
missingItems?: MissingItem[];
stats?: FilteringStats;
}> {
// Get media type from config - consistent with all other sources
const mediaType = getCollectionMediaType(config);
const mappedItems: NetworksCollectionItem[] = [];
const missingItems: MissingItem[] = [];
@@ -337,7 +318,7 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
movieResults.results || [],
tvResults.results || [],
item.title,
mediaType || 'both' // Collection media type preference, default to 'both'
mediaType // Collection media type from library
);
if (bestMatch) {
@@ -345,7 +326,7 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
// When using "Overall" FlixPatrol lists in a specific library (movie or TV),
// only include items that match that library type.
// Example: "Shrek" (movie) in an overall list should be skipped in a TV library
if (mediaType !== 'both' && bestMatch.mediaType !== mediaType) {
if (bestMatch.mediaType !== mediaType) {
logger.debug(
`Skipping ${bestMatch.mediaType} "${item.title}" - doesn't match ${mediaType} library type`,
{
@@ -616,7 +597,7 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
id: number;
}[],
originalTitle: string,
collectionMediaType: 'movie' | 'tv' | 'both'
collectionMediaType: 'movie' | 'tv'
): {
result: {
title?: string;
@@ -668,7 +649,7 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
}
// Collection type preference bonus
if (collectionMediaType !== 'both' && mediaType === collectionMediaType) {
if (mediaType === collectionMediaType) {
score += 25; // Prefer matches that align with collection type
}
@@ -130,39 +130,22 @@ export class OriginalsCollectionSync extends BaseCollectionSync<'originals'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
File diff suppressed because it is too large Load Diff
@@ -89,39 +89,22 @@ export class RadarrTagCollectionSync extends BaseCollectionSync<'radarrtag'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -160,14 +160,29 @@ export class FilteredHubCollectionSync extends BaseCollectionSync<'filtered_hub'
);
// Validate subtype
const subtype = config.subtype as 'recently_added' | 'recently_released';
const subtype = config.subtype as
| 'recently_added'
| 'recently_released'
| 'recently_released_episodes';
if (
!subtype ||
!['recently_added', 'recently_released'].includes(subtype)
![
'recently_added',
'recently_released',
'recently_released_episodes',
].includes(subtype)
) {
throw this.createSyncError(
CollectionSyncErrorType.CONFIGURATION_ERROR,
`Invalid filtered_hub subtype: ${subtype}. Must be 'recently_added' or 'recently_released'`
`Invalid filtered_hub subtype: ${subtype}. Must be 'recently_added', 'recently_released', or 'recently_released_episodes'`
);
}
// Validate that recently_released_episodes is only used with TV libraries
if (subtype === 'recently_released_episodes' && mediaType !== 'tv') {
throw this.createSyncError(
CollectionSyncErrorType.CONFIGURATION_ERROR,
`The 'recently_released_episodes' subtype is only supported for TV libraries`
);
}
@@ -91,39 +91,22 @@ export class SonarrTagCollectionSync extends BaseCollectionSync<'sonarrtag'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -35,8 +35,6 @@ interface TautulliCollectionItem extends CollectionItem {
* to the original TautulliCollectionSync class.
*/
export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
private tautulliClient: TautulliAPI | null = null;
constructor() {
super('tautulli');
}
@@ -429,14 +427,13 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
// Private helper methods
/**
* Get Tautulli API client with current settings
*/
private async getTautulliClient(): Promise<TautulliAPI> {
if (this.tautulliClient) {
return this.tautulliClient;
}
const settings = getSettings();
this.tautulliClient = new TautulliAPI(settings.tautulli);
return this.tautulliClient;
// Create fresh client with current settings
return new TautulliAPI(settings.tautulli);
}
private isValidTautulliConfig(config: CollectionConfig): boolean {
@@ -688,28 +688,6 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
// Extract tmdbIds from items and missingItems for orphan detection
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Log processing stats if available
if (mappingStats || filteringStats) {
logger.debug('TMDB collection processing stats', {
@@ -720,18 +698,22 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
});
}
// Process missing items - creates placeholders and/or sends to auto-requests
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) return { created: 0, updated: 0 };
@@ -1091,8 +1073,17 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
): Promise<SyncResult> {
// Generate collection name using template engine
const context = this.templateEngine.createFranchiseContext(franchiseData);
// Handle custom template selection - franchise collections are movie-only
const template = (() => {
if (config.template === 'custom') {
return config.customMovieTemplate || config.name || '{franchiseName}';
}
return config.template || '{franchiseName}';
})();
const collectionName = await this.templateEngine.processTemplate(
config.template || '{franchiseName}',
template,
context
);
@@ -1134,6 +1125,69 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
}
);
// Identify missing movies from the franchise
const plexTmdbIds = new Set(plexItems.map((item) => item.tmdbId));
const missingItems: MissingItem[] = [];
for (let index = 0; index < franchiseData.movies.length; index++) {
const movie = franchiseData.movies[index];
if (!plexTmdbIds.has(movie.tmdbId)) {
// Extract year from release date (format: YYYY-MM-DD)
let year: number | undefined;
if (movie.releaseDate) {
year = parseInt(movie.releaseDate.substring(0, 4));
}
missingItems.push({
tmdbId: movie.tmdbId,
mediaType: 'movie',
title: movie.title,
year,
originalPosition: index + 1,
source: this.source,
});
}
}
if (missingItems.length > 0) {
logger.info(
`Franchise ${collectionName}: ${missingItems.length} missing movies identified`,
{
label: 'TMDB Franchise',
foundInPlex: plexItems.length,
missingCount: missingItems.length,
totalFranchiseMovies: franchiseData.movies.length,
}
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
plexItems,
missingItems,
config,
plexClient,
libraryCache,
missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Combine Plex items with any placeholder items
let finalItems = plexItems;
if (placeholderItems.length > 0) {
finalItems = [...plexItems, ...placeholderItems];
logger.debug(
`Added ${placeholderItems.length} placeholder items to franchise ${collectionName}`,
{
label: 'TMDB Franchise',
plexItems: plexItems.length,
placeholderItems: placeholderItems.length,
total: finalItems.length,
}
);
}
// Check if we should skip auto-poster generation
// Only skip if useTmdbFranchisePoster is enabled AND the poster is actually available
const shouldSkipAutoPoster =
@@ -1147,7 +1201,7 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
// Label-based tracking is the primary method (like Overseerr),
// with name as fallback for user-created collections
const result = await this.createOrUpdateCollectionStandardized(
plexItems,
finalItems,
collectionName,
'movie',
configForProcessing,
@@ -1242,7 +1296,7 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
config,
collectionRatingKey,
plexClient,
plexItems,
finalItems,
{ customLabel }
);
} catch (error) {
@@ -100,41 +100,22 @@ export class TraktCollectionSync extends BaseCollectionSync<'trakt'> {
const { items, missingItems, mappingStats, filteringStats } =
await this.applyFilteringToMappedItems(mappedResult, config);
// Clean up placeholders (released items, orphaned items, stale items)
if (config.createPlaceholdersForMissing) {
const { cleanupPlaceholdersForConfig } = await import(
'@server/lib/collections/services/PlaceholderService'
);
// Extract tmdbIds from items and missingItems for orphan detection
const sourceTmdbIds = new Set([
...items
.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number'),
...(missingItems
?.map((item) => item.tmdbId)
.filter((id): id is number => typeof id === 'number') || []),
]);
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
}
// Handle placeholder cleanup and process missing items
const placeholderItems = await this.handlePlaceholdersAndMissingItems(
items,
missingItems,
config,
plexClient,
libraryCache,
missingItems && missingItems.length > 0
? () => this.handleAutoRequests(missingItems, config)
: undefined
);
// Process missing items - creates placeholders and/or sends to auto-requests
// Add placeholder items to the collection
let finalItems = items;
if (missingItems && missingItems.length > 0) {
const placeholderItems = await this.processMissingItems(
missingItems,
config,
plexClient,
() => this.handleAutoRequests(missingItems, config)
);
// Add placeholder items to the collection
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (placeholderItems.length > 0) {
finalItems = [...items, ...placeholderItems];
}
if (finalItems.length === 0) {
@@ -1583,7 +1583,7 @@ https://letterboxd.com/cinema/list/criterion-collection/
// Parse the HTML to extract IMDb items using the proper parser
const ImdbCollections = await import(
'@server/lib/collections/external/imdb'
'@server/lib/collections/sources/imdb'
);
const imdbCollections = new ImdbCollections.default();
const imdbItems = imdbCollections.parseImdbListHtml(
@@ -1624,7 +1624,7 @@ https://letterboxd.com/cinema/list/criterion-collection/
// For IMDb validation, we need to resolve TMDB IDs like the actual sync process does
const ImdbCollectionsModule = await import(
'@server/lib/collections/external/imdb'
'@server/lib/collections/sources/imdb'
);
const imdbValidator = new ImdbCollectionsModule.default();
@@ -1694,7 +1694,7 @@ https://letterboxd.com/cinema/list/criterion-collection/
// Parse the HTML to extract Letterboxd items using the proper parser
const LetterboxdCollections = await import(
'@server/lib/collections/external/letterboxd'
'@server/lib/collections/sources/letterboxd'
);
const letterboxdCollections =
new LetterboxdCollections.LetterboxdCollectionSync();
+21 -2
View File
@@ -30,6 +30,9 @@ export interface TemplateContext {
franchiseName?: string; // TMDB collection/franchise name
franchiseId?: number; // TMDB collection ID
movieCount?: number; // Number of movies in franchise
// Person-specific placeholders (actor/director collections)
actor?: string; // Actor name for actor collections
director?: string; // Director name for director collections
}
/**
@@ -158,6 +161,15 @@ export class TemplateEngine {
processed = processed.replace(/{franchiseName}/g, context.franchiseName);
}
// Person-specific placeholders (actor/director collections)
if (context.actor !== undefined) {
processed = processed.replace(/{actor}/g, context.actor);
}
if (context.director !== undefined) {
processed = processed.replace(/{director}/g, context.director);
}
// Debug logging to see the final result
logger.debug('Template processing result', {
label: 'Template Engine',
@@ -228,7 +240,7 @@ export class TemplateEngine {
try {
// Import the service to avoid circular dependencies
const { overseerrCollectionService } = await import(
'@server/lib/collections/external/overseerr'
'@server/lib/collections/sources/overseerr'
);
const overseerrSettings =
await overseerrCollectionService.getOverseerrSettings();
@@ -352,9 +364,16 @@ export class TemplateEngine {
public createFranchiseContext(
franchiseData: TmdbFranchiseSourceData
): TemplateContext {
// Strip " Collection" suffix from franchise name for cleaner templates
// e.g., "Moana Collection" -> "Moana"
const cleanFranchiseName = franchiseData.franchiseName.replace(
/ Collection$/i,
''
);
return {
...this.getDefaultContext(),
franchiseName: franchiseData.franchiseName,
franchiseName: cleanFranchiseName,
franchiseId: franchiseData.franchiseId,
movieCount: franchiseData.movies.length,
mediaType: 'movie' as const,
+2 -2
View File
@@ -286,10 +286,10 @@ class CollectionsQuickSync {
): Promise<{ deletedCount: number; affectedLibraries: Set<string> }> {
const placeholderRepository = getRepository(ComingSoonItem);
const { placeholderContextService } = await import(
'@server/lib/collections/services/PlaceholderContextService'
'@server/lib/placeholders/services/PlaceholderContextService'
);
const { removePlaceholder } = await import(
'@server/lib/comingsoon/placeholderManager'
'@server/lib/placeholders/placeholderManager'
);
let deletedCount = 0;
+95 -1
View File
@@ -118,7 +118,7 @@ class CollectionsSync {
if (settings.overseerr?.hostname && settings.overseerr?.apiKey) {
try {
const { overseerrCollectionService } = await import(
'@server/lib/collections/external/overseerr'
'@server/lib/collections/sources/overseerr'
);
const overseerrSettings =
await overseerrCollectionService.getOverseerrSettings();
@@ -195,6 +195,24 @@ class CollectionsSync {
}
}
// Wait for Overlay Application to complete if running
const overlayApplication = (await import('@server/lib/overlayApplication'))
.default;
if (overlayApplication.status.running) {
logger.info(
'Overlay Application is currently running, waiting for completion...',
{
label: 'Collections Sync',
}
);
while (overlayApplication.status.running) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
logger.info('Overlay Application completed, starting Collections Sync', {
label: 'Collections Sync',
});
}
// Wait for any running individual collection syncs to complete
const { IndividualCollectionScheduler } = await import(
'@server/lib/collections/services/IndividualCollectionScheduler'
@@ -377,6 +395,82 @@ class CollectionsSync {
);
}
// Clean up orphaned placeholder records and files
this.setStage('Cleaning up orphaned placeholders...');
let filesWereRemoved = false;
try {
const {
cleanupOrphanedPlaceholderRecords,
cleanupOrphanedPlaceholderFiles,
} = await import(
'@server/lib/placeholders/services/PlaceholderCleanup'
);
// Step 1: Remove orphaned DB records (where collection no longer exists)
await cleanupOrphanedPlaceholderRecords();
// Step 2: Remove orphaned files (where no DB records reference them)
const filesRemoved = await cleanupOrphanedPlaceholderFiles();
filesWereRemoved = filesRemoved > 0;
logger.info('Orphaned placeholder cleanup completed', {
label: 'Collections Sync',
});
} catch (error) {
logger.warn('Orphaned placeholder cleanup failed - continuing', {
label: 'Collections Sync',
error: error instanceof Error ? error.message : String(error),
});
}
// Trigger Plex scan if we deleted placeholder files
if (filesWereRemoved) {
this.setStage('Scanning Plex libraries for removed placeholders...');
try {
const settings = getSettings();
const libraries = await plexClient.getLibraries();
// Scan movie libraries if configured
if (settings.main.placeholderMovieRootFolder) {
const movieLibraries = libraries.filter(
(lib) => lib.type === 'movie'
);
for (const movieLib of movieLibraries) {
await plexClient.scanLibrary(movieLib.key);
logger.info(
'Triggered scan for movie library after placeholder cleanup',
{
label: 'Collections Sync',
libraryKey: movieLib.key,
libraryTitle: movieLib.title,
}
);
}
}
// Scan TV libraries if configured
if (settings.main.placeholderTVRootFolder) {
const tvLibraries = libraries.filter((lib) => lib.type === 'show');
for (const tvLib of tvLibraries) {
await plexClient.scanLibrary(tvLib.key);
logger.info(
'Triggered scan for TV library after placeholder cleanup',
{
label: 'Collections Sync',
libraryKey: tvLib.key,
libraryTitle: tvLib.title,
}
);
}
}
} catch (error) {
logger.warn('Failed to trigger Plex scan after placeholder cleanup', {
label: 'Collections Sync',
error: error instanceof Error ? error.message : String(error),
});
}
}
const duration = Date.now() - startTime;
// Run discovery to refresh missing warnings
-253
View File
@@ -1,253 +0,0 @@
import logger from '@server/logger';
import fs from 'fs/promises';
import path from 'path';
import type { PlaceholderOptions, PlaceholderResult } from './types';
/**
* Sanitize filename to remove invalid characters
*/
function sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Create placeholder file for movie
*/
async function createMoviePlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { title, year, tmdbId, libraryPath, trailerPath } = options;
// Folder format: MovieName (Year)
const sanitizedTitle = sanitizeFilename(title);
const yearStr = year ? ` (${year})` : '';
const folderName = `${sanitizedTitle}${yearStr}`;
const movieFolder = path.join(libraryPath, folderName);
// Filename format: MovieName (Year) {tmdb-12345} {edition-Trailer}.mp4
const filename = `${folderName} {tmdb-${tmdbId}} {edition-Trailer}.mp4`;
const destinationPath = path.join(movieFolder, filename);
logger.debug('Creating movie placeholder', {
label: 'Coming Soon Placeholder',
title,
filename,
movieFolder,
destinationPath,
});
// Create movie folder
await fs.mkdir(movieFolder, { recursive: true });
// Copy trailer to movie folder
await fs.copyFile(trailerPath, destinationPath);
// Clean up temporary trailer file
try {
await fs.unlink(trailerPath);
logger.debug('Cleaned up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
});
} catch (error) {
logger.warn('Failed to clean up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
error: error instanceof Error ? error.message : String(error),
});
}
logger.info('Created movie placeholder', {
label: 'Coming Soon Placeholder',
title,
filename,
});
return {
placeholderPath: destinationPath,
filename,
};
}
/**
* Create placeholder file for TV show
*/
async function createTVPlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { title, year, libraryPath, trailerPath } = options;
// Directory format: ShowName (Year)/Season 00/S00E00.Trailer.mp4
const sanitizedTitle = sanitizeFilename(title);
const yearStr = year ? ` (${year})` : '';
const showDir = path.join(libraryPath, `${sanitizedTitle}${yearStr}`);
const seasonDir = path.join(showDir, 'Season 00');
logger.debug('Creating TV show placeholder', {
label: 'Coming Soon Placeholder',
title,
showDir,
seasonDir,
});
// Create directories
await fs.mkdir(seasonDir, { recursive: true });
// Create trailer file
const filename = 'S00E00.Trailer.mp4';
const destinationPath = path.join(seasonDir, filename);
await fs.copyFile(trailerPath, destinationPath);
// Clean up temporary trailer file
try {
await fs.unlink(trailerPath);
logger.debug('Cleaned up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
});
} catch (error) {
logger.warn('Failed to clean up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
error: error instanceof Error ? error.message : String(error),
});
}
// Create .comingsoon marker file for identification
const markerPath = path.join(seasonDir, '.comingsoon');
await fs.writeFile(
markerPath,
JSON.stringify({
createdAt: new Date().toISOString(),
title,
year,
}),
'utf-8'
);
logger.info('Created TV show placeholder', {
label: 'Coming Soon Placeholder',
title,
filename: destinationPath,
});
return {
placeholderPath: destinationPath,
filename,
};
}
/**
* Create placeholder file in Plex library
*/
export async function createPlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { mediaType } = options;
try {
if (mediaType === 'movie') {
return await createMoviePlaceholder(options);
} else {
return await createTVPlaceholder(options);
}
} catch (error) {
logger.error('Failed to create placeholder', {
label: 'Coming Soon Placeholder',
error: error instanceof Error ? error.message : String(error),
title: options.title,
mediaType: options.mediaType,
});
throw error;
}
}
/**
* Remove placeholder file
*/
export async function removePlaceholder(
placeholderPath: string,
mediaType: 'movie' | 'tv'
): Promise<void> {
try {
// Safety check: Verify path contains placeholder marker (supports both old and new format)
if (
!placeholderPath.includes('{edition-Trailer}') &&
!placeholderPath.includes('{edition-Placeholder}') &&
!placeholderPath.includes('{edition-Coming Soon}') &&
!placeholderPath.includes('S00E00.Trailer.mp4')
) {
logger.warn(
'Refusing to delete - path does not appear to be a placeholder',
{
label: 'Coming Soon Placeholder',
path: placeholderPath,
mediaType,
}
);
throw new Error('Invalid placeholder path - missing placeholder markers');
}
logger.debug('Removing placeholder', {
label: 'Coming Soon Placeholder',
path: placeholderPath,
mediaType,
});
// Delete the file
await fs.unlink(placeholderPath);
// For TV shows, also clean up parent directories if empty
if (mediaType === 'tv') {
const seasonDir = path.dirname(placeholderPath);
const showDir = path.dirname(seasonDir);
// Remove .comingsoon marker if it exists
const markerPath = path.join(seasonDir, '.comingsoon');
try {
await fs.unlink(markerPath);
} catch {
// Marker file might not exist, ignore
}
// Try to remove Season 00 directory if it's empty
try {
const files = await fs.readdir(seasonDir);
if (files.length === 0) {
await fs.rmdir(seasonDir);
logger.debug('Removed empty season directory', {
label: 'Coming Soon Placeholder',
path: seasonDir,
});
// Try to remove show directory if it's empty
const showFiles = await fs.readdir(showDir);
if (showFiles.length === 0) {
await fs.rmdir(showDir);
logger.debug('Removed empty show directory', {
label: 'Coming Soon Placeholder',
path: showDir,
});
}
}
} catch {
// Directory not empty or other error, ignore
}
}
logger.info('Removed placeholder successfully', {
label: 'Coming Soon Placeholder',
path: placeholderPath,
});
} catch (error) {
logger.error('Failed to remove placeholder', {
label: 'Coming Soon Placeholder',
error: error instanceof Error ? error.message : String(error),
path: placeholderPath,
});
throw error;
}
}
+24 -3
View File
@@ -324,14 +324,35 @@ async function getSystemIcons(): Promise<IconMetadata[]> {
}
const files = await fs.promises.readdir(SERVICES_ICONS_DIR);
const svgFiles = files.filter((file) => file.endsWith('.svg'));
const imageFiles = files.filter((file) => {
const lower = file.toLowerCase();
return (
lower.endsWith('.svg') ||
lower.endsWith('.png') ||
lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.webp') ||
lower.endsWith('.gif')
);
});
const systemIcons: IconMetadata[] = [];
for (const file of svgFiles) {
for (const file of imageFiles) {
try {
const filePath = path.join(SERVICES_ICONS_DIR, file);
const stats = await fs.promises.stat(filePath);
const iconName = path.parse(file).name;
const ext = path.extname(file).toLowerCase();
// Determine MIME type based on extension
const mimeTypeMap: Record<string, string> = {
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
};
const iconMetadata: IconMetadata = {
id: `system-${iconName}`,
@@ -340,7 +361,7 @@ async function getSystemIcons(): Promise<IconMetadata[]> {
type: 'system',
category: 'services',
tags: ['service', iconName],
mimeType: 'image/svg+xml',
mimeType: mimeTypeMap[ext] || 'image/svg+xml',
size: stats.size,
uploadedAt: new Date(stats.mtime).toISOString(),
description: `${iconName} service logo`,
@@ -1,360 +0,0 @@
import { getRepository } from '@server/datasource';
import {
PosterTemplate,
type ContentGridProps,
type LayeredElement,
type PosterTemplateData,
type RasterElementProps,
type SVGElementProps,
type TextElementProps,
} from '@server/entity/PosterTemplate';
import logger from '@server/logger';
/**
* Legacy template data structure for migration purposes
*/
interface LegacyTemplateData {
width: number;
height: number;
background: {
type: 'color' | 'gradient';
color?: string;
secondaryColor?: string;
useSourceColors?: boolean;
};
// New unified system (may exist if partially migrated)
elements?: LayeredElement[];
migrated?: boolean;
// Legacy element arrays we're migrating FROM
textElements?: {
id: string;
type: 'collection-title' | 'custom-text';
text?: string;
x: number;
y: number;
width: number;
height: number;
fontSize: number;
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
color: string;
textAlign: 'left' | 'center' | 'right';
maxLines?: number;
}[];
iconElements?: {
id: string;
type: 'source-logo' | 'custom-icon';
iconPath?: string;
x: number;
y: number;
width: number;
height: number;
grayscale: boolean;
}[];
rasterElements?: {
id: string;
type: 'raster-image';
imagePath: string;
x: number;
y: number;
width: number;
height: number;
}[];
svgElements?: {
id: string;
type: 'source-logo' | 'svg-icon';
iconPath?: string;
x: number;
y: number;
width: number;
height: number;
grayscale: boolean;
}[];
contentGrid?: {
id: string;
x: number;
y: number;
width: number;
height: number;
columns: number;
rows: number;
spacing: number;
cornerRadius: number;
};
}
/**
* Default layer order assignments for migrating elements to unified system
*/
const DEFAULT_LAYER_ORDERS = {
RASTER: 10,
CONTENT_GRID: 20,
SVG: 30,
TEXT: 40,
};
const LAYER_ORDER_SPACING = 1;
/**
* Migrates all poster templates to unified layering system for v1.3.2
* Converts legacy template data structure to new unified format
*/
export async function runPosterTemplateMigration(): Promise<void> {
try {
const templateRepository = getRepository(PosterTemplate);
const allTemplates = await templateRepository.find();
if (allTemplates.length === 0) {
logger.info('No poster templates found, skipping migration');
return;
}
let migratedCount = 0;
let alreadyMigratedCount = 0;
let errorCount = 0;
for (const template of allTemplates) {
try {
// Get raw template data without validation (migration might not be complete yet)
const templateData = JSON.parse(
template.templateData
) as LegacyTemplateData;
// Skip if already migrated
if (templateData.migrated && templateData.elements) {
alreadyMigratedCount++;
continue;
}
// Migrate to unified format
const migratedData = migrateLegacyTemplateData(templateData);
// Save migrated template (directly set templateData to avoid validation)
template.templateData = JSON.stringify(migratedData);
await templateRepository.save(template);
migratedCount++;
logger.debug(`Migrated poster template: ${template.name}`, {
templateId: template.id,
elementsCount: migratedData.elements?.length || 0,
});
} catch (error) {
errorCount++;
logger.error(`Failed to migrate poster template: ${template.name}`, {
templateId: template.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
logger.info('Poster template migration completed', {
label: 'Poster Template Migration v1.3.2',
totalTemplates: allTemplates.length,
migrated: migratedCount,
alreadyMigrated: alreadyMigratedCount,
errors: errorCount,
});
if (errorCount > 0) {
logger.warn(
`${errorCount} templates failed to migrate - check logs for details`
);
}
} catch (error) {
logger.error('Poster template migration failed:', error);
throw error;
}
}
/**
* Converts legacy template data to unified layered format
*/
export function migrateLegacyTemplateData(
templateData: LegacyTemplateData
): PosterTemplateData {
const elements: LayeredElement[] = [];
let currentRasterOrder = DEFAULT_LAYER_ORDERS.RASTER;
let currentSVGOrder = DEFAULT_LAYER_ORDERS.SVG;
let currentTextOrder = DEFAULT_LAYER_ORDERS.TEXT;
// Migrate raster elements
if (templateData.rasterElements) {
templateData.rasterElements.forEach((rasterElement) => {
const element: LayeredElement = {
id: rasterElement.id,
layerOrder: currentRasterOrder,
type: 'raster',
x: rasterElement.x,
y: rasterElement.y,
width: rasterElement.width,
height: rasterElement.height,
properties: {
imagePath: rasterElement.imagePath,
} as RasterElementProps,
};
elements.push(element);
currentRasterOrder += LAYER_ORDER_SPACING;
});
}
// Migrate legacy iconElements (detect raster vs SVG by file extension)
if (templateData.iconElements) {
const rasterExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
templateData.iconElements.forEach((iconElement) => {
if (iconElement.iconPath) {
const isRaster = rasterExtensions.some((ext) =>
iconElement.iconPath?.toLowerCase().endsWith(ext)
);
if (isRaster) {
// Migrate as raster element
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentRasterOrder,
type: 'raster',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
imagePath: iconElement.iconPath,
} as RasterElementProps,
};
elements.push(element);
currentRasterOrder += LAYER_ORDER_SPACING;
} else {
// Migrate as SVG element
const iconType =
iconElement.type === 'custom-icon' ? 'svg-icon' : iconElement.type;
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
iconType,
iconPath: iconElement.iconPath,
grayscale: iconElement.grayscale || false,
} as SVGElementProps,
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
}
} else {
// Icon without path - treat as SVG placeholder
const iconType =
iconElement.type === 'custom-icon' ? 'svg-icon' : iconElement.type;
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
iconType,
iconPath: iconElement.iconPath,
grayscale: iconElement.grayscale || false,
} as SVGElementProps,
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
}
});
}
// Migrate svgElements
if (templateData.svgElements) {
templateData.svgElements.forEach((svgElement) => {
const element: LayeredElement = {
id: svgElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: svgElement.x,
y: svgElement.y,
width: svgElement.width,
height: svgElement.height,
properties: {
iconType: svgElement.type,
iconPath: svgElement.iconPath,
grayscale: svgElement.grayscale,
} as SVGElementProps,
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
});
}
// Migrate content grid (if exists)
if (templateData.contentGrid) {
const element: LayeredElement = {
id: templateData.contentGrid.id,
layerOrder: DEFAULT_LAYER_ORDERS.CONTENT_GRID,
type: 'content-grid',
x: templateData.contentGrid.x,
y: templateData.contentGrid.y,
width: templateData.contentGrid.width,
height: templateData.contentGrid.height,
properties: {
columns: templateData.contentGrid.columns,
rows: templateData.contentGrid.rows,
spacing: templateData.contentGrid.spacing,
cornerRadius: templateData.contentGrid.cornerRadius,
} as ContentGridProps,
};
elements.push(element);
}
// Migrate text elements
if (templateData.textElements) {
templateData.textElements.forEach((textElement) => {
const element: LayeredElement = {
id: textElement.id,
layerOrder: currentTextOrder,
type: 'text',
x: textElement.x,
y: textElement.y,
width: textElement.width,
height: textElement.height,
properties: {
elementType: textElement.type,
text: textElement.text,
fontSize: textElement.fontSize,
fontFamily: textElement.fontFamily,
fontWeight: textElement.fontWeight,
fontStyle: textElement.fontStyle,
color: textElement.color,
textAlign: textElement.textAlign,
maxLines: textElement.maxLines,
} as TextElementProps,
};
elements.push(element);
currentTextOrder += LAYER_ORDER_SPACING;
});
}
// Sort elements by layer order
elements.sort((a, b) => a.layerOrder - b.layerOrder);
// Return clean migrated data (remove legacy fields)
return {
width: templateData.width,
height: templateData.height,
background: templateData.background,
elements,
migrated: true,
};
}
+40 -1
View File
@@ -1,7 +1,6 @@
import { getRepository } from '@server/datasource';
import { OverlayLibraryConfig } from '@server/entity/OverlayLibraryConfig';
import logger from '@server/logger';
import { overlayLibraryService } from './overlays/OverlayLibraryService';
/**
* Job for applying overlay templates to configured Plex libraries
@@ -89,6 +88,46 @@ class OverlayApplication {
}
}
// Wait for Collections Sync to complete if running
const collectionsSync = (await import('@server/lib/collectionsSync'))
.default;
if (collectionsSync.status.running) {
logger.info(
'Collections Sync is currently running, waiting for completion...',
{
label: 'Overlay Application',
}
);
while (collectionsSync.status.running) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
logger.info('Collections Sync completed, starting Overlay Application', {
label: 'Overlay Application',
});
}
// Wait for any per-library overlay syncs to complete
const { overlayLibraryService } = await import(
'@server/lib/overlays/OverlayLibraryService'
);
let runningLibraries = overlayLibraryService.getAllRunningLibraries();
if (runningLibraries.length > 0) {
logger.info(
'Per-library overlay syncs are currently running, waiting for completion...',
{
label: 'Overlay Application',
runningLibraries: runningLibraries.map((l) => l.libraryName),
}
);
while (runningLibraries.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 1000));
runningLibraries = overlayLibraryService.getAllRunningLibraries();
}
logger.info('Per-library overlay syncs completed, starting full sync', {
label: 'Overlay Application',
});
}
this.running = true;
this.cancelled = false;
this.currentStage = '';
@@ -0,0 +1,795 @@
import ImdbAPI from '@server/api/imdb';
import ImdbRatingsAPI from '@server/api/imdbRatings';
import type { MaintainerrCollection } from '@server/api/maintainerr';
import type { PlexLibraryItem } from '@server/api/plexapi';
import RottenTomatoes from '@server/api/rottentomatoes';
import type { RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { OverlayRenderContext } from './OverlayTemplateRenderer';
/**
* Shared IMDb client for reuse across overlay operations
*/
let sharedImdbClient: ImdbAPI | undefined;
/**
* Get or create shared IMDb client
*/
function getImdbClient(): ImdbAPI {
if (!sharedImdbClient) {
sharedImdbClient = new ImdbAPI();
}
return sharedImdbClient;
}
/**
* Get all movies from a Radarr instance (with optional caching)
*/
async function getRadarrMovies(
radarrSettings: {
hostname: string;
port: number;
useSsl: boolean;
baseUrl?: string;
apiKey: string;
},
cache?: Map<string, RadarrMovie[]>
): Promise<RadarrMovie[]> {
const RadarrAPI = (await import('@server/api/servarr/radarr')).default;
// Build URL manually (same pattern as buildUrl)
const protocol = radarrSettings.useSsl ? 'https' : 'http';
const url = `${protocol}://${radarrSettings.hostname}:${radarrSettings.port}${
radarrSettings.baseUrl || ''
}/api/v3`;
const cacheKey = url;
// Check cache if provided
if (cache) {
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
}
const radarr = new RadarrAPI({
url,
apiKey: radarrSettings.apiKey,
});
const movies = await radarr.getMovies();
// Store in cache if provided
if (cache) {
cache.set(cacheKey, movies);
logger.debug('Cached Radarr movies', {
label: 'OverlayContextBuilder',
url,
movieCount: movies.length,
});
}
return movies;
}
/**
* Get all series from a Sonarr instance (with optional caching)
*/
async function getSonarrSeries(
sonarrSettings: {
hostname: string;
port: number;
useSsl: boolean;
baseUrl?: string;
apiKey: string;
},
cache?: Map<string, SonarrSeries[]>
): Promise<SonarrSeries[]> {
const SonarrAPI = (await import('@server/api/servarr/sonarr')).default;
// Build URL manually (same pattern as buildUrl)
const protocol = sonarrSettings.useSsl ? 'https' : 'http';
const url = `${protocol}://${sonarrSettings.hostname}:${sonarrSettings.port}${
sonarrSettings.baseUrl || ''
}/api/v3`;
const cacheKey = url;
// Check cache if provided
if (cache) {
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
}
const sonarr = new SonarrAPI({
url,
apiKey: sonarrSettings.apiKey,
});
const series = await sonarr.getSeries();
// Store in cache if provided
if (cache) {
cache.set(cacheKey, series);
logger.debug('Cached Sonarr series', {
label: 'OverlayContextBuilder',
url,
seriesCount: series.length,
});
}
return series;
}
/**
* Get TVDB ID from TMDB ID for TV shows
* Required for Sonarr lookups since Sonarr uses TVDB IDs
*/
export async function getTvdbIdFromTmdb(
tmdbId: number
): Promise<number | undefined> {
try {
const tmdbClient = new TheMovieDb();
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
return showDetails.external_ids?.tvdb_id;
} catch (error) {
logger.debug('Failed to get TVDB ID from TMDB', {
label: 'OverlayContextBuilder',
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
/**
* Build context for dynamic field replacement
*/
export async function buildRenderContext(
item: PlexLibraryItem,
mediaType: 'movie' | 'show',
isPlaceholder = false,
maintainerrCollections?: MaintainerrCollection[]
): Promise<OverlayRenderContext> {
const context: OverlayRenderContext = {
title: item.title,
year: item.year,
isPlaceholder,
mediaType,
downloaded: !isPlaceholder, // Real items in Plex are downloaded, placeholders are not
};
// Extract TMDb ID from GUID
let tmdbId: number | undefined;
if (item.Guid && Array.isArray(item.Guid)) {
const tmdbGuid = item.Guid.find((g) => g.id?.includes('tmdb://'));
if (tmdbGuid) {
const match = tmdbGuid.id.match(/tmdb:\/\/(\d+)/);
if (match) {
tmdbId = parseInt(match[1]);
}
}
}
if (tmdbId) {
try {
// Fetch TMDb data
const tmdbClient = new TheMovieDb();
const tmdbData =
mediaType === 'movie'
? await tmdbClient.getMovie({ movieId: tmdbId })
: await tmdbClient.getTvShow({ tvId: tmdbId });
// Get IMDb ID
const imdbId = tmdbData.external_ids?.imdb_id;
// Fetch ratings
if (imdbId) {
// IMDb rating
try {
const imdbApi = new ImdbRatingsAPI();
const imdbRatings = await imdbApi.getRatings(imdbId);
if (imdbRatings.length > 0 && imdbRatings[0].rating !== null) {
context.imdbRating = imdbRatings[0].rating;
}
} catch (error) {
logger.debug('Failed to fetch IMDb rating', {
label: 'OverlayContextBuilder',
imdbId,
});
}
// IMDb Top 250 check
try {
const imdbClient = getImdbClient();
const imdbMediaType: 'movie' | 'tv' =
mediaType === 'show' ? 'tv' : 'movie';
const top250Result = await imdbClient.checkTop250(
imdbId,
imdbMediaType
);
if (top250Result.isTop250) {
context.isImdbTop250 = true;
context.imdbTop250Rank = top250Result.rank;
}
} catch (error) {
logger.debug('Failed to check IMDb Top 250', {
label: 'OverlayContextBuilder',
imdbId,
error: error instanceof Error ? error.message : String(error),
});
}
// Rotten Tomatoes ratings
try {
const rtClient = new RottenTomatoes();
const rtRating =
mediaType === 'movie'
? await rtClient.getMovieRatings(
context.title || '',
context.year || 0
)
: await rtClient.getTVRatings(context.title || '', context.year);
if (rtRating) {
context.rtCriticsScore = rtRating.criticsScore;
context.rtAudienceScore = rtRating.audienceScore;
logger.debug('Fetched RT ratings', {
label: 'OverlayContextBuilder',
title: context.title,
criticsScore: rtRating.criticsScore,
audienceScore: rtRating.audienceScore,
});
} else {
logger.debug('RT rating not found', {
label: 'OverlayContextBuilder',
title: context.title,
year: context.year,
});
}
} catch (error) {
logger.debug('Failed to fetch RT rating', {
label: 'OverlayContextBuilder',
title: context.title,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Movie-specific metadata
if (mediaType === 'movie' && 'credits' in tmdbData) {
const director = tmdbData.credits?.crew?.find(
(c) => c.job === 'Director'
);
if (director) {
context.director = director.name;
}
}
// Studio/Network
if (
'production_companies' in tmdbData &&
tmdbData.production_companies?.[0]
) {
context.studio = tmdbData.production_companies[0].name;
}
// Genre (concatenate all genres for matching)
if (
'genres' in tmdbData &&
tmdbData.genres &&
tmdbData.genres.length > 0
) {
context.genre = tmdbData.genres
.map((g: { name: string }) => g.name)
.join(', ');
}
// Runtime
if (mediaType === 'movie' && 'runtime' in tmdbData) {
context.runtime = tmdbData.runtime;
} else if (
mediaType === 'show' &&
'episode_run_time' in tmdbData &&
tmdbData.episode_run_time?.[0]
) {
context.runtime = tmdbData.episode_run_time[0];
}
// TMDB Status (TV shows only) - using Kometa's user-friendly mapping
if (mediaType === 'show' && 'status' in tmdbData) {
const rawStatus = tmdbData.status;
// Map TMDB status to user-friendly names (based on Kometa)
let mappedStatus: string;
switch (rawStatus) {
case 'Returning Series':
mappedStatus = 'RETURNING';
break;
case 'Ended':
mappedStatus = 'ENDED';
break;
case 'Canceled':
mappedStatus = 'CANCELLED';
break;
case 'Planned':
mappedStatus = 'PLANNED';
break;
case 'In Production':
mappedStatus = 'IN PRODUCTION';
break;
case 'Pilot':
mappedStatus = 'PILOT';
break;
default:
mappedStatus = rawStatus.toUpperCase();
}
// Check if an episode aired in last 15 days to determine "AIRING" status
// Only override to AIRING if status is "Returning Series"
// Use last_episode_to_air.air_date for accuracy (more reliable than last_air_date)
if (
rawStatus === 'Returning Series' &&
'last_episode_to_air' in tmdbData &&
tmdbData.last_episode_to_air?.air_date
) {
const lastAired = new Date(tmdbData.last_episode_to_air.air_date);
const daysSinceAired = Math.floor(
(Date.now() - lastAired.getTime()) / (1000 * 60 * 60 * 24)
);
logger.debug('Checking AIRING status', {
label: 'OverlayContextBuilder',
title: context.title,
lastEpisodeAirDate: tmdbData.last_episode_to_air.air_date,
daysSinceAired,
threshold: 15,
});
if (daysSinceAired <= 15) {
mappedStatus = 'AIRING';
}
}
context.tmdbStatus = mappedStatus;
}
} catch (error) {
logger.debug('Failed to fetch external metadata', {
label: 'OverlayContextBuilder',
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Plex-specific metadata from Media (skip for placeholder items)
if (!isPlaceholder && item.Media?.[0]) {
const media = item.Media[0];
// Resolution - use raw value from Plex (e.g., "720", "1080", "4k")
if (media.videoResolution) {
context.resolution = media.videoResolution;
}
// Dimensions
context.width = media.width;
context.height = media.height;
context.aspectRatio = media.aspectRatio;
// Video specs (from Media level)
context.videoCodec = media.videoCodec;
context.videoProfile = media.videoProfile;
context.videoFrameRate = media.videoFrameRate;
// Audio specs (from Media level)
context.audioCodec = media.audioCodec;
context.audioChannels = media.audioChannels;
// File info
context.container = media.container;
context.bitrate = media.bitrate;
// Extract detailed info from Streams
if (media.Part?.[0]?.Stream) {
const streams = media.Part[0].Stream;
// Find video stream (streamType 1)
const videoStream = streams.find((s) => s.streamType === 1);
if (videoStream) {
// HDR/Dolby Vision detection
context.dolbyVision = videoStream.DOVIPresent || false;
// Dolby Vision Profile (5, 7, 8, etc.)
if (videoStream.DOVIProfile !== undefined) {
context.dolbyVisionProfile = videoStream.DOVIProfile;
}
// Check for HDR in color transfer characteristic
context.hdr =
videoStream.colorTrc?.toLowerCase().includes('smpte2084') ||
videoStream.colorTrc?.toLowerCase().includes('arib') ||
false;
// Color transfer characteristic (for distinguishing HDR10 vs HLG, etc.)
if (videoStream.colorTrc) {
context.colorTrc = videoStream.colorTrc;
}
// Parse bitDepth as number (Plex returns it as string)
if (videoStream.bitDepth) {
context.bitDepth = parseInt(String(videoStream.bitDepth), 10);
}
}
// Find audio stream (streamType 2) - prefer first one
const audioStream = streams.find((s) => s.streamType === 2);
if (audioStream) {
// Detailed audio format from displayTitle
if (audioStream.displayTitle) {
context.audioFormat = audioStream.displayTitle;
}
// Audio channel layout
if (audioStream.audioChannelLayout) {
context.audioChannelLayout = audioStream.audioChannelLayout;
}
if (audioStream.channels) {
context.audioChannels = audioStream.channels;
}
}
// Get file path from Part
if (media.Part[0].file) {
context.filePath = media.Part[0].file;
}
// Get file size
if (media.Part[0].size) {
context.fileSize = media.Part[0].size;
}
}
}
// Playback stats and dates
if (item.viewCount !== undefined) {
context.viewCount = item.viewCount;
}
if (item.lastViewedAt) {
context.lastPlayed = new Date(item.lastViewedAt * 1000);
}
if (item.addedAt) {
context.dateAdded = new Date(item.addedAt * 1000);
}
// TV-specific
if (mediaType === 'show') {
// For episode-level items, use parentIndex for season
// For show-level items (placeholders/shows), parentIndex is undefined
if (item.parentIndex !== undefined) {
context.seasonNumber = item.parentIndex;
}
if (item.index !== undefined) {
context.episodeNumber = item.index;
}
}
// Maintainerr integration - calculate daysUntilAction
// Use cached collections if provided, otherwise fetch them
if (
item.ratingKey &&
maintainerrCollections &&
maintainerrCollections.length > 0
) {
try {
// Find ALL collections containing this item
const matchingCollections: {
collection: MaintainerrCollection;
daysUntilAction: number;
}[] = [];
for (const collection of maintainerrCollections) {
const mediaItem = collection.media.find(
(m) => m.plexId === Number(item.ratingKey)
);
if (mediaItem && collection.deleteAfterDays) {
// Calculate days since item was added to collection
const addedDate = new Date(mediaItem.addDate);
const now = new Date();
const daysSinceAdded = Math.floor(
(now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24)
);
// Calculate days until action: deleteAfterDays - daysSinceAdded
// Positive = days remaining, negative = overdue
const daysUntilAction = collection.deleteAfterDays - daysSinceAdded;
matchingCollections.push({ collection, daysUntilAction });
}
}
// If item is in multiple collections, use the one with LOWEST daysUntilAction
if (matchingCollections.length > 0) {
const selected = matchingCollections.reduce((min, curr) =>
curr.daysUntilAction < min.daysUntilAction ? curr : min
);
context.daysUntilAction = selected.daysUntilAction;
logger.debug('Calculated Maintainerr daysUntilAction', {
label: 'OverlayContextBuilder',
ratingKey: item.ratingKey,
title: item.title,
matchingCollections: matchingCollections.length,
selectedCollection: selected.collection.title,
daysUntilAction: selected.daysUntilAction,
});
}
} catch (error) {
logger.debug('Failed to calculate Maintainerr daysUntilAction', {
label: 'OverlayContextBuilder',
ratingKey: item.ratingKey,
error: error instanceof Error ? error.message : String(error),
});
}
}
return context;
}
/**
* Fetch release date information from TMDB
* For movies: Gets digital/physical/theatrical release dates
* For TV: Gets next episode air date
*/
export async function fetchReleaseDateInfo(
tmdbId: number,
mediaType: 'movie' | 'show'
): Promise<
| {
releaseDate?: string;
nextEpisodeAirDate?: string;
nextSeasonAirDate?: string;
seasonNumber?: number;
}
| undefined
> {
try {
const tmdbClient = new TheMovieDb();
if (mediaType === 'movie') {
const movieDetails = await tmdbClient.getMovie({ movieId: tmdbId });
// For movies, use proper release date calculation (digital > physical > theatrical+90)
// This matches PlaceholderContextService implementation
if (movieDetails.release_dates?.results) {
const { extractReleaseDates, determineReleaseDate } = await import(
'@server/utils/dateHelpers'
);
const extracted = extractReleaseDates(
movieDetails.release_dates.results
);
const determined = determineReleaseDate(
extracted.digitalRelease,
extracted.physicalRelease,
extracted.inCinemas
);
if (determined) {
return {
releaseDate: determined.releaseDate,
};
}
}
// Fallback to simple release_date if release_dates not available
if (movieDetails.release_date) {
return {
releaseDate: movieDetails.release_date,
};
}
} else {
// For TV shows
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
// Get next episode info
const nextEpisode = showDetails.next_episode_to_air;
if (nextEpisode?.air_date) {
const seasonNumber = nextEpisode.season_number;
const episodeNumber = nextEpisode.episode_number;
// nextSeasonAirDate is ONLY for season premieres (episode 1)
const nextSeasonAirDate =
episodeNumber === 1 ? nextEpisode.air_date : undefined;
return {
releaseDate: showDetails.first_air_date || nextEpisode.air_date,
nextEpisodeAirDate: nextEpisode.air_date,
nextSeasonAirDate,
seasonNumber,
};
}
// No next episode, use first_air_date if available
if (showDetails.first_air_date) {
return {
releaseDate: showDetails.first_air_date,
};
}
}
return undefined;
} catch (error) {
logger.debug('Failed to fetch release date info', {
label: 'OverlayContextBuilder',
tmdbId,
mediaType,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
/**
* Check monitoring status in Radarr/Sonarr
* Returns whether item exists in *arr and if it's monitored (series-level)
*
* @param tmdbId - TMDB ID of the item
* @param mediaType - Media type ('movie' or 'show')
* @param radarrCache - Optional cache for Radarr movie data
* @param sonarrCache - Optional cache for Sonarr series data
*/
export async function checkMonitoringStatus(
tmdbId: number,
mediaType: 'movie' | 'show',
radarrCache?: Map<string, RadarrMovie[]>,
sonarrCache?: Map<string, SonarrSeries[]>
): Promise<{
inRadarr?: boolean;
inSonarr?: boolean;
isMonitored?: boolean;
hasFile?: boolean;
}> {
try {
const settings = getSettings();
if (
mediaType === 'movie' &&
settings.radarr &&
settings.radarr.length > 0
) {
// Check Radarr for movies
for (const radarrSettings of settings.radarr) {
if (!radarrSettings.hostname) {
continue;
}
try {
const movies = await getRadarrMovies(radarrSettings, radarrCache);
const movie = movies.find((m) => m.tmdbId === tmdbId);
if (movie) {
logger.debug('Found movie in Radarr', {
label: 'OverlayContextBuilder',
tmdbId,
monitored: movie.monitored,
hasFile: movie.hasFile,
});
return {
inRadarr: true,
isMonitored: movie.monitored,
hasFile: movie.hasFile,
};
}
} catch (error) {
logger.debug('Failed to check Radarr instance', {
label: 'OverlayContextBuilder',
hostname: radarrSettings.hostname,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
return { inRadarr: false, isMonitored: false };
} else if (
mediaType === 'show' &&
settings.sonarr &&
settings.sonarr.length > 0
) {
// Check Sonarr for TV shows - prefer TVDB ID, fallback to title match
const tvdbId = await getTvdbIdFromTmdb(tmdbId);
// Get title from TMDB for fallback matching
let tmdbTitle: string | undefined;
if (!tvdbId) {
try {
const tmdbClient = new TheMovieDb();
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
tmdbTitle = showDetails.name || showDetails.original_name;
} catch {
// Ignore errors, just won't have title fallback
}
}
for (const sonarrSettings of settings.sonarr) {
if (!sonarrSettings.hostname) {
continue;
}
try {
const allSeries = await getSonarrSeries(sonarrSettings, sonarrCache);
let series;
// Try TVDB ID first if available
if (tvdbId) {
series = allSeries.find((s) => s.tvdbId === tvdbId);
}
// Fallback to title match if no TVDB ID or not found
if (!series && tmdbTitle) {
const normalizedTmdbTitle = tmdbTitle.toLowerCase();
const normalizedTmdbTitleNoSpecial = normalizedTmdbTitle.replace(
/[^\w\s]/g,
''
);
series = allSeries.find(
(s) =>
s.title.toLowerCase() === normalizedTmdbTitle ||
s.title.toLowerCase().replace(/[^\w\s]/g, '') ===
normalizedTmdbTitleNoSpecial
);
}
if (series) {
const hasFile = (series.statistics?.episodeFileCount || 0) > 0;
logger.debug('Found series in Sonarr', {
label: 'OverlayContextBuilder',
tmdbId,
tvdbId,
tmdbTitle,
sonarrTitle: series.title,
matchedBy:
tvdbId && series.tvdbId === tvdbId ? 'tvdbId' : 'title',
monitored: series.monitored,
episodeFileCount: series.statistics?.episodeFileCount,
hasFile,
});
return {
inSonarr: true,
isMonitored: series.monitored,
hasFile,
};
}
} catch (error) {
logger.debug('Failed to check Sonarr instance', {
label: 'OverlayContextBuilder',
hostname: sonarrSettings.hostname,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
return { inSonarr: false, isMonitored: false };
}
return {};
} catch (error) {
logger.debug('Failed to check monitoring status', {
label: 'OverlayContextBuilder',
mediaType,
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
return {};
}
}
+103 -703
View File
@@ -1,11 +1,8 @@
import ImdbAPI from '@server/api/imdb';
import ImdbRatingsAPI from '@server/api/imdbRatings';
import type { MaintainerrCollection } from '@server/api/maintainerr';
import type { PlexLibraryItem } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RottenTomatoes from '@server/api/rottentomatoes';
import type { RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { getRepository } from '@server/datasource';
import { OverlayLibraryConfig } from '@server/entity/OverlayLibraryConfig';
import { OverlayTemplate } from '@server/entity/OverlayTemplate';
@@ -14,6 +11,11 @@ import logger from '@server/logger';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import {
buildRenderContext,
checkMonitoringStatus,
fetchReleaseDateInfo,
} from './OverlayContextBuilder';
import type { OverlayRenderContext } from './OverlayTemplateRenderer';
import {
evaluateCondition,
@@ -32,21 +34,43 @@ export interface OverlayItemInput {
* Service for applying overlay templates to Plex library items
*/
class OverlayLibraryService {
// Shared API clients to avoid creating new instances for each item
private imdbClient?: ImdbAPI;
// Cache for Radarr/Sonarr library data (per job)
private radarrMoviesCache?: Map<string, RadarrMovie[]>;
private sonarrSeriesCache?: Map<string, SonarrSeries[]>;
private maintainerrCollectionsCache?: MaintainerrCollection[];
// Track running libraries
private runningLibraries = new Map<
string,
{ libraryName: string; startTime: number }
>();
/**
* Get or create shared IMDb client
* Get status for a specific library
*/
private async getImdbClient() {
if (!this.imdbClient) {
this.imdbClient = new ImdbAPI();
public getLibraryStatus(libraryId: string) {
const status = this.runningLibraries.get(libraryId);
if (!status) {
return { running: false };
}
return this.imdbClient;
return {
running: true,
libraryName: status.libraryName,
startTime: status.startTime,
};
}
/**
* Get all running libraries
*/
public getAllRunningLibraries() {
return Array.from(this.runningLibraries.entries()).map(
([libraryId, status]) => ({
libraryId,
libraryName: status.libraryName,
startTime: status.startTime,
})
);
}
/**
@@ -55,96 +79,7 @@ class OverlayLibraryService {
private clearLibraryCaches() {
this.radarrMoviesCache = new Map();
this.sonarrSeriesCache = new Map();
}
/**
* Get all movies from a Radarr instance (with caching)
*/
private async getRadarrMovies(radarrSettings: {
hostname: string;
port: number;
useSsl: boolean;
baseUrl?: string;
apiKey: string;
}): Promise<RadarrMovie[]> {
const RadarrAPI = (await import('@server/api/servarr/radarr')).default;
// Build URL manually (same pattern as buildUrl)
const protocol = radarrSettings.useSsl ? 'https' : 'http';
const url = `${protocol}://${radarrSettings.hostname}:${
radarrSettings.port
}${radarrSettings.baseUrl || ''}/api/v3`;
const cacheKey = url;
if (!this.radarrMoviesCache) {
this.radarrMoviesCache = new Map();
}
const cached = this.radarrMoviesCache.get(cacheKey);
if (cached) {
return cached;
}
const radarr = new RadarrAPI({
url,
apiKey: radarrSettings.apiKey,
});
const movies = await radarr.getMovies();
this.radarrMoviesCache.set(cacheKey, movies);
logger.debug('Cached Radarr movies', {
label: 'OverlayLibrary',
url,
movieCount: movies.length,
});
return movies;
}
/**
* Get all series from a Sonarr instance (with caching)
*/
private async getSonarrSeries(sonarrSettings: {
hostname: string;
port: number;
useSsl: boolean;
baseUrl?: string;
apiKey: string;
}): Promise<SonarrSeries[]> {
const SonarrAPI = (await import('@server/api/servarr/sonarr')).default;
// Build URL manually (same pattern as buildUrl)
const protocol = sonarrSettings.useSsl ? 'https' : 'http';
const url = `${protocol}://${sonarrSettings.hostname}:${
sonarrSettings.port
}${sonarrSettings.baseUrl || ''}/api/v3`;
const cacheKey = url;
if (!this.sonarrSeriesCache) {
this.sonarrSeriesCache = new Map();
}
const cached = this.sonarrSeriesCache.get(cacheKey);
if (cached) {
return cached;
}
const sonarr = new SonarrAPI({
url,
apiKey: sonarrSettings.apiKey,
});
const series = await sonarr.getSeries();
this.sonarrSeriesCache.set(cacheKey, series);
logger.debug('Cached Sonarr series', {
label: 'OverlayLibrary',
url,
seriesCount: series.length,
});
return series;
this.maintainerrCollectionsCache = undefined;
}
/**
@@ -154,6 +89,18 @@ class OverlayLibraryService {
libraryId: string,
checkCancelled?: () => boolean
): Promise<void> {
// Get library configuration first to get name
const configRepository = getRepository(OverlayLibraryConfig);
const config = await configRepository.findOne({
where: { libraryId },
});
// Mark as running
this.runningLibraries.set(libraryId, {
libraryName: config?.libraryName || libraryId,
startTime: Date.now(),
});
try {
// Clear library caches at start of job
this.clearLibraryCaches();
@@ -163,12 +110,6 @@ class OverlayLibraryService {
libraryId,
});
// Get library configuration
const configRepository = getRepository(OverlayLibraryConfig);
const config = await configRepository.findOne({
where: { libraryId },
});
if (!config || config.enabledOverlays.length === 0) {
logger.info('No overlays enabled for library', {
label: 'OverlayLibrary',
@@ -211,6 +152,28 @@ class OverlayLibraryService {
templates: sortedTemplates.map((t) => t.name),
});
// Fetch Maintainerr collections once for the entire job
const settings = getSettings();
if (settings.maintainerr?.hostname && settings.maintainerr?.apiKey) {
try {
const MaintainerrAPI = (await import('@server/api/maintainerr'))
.default;
const maintainerrClient = new MaintainerrAPI(settings.maintainerr);
this.maintainerrCollectionsCache =
await maintainerrClient.getCollections();
logger.info('Fetched Maintainerr collections for overlay job', {
label: 'OverlayLibrary',
collectionsCount: this.maintainerrCollectionsCache.length,
});
} catch (error) {
logger.error('Failed to fetch Maintainerr collections', {
label: 'OverlayLibrary',
error: error instanceof Error ? error.message : String(error),
});
this.maintainerrCollectionsCache = [];
}
}
// Get library items from Plex
const { getAdminUser } = await import(
'@server/lib/collections/core/CollectionUtilities'
@@ -320,6 +283,9 @@ class OverlayLibraryService {
error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
// Remove from running libraries
this.runningLibraries.delete(libraryId);
}
}
@@ -536,7 +502,7 @@ class OverlayLibraryService {
// Check if this is a placeholder (async version with API call for suspicious items)
const { placeholderContextService } = await import(
'@server/lib/collections/services/PlaceholderContextService'
'@server/lib/placeholders/services/PlaceholderContextService'
);
const plexMetadata = item as {
type: string;
@@ -576,16 +542,17 @@ class OverlayLibraryService {
});
// Build base context for dynamic fields
const baseContext = await this.buildRenderContext(
const baseContext = await buildRenderContext(
item,
actualMediaType,
isPlaceholder
isPlaceholder,
this.maintainerrCollectionsCache
);
// Fetch fresh release date information for ALL items with TMDB ID
let releaseDateContext: Partial<OverlayRenderContext> = {};
if (tmdbId) {
const releaseDateInfo = await this.fetchReleaseDateInfo(
const releaseDateInfo = await fetchReleaseDateInfo(
tmdbId,
actualMediaType
);
@@ -647,9 +614,11 @@ class OverlayLibraryService {
// Check monitoring status for ALL items with TMDB ID
let monitoringContext: Partial<OverlayRenderContext> = {};
if (tmdbId) {
monitoringContext = await this.checkMonitoringStatus(
monitoringContext = await checkMonitoringStatus(
tmdbId,
actualMediaType
actualMediaType,
this.radarrMoviesCache,
this.sonarrSeriesCache
);
}
@@ -884,6 +853,26 @@ class OverlayLibraryService {
// Upload modified poster back to Plex
await plexApi.uploadPosterFromFile(item.ratingKey, tempFilePath);
// Lock poster to prevent Plex from auto-updating it during library scans
try {
await plexApi.lockPoster(item.ratingKey);
logger.debug('Locked poster after overlay application', {
label: 'OverlayLibrary',
itemTitle: item.title,
ratingKey: item.ratingKey,
});
} catch (lockError) {
logger.warn('Failed to lock poster after overlay application', {
label: 'OverlayLibrary',
itemTitle: item.title,
ratingKey: item.ratingKey,
error:
lockError instanceof Error
? lockError.message
: String(lockError),
});
}
// Record overlay metadata tracking with base poster info
try {
const newPosterUrl = await plexApi.getCurrentPosterUrl(
@@ -977,595 +966,6 @@ class OverlayLibraryService {
throw error;
}
}
/**
* Build context for dynamic field replacement
*/
private async buildRenderContext(
item: PlexLibraryItem,
mediaType: 'movie' | 'show',
isPlaceholder = false
): Promise<OverlayRenderContext> {
const context: OverlayRenderContext = {
title: item.title,
year: item.year,
isPlaceholder,
mediaType,
downloaded: !isPlaceholder, // Real items in Plex are downloaded, placeholders are not
};
// Extract TMDb ID from GUID
let tmdbId: number | undefined;
if (item.Guid && Array.isArray(item.Guid)) {
const tmdbGuid = item.Guid.find((g) => g.id?.includes('tmdb://'));
if (tmdbGuid) {
const match = tmdbGuid.id.match(/tmdb:\/\/(\d+)/);
if (match) {
tmdbId = parseInt(match[1]);
}
}
}
if (tmdbId) {
try {
// Fetch TMDb data
const tmdbClient = new TheMovieDb();
const tmdbData =
mediaType === 'movie'
? await tmdbClient.getMovie({ movieId: tmdbId })
: await tmdbClient.getTvShow({ tvId: tmdbId });
// Get IMDb ID
const imdbId = tmdbData.external_ids?.imdb_id;
// Fetch ratings
if (imdbId) {
// IMDb rating
try {
const imdbApi = new ImdbRatingsAPI();
const imdbRatings = await imdbApi.getRatings(imdbId);
if (imdbRatings.length > 0 && imdbRatings[0].rating !== null) {
context.imdbRating = imdbRatings[0].rating;
}
} catch (error) {
logger.debug('Failed to fetch IMDb rating', {
label: 'OverlayLibrary',
imdbId,
});
}
// IMDb Top 250 check
try {
const imdbClient = await this.getImdbClient();
const imdbMediaType: 'movie' | 'tv' =
mediaType === 'show' ? 'tv' : 'movie';
const top250Result = await imdbClient.checkTop250(
imdbId,
imdbMediaType
);
if (top250Result.isTop250) {
context.isImdbTop250 = true;
context.imdbTop250Rank = top250Result.rank;
}
} catch (error) {
logger.debug('Failed to check IMDb Top 250', {
label: 'OverlayLibrary',
imdbId,
error: error instanceof Error ? error.message : String(error),
});
}
// Rotten Tomatoes ratings
try {
const rtClient = new RottenTomatoes();
const rtRating =
mediaType === 'movie'
? await rtClient.getMovieRatings(
context.title || '',
context.year || 0
)
: await rtClient.getTVRatings(
context.title || '',
context.year
);
if (rtRating) {
context.rtCriticsScore = rtRating.criticsScore;
context.rtAudienceScore = rtRating.audienceScore;
logger.debug('Fetched RT ratings', {
label: 'OverlayLibrary',
title: context.title,
criticsScore: rtRating.criticsScore,
audienceScore: rtRating.audienceScore,
});
} else {
logger.debug('RT rating not found', {
label: 'OverlayLibrary',
title: context.title,
year: context.year,
});
}
} catch (error) {
logger.debug('Failed to fetch RT rating', {
label: 'OverlayLibrary',
title: context.title,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Movie-specific metadata
if (mediaType === 'movie' && 'credits' in tmdbData) {
const director = tmdbData.credits?.crew?.find(
(c) => c.job === 'Director'
);
if (director) {
context.director = director.name;
}
}
// Studio/Network
if (
'production_companies' in tmdbData &&
tmdbData.production_companies?.[0]
) {
context.studio = tmdbData.production_companies[0].name;
}
// Genre (concatenate all genres for matching)
if (
'genres' in tmdbData &&
tmdbData.genres &&
tmdbData.genres.length > 0
) {
context.genre = tmdbData.genres
.map((g: { name: string }) => g.name)
.join(', ');
}
// Runtime
if (mediaType === 'movie' && 'runtime' in tmdbData) {
context.runtime = tmdbData.runtime;
} else if (
mediaType === 'show' &&
'episode_run_time' in tmdbData &&
tmdbData.episode_run_time?.[0]
) {
context.runtime = tmdbData.episode_run_time[0];
}
// TMDB Status (TV shows only) - using Kometa's user-friendly mapping
if (mediaType === 'show' && 'status' in tmdbData) {
const rawStatus = tmdbData.status;
// Map TMDB status to user-friendly names (based on Kometa)
let mappedStatus: string;
switch (rawStatus) {
case 'Returning Series':
mappedStatus = 'RETURNING';
break;
case 'Ended':
mappedStatus = 'ENDED';
break;
case 'Canceled':
mappedStatus = 'CANCELLED';
break;
case 'Planned':
mappedStatus = 'PLANNED';
break;
case 'In Production':
mappedStatus = 'IN PRODUCTION';
break;
case 'Pilot':
mappedStatus = 'PILOT';
break;
default:
mappedStatus = rawStatus.toUpperCase();
}
// Check if an episode aired in last 15 days to determine "AIRING" status
// Only override to AIRING if status is "Returning Series"
// Use last_episode_to_air.air_date for accuracy (more reliable than last_air_date)
if (
rawStatus === 'Returning Series' &&
'last_episode_to_air' in tmdbData &&
tmdbData.last_episode_to_air?.air_date
) {
const lastAired = new Date(tmdbData.last_episode_to_air.air_date);
const daysSinceAired = Math.floor(
(Date.now() - lastAired.getTime()) / (1000 * 60 * 60 * 24)
);
logger.debug('Checking AIRING status', {
label: 'OverlayLibrary',
title: context.title,
lastEpisodeAirDate: tmdbData.last_episode_to_air.air_date,
daysSinceAired,
threshold: 15,
});
if (daysSinceAired <= 15) {
mappedStatus = 'AIRING';
}
}
context.tmdbStatus = mappedStatus;
}
} catch (error) {
logger.debug('Failed to fetch external metadata', {
label: 'OverlayLibrary',
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Plex-specific metadata from Media (skip for placeholder items)
if (!isPlaceholder && item.Media?.[0]) {
const media = item.Media[0];
// Resolution - use raw value from Plex (e.g., "720", "1080", "4k")
if (media.videoResolution) {
context.resolution = media.videoResolution;
}
// Dimensions
context.width = media.width;
context.height = media.height;
context.aspectRatio = media.aspectRatio;
// Video specs (from Media level)
context.videoCodec = media.videoCodec;
context.videoProfile = media.videoProfile;
context.videoFrameRate = media.videoFrameRate;
// Audio specs (from Media level)
context.audioCodec = media.audioCodec;
context.audioChannels = media.audioChannels;
// File info
context.container = media.container;
context.bitrate = media.bitrate;
// Extract detailed info from Streams
if (media.Part?.[0]?.Stream) {
const streams = media.Part[0].Stream;
// Find video stream (streamType 1)
const videoStream = streams.find((s) => s.streamType === 1);
if (videoStream) {
// HDR/Dolby Vision detection
context.dolbyVision = videoStream.DOVIPresent || false;
// Check for HDR in color transfer characteristic
context.hdr =
videoStream.colorTrc?.toLowerCase().includes('smpte2084') ||
videoStream.colorTrc?.toLowerCase().includes('arib') ||
false;
// Parse bitDepth as number (Plex returns it as string)
if (videoStream.bitDepth) {
context.bitDepth = parseInt(String(videoStream.bitDepth), 10);
}
}
// Find audio stream (streamType 2) - prefer first one
const audioStream = streams.find((s) => s.streamType === 2);
if (audioStream) {
// Detailed audio format from displayTitle
if (audioStream.displayTitle) {
context.audioFormat = audioStream.displayTitle;
}
// Audio channel layout
if (audioStream.audioChannelLayout) {
context.audioChannelLayout = audioStream.audioChannelLayout;
}
if (audioStream.channels) {
context.audioChannels = audioStream.channels;
}
}
// Get file path from Part
if (media.Part[0].file) {
context.filePath = media.Part[0].file;
}
// Get file size
if (media.Part[0].size) {
context.fileSize = media.Part[0].size;
}
}
}
// Playback stats and dates
if (item.viewCount !== undefined) {
context.viewCount = item.viewCount;
}
if (item.lastViewedAt) {
context.lastPlayed = new Date(item.lastViewedAt * 1000);
}
if (item.addedAt) {
context.dateAdded = new Date(item.addedAt * 1000);
}
// TV-specific
if (mediaType === 'show') {
// For episode-level items, use parentIndex for season
// For show-level items (placeholders/shows), parentIndex is undefined
if (item.parentIndex !== undefined) {
context.seasonNumber = item.parentIndex;
}
if (item.index !== undefined) {
context.episodeNumber = item.index;
}
}
return context;
}
/**
* Fetch release date information from TMDB
* For movies: Gets digital/physical/theatrical release dates
* For TV: Gets next episode air date
*/
private async fetchReleaseDateInfo(
tmdbId: number,
mediaType: 'movie' | 'show'
): Promise<
| {
releaseDate?: string;
nextEpisodeAirDate?: string;
nextSeasonAirDate?: string;
seasonNumber?: number;
}
| undefined
> {
try {
const tmdbClient = new TheMovieDb();
if (mediaType === 'movie') {
const movieDetails = await tmdbClient.getMovie({ movieId: tmdbId });
// For movies, use proper release date calculation (digital > physical > theatrical+90)
// This matches PlaceholderContextService implementation
if (movieDetails.release_dates?.results) {
const { extractReleaseDates, determineReleaseDate } = await import(
'@server/utils/dateHelpers'
);
const extracted = extractReleaseDates(
movieDetails.release_dates.results
);
const determined = determineReleaseDate(
extracted.digitalRelease,
extracted.physicalRelease,
extracted.inCinemas
);
if (determined) {
return {
releaseDate: determined.releaseDate,
};
}
}
// Fallback to simple release_date if release_dates not available
if (movieDetails.release_date) {
return {
releaseDate: movieDetails.release_date,
};
}
} else {
// For TV shows
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
// Get next episode info
const nextEpisode = showDetails.next_episode_to_air;
if (nextEpisode?.air_date) {
const seasonNumber = nextEpisode.season_number;
const episodeNumber = nextEpisode.episode_number;
// nextSeasonAirDate is ONLY for season premieres (episode 1)
const nextSeasonAirDate =
episodeNumber === 1 ? nextEpisode.air_date : undefined;
return {
releaseDate: showDetails.first_air_date || nextEpisode.air_date,
nextEpisodeAirDate: nextEpisode.air_date,
nextSeasonAirDate,
seasonNumber,
};
}
// No next episode, use first_air_date if available
if (showDetails.first_air_date) {
return {
releaseDate: showDetails.first_air_date,
};
}
}
return undefined;
} catch (error) {
logger.debug('Failed to fetch release date info', {
label: 'OverlayLibrary',
tmdbId,
mediaType,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
/**
* Check monitoring status in Radarr/Sonarr
* Returns whether item exists in *arr and if it's monitored (series-level)
*/
private async checkMonitoringStatus(
tmdbId: number,
mediaType: 'movie' | 'show'
): Promise<{
inRadarr?: boolean;
inSonarr?: boolean;
isMonitored?: boolean;
hasFile?: boolean;
}> {
try {
const settings = getSettings();
if (
mediaType === 'movie' &&
settings.radarr &&
settings.radarr.length > 0
) {
// Check Radarr for movies
for (const radarrSettings of settings.radarr) {
if (!radarrSettings.hostname) {
continue;
}
try {
const movies = await this.getRadarrMovies(radarrSettings);
const movie = movies.find((m) => m.tmdbId === tmdbId);
if (movie) {
logger.debug('Found movie in Radarr', {
label: 'OverlayLibrary',
tmdbId,
monitored: movie.monitored,
hasFile: movie.hasFile,
});
return {
inRadarr: true,
isMonitored: movie.monitored,
hasFile: movie.hasFile,
};
}
} catch (error) {
logger.debug('Failed to check Radarr instance', {
label: 'OverlayLibrary',
hostname: radarrSettings.hostname,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
return { inRadarr: false, isMonitored: false };
} else if (
mediaType === 'show' &&
settings.sonarr &&
settings.sonarr.length > 0
) {
// Check Sonarr for TV shows - prefer TVDB ID, fallback to title match
const tvdbId = await this.getTvdbIdFromTmdb(tmdbId);
// Get title from TMDB for fallback matching
let tmdbTitle: string | undefined;
if (!tvdbId) {
try {
const tmdbClient = new TheMovieDb();
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
tmdbTitle = showDetails.name || showDetails.original_name;
} catch {
// Ignore errors, just won't have title fallback
}
}
for (const sonarrSettings of settings.sonarr) {
if (!sonarrSettings.hostname) {
continue;
}
try {
const allSeries = await this.getSonarrSeries(sonarrSettings);
let series;
// Try TVDB ID first if available
if (tvdbId) {
series = allSeries.find((s) => s.tvdbId === tvdbId);
}
// Fallback to title match if no TVDB ID or not found
if (!series && tmdbTitle) {
const normalizedTmdbTitle = tmdbTitle.toLowerCase();
const normalizedTmdbTitleNoSpecial = normalizedTmdbTitle.replace(
/[^\w\s]/g,
''
);
series = allSeries.find(
(s) =>
s.title.toLowerCase() === normalizedTmdbTitle ||
s.title.toLowerCase().replace(/[^\w\s]/g, '') ===
normalizedTmdbTitleNoSpecial
);
}
if (series) {
const hasFile = (series.statistics?.episodeFileCount || 0) > 0;
logger.debug('Found series in Sonarr', {
label: 'OverlayLibrary',
tmdbId,
tvdbId,
tmdbTitle,
sonarrTitle: series.title,
matchedBy:
tvdbId && series.tvdbId === tvdbId ? 'tvdbId' : 'title',
monitored: series.monitored,
episodeFileCount: series.statistics?.episodeFileCount,
hasFile,
});
return {
inSonarr: true,
isMonitored: series.monitored,
hasFile,
};
}
} catch (error) {
logger.debug('Failed to check Sonarr instance', {
label: 'OverlayLibrary',
hostname: sonarrSettings.hostname,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
return { inSonarr: false, isMonitored: false };
}
return {};
} catch (error) {
logger.debug('Failed to check monitoring status', {
label: 'OverlayLibrary',
mediaType,
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
return {};
}
}
/**
* Get TVDB ID from TMDB ID for TV shows
* Required for Sonarr lookups since Sonarr uses TVDB IDs
*/
private async getTvdbIdFromTmdb(tmdbId: number): Promise<number | undefined> {
try {
const tmdbClient = new TheMovieDb();
const showDetails = await tmdbClient.getTvShow({ tvId: tmdbId });
return showDetails.external_ids?.tvdb_id;
} catch (error) {
logger.debug('Failed to get TVDB ID from TMDB', {
label: 'OverlayLibrary',
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
}
export const overlayLibraryService = new OverlayLibraryService();
+100 -5
View File
@@ -83,6 +83,91 @@ function evaluateSection(
return result;
}
/**
* Evaluate condition and return detailed results for debugging
* Returns the same boolean result as evaluateCondition, plus detailed evaluation info
*/
export function evaluateConditionDetailed(
condition: ApplicationCondition | undefined,
context: OverlayRenderContext
): {
matched: boolean;
sectionResults: {
sectionIndex: number;
sectionOperator?: 'and' | 'or';
matched: boolean;
ruleResults: {
ruleIndex: number;
ruleOperator?: 'and' | 'or';
field: string;
operator: string;
value: unknown;
actualValue: unknown;
matched: boolean;
}[];
}[];
} {
if (!condition || !condition.sections || condition.sections.length === 0) {
return {
matched: true,
sectionResults: [],
};
}
const sectionResults = condition.sections.map((section, sectionIndex) => {
const ruleResults = section.rules.map((rule, ruleIndex) => {
const actualValue = context[rule.field];
const matched = evaluateRule(rule, context);
return {
ruleIndex,
ruleOperator: rule.ruleOperator,
field: rule.field,
operator: rule.operator,
value: rule.value,
actualValue,
matched,
};
});
// Determine section match based on rule operator logic
let sectionMatched = ruleResults[0]?.matched ?? true;
for (let i = 1; i < ruleResults.length; i++) {
const ruleResult = ruleResults[i];
if (ruleResult.ruleOperator === 'or') {
sectionMatched = sectionMatched || ruleResult.matched;
} else {
// Default to AND
sectionMatched = sectionMatched && ruleResult.matched;
}
}
return {
sectionIndex,
sectionOperator: section.sectionOperator,
matched: sectionMatched,
ruleResults,
};
});
// Determine overall match based on section operator logic
let overallMatched = sectionResults[0]?.matched ?? true;
for (let i = 1; i < sectionResults.length; i++) {
const sectionResult = sectionResults[i];
if (sectionResult.sectionOperator === 'and') {
overallMatched = overallMatched && sectionResult.matched;
} else {
// Default to OR
overallMatched = overallMatched || sectionResult.matched;
}
}
return {
matched: overallMatched,
sectionResults,
};
}
/**
* Evaluate a single rule (field/operator/value comparison)
*/
@@ -188,7 +273,7 @@ export interface OverlayRenderContext {
isImdbTop250?: boolean; // True if item is in IMDb Top 250 list
rtCriticsScore?: number;
rtAudienceScore?: number;
metacriticScore?: number;
// metacriticScore?: number; // TODO: Implement Metacritic integration
// TMDB Metadata
title?: string;
@@ -213,6 +298,8 @@ export interface OverlayRenderContext {
bitDepth?: number; // 8, 10, 12
hdr?: boolean; // HDR10/HDR10+
dolbyVision?: boolean; // Dolby Vision
dolbyVisionProfile?: number; // Dolby Vision Profile (5, 7, 8, etc.)
colorTrc?: string; // Color transfer characteristic (e.g., 'smpte2084' for HDR10, 'arib' for HLG)
// Audio specs
audioCodec?: string; // 'truehd', 'dts', 'aac'
@@ -257,8 +344,9 @@ export interface OverlayRenderContext {
inSonarr?: boolean;
hasFile?: boolean; // Whether *arr reports item has files
downloaded?: boolean; // Derived from hasFile for monitored items, or !isPlaceholder for others
isTrending?: boolean;
isWatched?: boolean;
// Maintainerr integration
daysUntilAction?: number; // Days until Maintainerr takes action (negative = overdue)
// Item metadata
isPlaceholder: boolean; // true = Coming Soon item, false = real item in Plex
@@ -578,6 +666,7 @@ class OverlayTemplateRendererService {
font-weight="${props.fontWeight}"
font-style="${props.fontStyle}"
fill="${props.color}"
fill-opacity="${(props.opacity ?? 100) / 100}"
text-anchor="${
props.textAlign === 'center'
? 'middle'
@@ -664,7 +753,11 @@ class OverlayTemplateRendererService {
d="${path}"
fill="${props.fillColor}"
fill-opacity="${props.fillOpacity / 100}"
${props.borderColor ? `stroke="${props.borderColor}"` : ''}
${
borderWidth > 0 && props.borderColor
? `stroke="${props.borderColor}"`
: ''
}
${borderWidth > 0 ? `stroke-width="${borderWidth}"` : ''}
/>
</svg>
@@ -756,6 +849,7 @@ class OverlayTemplateRendererService {
const isDateField = [
'releaseDate',
'nextEpisodeAirDate',
'nextSeasonAirDate',
'lastPlayed',
'dateAdded',
].includes(segment.field);
@@ -779,7 +873,7 @@ class OverlayTemplateRendererService {
segment.field.includes('Score') ||
segment.field.includes('Rating')
) {
// RT/Metacritic scores are percentages - no decimal needed (e.g., 89)
// RT scores are percentages - no decimal needed (e.g., 89)
formattedValue = Math.round(variableValue).toString();
} else {
formattedValue = variableValue.toString();
@@ -818,6 +912,7 @@ class OverlayTemplateRendererService {
font-weight="${props.fontWeight}"
font-style="${props.fontStyle}"
fill="${props.color}"
fill-opacity="${(props.opacity ?? 100) / 100}"
text-anchor="${
props.textAlign === 'center'
? 'middle'
+3 -2
View File
@@ -302,6 +302,7 @@ class PlexBasePosterManager {
plexApi: PlexAPI,
item: PlexLibraryItem,
posterSource: 'tmdb' | 'plex',
libraryId: string,
metadata: {
basePosterSource?: 'tmdb' | 'plex';
originalPlexPosterUrl?: string;
@@ -360,7 +361,7 @@ class PlexBasePosterManager {
item.type === 'movie' ? 'movie' : 'show';
// Get TMDB poster URL (lightweight - no download)
const language = getTmdbLanguage();
const language = await getTmdbLanguage(libraryId);
const tmdbClient = new TheMovieDb();
let posterUrl: string | undefined;
@@ -668,7 +669,7 @@ class PlexBasePosterManager {
});
// Get TMDB poster URL (lightweight - no download yet)
const language = getTmdbLanguage();
const language = await getTmdbLanguage(libraryId);
const tmdbClient = new TheMovieDb();
let posterUrl: string | undefined;
+191
View File
@@ -194,6 +194,7 @@ export const PRESET_TEMPLATES: {
applicationCondition: {
sections: [
{
// Monitored items with upcoming release (movies, TV series premieres)
rules: [
{ field: 'isMonitored', operator: 'eq', value: true },
{
@@ -204,6 +205,19 @@ export const PRESET_TEMPLATES: {
}, // Only ≤30 days
],
},
{
// OR monitored TV shows with upcoming season premiere (e.g., Season 2 when Season 1 doesn't exist)
sectionOperator: 'or',
rules: [
{ field: 'isMonitored', operator: 'eq', value: true },
{
ruleOperator: 'and',
field: 'daysUntilNextSeason',
operator: 'lte',
value: 30,
}, // Only ≤30 days
],
},
],
},
templateData: {
@@ -388,6 +402,37 @@ export const PRESET_TEMPLATES: {
},
],
},
{
// OR TV SHOWS: Already released (daysAgo >= 0), not downloaded, monitored, in Sonarr (fallback for shows without daysAgoNextSeason)
sectionOperator: 'or',
rules: [
{ field: 'mediaType', operator: 'eq', value: 'show' },
{
ruleOperator: 'and',
field: 'daysAgo',
operator: 'gte',
value: 0,
},
{
ruleOperator: 'and',
field: 'downloaded',
operator: 'eq',
value: false,
},
{
ruleOperator: 'and',
field: 'isMonitored',
operator: 'eq',
value: true,
},
{
ruleOperator: 'and',
field: 'inSonarr',
operator: 'eq',
value: true,
},
],
},
],
},
templateData: {
@@ -472,6 +517,91 @@ export const PRESET_TEMPLATES: {
},
},
// HDR10
{
name: 'HDR10',
description: 'Shows HDR10 logo for HDR content without Dolby Vision',
type: 'technical',
applicationCondition: {
sections: [
{
rules: [{ field: 'hdr', operator: 'eq', value: true }],
},
],
},
templateData: {
width: 1000,
height: 1500,
elements: [
{
id: 'hdr-logo',
layerOrder: 0,
type: 'svg',
x: 0,
y: 310.50617627005715,
width: 163,
height: 77,
properties: {
iconType: 'custom-icon',
iconPath: '/api/v1/posters/icons/system/hdr.svg',
opacity: 100,
grayscale: false,
},
},
],
},
},
// Dolby Vision
{
name: 'Dolby Vision',
description: 'Shows Dolby Vision logo for any DoVi profile',
type: 'technical',
applicationCondition: {
sections: [
{
rules: [{ field: 'dolbyVision', operator: 'eq', value: true }],
},
],
},
templateData: {
width: 1000,
height: 1500,
elements: [
{
id: 'dovi-background',
layerOrder: 0,
type: 'tile',
x: -5.5246784801583,
y: 390.50433315836005,
width: 169,
height: 85,
properties: {
fillColor: '#BCB8B8',
fillOpacity: 40,
borderColor: '#FFFFFF',
borderWidth: 0,
lockCorners: true,
borderRadiusTopLeft: 10,
},
},
{
id: 'dovi-logo',
layerOrder: 1,
type: 'raster',
x: 5.9753215198417,
y: 384.50433315836005,
width: 146,
height: 97,
properties: {
imagePath: '/api/v1/posters/icons/system/dolbyVision.png',
opacity: 100,
},
},
],
},
},
// ========================================
// BOTTOM BANNERS - Countdown/Date/Status for placeholders
// ========================================
@@ -2609,6 +2739,67 @@ export const PRESET_TEMPLATES: {
},
},
// ========================================
// MAINTAINERR INTEGRATION
// ========================================
// Maintainerr - Deleting Soon
{
name: 'Maintainerr Deleting Soon',
description:
'Bottom banner showing countdown for items marked for deletion by Maintainerr',
type: 'status',
applicationCondition: {
sections: [
{
rules: [{ field: 'daysUntilAction', operator: 'gte', value: 0 }],
},
],
},
templateData: {
width: 1000,
height: 1500,
elements: [
{
id: 'maintainerr-deleting-banner-bg',
layerOrder: 0,
type: 'tile',
x: 0,
y: 1405,
width: 1000,
height: 95,
properties: {
fillColor: '#F59E0B',
fillOpacity: 100,
borderRadius: 0,
},
},
{
id: 'maintainerr-deleting-text',
layerOrder: 1,
type: 'variable',
x: 0,
y: 1382,
width: 1000,
height: 141,
properties: {
segments: [
{ type: 'text', value: 'DELETING IN ' },
{ type: 'variable', field: 'daysUntilAction' },
{ type: 'text', value: ' DAYS' },
],
fontSize: 74,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
color: '#FFFFFF',
textAlign: 'center',
},
},
],
},
},
// ========================================
// TV SHOW STATUS
// ========================================
@@ -0,0 +1,510 @@
import logger from '@server/logger';
import fs from 'fs/promises';
import path from 'path';
import type { PlaceholderOptions, PlaceholderResult } from './types';
/**
* Sanitize filename to remove invalid characters
*/
function sanitizeFilename(filename: string): string {
return filename
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Create placeholder file for movie
*/
async function createMoviePlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { title, year, tmdbId, libraryPath, trailerPath } = options;
// Folder format: MovieName (Year)
const sanitizedTitle = sanitizeFilename(title);
const yearStr = year ? ` (${year})` : '';
const folderName = `${sanitizedTitle}${yearStr}`;
const movieFolder = path.join(libraryPath, folderName);
// Filename format: MovieName (Year) {tmdb-12345} {edition-Trailer}.mp4
const filename = `${folderName} {tmdb-${tmdbId}} {edition-Trailer}.mp4`;
const destinationPath = path.join(movieFolder, filename);
logger.debug('Creating movie placeholder', {
label: 'Coming Soon Placeholder',
title,
filename,
movieFolder,
destinationPath,
});
// Create movie folder
await fs.mkdir(movieFolder, { recursive: true });
// Copy trailer to movie folder
await fs.copyFile(trailerPath, destinationPath);
// Clean up temporary trailer file
try {
await fs.unlink(trailerPath);
logger.debug('Cleaned up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
});
} catch (error) {
logger.warn('Failed to clean up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
error: error instanceof Error ? error.message : String(error),
});
}
logger.info('Created movie placeholder', {
label: 'Coming Soon Placeholder',
title,
filename,
});
return {
placeholderPath: destinationPath,
filename,
};
}
/**
* Create placeholder file for TV show
*/
async function createTVPlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { title, year, libraryPath, trailerPath } = options;
// Directory format: ShowName (Year)/Season 00/S00E00.Trailer.mp4
const sanitizedTitle = sanitizeFilename(title);
const yearStr = year ? ` (${year})` : '';
const showDir = path.join(libraryPath, `${sanitizedTitle}${yearStr}`);
const seasonDir = path.join(showDir, 'Season 00');
logger.debug('Creating TV show placeholder', {
label: 'Coming Soon Placeholder',
title,
showDir,
seasonDir,
});
// Create directories
await fs.mkdir(seasonDir, { recursive: true });
// Create trailer file
const filename = 'S00E00.Trailer.mp4';
const destinationPath = path.join(seasonDir, filename);
await fs.copyFile(trailerPath, destinationPath);
// Clean up temporary trailer file
try {
await fs.unlink(trailerPath);
logger.debug('Cleaned up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
});
} catch (error) {
logger.warn('Failed to clean up temporary trailer file', {
label: 'Coming Soon Placeholder',
path: trailerPath,
error: error instanceof Error ? error.message : String(error),
});
}
// Create .comingsoon marker file for identification
const markerPath = path.join(seasonDir, '.comingsoon');
await fs.writeFile(
markerPath,
JSON.stringify({
createdAt: new Date().toISOString(),
title,
year,
tmdbId: options.tmdbId,
tvdbId: options.tvdbId,
}),
'utf-8'
);
logger.info('Created TV show placeholder', {
label: 'Coming Soon Placeholder',
title,
filename: destinationPath,
});
return {
placeholderPath: destinationPath,
filename,
};
}
/**
* Create placeholder file in Plex library
*/
export async function createPlaceholder(
options: PlaceholderOptions
): Promise<PlaceholderResult> {
const { mediaType } = options;
try {
if (mediaType === 'movie') {
return await createMoviePlaceholder(options);
} else {
return await createTVPlaceholder(options);
}
} catch (error) {
logger.error('Failed to create placeholder', {
label: 'Coming Soon Placeholder',
error: error instanceof Error ? error.message : String(error),
title: options.title,
mediaType: options.mediaType,
});
throw error;
}
}
/**
* Remove placeholder file
*/
export async function removePlaceholder(
placeholderPath: string,
mediaType: 'movie' | 'tv'
): Promise<void> {
try {
// Safety check: Verify path contains placeholder marker (supports both old and new format)
if (
!placeholderPath.includes('{edition-Trailer}') &&
!placeholderPath.includes('{edition-Placeholder}') &&
!placeholderPath.includes('{edition-Coming Soon}') &&
!placeholderPath.includes('S00E00.Trailer.mp4')
) {
logger.warn(
'Refusing to delete - path does not appear to be a placeholder',
{
label: 'Coming Soon Placeholder',
path: placeholderPath,
mediaType,
}
);
throw new Error('Invalid placeholder path - missing placeholder markers');
}
logger.debug('Removing placeholder', {
label: 'Coming Soon Placeholder',
path: placeholderPath,
mediaType,
});
// Delete the file
await fs.unlink(placeholderPath);
// For TV shows, also clean up parent directories if empty
if (mediaType === 'tv') {
const seasonDir = path.dirname(placeholderPath);
const showDir = path.dirname(seasonDir);
// Remove .comingsoon marker if it exists
const markerPath = path.join(seasonDir, '.comingsoon');
try {
await fs.unlink(markerPath);
} catch {
// Marker file might not exist, ignore
}
// Try to remove Season 00 directory if it's empty
try {
const files = await fs.readdir(seasonDir);
if (files.length === 0) {
await fs.rmdir(seasonDir);
logger.debug('Removed empty season directory', {
label: 'Coming Soon Placeholder',
path: seasonDir,
});
// Try to remove show directory if it's empty
const showFiles = await fs.readdir(showDir);
if (showFiles.length === 0) {
await fs.rmdir(showDir);
logger.debug('Removed empty show directory', {
label: 'Coming Soon Placeholder',
path: showDir,
});
}
}
} catch {
// Directory not empty or other error, ignore
}
}
logger.info('Removed placeholder successfully', {
label: 'Coming Soon Placeholder',
path: placeholderPath,
});
} catch (error) {
logger.error('Failed to remove placeholder', {
label: 'Coming Soon Placeholder',
error: error instanceof Error ? error.message : String(error),
path: placeholderPath,
});
throw error;
}
}
/**
* Marker file content structure
*/
export interface PlaceholderMarker {
createdAt: string;
title: string;
year?: number;
tmdbId?: number; // Optional for backward compatibility with old markers
tvdbId?: number;
}
/**
* Discovered marker with file path
*/
export interface DiscoveredMarker extends PlaceholderMarker {
filePath: string; // Path to the .comingsoon marker file
placeholderPath: string; // Path to the S00E00.Trailer.mp4 file
}
/**
* Scan a library directory for .comingsoon marker files
* Returns all discovered markers with their file paths
*/
export async function scanForMarkerFiles(
libraryPath: string
): Promise<DiscoveredMarker[]> {
const markers: DiscoveredMarker[] = [];
try {
// Get all items in the library root
const items = await fs.readdir(libraryPath, { withFileTypes: true });
for (const item of items) {
if (!item.isDirectory()) continue;
const showDir = path.join(libraryPath, item.name);
const season00Dir = path.join(showDir, 'Season 00');
// Check if Season 00 exists
try {
const season00Stat = await fs.stat(season00Dir);
if (!season00Stat.isDirectory()) continue;
} catch {
continue; // Season 00 doesn't exist
}
// Check for .comingsoon marker
const markerPath = path.join(season00Dir, '.comingsoon');
try {
const markerContent = await fs.readFile(markerPath, 'utf-8');
const markerData = JSON.parse(markerContent) as PlaceholderMarker;
// Path to the actual placeholder file
const placeholderPath = path.join(season00Dir, 'S00E00.Trailer.mp4');
markers.push({
...markerData,
filePath: markerPath,
placeholderPath,
});
logger.debug('Found placeholder marker', {
label: 'PlaceholderManager',
title: markerData.title,
hasTmdbId: !!markerData.tmdbId,
path: markerPath,
});
} catch (error) {
// Marker file doesn't exist or is invalid JSON - skip
logger.debug('No valid marker found in Season 00', {
label: 'PlaceholderManager',
path: season00Dir,
error: error instanceof Error ? error.message : String(error),
});
}
}
logger.info('Scanned library for placeholder markers', {
label: 'PlaceholderManager',
libraryPath,
markersFound: markers.length,
withTmdbId: markers.filter((m) => m.tmdbId).length,
withoutTmdbId: markers.filter((m) => !m.tmdbId).length,
});
return markers;
} catch (error) {
logger.error('Failed to scan for marker files', {
label: 'PlaceholderManager',
libraryPath,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Upgrade an old marker file to include tmdbId and tvdbId
*/
export async function upgradeMarkerFile(
markerPath: string,
tmdbId: number,
tvdbId?: number
): Promise<void> {
try {
// Read existing marker
const markerContent = await fs.readFile(markerPath, 'utf-8');
const markerData = JSON.parse(markerContent) as PlaceholderMarker;
// Add tmdbId and tvdbId
const upgradedMarker = {
...markerData,
tmdbId,
tvdbId,
};
// Write back to file
await fs.writeFile(
markerPath,
JSON.stringify(upgradedMarker, null, 2),
'utf-8'
);
logger.info('Upgraded marker file with TMDB ID', {
label: 'PlaceholderManager',
title: markerData.title,
tmdbId,
tvdbId,
path: markerPath,
});
} catch (error) {
logger.error('Failed to upgrade marker file', {
label: 'PlaceholderManager',
path: markerPath,
tmdbId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Discovered movie placeholder with metadata extracted from filename
*/
export interface DiscoveredMoviePlaceholder {
title: string; // Extracted from folder name
year?: number; // Extracted from folder name
tmdbId: number; // Extracted from {tmdb-12345}
placeholderPath: string; // Full path to the .mp4 file
folderPath: string; // Path to the movie folder
}
/**
* Scan a movie library directory for placeholder files based on filename pattern
* Movies use {edition-Trailer} and {tmdb-12345} in filename - no marker file needed
* Returns all discovered movie placeholders with extracted metadata
*/
export async function scanForMoviePlaceholders(
libraryPath: string
): Promise<DiscoveredMoviePlaceholder[]> {
const placeholders: DiscoveredMoviePlaceholder[] = [];
try {
// Get all items in the library root
const items = await fs.readdir(libraryPath, { withFileTypes: true });
for (const item of items) {
if (!item.isDirectory()) continue;
const movieFolder = path.join(libraryPath, item.name);
// Check for placeholder files in this folder
try {
const files = await fs.readdir(movieFolder);
for (const file of files) {
// Look for files with {edition-Trailer} pattern
if (
!file.includes('{edition-Trailer}') &&
!file.includes('{edition-Placeholder}') &&
!file.includes('{edition-Coming Soon}')
) {
continue;
}
// Extract TMDB ID from {tmdb-12345} pattern
const tmdbMatch = file.match(/\{tmdb-(\d+)\}/);
if (!tmdbMatch) {
logger.warn('Placeholder file missing TMDB ID in filename', {
label: 'PlaceholderManager',
file,
folder: movieFolder,
});
continue;
}
const tmdbId = parseInt(tmdbMatch[1], 10);
// Extract title and year from folder name
// Format: "MovieTitle (Year)" or "MovieTitle"
const folderName = item.name;
const yearMatch = folderName.match(/\((\d{4})\)$/);
const year = yearMatch ? parseInt(yearMatch[1], 10) : undefined;
const title = yearMatch
? folderName.substring(0, folderName.lastIndexOf('(')).trim()
: folderName;
const placeholderPath = path.join(movieFolder, file);
placeholders.push({
title,
year,
tmdbId,
placeholderPath,
folderPath: movieFolder,
});
logger.debug('Found movie placeholder', {
label: 'PlaceholderManager',
title,
year,
tmdbId,
path: placeholderPath,
});
// Only process first placeholder file per folder
break;
}
} catch (error) {
// Can't read folder contents, skip
logger.debug('Could not read movie folder', {
label: 'PlaceholderManager',
path: movieFolder,
error: error instanceof Error ? error.message : String(error),
});
}
}
logger.info('Scanned movie library for placeholders', {
label: 'PlaceholderManager',
libraryPath,
placeholdersFound: placeholders.length,
});
return placeholders;
} catch (error) {
logger.error('Failed to scan for movie placeholders', {
label: 'PlaceholderManager',
libraryPath,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
@@ -0,0 +1,816 @@
import type PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { ComingSoonItem } from '@server/entity/ComingSoonItem';
import type { LibraryItemsCache } from '@server/lib/collections/core/CollectionUtilities';
import type { CollectionConfig } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs/promises';
import path from 'path';
import { Like, Not } from 'typeorm';
import { getReleasedDays } from './PlaceholderCreation';
/**
* Helper function to clean up a placeholder when real content is detected
* Deletes the placeholder file and ALL database records for this TMDB ID across all collections
*/
export async function cleanupPlaceholderForRealContent(
tmdbId: number,
placeholderPath: string,
mediaType: 'movie' | 'tv'
): Promise<void> {
const { removePlaceholder } = await import(
'@server/lib/placeholders/placeholderManager'
);
const repository = getRepository(ComingSoonItem);
try {
// Delete the placeholder file
await removePlaceholder(placeholderPath, mediaType);
logger.info('Deleted placeholder file - real content detected', {
label: 'PlaceholderService',
tmdbId,
mediaType,
placeholderPath,
});
// Delete ALL database records for this TMDB ID (across all collections)
const allRecords = await repository.find({
where: { tmdbId },
});
if (allRecords.length > 0) {
await repository.delete({ tmdbId });
logger.info(
'Deleted placeholder database records across all collections',
{
label: 'PlaceholderService',
tmdbId,
recordsDeleted: allRecords.length,
collections: allRecords.map((r) => r.configId),
}
);
}
} catch (error) {
logger.error('Failed to clean up placeholder for real content', {
label: 'PlaceholderService',
tmdbId,
mediaType,
placeholderPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Handle placeholder operations based on createPlaceholdersForMissing setting
* - If enabled: runs cleanup (released items, orphaned items, stale items)
* - If disabled: deletes all placeholder records for the config
* Files will be cleaned up later by orphaned file cleanup
*/
export async function handlePlaceholderCleanup(
config: CollectionConfig,
plexClient: PlexAPI,
libraryCache?: LibraryItemsCache,
sourceTmdbIds?: Set<number>
): Promise<void> {
logger.debug('handlePlaceholderCleanup called', {
label: 'PlaceholderService',
configId: config.id,
configName: config.name,
createPlaceholdersForMissing: config.createPlaceholdersForMissing,
willDeleteAll: !config.createPlaceholdersForMissing,
});
if (config.createPlaceholdersForMissing) {
// Setting enabled - run normal cleanup
await cleanupPlaceholdersForConfig(
config,
plexClient,
libraryCache,
sourceTmdbIds
);
} else {
// Setting disabled - delete all placeholders for this config
await deleteAllPlaceholdersForConfig(config.id);
}
}
/**
* Delete all placeholder records for a config when createPlaceholdersForMissing is disabled
* Files will be cleaned up later by orphaned file cleanup
*/
export async function deleteAllPlaceholdersForConfig(
configId: string
): Promise<void> {
try {
const repository = getRepository(ComingSoonItem);
// Find all placeholders for this config (including multi-source sub-configs)
const placeholders = await repository.find({
where: [
{ configId },
{ configId: Like(`${configId}-source-%`) }, // Multi-source sub-collections
],
});
if (placeholders.length === 0) {
return;
}
logger.info(
`Deleting ${placeholders.length} placeholder records for config with createPlaceholdersForMissing disabled`,
{
label: 'PlaceholderService',
configId,
count: placeholders.length,
}
);
await repository.remove(placeholders);
logger.info('Placeholder records deleted successfully', {
label: 'PlaceholderService',
configId,
removed: placeholders.length,
});
} catch (error) {
logger.error('Failed to delete placeholder records for config', {
label: 'PlaceholderService',
configId,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Cleanup orphaned placeholder DB records where the collection no longer exists
* This runs during full sync to clean up records from deleted collections
*/
export async function cleanupOrphanedPlaceholderRecords(): Promise<void> {
try {
const repository = getRepository(ComingSoonItem);
const settings = getSettings();
// Get all active collection config IDs
const activeConfigs = settings.plex.collectionConfigs || [];
const activeConfigIds = new Set(activeConfigs.map((c) => c.id));
// Get all placeholder records
const allRecords = await repository.find();
logger.debug('Starting orphaned placeholder record cleanup', {
label: 'PlaceholderService',
totalRecords: allRecords.length,
activeConfigCount: activeConfigs.length,
sampleConfigIds: allRecords.slice(0, 5).map((r) => r.configId),
});
if (allRecords.length === 0) {
logger.debug('No placeholder records in database', {
label: 'PlaceholderService',
});
return;
}
// Find orphaned records
const orphanedRecords = allRecords.filter((record) => {
// Check if configId exists in active configs
if (activeConfigIds.has(record.configId)) {
return false;
}
// For multi-source sub-collections (e.g., "33079-source-1762115269335")
// Check if the parent config exists
const match = record.configId.match(/^(\d+)-source-/);
if (match) {
const parentId = match[1];
logger.debug('Checking multi-source placeholder record', {
label: 'PlaceholderService',
recordConfigId: record.configId,
extractedParentId: parentId,
parentExists: activeConfigIds.has(parentId),
activeConfigIds: Array.from(activeConfigIds),
});
if (activeConfigIds.has(parentId)) {
return false; // Parent exists, keep record
}
} else if (record.configId.includes('-source-')) {
// Log if we have a source ID but regex didn't match
logger.warn('Multi-source configId did not match regex pattern', {
label: 'PlaceholderService',
recordConfigId: record.configId,
regexPattern: '/^(\\d+)-source-/',
});
}
return true; // No matching config found - orphaned
});
if (orphanedRecords.length === 0) {
logger.debug('No orphaned placeholder records found', {
label: 'PlaceholderService',
totalRecords: allRecords.length,
});
return;
}
logger.info(
`Found ${orphanedRecords.length} orphaned placeholder records to clean up`,
{
label: 'PlaceholderService',
orphanedCount: orphanedRecords.length,
totalRecords: allRecords.length,
}
);
// Delete orphaned records (files will be cleaned up separately)
await repository.remove(orphanedRecords);
logger.info('Orphaned placeholder records cleaned up', {
label: 'PlaceholderService',
removed: orphanedRecords.length,
});
} catch (error) {
logger.error('Failed to cleanup orphaned placeholder records', {
label: 'PlaceholderService',
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Cleanup orphaned placeholder files where no DB records reference them
* This runs after record cleanup to remove files that are no longer tracked
* @returns Number of files removed
*/
export async function cleanupOrphanedPlaceholderFiles(): Promise<number> {
try {
const repository = getRepository(ComingSoonItem);
const settings = getSettings();
const movieLibraryPath = settings.main.placeholderMovieRootFolder;
const tvLibraryPath = settings.main.placeholderTVRootFolder;
if (!movieLibraryPath && !tvLibraryPath) {
logger.debug(
'No placeholder library paths configured, skipping file cleanup',
{
label: 'PlaceholderService',
}
);
return 0;
}
// Get all placeholder file paths from database
const allRecords = await repository.find();
logger.debug('Starting orphaned placeholder file cleanup', {
label: 'PlaceholderService',
totalRecordsInDatabase: allRecords.length,
samplePaths: allRecords.slice(0, 5).map((r) => r.placeholderPath),
});
const trackedPaths = new Set(allRecords.map((r) => r.placeholderPath));
let filesRemoved = 0;
// Scan movie library for orphaned files
if (movieLibraryPath) {
try {
const movieFolders = await fs.readdir(movieLibraryPath);
for (const folder of movieFolders) {
const folderPath = path.join(movieLibraryPath, folder);
try {
const stats = await fs.stat(folderPath);
if (!stats.isDirectory()) continue;
const files = await fs.readdir(folderPath);
for (const file of files) {
// Check if this is a placeholder file (contains edition-Trailer)
if (!file.includes('{edition-Trailer}')) continue;
const filePath = path.join(folderPath, file);
const relativePath = path.join(folder, file);
// Check if any DB record references this file
if (!trackedPaths.has(relativePath)) {
// Orphaned file - delete it
try {
const { removePlaceholder } = await import(
'@server/lib/placeholders/placeholderManager'
);
await removePlaceholder(filePath, 'movie');
filesRemoved++;
logger.info('Removed orphaned placeholder file', {
label: 'PlaceholderService',
path: relativePath,
mediaType: 'movie',
});
} catch (error) {
logger.warn('Failed to remove orphaned placeholder file', {
label: 'PlaceholderService',
path: relativePath,
error:
error instanceof Error ? error.message : String(error),
});
}
}
}
} catch (error) {
// Folder access error, skip
continue;
}
}
} catch (error) {
logger.warn('Failed to scan movie library for orphaned files', {
label: 'PlaceholderService',
path: movieLibraryPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Scan TV library for orphaned files
if (tvLibraryPath) {
try {
const showFolders = await fs.readdir(tvLibraryPath);
for (const showFolder of showFolders) {
const showPath = path.join(tvLibraryPath, showFolder);
try {
const stats = await fs.stat(showPath);
if (!stats.isDirectory()) continue;
const seasonFolders = await fs.readdir(showPath);
for (const seasonFolder of seasonFolders) {
if (seasonFolder !== 'Season 00') continue; // Only check Season 00
const seasonPath = path.join(showPath, seasonFolder);
const seasonStats = await fs.stat(seasonPath);
if (!seasonStats.isDirectory()) continue;
const files = await fs.readdir(seasonPath);
for (const file of files) {
// Check if this is a placeholder file (S00E00.Trailer.mp4)
if (file !== 'S00E00.Trailer.mp4') continue;
const filePath = path.join(seasonPath, file);
const relativePath = path.join(showFolder, seasonFolder, file);
// Check if any DB record references this file
if (!trackedPaths.has(relativePath)) {
// Orphaned file - delete it
try {
const { removePlaceholder } = await import(
'@server/lib/placeholders/placeholderManager'
);
await removePlaceholder(filePath, 'tv');
filesRemoved++;
logger.info('Removed orphaned placeholder file', {
label: 'PlaceholderService',
path: relativePath,
mediaType: 'tv',
});
} catch (error) {
logger.warn('Failed to remove orphaned placeholder file', {
label: 'PlaceholderService',
path: relativePath,
error:
error instanceof Error ? error.message : String(error),
});
}
}
}
}
} catch (error) {
// Folder access error, skip
continue;
}
}
} catch (error) {
logger.warn('Failed to scan TV library for orphaned files', {
label: 'PlaceholderService',
path: tvLibraryPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (filesRemoved > 0) {
logger.info('Orphaned placeholder files cleaned up', {
label: 'PlaceholderService',
filesRemoved,
});
}
return filesRemoved;
} catch (error) {
logger.error('Failed to cleanup orphaned placeholder files', {
label: 'PlaceholderService',
error: error instanceof Error ? error.message : String(error),
});
return 0;
}
}
/**
* Clean up placeholders for a collection:
* 1. Items with real content detected in Plex (via discovery system)
* 2. Items no longer in source data (orphaned items)
* 3. Items that have been placeholders for 7+ days (stale items)
*
* Released items are tracked for configured window (placeholderReleasedDays, default: 7 days),
* then database records are removed and overlay system automatically updates posters.
*
* Works for ANY collection type that creates placeholders.
*
* @param config - Collection configuration
* @param plexClient - Plex API client
* @param libraryCache - Optional cached library items for verification
* @param sourceTmdbIds - Optional set of tmdbIds from current source for orphan detection
*/
export async function cleanupPlaceholdersForConfig(
config: CollectionConfig,
plexClient: PlexAPI,
libraryCache?: LibraryItemsCache,
sourceTmdbIds?: Set<number>
): Promise<void> {
let repository;
let placeholders;
try {
repository = getRepository(ComingSoonItem);
// Find placeholders for this config (including multi-source sub-configs)
placeholders = await repository.find({
where: [
{ configId: config.id },
{ configId: Like(`${config.id}-source-%`) }, // Multi-source sub-collections
],
});
} catch (error) {
// If table doesn't exist yet (first run), skip cleanup
logger.debug('Skipping placeholder cleanup - table not initialized yet', {
label: 'PlaceholderService',
error: error instanceof Error ? error.message : String(error),
});
return;
}
if (placeholders.length === 0) {
return;
}
logger.info('Checking placeholders for cleanup', {
label: 'PlaceholderService',
configName: config.name,
count: placeholders.length,
});
let removedCount = 0;
// NOTE: Title fixing and real content cleanup now happens globally during discovery
// This function only handles collection-specific orphaned/stale item cleanup
// Get released window from general config (not Coming Soon specific!)
const releasedWindowDays = getReleasedDays(config);
// Check for orphaned items (not in source) and stale items (too old)
if (sourceTmdbIds && sourceTmdbIds.size > 0) {
const STALE_THRESHOLD_DAYS = 7; // 7 days
let orphanedCount = 0;
let staleCount = 0;
for (const placeholder of placeholders) {
try {
// No need to skip items - we process all orphaned items
const isOrphaned = !sourceTmdbIds.has(placeholder.tmdbId);
const isStale =
placeholder.createdAt &&
Date.now() - placeholder.createdAt.getTime() >
STALE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
// For orphaned items, check if past configured window
if (isOrphaned && !isStale) {
// This handles items that fall off source lists (e.g., Trakt Trending)
// Keep them for placeholderReleasedDays from:
// - Release date (if released) - so users see "recently released" items
// - Creation date (if not released yet) - so users see upcoming items
// Fetch release date from TMDB to determine window start
const { placeholderContextService } = await import(
'@server/lib/placeholders/services/PlaceholderContextService'
);
const context = await placeholderContextService.getPlaceholderContext(
placeholder
);
let windowStartDate: Date = placeholder.createdAt;
let windowType = 'creation';
if (context.releaseDate) {
// Check if release date is in the past (item has been released)
const { isDateInFuture } = await import(
'@server/utils/dateHelpers'
);
if (!isDateInFuture(context.releaseDate)) {
// Item has been released - use release date as window start
// Parse ISO date string (YYYY-MM-DD) as UTC midnight
const dateOnly = context.releaseDate.split('T')[0];
windowStartDate = new Date(dateOnly + 'T00:00:00.000Z');
windowType = 'release';
}
}
const daysSinceWindowStart = Math.floor(
(Date.now() - windowStartDate.getTime()) / (24 * 60 * 60 * 1000)
);
if (daysSinceWindowStart > releasedWindowDays) {
const reason = `orphaned (${daysSinceWindowStart} days since ${windowType}, window: ${releasedWindowDays} days)`;
logger.info('Removing orphaned placeholder past window', {
label: 'PlaceholderService',
title: placeholder.title,
source: placeholder.source,
reason,
windowType,
daysSinceWindowStart,
releasedWindowDays,
releaseDate: context.releaseDate,
});
// Remove placeholder file if it exists
let fileRemovalSucceeded = false;
if (placeholder.placeholderPath) {
const { removePlaceholder } = await import(
'@server/lib/placeholders/placeholderManager'
);
const settings = getSettings();
const libraryPath =
placeholder.mediaType === 'movie'
? settings.main.placeholderMovieRootFolder
: settings.main.placeholderTVRootFolder;
if (!libraryPath) {
logger.error(
'Library path not configured - cannot remove placeholder file',
{
label: 'PlaceholderService',
title: placeholder.title,
mediaType: placeholder.mediaType,
}
);
continue;
}
// Construct full path from relative path
const fullPath = path.join(
libraryPath,
placeholder.placeholderPath
);
// Check if any OTHER collection still needs this file
const otherCollectionRecords = await repository.find({
where: {
placeholderPath: placeholder.placeholderPath,
configId: Not(config.id),
},
});
if (otherCollectionRecords.length > 0) {
// Other collections still use this file - don't delete it
fileRemovalSucceeded = true;
logger.info(
'Placeholder file past window shared with other collections - keeping file',
{
label: 'PlaceholderService',
title: placeholder.title,
otherCollections: otherCollectionRecords.length,
}
);
} else {
// No other collections use this file - safe to delete
try {
await removePlaceholder(fullPath, placeholder.mediaType);
fileRemovalSucceeded = true;
logger.info('Removed placeholder file', {
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
});
} catch (error) {
// If file doesn't exist (ENOENT), treat as successful removal
const isFileNotFound =
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT';
if (isFileNotFound) {
fileRemovalSucceeded = true;
logger.info(
'Placeholder file already removed - cleaning up database record',
{
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
}
);
} else {
logger.error(
'Failed to remove placeholder file - keeping database record',
{
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
error:
error instanceof Error
? error.message
: String(error),
}
);
continue;
}
}
}
} else {
fileRemovalSucceeded = true; // No file to remove
}
// Remove from database if file removal succeeded
if (fileRemovalSucceeded) {
await repository.remove(placeholder);
removedCount++;
orphanedCount++;
logger.info('Removed placeholder from database', {
label: 'PlaceholderService',
title: placeholder.title,
source: placeholder.source,
reason,
});
}
}
}
// Stale items (7+ days old) - always remove
if (isStale) {
const reason = `stale (${STALE_THRESHOLD_DAYS}+ days old)`;
logger.info('Removing stale placeholder', {
label: 'PlaceholderService',
title: placeholder.title,
source: placeholder.source,
reason,
age: placeholder.createdAt
? Math.floor(
(Date.now() - placeholder.createdAt.getTime()) /
(24 * 60 * 60 * 1000)
)
: 'unknown',
});
// Remove placeholder file if it exists
let fileRemovalSucceeded = false;
if (placeholder.placeholderPath) {
const { removePlaceholder } = await import(
'@server/lib/placeholders/placeholderManager'
);
const settings = getSettings();
const libraryPath =
placeholder.mediaType === 'movie'
? settings.main.placeholderMovieRootFolder
: settings.main.placeholderTVRootFolder;
if (!libraryPath) {
logger.error(
'Library path not configured - cannot remove placeholder file',
{
label: 'PlaceholderService',
title: placeholder.title,
mediaType: placeholder.mediaType,
}
);
continue;
}
// Construct full path from relative path
const fullPath = path.join(
libraryPath,
placeholder.placeholderPath
);
// Check if any OTHER collection still needs this file
const otherCollectionRecords = await repository.find({
where: {
placeholderPath: placeholder.placeholderPath,
configId: Not(config.id),
},
});
if (otherCollectionRecords.length > 0) {
// Other collections still use this file - don't delete it
fileRemovalSucceeded = true;
logger.info(
'Stale placeholder file shared with other collections - keeping file',
{
label: 'PlaceholderService',
title: placeholder.title,
otherCollections: otherCollectionRecords.length,
}
);
} else {
// No other collections use this file - safe to delete
try {
await removePlaceholder(fullPath, placeholder.mediaType);
fileRemovalSucceeded = true;
logger.info('Removed placeholder file', {
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
});
} catch (error) {
// If file doesn't exist (ENOENT), treat as successful removal
const isFileNotFound =
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT';
if (isFileNotFound) {
fileRemovalSucceeded = true;
logger.info(
'Placeholder file already removed - cleaning up database record',
{
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
}
);
} else {
logger.error(
'Failed to remove placeholder file - keeping database record',
{
label: 'PlaceholderService',
title: placeholder.title,
path: fullPath,
error:
error instanceof Error ? error.message : String(error),
}
);
continue;
}
}
}
} else {
fileRemovalSucceeded = true; // No file to remove
}
// Remove from database if file removal succeeded
if (fileRemovalSucceeded) {
await repository.remove(placeholder);
removedCount++;
staleCount++;
logger.info('Removed placeholder from database', {
label: 'PlaceholderService',
title: placeholder.title,
source: placeholder.source,
reason,
});
}
}
} catch (error) {
logger.error('Error removing stale placeholder', {
label: 'PlaceholderService',
title: placeholder.title,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (orphanedCount > 0 || staleCount > 0) {
logger.info('Orphaned/stale placeholder cleanup summary', {
label: 'PlaceholderService',
configName: config.name,
orphaned: orphanedCount,
stale: staleCount,
total: orphanedCount + staleCount,
});
}
}
if (removedCount > 0) {
logger.info('Placeholder cleanup completed', {
label: 'PlaceholderService',
configName: config.name,
removed: removedCount,
});
}
}
@@ -0,0 +1,464 @@
import type PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { ComingSoonItem } from '@server/entity/ComingSoonItem';
import {
findPlexItemsByTitle,
findPlexItemsByTmdbIds,
} from '@server/lib/collections/core/CollectionUtilities';
import logger from '@server/logger';
/**
* Discovered placeholder with Plex item information
*/
export interface DiscoveredPlaceholder {
marker: {
title: string;
year?: number;
tmdbId?: number;
tvdbId?: number;
filePath: string;
placeholderPath: string;
};
plexItem?: {
ratingKey: string;
title: string;
};
needsTitleFix: boolean;
discoveryMethod: 'tmdb-id' | 'database-lookup' | 'title-search' | 'not-found';
}
/**
* Extract TMDB ID from Plex item metadata
*/
function extractTmdbIdFromPlexItem(plexItem: {
Guid?: { id: string }[];
}): number | null {
if (!plexItem.Guid || plexItem.Guid.length === 0) {
return null;
}
for (const guid of plexItem.Guid) {
const tmdbMatch = guid.id.match(/tmdb:\/\/(\d+)/);
if (tmdbMatch) {
return parseInt(tmdbMatch[1], 10);
}
}
return null;
}
/**
* Discovered movie placeholder with Plex item information
*/
export interface DiscoveredMoviePlaceholder {
movie: {
title: string;
year?: number;
tmdbId: number;
placeholderPath: string;
folderPath: string;
};
plexItem?: {
ratingKey: string;
title: string;
};
needsCleanup: boolean;
discoveryMethod: 'tmdb-id' | 'not-found';
}
/**
* Three-tier placeholder discovery system using .comingsoon marker files
*
* Tier 1: Markers with tmdbId Direct TMDB lookup (fast, for new placeholders)
* Tier 2: Markers without tmdbId + DB record Database-assisted upgrade (migration path)
* Tier 3: Markers without tmdbId + No DB record Title-based fallback (orphan recovery)
*
* This approach scales O(p) with placeholder count, not O(n) with library size
*
* @param plexClient - Plex API client
* @param libraryId - Library ID to search in
* @param libraryPath - Filesystem path to library root
* @returns Array of discovered placeholders with Plex matching information
*/
export async function discoverPlaceholdersFromMarkers(
plexClient: PlexAPI,
libraryId: string,
libraryPath: string
): Promise<DiscoveredPlaceholder[]> {
const { scanForMarkerFiles, upgradeMarkerFile } = await import(
'@server/lib/placeholders/placeholderManager'
);
// Step 1: Scan filesystem for .comingsoon marker files
const markers = await scanForMarkerFiles(libraryPath);
if (markers.length === 0) {
logger.debug('No placeholder markers found in library', {
label: 'PlaceholderService',
libraryId,
libraryPath,
});
return [];
}
const discovered: DiscoveredPlaceholder[] = [];
const repository = getRepository(ComingSoonItem);
// Separate markers by tier for efficient batch processing
const tier1Markers = markers.filter((m) => m.tmdbId);
const tier2And3Markers = markers.filter((m) => !m.tmdbId);
// Import PlaceholderContextService for verification
const { placeholderContextService } = await import(
'@server/lib/placeholders/services/PlaceholderContextService'
);
// TIER 1: Batch process markers with tmdbId (new format)
if (tier1Markers.length > 0) {
logger.info('Processing Tier 1: Markers with TMDB IDs', {
label: 'PlaceholderService',
count: tier1Markers.length,
});
// Batch query Plex for all tmdbIds at once
const tmdbLookups = tier1Markers
.filter((m) => m.tmdbId !== undefined)
.map((m) => ({
tmdbId: m.tmdbId as number,
mediaType: 'tv' as const,
title: m.title,
}));
const plexMatches = await findPlexItemsByTmdbIds(
plexClient,
tmdbLookups,
libraryId
);
for (const marker of tier1Markers) {
if (!marker.tmdbId) {
continue;
}
const plexItem = plexMatches.get(`${marker.tmdbId}-tv`);
// Verify it's still a placeholder (check if real content was added)
let needsTitleFix = false;
if (plexItem) {
const plexMetadata = await plexClient.getMetadata(
plexItem.ratingKey.toString(),
{ includeChildren: true }
);
const isStillPlaceholder =
placeholderContextService.isPlaceholderItem(plexMetadata);
needsTitleFix = isStillPlaceholder;
if (!isStillPlaceholder) {
logger.info('Placeholder has real content now - skipping title fix', {
label: 'PlaceholderService',
title: marker.title,
ratingKey: plexItem.ratingKey,
});
}
}
discovered.push({
marker,
plexItem: plexItem
? { ratingKey: plexItem.ratingKey, title: plexItem.title }
: undefined,
needsTitleFix,
discoveryMethod: 'tmdb-id',
});
}
}
// TIER 2 & 3: Process old markers without tmdbId
for (const marker of tier2And3Markers) {
// TIER 2: Check database for existing record
const dbRecord = await repository.findOne({
where: { placeholderPath: marker.placeholderPath },
});
if (dbRecord) {
logger.info('Tier 2: Found database record for old marker', {
label: 'PlaceholderService',
title: marker.title,
tmdbId: dbRecord.tmdbId,
});
// Upgrade marker file with tmdbId from database
try {
await upgradeMarkerFile(
marker.filePath,
dbRecord.tmdbId,
dbRecord.tvdbId
);
// Query Plex using tmdbId from database
const plexMatches = await findPlexItemsByTmdbIds(
plexClient,
[{ tmdbId: dbRecord.tmdbId, mediaType: 'tv', title: marker.title }],
libraryId
);
const plexItem = plexMatches.get(`${dbRecord.tmdbId}-tv`);
// Verify it's still a placeholder (check if real content was added)
let needsTitleFix = false;
if (plexItem) {
const plexMetadata = await plexClient.getMetadata(
plexItem.ratingKey.toString(),
{ includeChildren: true }
);
const isStillPlaceholder =
placeholderContextService.isPlaceholderItem(plexMetadata);
needsTitleFix = isStillPlaceholder;
if (!isStillPlaceholder) {
logger.info(
'Placeholder has real content now - skipping title fix',
{
label: 'PlaceholderService',
title: marker.title,
ratingKey: plexItem.ratingKey,
}
);
}
}
discovered.push({
marker: {
...marker,
tmdbId: dbRecord.tmdbId,
tvdbId: dbRecord.tvdbId,
},
plexItem: plexItem
? { ratingKey: plexItem.ratingKey, title: plexItem.title }
: undefined,
needsTitleFix,
discoveryMethod: 'database-lookup',
});
continue;
} catch (error) {
logger.warn('Failed to upgrade marker from database record', {
label: 'PlaceholderService',
title: marker.title,
error: error instanceof Error ? error.message : String(error),
});
// Fall through to Tier 3
}
}
// TIER 3: Truly orphaned - fallback to title search
logger.info('Tier 3: Orphaned placeholder - using title search', {
label: 'PlaceholderService',
title: marker.title,
year: marker.year,
path: marker.filePath,
});
const titleMatches = await findPlexItemsByTitle(
plexClient,
marker.title,
marker.year,
libraryId,
'tv'
);
if (titleMatches.length > 0) {
const plexItem = titleMatches[0];
// Try to extract tmdbId from Plex item and upgrade marker
const plexItemDetails = await plexClient.getMetadata(plexItem.ratingKey, {
includeChildren: true,
});
const tmdbId = extractTmdbIdFromPlexItem(plexItemDetails);
// Verify it's still a placeholder (check if real content was added)
const isStillPlaceholder =
placeholderContextService.isPlaceholderItem(plexItemDetails);
if (!isStillPlaceholder) {
logger.info(
'Orphan found but has real content now - skipping title fix',
{
label: 'PlaceholderService',
title: marker.title,
ratingKey: plexItem.ratingKey,
}
);
}
if (tmdbId) {
try {
await upgradeMarkerFile(marker.filePath, tmdbId);
// Create database record for future runs
await repository.save({
tmdbId,
title: marker.title,
placeholderPath: marker.placeholderPath,
configId: '', // Will be updated when placeholder is properly tracked
createdAt: new Date(),
updatedAt: new Date(),
});
logger.info('Adopted orphaned placeholder and created DB record', {
label: 'PlaceholderService',
title: marker.title,
tmdbId,
ratingKey: plexItem.ratingKey,
});
} catch (error) {
logger.warn('Failed to upgrade orphaned marker', {
label: 'PlaceholderService',
title: marker.title,
error: error instanceof Error ? error.message : String(error),
});
}
}
discovered.push({
marker: { ...marker, tmdbId: tmdbId ?? undefined },
plexItem: { ratingKey: plexItem.ratingKey, title: plexItem.title },
needsTitleFix: isStillPlaceholder,
discoveryMethod: 'title-search',
});
} else {
// Not found in Plex at all
logger.warn('Orphaned placeholder not found in Plex', {
label: 'PlaceholderService',
title: marker.title,
path: marker.placeholderPath,
});
discovered.push({
marker,
plexItem: undefined,
needsTitleFix: false,
discoveryMethod: 'not-found',
});
}
}
logger.info('Placeholder discovery complete', {
label: 'PlaceholderService',
libraryId,
total: discovered.length,
tier1: discovered.filter((d) => d.discoveryMethod === 'tmdb-id').length,
tier2: discovered.filter((d) => d.discoveryMethod === 'database-lookup')
.length,
tier3: discovered.filter((d) => d.discoveryMethod === 'title-search')
.length,
notFound: discovered.filter((d) => d.discoveryMethod === 'not-found')
.length,
});
return discovered;
}
/**
* Movie placeholder discovery using filename patterns
*
* Movies store metadata in filename: {tmdb-12345} {edition-Trailer}
* This is simpler than TV shows - just scan filenames and extract TMDB IDs
*
* This approach scales O(p) with placeholder count, not O(n) with library size
*
* @param plexClient - Plex API client
* @param libraryId - Library ID to search in
* @param libraryPath - Filesystem path to library root
* @returns Array of discovered movie placeholders with Plex matching information
*/
export async function discoverMoviePlaceholdersFromFilenames(
plexClient: PlexAPI,
libraryId: string,
libraryPath: string
): Promise<DiscoveredMoviePlaceholder[]> {
const { scanForMoviePlaceholders } = await import(
'@server/lib/placeholders/placeholderManager'
);
// Step 1: Scan filesystem for placeholder files
const movies = await scanForMoviePlaceholders(libraryPath);
if (movies.length === 0) {
logger.debug('No movie placeholders found in library', {
label: 'PlaceholderService',
libraryId,
libraryPath,
});
return [];
}
const discovered: DiscoveredMoviePlaceholder[] = [];
// Import PlaceholderContextService for verification
const { placeholderContextService } = await import(
'@server/lib/placeholders/services/PlaceholderContextService'
);
// Step 2: Batch query Plex for all TMDB IDs at once
const tmdbLookups = movies.map((m) => ({
tmdbId: m.tmdbId,
mediaType: 'movie' as const,
title: m.title,
}));
logger.info('Discovering movie placeholders via filename parsing', {
label: 'PlaceholderService',
count: movies.length,
});
const plexMatches = await findPlexItemsByTmdbIds(
plexClient,
tmdbLookups,
libraryId
);
// Step 3: Match filesystem placeholders to Plex items and verify they're still placeholders
for (const movie of movies) {
const plexItem = plexMatches.get(`${movie.tmdbId}-movie`);
// Verify it's still a placeholder (check if real movie was added)
let needsCleanup = false;
if (plexItem) {
const plexMetadata = await plexClient.getMetadata(
plexItem.ratingKey.toString(),
{ includeChildren: true }
);
const isStillPlaceholder =
placeholderContextService.isPlaceholderItem(plexMetadata);
if (!isStillPlaceholder) {
logger.info('Movie placeholder has real content now', {
label: 'PlaceholderService',
title: movie.title,
ratingKey: plexItem.ratingKey,
});
needsCleanup = true; // Mark for cleanup - real movie exists
}
}
discovered.push({
movie,
plexItem: plexItem
? { ratingKey: plexItem.ratingKey, title: plexItem.title }
: undefined,
needsCleanup,
discoveryMethod: plexItem ? 'tmdb-id' : 'not-found',
});
}
logger.info('Movie placeholder discovery complete', {
label: 'PlaceholderService',
libraryId,
total: discovered.length,
matched: discovered.filter((d) => d.plexItem).length,
notFound: discovered.filter((d) => !d.plexItem).length,
});
return discovered;
}
@@ -0,0 +1,133 @@
import type PlexAPI from '@server/api/plexapi';
import logger from '@server/logger';
/**
* Ensure a TV show placeholder has the correct episode title set
* Includes retry logic to handle cases where Plex hasn't fully populated episode metadata yet
* @param plexClient - Plex API client
* @param showRatingKey - The show's rating key in Plex
* @param showTitle - The show's title (for logging)
* @param maxRetries - Maximum number of retry attempts (default: 5)
* @param retryDelayMs - Delay between retries in milliseconds (default: 2000)
* @returns true if title was set successfully, false otherwise
*/
export async function ensurePlaceholderEpisodeTitle(
plexClient: PlexAPI,
showRatingKey: string,
showTitle: string,
maxRetries = 5,
retryDelayMs = 2000
): Promise<boolean> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Get seasons for the show
const seasons = await plexClient.getChildrenMetadata(showRatingKey);
if (!seasons || seasons.length === 0) {
logger.debug(
`Attempt ${attempt}/${maxRetries}: No seasons found for show`,
{
label: 'PlaceholderService',
title: showTitle,
ratingKey: showRatingKey,
}
);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
return false;
}
// Find Season 00
const season00 = seasons.find((season) => season.index === 0);
if (!season00) {
logger.debug(
`Attempt ${attempt}/${maxRetries}: Season 00 not found for show`,
{
label: 'PlaceholderService',
title: showTitle,
availableSeasons: seasons.map((s) => s.index),
}
);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
return false;
}
// Get episodes from Season 00
const episodesData = await plexClient.getChildrenMetadata(
season00.ratingKey
);
if (!episodesData || episodesData.length === 0) {
logger.debug(
`Attempt ${attempt}/${maxRetries}: No episodes found in Season 00`,
{
label: 'PlaceholderService',
title: showTitle,
season00RatingKey: season00.ratingKey,
}
);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
return false;
}
// Get the first episode (should be S00E00)
const episode = episodesData[0];
// Check if title is already correct
if (episode.title === 'Trailer (Placeholder)') {
logger.debug('Episode title already correct', {
label: 'PlaceholderService',
title: showTitle,
episodeRatingKey: episode.ratingKey,
});
return true;
}
// Set the correct title
await plexClient.updateItemTitle(
episode.ratingKey,
'Trailer (Placeholder)'
);
logger.info('Successfully set placeholder episode title', {
label: 'PlaceholderService',
title: showTitle,
episodeRatingKey: episode.ratingKey,
oldTitle: episode.title,
attempt,
});
return true;
} catch (error) {
logger.warn(
`Attempt ${attempt}/${maxRetries}: Error setting placeholder episode title`,
{
label: 'PlaceholderService',
title: showTitle,
ratingKey: showRatingKey,
error: error instanceof Error ? error.message : String(error),
}
);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
return false;
}
}
return false;
}
@@ -1,8 +1,9 @@
import logger from '@server/logger';
import { spawn } from 'child_process';
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import type { TrailerDownloadOptions, VideoMetadata } from './types';
import type { TrailerDownloadOptions } from './types';
// Polyfill Intl.ListFormat if not available (needed for @sindresorhus/is in ts-node/CommonJS context)
// This must be done BEFORE any dynamic imports that might use it
@@ -38,119 +39,6 @@ async function getYoutubeSearch() {
return youtubeSearchModule;
}
/**
* Extract video metadata using yt-dlp before downloading
* This allows us to check duration and file size before committing to download
*/
async function extractVideoMetadata(videoUrl: string): Promise<VideoMetadata> {
return new Promise((resolve, reject) => {
logger.debug('Extracting video metadata with yt-dlp', {
label: 'Coming Soon Trailer',
videoUrl,
});
// Use yt-dlp to extract metadata without downloading
const ytdlp = spawn('yt-dlp', [
'--dump-json',
'-f',
'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080]',
videoUrl,
]);
let stdoutOutput = '';
let stderrOutput = '';
ytdlp.stdout.on('data', (data) => {
stdoutOutput += data.toString();
});
ytdlp.stderr.on('data', (data) => {
stderrOutput += data.toString();
});
ytdlp.on('close', (code) => {
if (code === 0) {
try {
const metadata = JSON.parse(stdoutOutput) as VideoMetadata;
logger.debug('Successfully extracted video metadata', {
label: 'Coming Soon Trailer',
duration: metadata.duration,
filesize: metadata.filesize,
filesize_approx: metadata.filesize_approx,
title: metadata.title,
});
resolve(metadata);
} catch (parseError) {
reject(
new Error(
`Failed to parse yt-dlp metadata: ${
parseError instanceof Error
? parseError.message
: String(parseError)
}`
)
);
}
} else {
logger.error('yt-dlp metadata extraction failed', {
label: 'Coming Soon Trailer',
code,
stdout: stdoutOutput,
stderr: stderrOutput,
});
reject(
new Error(
`yt-dlp metadata extraction exited with code ${code}: ${stderrOutput}`
)
);
}
});
ytdlp.on('error', (error) => {
logger.error('yt-dlp metadata extraction spawn error', {
label: 'Coming Soon Trailer',
error: error.message,
});
reject(error);
});
});
}
/**
* Validate video metadata against configured limits
* Returns true if video passes validation, false otherwise
*/
function validateVideoMetadata(
metadata: VideoMetadata,
options: TrailerDownloadOptions
): { valid: boolean; reason?: string } {
const maxDuration = options.maxDuration || 300; // Default: 5 minutes
const maxFileSize = options.maxFileSize || 314572800; // Default: 300 MB
// Check duration
if (metadata.duration > maxDuration) {
return {
valid: false,
reason: `Video duration (${Math.round(
metadata.duration
)}s) exceeds maximum (${maxDuration}s)`,
};
}
// Check file size (use filesize if available, otherwise filesize_approx)
const estimatedSize = metadata.filesize || metadata.filesize_approx;
if (estimatedSize && estimatedSize > maxFileSize) {
const sizeMB = Math.round(estimatedSize / 1024 / 1024);
const maxSizeMB = Math.round(maxFileSize / 1024 / 1024);
return {
valid: false,
reason: `Video file size (~${sizeMB}MB) exceeds maximum (${maxSizeMB}MB)`,
};
}
return { valid: true };
}
/**
* Copy static placeholder video
* This is used as a fallback when no trailer is found
@@ -185,30 +73,65 @@ async function copyPlaceholderVideo(outputPath: string): Promise<void> {
}
/**
* Download YouTube video using yt-dlp
* Download YouTube video using yt-dlp with duration filtering
* Uses yt-dlp binary which is more reliable than JavaScript libraries for YouTube downloads
* Duration filter rejects videos over 3.5 minutes (210s) to avoid compilation videos
*/
async function downloadWithYtDlp(
videoUrl: string,
outputPath: string
outputPath: string,
maxDuration = 210
): Promise<void> {
return new Promise((resolve, reject) => {
logger.debug('Downloading with yt-dlp', {
label: 'Coming Soon Trailer',
videoUrl,
outputPath,
maxDuration,
});
// yt-dlp command with 1080p max resolution and automatic merging
const ytdlp = spawn('yt-dlp', [
// Build yt-dlp arguments with duration filter
// Note: duration must be filtered using --match-filter, not in format selector
const args = [
'--break-on-reject',
'--match-filter',
`duration < ${maxDuration}`,
'-f',
'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080]',
'--merge-output-format',
'mp4',
'-o',
outputPath,
videoUrl,
]);
];
// Auto-detect cookies file in config directory
const cookiesPath = path.join(
process.cwd(),
'config',
'youtube-cookies.txt'
);
try {
fs.accessSync(cookiesPath);
args.push('--cookies', cookiesPath);
logger.debug('Using YouTube cookies for download', {
label: 'Coming Soon Trailer',
cookiesPath,
});
} catch {
// Cookies file doesn't exist, continue without it
logger.debug(
'No YouTube cookies file found, proceeding without cookies',
{
label: 'Coming Soon Trailer',
expectedPath: cookiesPath,
}
);
}
args.push(videoUrl);
// yt-dlp command with 1080p max resolution, duration filter, and automatic merging
const ytdlp = spawn('yt-dlp', args);
let stdoutOutput = '';
let stderrOutput = '';
@@ -229,12 +152,32 @@ async function downloadWithYtDlp(
});
resolve();
} else {
logger.error('yt-dlp download failed', {
label: 'Coming Soon Trailer',
code,
stdout: stdoutOutput,
stderr: stderrOutput,
});
// Check if this is a duration filter rejection (code 101)
const isDurationFilterRejection =
code === 101 && stdoutOutput.includes('does not pass filter');
if (isDurationFilterRejection) {
// Extract video title from stdout if available
const titleMatch = stdoutOutput.match(
/\[download\] (.+?) does not pass filter/
);
const videoTitle = titleMatch ? titleMatch[1] : 'Video';
logger.info('Video rejected by duration filter (over 3.5 minutes)', {
label: 'Coming Soon Trailer',
videoTitle,
maxDuration: maxDuration,
});
} else {
// Actual error (network, bot detection, etc.)
logger.error('yt-dlp download failed', {
label: 'Coming Soon Trailer',
code,
stdout: stdoutOutput,
stderr: stderrOutput,
});
}
reject(new Error(`yt-dlp exited with code ${code}: ${stderrOutput}`));
}
});
@@ -296,46 +239,15 @@ async function searchAndDownloadTrailer(
videoId,
});
// Extract metadata before downloading to check duration and file size
let metadata: VideoMetadata;
try {
metadata = await extractVideoMetadata(videoUrl);
} catch (metadataError) {
logger.warn('Failed to extract video metadata, using fallback', {
label: 'Coming Soon Trailer',
title,
error:
metadataError instanceof Error
? metadataError.message
: String(metadataError),
});
await copyPlaceholderVideo(outputPath);
return;
}
// Validate metadata against limits
const validation = validateVideoMetadata(metadata, options);
if (!validation.valid) {
logger.warn('Video failed validation, using fallback', {
label: 'Coming Soon Trailer',
title,
reason: validation.reason,
duration: metadata.duration,
filesize: metadata.filesize || metadata.filesize_approx,
});
await copyPlaceholderVideo(outputPath);
return;
}
// Download trailer with yt-dlp (automatically handles 1080p video+audio and merging)
logger.info('Downloading YouTube trailer in 1080p with yt-dlp', {
// Download trailer with yt-dlp (includes duration filter to reject videos over 3.5 minutes)
const maxDuration = options.maxDuration || 210; // Default: 3.5 minutes
logger.info('Downloading YouTube trailer with yt-dlp', {
label: 'Coming Soon Trailer',
title,
duration: Math.round(metadata.duration),
estimatedSize: metadata.filesize || metadata.filesize_approx,
maxDuration,
});
await downloadWithYtDlp(videoUrl, outputPath);
await downloadWithYtDlp(videoUrl, outputPath, maxDuration);
logger.info('Successfully downloaded 1080p trailer', {
label: 'Coming Soon Trailer',
@@ -343,12 +255,29 @@ async function searchAndDownloadTrailer(
outputPath,
});
} catch (error) {
logger.error('Failed to download YouTube trailer, using fallback', {
label: 'Coming Soon Trailer',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
title,
});
const errorMessage = error instanceof Error ? error.message : String(error);
const isDurationFilterRejection =
errorMessage.includes('code 101') &&
errorMessage.includes('does not pass filter');
if (isDurationFilterRejection) {
// Video was rejected by duration filter (too long)
logger.info(
'Trailer video too long (over 3.5 minutes), using placeholder instead',
{
label: 'Coming Soon Trailer',
title,
}
);
} else {
// Actual error (network, bot detection, etc.)
logger.error('Failed to download YouTube trailer, using fallback', {
label: 'Coming Soon Trailer',
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
title,
});
}
// Fallback to placeholder video if download fails
await copyPlaceholderVideo(outputPath);
}
@@ -401,8 +330,7 @@ export async function downloadTrailer(
title,
year,
outputPath,
maxDuration: 300, // 5 minutes max
maxFileSize: 314572800, // 300 MB max
maxDuration: 210, // 3.5 minutes max (avoids compilation videos)
});
return outputPath;
@@ -42,24 +42,6 @@ export interface TrailerDownloadOptions {
year?: number;
/** Output path for downloaded file */
outputPath: string;
/** Maximum duration in seconds (optional, default: 300) */
/** Maximum duration in seconds (optional, default: 210 = 3.5 minutes) */
maxDuration?: number;
/** Maximum file size in bytes (optional, default: 314572800 = 300 MB) */
maxFileSize?: number;
}
/**
* Metadata extracted from yt-dlp before download
*/
export interface VideoMetadata {
/** Video duration in seconds */
duration: number;
/** Estimated file size in bytes */
filesize?: number;
/** Approximate file size in bytes (fallback if filesize not available) */
filesize_approx?: number;
/** Video title */
title?: string;
/** Video ID */
id?: string;
}
+97 -6
View File
@@ -3,6 +3,7 @@ import { getRepository } from '@server/datasource';
import type {
ContentGridProps,
LayeredElement,
PersonElementProps,
PosterTemplateData,
RasterElementProps,
SVGElementProps,
@@ -90,6 +91,8 @@ export interface PosterGenerationConfig {
autoPosterTemplate?: number | null; // Template ID for auto-generated posters
templateData?: PosterTemplateData; // Template data for customized colors and layout
dynamicLogo?: string; // Path to dynamic logo file
personImageUrl?: string; // Dynamic person image (e.g., director portrait)
libraryId?: string; // Library ID for per-library TMDB language setting
}
export interface CollectionItemWithPoster {
@@ -184,9 +187,10 @@ async function getColorScheme(
* Fetch poster URLs from TMDB for collection items
*/
async function fetchTMDbPosterUrls(
items: CollectionItemWithPoster[]
items: CollectionItemWithPoster[],
libraryId?: string
): Promise<CollectionItemWithPoster[]> {
const language = getTmdbLanguage();
const language = await getTmdbLanguage(libraryId);
const tmdb = new TheMovieDb({ originalLanguage: language });
const itemsWithPosters: CollectionItemWithPoster[] = [];
@@ -326,6 +330,11 @@ async function downloadImageAsBase64(
url: string,
retries = 2
): Promise<string | null> {
// Handle data URIs directly (already encoded)
if (url.startsWith('data:')) {
return url;
}
// Check cache first
if (base64Cache.has(url)) {
const cachedResult = base64Cache.get(url);
@@ -1026,6 +1035,7 @@ async function generateTemplateTextElements(
color: string;
textAlign: string;
maxLines?: number;
textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
}[],
collectionName: string
): Promise<string> {
@@ -1034,6 +1044,20 @@ async function generateTemplateTextElements(
for (const element of textElements) {
const text =
element.type === 'collection-title' ? collectionName : element.text || '';
const transform = element.textTransform || 'none';
const applyTransform = (value: string): string => {
switch (transform) {
case 'uppercase':
return value.toUpperCase();
case 'lowercase':
return value.toLowerCase();
case 'capitalize':
return value.replace(/\b\w/g, (c) => c.toUpperCase());
default:
return value;
}
};
const finalText = applyTransform(text);
// Handle text wrapping based on element dimensions
// Use line height (fontSize * 1.1) for accurate calculation to match createTemplateWrappedText
@@ -1041,7 +1065,7 @@ async function generateTemplateTextElements(
const maxLines =
element.maxLines || Math.floor(element.height / lineHeight);
const wrappedText = createTemplateWrappedText(
text,
finalText,
element.x,
element.y,
element.width,
@@ -1457,7 +1481,9 @@ async function generateUnifiedLayeredElements(
collectionName: string,
collectionType?: string,
dynamicLogo?: string,
itemsWithPosters: CollectionItemWithPoster[] = []
itemsWithPosters: CollectionItemWithPoster[] = [],
personImageBase64?: string,
personImageUrl?: string
): Promise<string> {
// Sort elements by layer order to ensure proper rendering sequence
const sortedElements = [...elements].sort(
@@ -1504,6 +1530,16 @@ async function generateUnifiedLayeredElements(
);
break;
}
case 'person': {
const props = element.properties as PersonElementProps;
elementContent = await generatePersonElement(
element,
props,
personImageBase64,
personImageUrl
);
break;
}
}
if (elementContent) {
@@ -1554,6 +1590,42 @@ async function generateRasterElement(
]);
}
/**
* Generate person element content (e.g., director portrait backdrops)
*/
async function generatePersonElement(
element: LayeredElement,
props: PersonElementProps,
personImageBase64?: string,
personImageUrl?: string
): Promise<string> {
const imageHref = personImageBase64 || personImageUrl || props.imagePath;
if (!imageHref) {
return '';
}
const overlayOpacity = Math.min(1, Math.max(0, props.overlayOpacity ?? 0.55));
const overlayColor = props.overlayColor || 'rgba(0,0,0,0.6)';
return `
<g>
<image xlink:href="${imageHref}"
x="${element.x}" y="${element.y}"
width="${element.width}" height="${element.height}"
preserveAspectRatio="xMidYMid slice"/>
${
overlayOpacity > 0
? `<rect x="${element.x}" y="${element.y}"
width="${element.width}" height="${element.height}"
fill="${overlayColor}"
opacity="${overlayOpacity}"/>`
: ''
}
</g>
`;
}
/**
* Generate SVG element content
*/
@@ -1606,6 +1678,7 @@ async function generateTextElement(
color: props.color,
textAlign: props.textAlign,
maxLines: props.maxLines,
textTransform: props.textTransform,
},
],
collectionName
@@ -1680,7 +1753,10 @@ export async function generatePosterSVG(
}
}
itemsWithPosters = await fetchTMDbPosterUrls(items.slice(0, maxItems));
itemsWithPosters = await fetchTMDbPosterUrls(
items.slice(0, maxItems),
config.libraryId
);
// Download and convert images to base64 for embedding
for (const item of itemsWithPosters) {
@@ -1702,6 +1778,17 @@ export async function generatePosterSVG(
}
}
// Fetch person image for person layers if provided
let personImageBase64: string | undefined;
if (config.personImageUrl) {
try {
personImageBase64 =
(await downloadImageAsBase64(config.personImageUrl)) || undefined;
} catch (error) {
logger.warn('Failed to fetch person image for poster:', error);
}
}
// Generate background based on template data
const backgroundContent = await generateTemplateBackground(
templateData.background,
@@ -1727,7 +1814,9 @@ export async function generatePosterSVG(
collectionName,
collectionType,
config.dynamicLogo,
itemsWithPosters
itemsWithPosters,
personImageBase64,
config.personImageUrl
);
return `
@@ -1782,6 +1871,7 @@ export async function generatePosterBuffer(
const defaultTemplate = await templateRepository.findOne({
where: { isDefault: true, isActive: true },
order: { updatedAt: 'DESC' },
});
if (!defaultTemplate) {
@@ -1806,6 +1896,7 @@ export async function generatePosterBuffer(
mediaType: config.mediaType || 'movie',
items: config.items || [],
dynamicLogo: config.dynamicLogo,
personImageUrl: config.personImageUrl,
});
logger.info('Poster generated successfully using template', {
+37
View File
@@ -629,3 +629,40 @@ export async function generatePoster(
throw new Error('Failed to generate poster');
}
}
/**
* Complete the auto-generated poster workflow after upload to Plex
* Downloads the poster back from Plex to get the recompressed version,
* stores only the hash registry entry (no file), and cleans up the temporary file.
*/
export async function completeAutoGeneratedPosterWorkflow(
tempFilename: string,
plexPosterUrl: string,
collectionIdentifier?: string,
originalName?: string
): Promise<void> {
try {
const tempPath = path.join(POSTER_STORAGE_DIR, tempFilename);
// Metadata tracking is handled by MetadataTrackingService in BaseCollectionSync
// This function just cleans up the temporary file
if (
await fs.promises
.access(tempPath)
.then(() => true)
.catch(() => false)
) {
await fs.promises.unlink(tempPath);
logger.info(`Cleaned up temporary poster file: ${tempFilename}`);
}
logger.info('Completed auto-generated poster workflow', {
originalName,
collectionIdentifier,
plexUrl: plexPosterUrl,
});
} catch (error) {
logger.error('Failed to complete auto-generated poster workflow:', error);
}
}
+126 -3
View File
@@ -3,6 +3,7 @@ import {
PosterTemplate,
type ContentGridProps,
type PosterTemplateData,
type TextElementProps,
} from '@server/entity/PosterTemplate';
import logger from '@server/logger';
import fs from 'fs';
@@ -20,6 +21,7 @@ export interface TemplatePreviewConfig {
collectionSubtype?: string;
mediaType?: 'movie' | 'tv';
items?: CollectionItemWithPoster[];
personImageUrl?: string;
}
export interface TemplateValidationResult {
@@ -37,6 +39,13 @@ interface LocalPosterItem {
posterPath: string;
}
interface LocalPersonItem {
name: string;
tmdbId: number;
filename: string;
profilePath: string;
}
/**
* Load local poster mapping for preview rendering
*/
@@ -69,6 +78,35 @@ function loadLocalPosterMapping(): LocalPosterItem[] {
}
}
function loadLocalPersonMapping(): LocalPersonItem[] {
try {
const mappingPath = path.join(
process.cwd(),
'public',
'preview-persons',
'person-mapping.json'
);
if (!fs.existsSync(mappingPath)) {
logger.warn(
'Local person mapping file not found, falling back to generated person images'
);
return [];
}
const mappingData = fs.readFileSync(mappingPath, 'utf8');
const personItems: LocalPersonItem[] = JSON.parse(mappingData);
logger.debug(`Loaded ${personItems.length} local preview persons`);
return personItems;
} catch (error) {
logger.warn(
'Failed to load local person mapping, falling back to generated person images:',
error
);
return [];
}
}
/**
* Validate a poster template data structure
*/
@@ -117,7 +155,11 @@ export function validateTemplateData(
if (!element.id) {
errors.push(`Element ${index} missing required id`);
}
if (!['text', 'raster', 'svg', 'content-grid'].includes(element.type)) {
if (
!['text', 'raster', 'svg', 'content-grid', 'person'].includes(
element.type
)
) {
errors.push(`Element ${index} has invalid type: ${element.type}`);
}
if (typeof element.layerOrder !== 'number') {
@@ -144,6 +186,48 @@ export function validateTemplateData(
};
}
/**
* Ensure textTransform defaults are present (and apply Person Spotlight uppercase fallback)
*/
function normalizeTextTransforms(
templateData: PosterTemplateData,
templateName?: string
): PosterTemplateData {
if (!Array.isArray(templateData.elements)) {
return templateData;
}
const prefersUppercase =
templateName?.toLowerCase().includes('person spotlight') ||
templateName?.toLowerCase().includes('director spotlight');
const normalizedElements = templateData.elements.map((el) => {
if (el.type !== 'text') {
return el;
}
const props = el.properties as TextElementProps;
const textTransform =
props.textTransform ??
(prefersUppercase && props.elementType === 'collection-title'
? 'uppercase'
: 'none');
return {
...el,
properties: {
...props,
textTransform,
},
};
});
return {
...templateData,
elements: normalizedElements,
};
}
/**
* Apply a template to generate a poster using collection data
*/
@@ -156,6 +240,7 @@ export async function applyTemplate(
mediaType?: 'movie' | 'tv';
items?: CollectionItemWithPoster[];
dynamicLogo?: string;
personImageUrl?: string;
}
): Promise<Buffer> {
const templateRepository = getRepository(PosterTemplate);
@@ -168,7 +253,10 @@ export async function applyTemplate(
throw new Error(`Template ${templateId} not found`);
}
const templateData = template.getTemplateData();
const templateData = normalizeTextTransforms(
template.getTemplateData(),
template.name
);
// Validate template before applying
const validation = validateTemplateData(templateData);
@@ -191,6 +279,7 @@ export async function applyTemplate(
templateData: templateData,
// Pass through dynamic logo if available
dynamicLogo: config.dynamicLogo,
personImageUrl: config.personImageUrl,
};
// Generate poster directly using SVG system to avoid recursion
@@ -223,7 +312,13 @@ export async function generateTemplatePreview(
throw new Error(`Template ${templateId} not found`);
}
const templateData = template.getTemplateData();
const templateData = normalizeTextTransforms(
template.getTemplateData(),
template.name
);
const hasPersonLayer =
Array.isArray(templateData.elements) &&
templateData.elements.some((el) => el.type === 'person');
// Generate enough sample items to fill the content grid
let gridSize = 0;
@@ -251,8 +346,10 @@ export async function generateTemplatePreview(
// Load local poster mapping for fast preview rendering
const localPosters = loadLocalPosterMapping();
const localPersons = loadLocalPersonMapping();
let sampleItems: CollectionItemWithPoster[] = [];
let personImageUrl: string | undefined = previewConfig?.personImageUrl;
if (localPosters.length > 0) {
// Use local posters for much faster preview rendering
@@ -685,12 +782,38 @@ export async function generateTemplatePreview(
sampleItems = fallbackSampleItems;
}
if (!personImageUrl && hasPersonLayer) {
if (localPersons.length > 0) {
const personAsset = localPersons[0];
const absolutePersonPath = path.join(
process.cwd(),
'public',
'preview-persons',
personAsset.filename
);
personImageUrl = `file://${absolutePersonPath}`;
logger.debug(
`Assigned local person image for preview: ${personAsset.name} (${personAsset.tmdbId})`
);
} else {
const fallbackPosterUrl = sampleItems[0]?.posterUrl;
if (fallbackPosterUrl) {
personImageUrl = fallbackPosterUrl;
logger.debug(
'Using first poster from preview grid as fallback person image'
);
}
}
}
const config = {
collectionName: previewConfig?.collectionName || 'Sample Collection',
collectionType: previewConfig?.collectionType || 'trakt',
collectionSubtype: previewConfig?.collectionSubtype,
mediaType: previewConfig?.mediaType || ('movie' as const),
items: previewConfig?.items || sampleItems,
// Do not auto-assign a person image for previews
personImageUrl: previewConfig?.personImageUrl || personImageUrl,
};
return await applyTemplate(templateId, config);
+4 -4
View File
@@ -46,7 +46,7 @@ export const findSearchProvider = (
searchProviders.push({
pattern: new RegExp(/(?<=tmdb:)\d+/),
search: async ({ id, language }) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
const moviePromise = tmdb.getMovie({ movieId: parseInt(id), language });
const tvShowPromise = tmdb.getTvShow({ tvId: parseInt(id), language });
@@ -95,7 +95,7 @@ searchProviders.push({
searchProviders.push({
pattern: new RegExp(/(?<=imdb:)(tt|nm)\d+/),
search: async ({ id, language }) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
const responses = await tmdb.getByExternalId({
externalId: id,
@@ -133,7 +133,7 @@ searchProviders.push({
searchProviders.push({
pattern: new RegExp(/(?<=tvdb:)\d+/),
search: async ({ id, language }) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
const responses = await tmdb.getByExternalId({
externalId: parseInt(id),
@@ -171,7 +171,7 @@ searchProviders.push({
searchProviders.push({
pattern: new RegExp(/(?<=year:)\d{4}/),
search: async ({ id: year, query }) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
const moviesPromise = tmdb.searchMovies({
query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '',
+159 -94
View File
@@ -1,3 +1,5 @@
import { getRepository } from '@server/datasource';
import { OverlayLibraryConfig } from '@server/entity/OverlayLibraryConfig';
import { defaultHubConfigService } from '@server/lib/collections/services/DefaultHubConfigService';
import { preExistingCollectionConfigService } from '@server/lib/collections/services/PreExistingCollectionConfigService';
import logger from '@server/logger';
@@ -57,6 +59,7 @@ export interface CollectionConfig {
| 'originals'
| 'myanimelist'
| 'anilist'
| 'plex'
| 'multi-source'
| 'radarrtag'
| 'sonarrtag'
@@ -116,6 +119,7 @@ export interface CollectionConfig {
readonly minimumYear?: number; // Only process movies/TV shows released on or after this year (0 = no limit)
readonly minimumImdbRating?: number; // Only process movies/TV shows with IMDb rating >= this value (0 = no limit)
readonly minimumRottenTomatoesRating?: number; // Only process movies/TV shows with Rotten Tomatoes critics score >= this value (0 = no limit)
readonly minimumRottenTomatoesAudienceRating?: number; // Only process movies/TV shows with Rotten Tomatoes audience score >= this value (0 = no limit)
readonly excludedGenres?: number[]; // @deprecated Use filterSettings.genres - Exclude items with these TMDB genre IDs from missing items search
readonly excludedCountries?: string[]; // @deprecated Use filterSettings.countries - Exclude items with these ISO 3166-1 country codes from missing items search
readonly excludedLanguages?: string[]; // @deprecated Use filterSettings.languages - Exclude items with these ISO 639-1 language codes from missing items search
@@ -176,6 +180,11 @@ export interface CollectionConfig {
readonly sonarrInstanceId?: number; // Selected Sonarr instance ID for tag-based collections
// Generic ordering options (applicable to all collection types)
readonly sortOrder?: CollectionSortOrder; // Sort order for collection items (default: 'default')
// Unified person minimum items (applies to both actors and directors)
readonly personMinimumItems?: number;
// Plex Library separator settings for auto person collections
readonly useSeparator?: boolean; // Create a separator collection for actors/directors multi-collections
readonly separatorTitle?: string; // Custom title for the separator collection
// Collection exclusion settings
readonly excludeFromCollections?: string[]; // Array of collection IDs to exclude items from (mutual exclusion)
// Poster settings
@@ -419,6 +428,15 @@ export interface TautulliSettings {
externalUrl?: string;
}
export interface MaintainerrSettings {
hostname?: string;
port?: number;
useSsl?: boolean;
urlBase?: string;
apiKey?: string;
externalUrl?: string;
}
export interface OverseerrSettings {
hostname?: string;
port?: number;
@@ -587,6 +605,7 @@ interface AllSettings {
main: MainSettings;
plex: PlexSettings;
tautulli: TautulliSettings;
maintainerr: MaintainerrSettings;
overseerr: OverseerrSettings;
myanimelist: MyAnimeListSettings;
serviceUser: ServiceUserSettings;
@@ -635,6 +654,7 @@ class Settings {
usersHomeUnlocked: false,
},
tautulli: {},
maintainerr: {},
overseerr: {},
myanimelist: {},
serviceUser: {
@@ -861,6 +881,14 @@ class Settings {
this.data.tautulli = data;
}
get maintainerr(): MaintainerrSettings {
return this.data.maintainerr;
}
set maintainerr(data: MaintainerrSettings) {
this.data.maintainerr = data;
}
get trakt(): TraktSettings {
return this.data.trakt;
}
@@ -1320,46 +1348,6 @@ class Settings {
);
}
/**
* Migrate poster templates to unified layering system for v1.3.2
*/
public async migratePosterTemplatesV132(): Promise<void> {
const migrationId = 'poster-template-unified-layers-v1.3.2';
// Initialize completedMigrations if it doesn't exist
if (!this.data.completedMigrations) {
this.data.completedMigrations = [];
}
// Check if migration already completed
if (this.data.completedMigrations.includes(migrationId)) {
return;
}
try {
// Import and run the migration
const { runPosterTemplateMigration } = await import(
'./migrations/posterTemplateMigrationV132'
);
await runPosterTemplateMigration();
// Mark migration as completed
this.data.completedMigrations.push(migrationId);
this.save();
logger.info(
'v1.3.2 Migration: Poster templates migrated to unified layering system',
{
label: 'Settings Migration',
migrationId,
}
);
} catch (error) {
logger.error('v1.3.2 Migration failed:', error);
throw error;
}
}
/**
* Normalize hub configs with hub-specific business rules
*/
@@ -1597,7 +1585,7 @@ class Settings {
* This is a one-time migration for users upgrading from older versions
*/
public migrateToUnifiedFilterSettings(): void {
const migrationId = 'unified-filter-settings';
const migrationId = 'unified-filter-settings-v2';
// Initialize completedMigrations if it doesn't exist
if (!this.data.completedMigrations) {
@@ -1619,66 +1607,77 @@ class Settings {
this.data.plex.collectionConfigs = this.data.plex.collectionConfigs.map(
(config) => {
// Skip if already using new format
if (config.filterSettings) {
return config;
}
// Check if collection has any old-format filters
// Check if we need to migrate old-format filters to new format
const hasOldFilters =
(config.excludedGenres && config.excludedGenres.length > 0) ||
(config.excludedCountries && config.excludedCountries.length > 0) ||
(config.excludedLanguages && config.excludedLanguages.length > 0);
if (!hasOldFilters) {
return config; // No filters to migrate
}
// Build new filterSettings if migrating from old format
let filterSettings = config.filterSettings;
// Build new filterSettings object
const filterSettings: {
genres?: { mode: 'exclude' | 'include'; values: number[] };
countries?: { mode: 'exclude' | 'include'; values: string[] };
languages?: { mode: 'exclude' | 'include'; values: string[] };
} = {};
if (hasOldFilters && !config.filterSettings) {
// Build new filterSettings object from old format
const newFilterSettings: {
genres?: { mode: 'exclude' | 'include'; values: number[] };
countries?: { mode: 'exclude' | 'include'; values: string[] };
languages?: { mode: 'exclude' | 'include'; values: string[] };
} = {};
if (config.excludedGenres && config.excludedGenres.length > 0) {
filterSettings.genres = {
mode: 'exclude',
values: config.excludedGenres,
};
}
if (config.excludedCountries && config.excludedCountries.length > 0) {
filterSettings.countries = {
mode: 'exclude',
values: config.excludedCountries,
};
}
if (config.excludedLanguages && config.excludedLanguages.length > 0) {
filterSettings.languages = {
mode: 'exclude',
values: config.excludedLanguages,
};
}
migratedCount++;
logger.info(
`Migrating collection "${config.name}" to unified filter settings`,
{
label: 'Settings Migration',
configId: config.id,
if (config.excludedGenres && config.excludedGenres.length > 0) {
newFilterSettings.genres = {
mode: 'exclude',
values: config.excludedGenres,
};
}
);
// Return collection with new format, removing old fields
return {
...config,
filterSettings,
excludedGenres: undefined,
excludedCountries: undefined,
excludedLanguages: undefined,
};
if (config.excludedCountries && config.excludedCountries.length > 0) {
newFilterSettings.countries = {
mode: 'exclude',
values: config.excludedCountries,
};
}
if (config.excludedLanguages && config.excludedLanguages.length > 0) {
newFilterSettings.languages = {
mode: 'exclude',
values: config.excludedLanguages,
};
}
filterSettings = newFilterSettings;
migratedCount++;
logger.info(
`Migrating collection "${config.name}" from old filter format to unified filterSettings`,
{
label: 'Settings Migration',
configId: config.id,
}
);
}
// Check if we need to clean up deprecated fields
const hasDeprecatedFields =
config.excludedGenres !== undefined ||
config.excludedCountries !== undefined ||
config.excludedLanguages !== undefined;
// Always remove deprecated fields (whether they had values or not)
if (hasDeprecatedFields) {
return {
...config,
filterSettings:
filterSettings && Object.keys(filterSettings).length > 0
? filterSettings
: undefined,
excludedGenres: undefined,
excludedCountries: undefined,
excludedLanguages: undefined,
};
}
return config;
}
);
@@ -1695,6 +1694,41 @@ class Settings {
this.save();
}
/**
* Migrate overlay-application job schedule from midnight to 3am
* Prevents conflict with plex-collections-sync which runs at midnight
* This is a one-time migration for users upgrading from older versions
*/
public migrateOverlayJobSchedule(): void {
const migrationId = 'overlay-job-schedule-fix';
// Initialize completedMigrations if it doesn't exist
if (!this.data.completedMigrations) {
this.data.completedMigrations = [];
}
// Skip if already completed
if (this.data.completedMigrations.includes(migrationId)) {
return;
}
// If overlay-application job is still at old default (midnight), update to 3am
const currentSchedule = this.data.jobs['overlay-application'].schedule;
if (currentSchedule === '0 0 0 * * *') {
// Old midnight default
this.data.jobs['overlay-application'].schedule = '0 0 3 * * *'; // New 3am default
logger.info(
'Migrated overlay-application schedule from midnight to 3am to avoid conflict with collections sync',
{
label: 'Settings Migration',
}
);
}
this.data.completedMigrations.push(migrationId);
this.save();
}
/**
* Normalize pre-existing configs with pre-existing collection business rules
*/
@@ -2095,9 +2129,40 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
/**
* Get the configured TMDB language for API calls
* Returns 'en' (English) as default if not configured
*
* Fallback chain:
* 1. Library-specific override (if libraryId provided)
* 2. Global setting (settings.main.tmdbLanguage)
* 3. Default 'en'
*
* @param libraryId - Optional Plex library ID for per-library override
* @returns ISO language code (e.g., 'en', 'fr', 'pt-BR')
*/
export const getTmdbLanguage = (): string => {
export const getTmdbLanguage = async (libraryId?: string): Promise<string> => {
// Step 1: Check for library-specific override
if (libraryId) {
try {
const overlayConfigRepo = getRepository(OverlayLibraryConfig);
const libraryConfig = await overlayConfigRepo.findOne({
where: { libraryId },
});
if (libraryConfig?.tmdbLanguage) {
return libraryConfig.tmdbLanguage;
}
} catch (error) {
logger.debug(
'Failed to fetch library TMDB language config, using global fallback',
{
label: 'Settings',
libraryId,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
// Step 2: Fall back to global setting
const settings = getSettings();
return settings.main.tmdbLanguage || 'en';
};
@@ -1,3 +1,4 @@
import logger from '@server/logger';
import type { MigrationInterface, QueryRunner } from 'typeorm';
interface LayeredElement {
@@ -42,6 +43,270 @@ interface SavedPosterData extends PosterTemplateData {
}[];
}
/**
* Legacy template data structure (pre-v1.3.2)
* This is for templates that failed to migrate from the app-level migration
*/
interface LegacyTemplateData {
width: number;
height: number;
background: {
type: 'color' | 'gradient';
color?: string;
secondaryColor?: string;
useSourceColors?: boolean;
};
elements?: LayeredElement[];
migrated?: boolean;
textElements?: {
id: string;
type: 'collection-title' | 'custom-text';
text?: string;
x: number;
y: number;
width: number;
height: number;
fontSize: number;
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
color: string;
textAlign: 'left' | 'center' | 'right';
maxLines?: number;
}[];
iconElements?: {
id: string;
type: 'source-logo' | 'custom-icon';
iconPath?: string;
x: number;
y: number;
width: number;
height: number;
grayscale: boolean;
}[];
rasterElements?: {
id: string;
type: 'raster-image';
imagePath: string;
x: number;
y: number;
width: number;
height: number;
}[];
svgElements?: {
id: string;
type: 'source-logo' | 'svg-icon';
iconPath?: string;
x: number;
y: number;
width: number;
height: number;
grayscale: boolean;
}[];
contentGrid?: {
id: string;
x: number;
y: number;
width: number;
height: number;
columns: number;
rows: number;
spacing: number;
cornerRadius: number;
};
}
/**
* Layer order constants for converting legacy templates
*/
const DEFAULT_LAYER_ORDERS = {
RASTER: 10,
CONTENT_GRID: 20,
SVG: 30,
TEXT: 40,
};
const LAYER_ORDER_SPACING = 1;
/**
* Convert legacy template format to unified format
* (Inline version of migrateLegacyTemplateData from posterTemplateMigrationV132.ts)
*/
function convertLegacyToUnified(
templateData: LegacyTemplateData
): LayeredElement[] {
const elements: LayeredElement[] = [];
let currentRasterOrder = DEFAULT_LAYER_ORDERS.RASTER;
let currentSVGOrder = DEFAULT_LAYER_ORDERS.SVG;
let currentTextOrder = DEFAULT_LAYER_ORDERS.TEXT;
// Migrate raster elements
if (templateData.rasterElements) {
templateData.rasterElements.forEach((rasterElement) => {
const element: LayeredElement = {
id: rasterElement.id,
layerOrder: currentRasterOrder,
type: 'raster',
x: rasterElement.x,
y: rasterElement.y,
width: rasterElement.width,
height: rasterElement.height,
properties: {
imagePath: rasterElement.imagePath,
},
};
elements.push(element);
currentRasterOrder += LAYER_ORDER_SPACING;
});
}
// Migrate legacy iconElements (detect raster vs SVG by file extension)
if (templateData.iconElements) {
const rasterExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
templateData.iconElements.forEach((iconElement) => {
if (iconElement.iconPath) {
const isRaster = rasterExtensions.some((ext) =>
iconElement.iconPath?.toLowerCase().endsWith(ext)
);
if (isRaster) {
// Migrate as raster element
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentRasterOrder,
type: 'raster',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
imagePath: iconElement.iconPath,
},
};
elements.push(element);
currentRasterOrder += LAYER_ORDER_SPACING;
} else {
// Migrate as SVG element
const iconType =
iconElement.type === 'custom-icon' ? 'svg-icon' : iconElement.type;
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
iconType,
iconPath: iconElement.iconPath,
grayscale: iconElement.grayscale || false,
},
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
}
} else {
// Icon without path - treat as SVG placeholder
const iconType =
iconElement.type === 'custom-icon' ? 'svg-icon' : iconElement.type;
const element: LayeredElement = {
id: iconElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: iconElement.x,
y: iconElement.y,
width: iconElement.width,
height: iconElement.height,
properties: {
iconType,
iconPath: iconElement.iconPath,
grayscale: iconElement.grayscale || false,
},
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
}
});
}
// Migrate svgElements
if (templateData.svgElements) {
templateData.svgElements.forEach((svgElement) => {
const element: LayeredElement = {
id: svgElement.id,
layerOrder: currentSVGOrder,
type: 'svg',
x: svgElement.x,
y: svgElement.y,
width: svgElement.width,
height: svgElement.height,
properties: {
iconType: svgElement.type,
iconPath: svgElement.iconPath,
grayscale: svgElement.grayscale,
},
};
elements.push(element);
currentSVGOrder += LAYER_ORDER_SPACING;
});
}
// Migrate content grid (if exists)
if (templateData.contentGrid) {
const element: LayeredElement = {
id: templateData.contentGrid.id,
layerOrder: DEFAULT_LAYER_ORDERS.CONTENT_GRID,
type: 'content-grid',
x: templateData.contentGrid.x,
y: templateData.contentGrid.y,
width: templateData.contentGrid.width,
height: templateData.contentGrid.height,
properties: {
columns: templateData.contentGrid.columns,
rows: templateData.contentGrid.rows,
spacing: templateData.contentGrid.spacing,
cornerRadius: templateData.contentGrid.cornerRadius,
},
};
elements.push(element);
}
// Migrate text elements
if (templateData.textElements) {
templateData.textElements.forEach((textElement) => {
const element: LayeredElement = {
id: textElement.id,
layerOrder: currentTextOrder,
type: 'text',
x: textElement.x,
y: textElement.y,
width: textElement.width,
height: textElement.height,
properties: {
elementType: textElement.type,
text: textElement.text,
fontSize: textElement.fontSize,
fontFamily: textElement.fontFamily,
fontWeight: textElement.fontWeight,
fontStyle: textElement.fontStyle,
color: textElement.color,
textAlign: textElement.textAlign,
maxLines: textElement.maxLines,
},
};
elements.push(element);
currentTextOrder += LAYER_ORDER_SPACING;
});
}
// Sort elements by layer order
elements.sort((a, b) => a.layerOrder - b.layerOrder);
return elements;
}
export class ScalePosterTemplatesTo1000x15001764183954558
implements MigrationInterface
{
@@ -52,10 +317,20 @@ export class ScalePosterTemplatesTo1000x15001764183954558
);
for (const template of posterTemplates) {
const data: PosterTemplateData = JSON.parse(template.templateData);
const data = JSON.parse(template.templateData) as LegacyTemplateData;
// Only migrate templates that are 500x750
if (data.width === 500 && data.height === 750) {
// Check if template has elements array (unified format)
// If not, convert from legacy format inline
if (!data.elements || !Array.isArray(data.elements)) {
logger.info(
`Converting legacy poster template (id: ${template.id}) from old format to unified format`
);
data.elements = convertLegacyToUnified(data);
data.migrated = true;
}
// Scale canvas dimensions
data.width = 1000;
data.height = 1500;
@@ -100,10 +375,22 @@ export class ScalePosterTemplatesTo1000x15001764183954558
);
for (const poster of savedPosters) {
const data: SavedPosterData = JSON.parse(poster.posterData);
const data = JSON.parse(poster.posterData) as
| SavedPosterData
| LegacyTemplateData;
// Only migrate posters that are 500x750
if (data.width === 500 && data.height === 750) {
// Check if poster has elements array (unified format)
// If not, convert from legacy format inline
if (!data.elements || !Array.isArray(data.elements)) {
logger.info(
`Converting legacy saved poster (id: ${poster.id}) from old format to unified format`
);
data.elements = convertLegacyToUnified(data as LegacyTemplateData);
(data as SavedPosterData).migrated = true;
}
// Scale canvas dimensions
data.width = 1000;
data.height = 1500;
@@ -135,8 +422,8 @@ export class ScalePosterTemplatesTo1000x15001764183954558
},
}));
// Scale content items if they exist
if (data.contentItems) {
// Scale content items if they exist (SavedPoster specific)
if ('contentItems' in data && data.contentItems) {
data.contentItems = data.contentItems.map((item) => ({
...item,
x: item.x * 2,
@@ -166,6 +453,14 @@ export class ScalePosterTemplatesTo1000x15001764183954558
// Only revert templates that are 1000x1500
if (data.width === 1000 && data.height === 1500) {
// Skip if template doesn't have elements array (shouldn't happen, but be defensive)
if (!data.elements || !Array.isArray(data.elements)) {
logger.warn(
`Skipping poster template (id: ${template.id}) during rollback - missing elements array`
);
continue;
}
// Scale canvas dimensions back
data.width = 500;
data.height = 750;
@@ -214,6 +509,14 @@ export class ScalePosterTemplatesTo1000x15001764183954558
// Only revert posters that are 1000x1500
if (data.width === 1000 && data.height === 1500) {
// Skip if poster doesn't have elements array (shouldn't happen, but be defensive)
if (!data.elements || !Array.isArray(data.elements)) {
logger.warn(
`Skipping saved poster (id: ${poster.id}) during rollback - missing elements array`
);
continue;
}
// Scale canvas dimensions back
data.width = 500;
data.height = 750;
@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTmdbLanguageToOverlayLibraryConfig1767202090927
implements MigrationInterface
{
name = 'AddTmdbLanguageToOverlayLibraryConfig1767202090927';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_overlay_library_config" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "libraryId" varchar NOT NULL, "libraryName" varchar NOT NULL, "mediaType" varchar NOT NULL, "enabledOverlays" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tmdbLanguage" varchar, CONSTRAINT "UQ_overlay_library_config_libraryId" UNIQUE ("libraryId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_overlay_library_config"("id", "libraryId", "libraryName", "mediaType", "enabledOverlays", "createdAt", "updatedAt") SELECT "id", "libraryId", "libraryName", "mediaType", "enabledOverlays", "createdAt", "updatedAt" FROM "overlay_library_config"`
);
await queryRunner.query(`DROP TABLE "overlay_library_config"`);
await queryRunner.query(
`ALTER TABLE "temporary_overlay_library_config" RENAME TO "overlay_library_config"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "overlay_library_config" RENAME TO "temporary_overlay_library_config"`
);
await queryRunner.query(
`CREATE TABLE "overlay_library_config" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "libraryId" varchar NOT NULL, "libraryName" varchar NOT NULL, "mediaType" varchar NOT NULL, "enabledOverlays" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_overlay_library_config_libraryId" UNIQUE ("libraryId"))`
);
await queryRunner.query(
`INSERT INTO "overlay_library_config"("id", "libraryId", "libraryName", "mediaType", "enabledOverlays", "createdAt", "updatedAt") SELECT "id", "libraryId", "libraryName", "mediaType", "enabledOverlays", "createdAt", "updatedAt" FROM "temporary_overlay_library_config"`
);
await queryRunner.query(`DROP TABLE "temporary_overlay_library_config"`);
}
}
+8 -37
View File
@@ -4,10 +4,8 @@ import { User } from '@server/entity/User';
import type { LibraryItemsCache } from '@server/lib/collections/core/CollectionUtilities';
import type {
CollectionItem,
FilteringStats,
ItemProducingSource,
MissingItem,
NetworksSourceData,
} from '@server/lib/collections/core/types';
import { libraryCacheService } from '@server/lib/collections/services/LibraryCacheService';
import type { CollectionConfig } from '@server/lib/settings';
@@ -690,7 +688,7 @@ async function processMultiSourcePreview(
imdbId?: string;
tmdbRating?: number;
}> => {
const language = getTmdbLanguage();
const language = await getTmdbLanguage(libraryId);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
@@ -1154,39 +1152,12 @@ async function processPreviewAsync(
});
// 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
);
}
const 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
@@ -1266,7 +1237,7 @@ async function processPreviewAsync(
imdbId?: string;
tmdbRating?: number;
}> => {
const language = getTmdbLanguage();
const language = await getTmdbLanguage(requestBody.libraryId);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
+68 -6
View File
@@ -2,9 +2,9 @@ import PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { PlexCollection } from '@server/lib/collections/core/types';
import { OriginalsCollectionSync } from '@server/lib/collections/external/originals';
import { libraryCacheService } from '@server/lib/collections/services/LibraryCacheService';
import { PreExistingCollectionConfigService } from '@server/lib/collections/services/PreExistingCollectionConfigService';
import { OriginalsCollectionSync } from '@server/lib/collections/sources/originals';
import { templateEngine } from '@server/lib/collections/utils/TemplateEngine';
import { TimeRestrictionUtils } from '@server/lib/collections/utils/TimeRestrictionUtils';
import collectionsSync from '@server/lib/collectionsSync';
@@ -253,6 +253,38 @@ collectionsRoutes.put('/:id/settings', isAuthenticated(), async (req, res) => {
const existingConfig = configs[existingConfigIndex];
// Debug logging for person settings payload (directors/actors)
if (
req.body?.type === 'plex' &&
(req.body?.subtype === 'directors' || req.body?.subtype === 'actors')
) {
const maybeNumber = (value: unknown): number | undefined => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const personMinimum = maybeNumber(req.body.personMinimumItems);
if (personMinimum !== undefined && personMinimum < 2) {
return res.status(400).json({
error: `${req.body.subtype} minimum items must be at least 2`,
message:
'Person collections require a minimum of 2 items, 1 is not allowed',
});
}
if (personMinimum !== undefined) {
req.body.personMinimumItems = personMinimum;
}
logger.info(`Updating plex/${req.body.subtype} config`, {
label: 'Collections API',
id,
incomingMinimumItems: personMinimum,
rawBodyKeys: Object.keys(req.body || {}),
rawBody: req.body,
});
}
// Check if this is a linked collection - if so, update all linked configs
const configsToUpdate = [];
if (existingConfig.isLinked && existingConfig.linkId) {
@@ -1026,7 +1058,7 @@ collectionsRoutes.delete('/:id', isAuthenticated(), async (req, res) => {
const { PlaceholderItem } = await import(
'@server/entity/PlaceholderItem'
);
const { Not } = await import('typeorm');
const { Not, Like } = await import('typeorm');
const path = await import('path');
const repository = getRepository(PlaceholderItem);
@@ -1034,8 +1066,13 @@ collectionsRoutes.delete('/:id', isAuthenticated(), async (req, res) => {
let totalFilesRemoved = 0;
for (const deletedConfig of configsToDelete) {
// Find both direct records AND multi-source sub-collection records
// Multi-source collections have IDs like "33079-source-1762115269335"
const orphanedRecords = await repository.find({
where: { configId: deletedConfig.id },
where: [
{ configId: deletedConfig.id },
{ configId: Like(`${deletedConfig.id}-source-%`) },
],
});
if (orphanedRecords.length === 0) {
@@ -1052,17 +1089,23 @@ collectionsRoutes.delete('/:id', isAuthenticated(), async (req, res) => {
}
);
// Collect all config IDs being deleted (parent + all sub-sources)
const allDeletedConfigIds = Array.from(
new Set(orphanedRecords.map((r) => r.configId))
);
for (const record of orphanedRecords) {
try {
let fileDeleted = false;
// Check if we should delete the placeholder file
if (record.placeholderPath) {
// Check if any OTHER collection still needs this file
// Check if any OTHER collection (excluding all deleted IDs) still needs this file
const { In } = await import('typeorm');
const otherCollectionRecords = await repository.find({
where: {
placeholderPath: record.placeholderPath,
configId: Not(deletedConfig.id),
configId: Not(In(allDeletedConfigIds)),
},
});
@@ -1081,7 +1124,7 @@ collectionsRoutes.delete('/:id', isAuthenticated(), async (req, res) => {
try {
const { removePlaceholder } = await import(
'@server/lib/comingsoon/placeholderManager'
'@server/lib/placeholders/placeholderManager'
);
await removePlaceholder(fullPath, record.mediaType);
fileDeleted = true;
@@ -1654,6 +1697,25 @@ collectionsRoutes.post('/:id/sync', isAuthenticated(), async (req, res) => {
});
}
// Check if full sync is running before allowing individual sync
const collectionsSync = (await import('@server/lib/collectionsSync'))
.default;
if (collectionsSync.running) {
logger.warn(
'Manual individual sync blocked - full sync is currently running',
{
label: 'Individual Collection Sync',
collectionId: id,
collectionName: collectionConfig.name,
}
);
return res.status(409).json({
status: 'error',
message:
'Cannot start individual collection sync while a full sync is running. Please wait for the full sync to complete.',
});
}
logger.info(
`Starting manual sync for collection: ${collectionConfig.name}`,
{
+19 -6
View File
@@ -1,6 +1,7 @@
import logger from '@server/logger';
import { exec } from 'child_process';
import { Router } from 'express';
import path from 'path';
import { promisify } from 'util';
const execAsync = promisify(exec);
@@ -48,8 +49,8 @@ fontsRoutes.get('/', async (req, res) => {
continue;
}
// Only include TTF fonts for web serving
if (filePath.endsWith('.ttf')) {
// Include both TTF and OTF fonts for web serving
if (filePath.endsWith('.ttf') || filePath.endsWith('.otf')) {
// Prioritize system fonts over local fonts for unified behavior
if (
!fontMap.has(family) ||
@@ -62,18 +63,30 @@ fontsRoutes.get('/', async (req, res) => {
}
// Convert to final format with exact CSS values and font URLs
const configFontsPath = path.join(process.cwd(), 'config', 'fonts');
const fonts: FontInfo[] = [];
for (const [family, filePath] of fontMap) {
// Only include fonts from /usr/share/fonts/ for unified Docker/local behavior
if (!filePath.startsWith('/usr/share/fonts/')) {
// Include fonts from system directory OR custom directory
if (
!filePath.startsWith('/usr/share/fonts/') &&
!filePath.startsWith(configFontsPath)
) {
continue;
}
// Store clean font name - quotes will be added during CSS/SVG generation when needed
const cssValue = family;
// Convert system path to web URL
const fontUrl = filePath.replace('/usr/share/fonts/', '/fonts/');
// Convert path to web URL
let fontUrl: string;
if (filePath.startsWith('/usr/share/fonts/')) {
fontUrl = filePath.replace('/usr/share/fonts/', '/fonts/');
} else if (filePath.startsWith(configFontsPath)) {
fontUrl = filePath.replace(configFontsPath, '/custom-fonts');
} else {
continue;
}
fonts.push({
family,
+15 -11
View File
@@ -32,17 +32,19 @@ import myanimelistRoutes from './myanimelist';
import overlayLibraryConfigsRoutes from './overlayLibraryConfigs';
import overlaySettingsRoutes from './overlaySettings';
import overlayTemplatesRoutes from './overlayTemplates';
import overlayTestRoutes from './overlayTest';
import postersRoutes from './posters';
import preExistingRoutes from './preexisting';
import ratingsRoutes from './ratings';
import reorderRoutes from './reorder';
import searchRoutes from './search';
import sourceColorsRoutes from './sourceColors';
import traktOAuthRoutes from './trakt-oauth';
// Import createTmdbWithRegionLanguage function directly from discover (inline)
export const createTmdbWithRegionLanguage = (): TheMovieDb => {
return new TheMovieDb({ originalLanguage: getTmdbLanguage() });
export const createTmdbWithRegionLanguage = async (): Promise<TheMovieDb> => {
return new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
};
// Movie, search, and TV routes removed - discovery functionality not needed
import overseerrRoutes from './overseerr';
@@ -161,6 +163,8 @@ router.use(
overlayLibraryConfigsRoutes
);
router.use('/overlay-settings', isAuthenticated(), overlaySettingsRoutes);
router.use('/overlay-test', isAuthenticated(), overlayTestRoutes);
router.use('/plex', isAuthenticated(), searchRoutes);
router.use('/posters', isAuthenticated(), postersRoutes);
router.use('/preexisting', isAuthenticated(), preExistingRoutes);
router.use('/ratings', isAuthenticated(), ratingsRoutes);
@@ -172,7 +176,7 @@ router.use('/anilist', anilistRoutes);
router.use('/myanimelist', myanimelistRoutes);
router.get<{ id: string }>('/movie/:id', async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const movie = await tmdb.getMovie({ movieId: Number(req.params.id) });
@@ -192,7 +196,7 @@ router.get<{ id: string }>('/movie/:id', async (req, res, next) => {
});
router.get<{ id: string }>('/tv/:id', async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const tv = await tmdb.getTvShow({ tvId: Number(req.params.id) });
@@ -212,7 +216,7 @@ router.get<{ id: string }>('/tv/:id', async (req, res, next) => {
});
router.get<{ id: string }>('/studio/:id', async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const studio = await tmdb.getStudio(Number(req.params.id));
@@ -232,7 +236,7 @@ router.get<{ id: string }>('/studio/:id', async (req, res, next) => {
});
router.get<{ id: string }>('/network/:id', async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const network = await tmdb.getNetwork(Number(req.params.id));
@@ -252,7 +256,7 @@ router.get<{ id: string }>('/network/:id', async (req, res, next) => {
});
router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const genres = await tmdb.getMovieGenres({
@@ -273,7 +277,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
});
router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const genres = await tmdb.getTvGenres({
@@ -294,7 +298,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
});
router.get('/genres/combined', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
try {
const [movieGenres, tvGenres] = await Promise.all([
@@ -463,7 +467,7 @@ router.get('/languages/combined', isAuthenticated(), async (req, res, next) => {
});
router.get('/backdrops', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
const tmdb = await createTmdbWithRegionLanguage();
try {
const data = (
@@ -496,7 +500,7 @@ router.get('/backdrops', async (req, res, next) => {
});
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
const tmdb = await createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
+36 -1
View File
@@ -113,7 +113,7 @@ router.post('/:libraryId', async (req, res, next) => {
}
const { libraryId } = req.params;
const { libraryName, mediaType, enabledOverlays } = req.body;
const { libraryName, mediaType, enabledOverlays, tmdbLanguage } = req.body;
if (!libraryName || !mediaType) {
return res.status(400).json({
@@ -144,6 +144,7 @@ router.post('/:libraryId', async (req, res, next) => {
config.libraryName = libraryName;
config.mediaType = mediaType;
config.enabledOverlays = enabledOverlays;
config.tmdbLanguage = tmdbLanguage || undefined;
} else {
// Create new
config = new OverlayLibraryConfig({
@@ -151,6 +152,7 @@ router.post('/:libraryId', async (req, res, next) => {
libraryName,
mediaType,
enabledOverlays,
tmdbLanguage: tmdbLanguage || undefined,
});
}
@@ -225,6 +227,26 @@ router.post('/:libraryId/apply', async (req, res, next) => {
const { libraryId } = req.params;
// Check if full overlay application is running
const overlayApplication = (await import('@server/lib/overlayApplication'))
.default;
if (overlayApplication.running) {
return res.status(409).json({
error:
'Full overlay sync is currently running. Please wait for it to complete or cancel it before syncing individual libraries.',
conflictType: 'full-sync-running',
});
}
// Check if this library is already being processed
const libraryStatus = overlayLibraryService.getLibraryStatus(libraryId);
if (libraryStatus.running) {
return res.status(409).json({
error: 'This library is already being synced.',
conflictType: 'library-already-running',
});
}
logger.info('Applying overlays to library', {
libraryId,
userId: req.user?.id,
@@ -252,4 +274,17 @@ router.post('/:libraryId/apply', async (req, res, next) => {
}
});
// GET /api/v1/overlay-library-configs/:libraryId/status - Get overlay application status for library
router.get('/:libraryId/status', (req, res) => {
const { libraryId } = req.params;
const status = overlayLibraryService.getLibraryStatus(libraryId);
return res.status(200).json(status);
});
// GET /api/v1/overlay-library-configs/status/all - Get all running libraries
router.get('/status/all', (_req, res) => {
const runningLibraries = overlayLibraryService.getAllRunningLibraries();
return res.status(200).json({ runningLibraries });
});
export default router;
+15 -8
View File
@@ -47,7 +47,9 @@ async function fetchPreviewPosterMetadata(
rtCriticsScore?: number;
rtAudienceScore?: number;
}> {
const tmdbClient = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdbClient = new TheMovieDb({
originalLanguage: await getTmdbLanguage(),
});
let title = 'Sample Title';
let year: number | undefined;
let imdbId: string | undefined;
@@ -147,6 +149,7 @@ interface PreviewPosterMetadata {
daysUntilRelease?: number;
releaseDate?: string;
runtime?: number;
daysUntilAction?: number;
}
// Apply authentication to all routes
@@ -218,7 +221,7 @@ router.get('/preview-metadata/:posterId', async (req, res, next) => {
const tmdbId = parseInt(tmdbIdStr);
const isMovie = type === 'movie';
const tmdb = new TheMovieDb({ originalLanguage: getTmdbLanguage() });
const tmdb = new TheMovieDb({ originalLanguage: await getTmdbLanguage() });
let metadata: PreviewPosterMetadata;
@@ -297,6 +300,7 @@ router.get('/preview-metadata/:posterId', async (req, res, next) => {
status: 'Available',
releaseDate: movieDetails.release_date,
runtime: movieDetails.runtime,
daysUntilAction: 5, // Simulated Maintainerr data for preview
};
} else {
// TV show
@@ -358,6 +362,7 @@ router.get('/preview-metadata/:posterId', async (req, res, next) => {
? 'Ended'
: 'Continuing',
releaseDate: tvDetails.first_air_date,
daysUntilAction: 3, // Simulated Maintainerr data for preview
};
}
@@ -694,7 +699,7 @@ router.get('/:id/preview', async (req, res, next) => {
// Ratings (additional)
imdbTop250Rank: 42,
isImdbTop250: true,
metacriticScore: 85,
// metacriticScore: 85, // TODO: Implement Metacritic integration
// TMDB Metadata
director: 'Christopher Nolan',
@@ -754,8 +759,9 @@ router.get('/:id/preview', async (req, res, next) => {
inSonarr: true, // Always populate for previews
hasFile: true,
downloaded: true,
isTrending: true,
isWatched: false,
// Maintainerr integration
daysUntilAction: 5, // Always populate for previews
// Item metadata
isPlaceholder: false,
@@ -902,7 +908,7 @@ router.post('/combined-preview', async (req, res, next) => {
// Ratings (additional)
imdbTop250Rank: 42,
isImdbTop250: true,
metacriticScore: 85,
// metacriticScore: 85, // TODO: Implement Metacritic integration
// TMDB Metadata
director: 'Christopher Nolan',
@@ -962,8 +968,9 @@ router.post('/combined-preview', async (req, res, next) => {
inSonarr: true, // Always populate for previews
hasFile: true,
downloaded: true,
isTrending: true,
isWatched: false,
// Maintainerr integration
daysUntilAction: 5, // Always populate for previews
// Item metadata
isPlaceholder: false,
+400
View File
@@ -0,0 +1,400 @@
import PlexAPI from '@server/api/plexapi';
import { getRepository } from '@server/datasource';
import { OverlayLibraryConfig } from '@server/entity/OverlayLibraryConfig';
import { OverlayTemplate } from '@server/entity/OverlayTemplate';
import {
buildRenderContext,
checkMonitoringStatus,
fetchReleaseDateInfo,
} from '@server/lib/overlays/OverlayContextBuilder';
import type { OverlayRenderContext } from '@server/lib/overlays/OverlayTemplateRenderer';
import {
evaluateConditionDetailed,
overlayTemplateRenderer,
} from '@server/lib/overlays/OverlayTemplateRenderer';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const overlayTestRouter = Router();
/**
* Test overlay application on a single Plex item
* POST /api/v1/overlay-test
* Body: { ratingKey: string }
*/
overlayTestRouter.post('/', async (req, res) => {
try {
const { ratingKey } = req.body;
if (!ratingKey || typeof ratingKey !== 'string') {
return res.status(400).json({ error: 'ratingKey is required' });
}
logger.info('Starting overlay test', {
label: 'OverlayTest',
ratingKey,
});
// Get admin user for Plex API access
const { getAdminUser } = await import(
'@server/lib/collections/core/CollectionUtilities'
);
const admin = await getAdminUser();
if (!admin) {
return res.status(500).json({ error: 'No admin user found' });
}
const plexApi = new PlexAPI({ plexToken: admin.plexToken });
// Fetch item metadata
const item = await plexApi.getMetadata(ratingKey);
if (!item) {
return res.status(404).json({ error: 'Item not found in Plex' });
}
// Skip episodes and seasons
if (item.type === 'episode' || item.type === 'season') {
return res.status(400).json({
error:
'Overlays only apply to movies and shows, not episodes or seasons',
});
}
// Get library information
const libraryId = (
item as { librarySectionID?: string }
).librarySectionID?.toString();
if (!libraryId) {
return res.status(400).json({ error: 'Could not determine library ID' });
}
let libraryName =
(item as { librarySectionTitle?: string }).librarySectionTitle ||
'Unknown Library';
if (!(item as { librarySectionTitle?: string }).librarySectionTitle) {
try {
const libraries = await plexApi.getLibraries();
const library = libraries.find((lib) => lib.key === libraryId);
libraryName = library?.title || 'Unknown Library';
} catch (error) {
logger.warn('Failed to fetch library name', {
label: 'OverlayTest',
libraryId,
});
}
}
// Get library configuration
const configRepository = getRepository(OverlayLibraryConfig);
const config = await configRepository.findOne({
where: { libraryId },
});
if (!config || config.enabledOverlays.length === 0) {
return res.status(400).json({
error: `No overlays enabled for library "${libraryName}"`,
item: {
ratingKey: item.ratingKey,
title: item.title,
year: (item as { year?: number }).year,
type: item.type,
libraryId,
libraryName,
},
});
}
// Get enabled overlay templates
const templateRepository = getRepository(OverlayTemplate);
const enabledTemplateIds = config.enabledOverlays
.filter((o) => o.enabled)
.map((o) => o.templateId);
const templates = await templateRepository.findByIds(enabledTemplateIds);
if (templates.length === 0) {
return res.status(400).json({
error: `No templates found for library "${libraryName}"`,
});
}
// Sort templates by layer order
const sortedTemplates = templates.sort((a, b) => {
const orderA =
config.enabledOverlays.find((o) => o.templateId === a.id)?.layerOrder ||
0;
const orderB =
config.enabledOverlays.find((o) => o.templateId === b.id)?.layerOrder ||
0;
return orderA - orderB;
});
// Derive actual media type from item.type
const actualMediaType: 'movie' | 'show' =
item.type === 'movie' ? 'movie' : 'show';
// Extract TMDB ID from item GUIDs
let tmdbId: number | undefined;
if (item.Guid && Array.isArray(item.Guid)) {
const tmdbGuid = item.Guid.find((g) => g.id?.includes('tmdb://'));
if (tmdbGuid) {
const match = tmdbGuid.id.match(/tmdb:\/\/(\d+)/);
if (match) {
tmdbId = parseInt(match[1]);
}
}
}
// Check if this is a placeholder
const { placeholderContextService } = await import(
'@server/lib/placeholders/services/PlaceholderContextService'
);
const plexMetadata = item as {
type: string;
guid?: string;
editionTitle?: string;
Guid?: { id: string }[];
childCount?: number;
Children?: { Metadata?: unknown[] };
seasonCount?: number;
leafCount?: number;
ratingKey?: string;
};
const isPlaceholder =
await placeholderContextService.isPlaceholderItemAsync(
plexMetadata,
plexApi['plexClient'] as {
query: (path: string) => Promise<{
MediaContainer?: { Directory?: unknown[]; Metadata?: unknown[] };
}>;
}
);
logger.debug('Placeholder detection result', {
label: 'OverlayTest',
itemTitle: item.title,
ratingKey: item.ratingKey,
isPlaceholder,
});
// Build base context
const baseContext = await buildRenderContext(
item,
actualMediaType,
isPlaceholder
);
// Fetch release date information if TMDB ID available
let releaseDateContext: Partial<OverlayRenderContext> = {};
if (tmdbId) {
const releaseDateInfo = await fetchReleaseDateInfo(
tmdbId,
actualMediaType
);
if (releaseDateInfo) {
const { calculateDaysSince } = await import(
'@server/utils/dateHelpers'
);
let daysUntilRelease: number | undefined;
let daysAgo: number | undefined;
let daysUntilNextEpisode: number | undefined;
let daysUntilNextSeason: number | undefined;
let daysAgoNextSeason: number | undefined;
if (releaseDateInfo.releaseDate) {
const daysSince = calculateDaysSince(releaseDateInfo.releaseDate);
if (daysSince < 0) {
daysUntilRelease = -daysSince;
} else {
daysAgo = daysSince;
}
}
if (releaseDateInfo.nextEpisodeAirDate) {
const daysSince = calculateDaysSince(
releaseDateInfo.nextEpisodeAirDate
);
if (daysSince < 0) {
daysUntilNextEpisode = -daysSince;
}
}
if (releaseDateInfo.nextSeasonAirDate) {
const daysSince = calculateDaysSince(
releaseDateInfo.nextSeasonAirDate
);
if (daysSince < 0) {
daysUntilNextSeason = -daysSince;
} else {
daysAgoNextSeason = daysSince;
}
}
releaseDateContext = {
releaseDate: releaseDateInfo.releaseDate,
daysUntilRelease,
daysAgo,
nextEpisodeAirDate: releaseDateInfo.nextEpisodeAirDate,
daysUntilNextEpisode,
nextSeasonAirDate: releaseDateInfo.nextSeasonAirDate,
daysUntilNextSeason,
daysAgoNextSeason,
seasonNumber: releaseDateInfo.seasonNumber,
};
}
}
// Check monitoring status if TMDB ID available
let monitoringContext: Partial<OverlayRenderContext> = {};
if (tmdbId) {
monitoringContext = await checkMonitoringStatus(
tmdbId,
actualMediaType,
undefined,
undefined
);
}
// Merge contexts
let actualIsPlaceholder = isPlaceholder;
if (monitoringContext.hasFile === true) {
actualIsPlaceholder = false; // *arr has files, so it's definitely not a placeholder
}
let downloaded: boolean;
if (actualIsPlaceholder) {
downloaded = false;
} else if (typeof monitoringContext.hasFile === 'boolean') {
downloaded = monitoringContext.hasFile;
} else {
downloaded = true;
}
const context: OverlayRenderContext = {
...baseContext,
isPlaceholder: actualIsPlaceholder,
downloaded,
...releaseDateContext,
...monitoringContext,
};
// Evaluate all templates with detailed results
const templateResults = sortedTemplates.map((template) => {
const condition = template.getApplicationCondition();
const detailedResult = evaluateConditionDetailed(condition, context);
return {
id: template.id,
name: template.name,
matched: detailedResult.matched,
appliedCondition: condition,
conditionResults: {
sectionResults: detailedResult.sectionResults,
},
};
});
// Get poster source preference
const settings = getSettings();
const posterSource = settings.overlays?.defaultPosterSource || 'tmdb';
// Fetch base poster
const { plexBasePosterManager } = await import(
'@server/lib/overlays/PlexBasePosterManager'
);
let basePosterResult: {
posterBuffer: Buffer;
basePosterChanged: boolean;
sourceUrl: string;
filename: string;
fileModTime?: number | null;
};
try {
basePosterResult = await plexBasePosterManager.getBasePosterForOverlay(
plexApi,
item,
libraryId,
libraryName,
config.mediaType,
posterSource,
{},
tmdbId
);
} catch (error) {
logger.error('Failed to get base poster', {
label: 'OverlayTest',
itemTitle: item.title,
ratingKey: item.ratingKey,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: 'Failed to fetch base poster',
message: error instanceof Error ? error.message : String(error),
});
}
let posterBuffer = basePosterResult.posterBuffer;
// Apply matching overlays in order
const matchingTemplates = sortedTemplates.filter(
(template) => templateResults.find((tr) => tr.id === template.id)?.matched
);
for (const template of matchingTemplates) {
const templateData = template.getTemplateData();
posterBuffer = await overlayTemplateRenderer.renderOverlay(
posterBuffer,
templateData,
context
);
}
// Return all context variables as a flat list (no grouping)
const allContext: Record<string, unknown> = {};
for (const key in context) {
allContext[key] = context[key as keyof typeof context];
}
logger.info('Overlay test completed successfully', {
label: 'OverlayTest',
ratingKey,
itemTitle: item.title,
templatesEvaluated: templateResults.length,
templatesMatched: matchingTemplates.length,
});
return res.status(200).json({
poster: posterBuffer.toString('base64'),
item: {
ratingKey: item.ratingKey,
title: item.title,
year: (item as { year?: number }).year,
type: item.type,
libraryId,
libraryName,
},
templates: templateResults,
context: allContext,
});
} catch (error) {
logger.error('Failed to test overlay', {
label: 'OverlayTest',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return res.status(500).json({
error: 'Failed to test overlay',
message: error instanceof Error ? error.message : String(error),
});
}
});
export default overlayTestRouter;
+11 -9
View File
@@ -42,15 +42,17 @@ router.get('/templates', async (req, res, next) => {
order: { isDefault: 'DESC', createdAt: 'ASC' },
});
const templatesResponse = templates.map((template: PosterTemplate) => ({
id: template.id,
name: template.name,
description: template.description,
isDefault: template.isDefault,
templateData: template.getTemplateData(),
createdAt: template.createdAt,
updatedAt: template.updatedAt,
}));
const templatesResponse = templates.map((template: PosterTemplate) => {
return {
id: template.id,
name: template.name,
description: template.description,
isDefault: template.isDefault,
templateData: template.getTemplateData(),
createdAt: template.createdAt,
updatedAt: template.updatedAt,
};
});
return res.status(200).json({
templates: templatesResponse,
+166
View File
@@ -0,0 +1,166 @@
import type { PlexLibraryItem } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import logger from '@server/logger';
import { Router } from 'express';
const searchRouter = Router();
interface PlexSearchResult {
ratingKey: string;
title: string;
year?: number;
type: 'movie' | 'show';
thumb?: string;
libraryId: string;
libraryName: string;
}
/**
* Search across all Plex libraries
* GET /api/v1/plex/search?query=...&limit=20
*/
searchRouter.get('/search', async (req, res) => {
try {
const query = req.query.query as string;
const limit = parseInt((req.query.limit as string) || '20', 10);
if (!query || query.trim().length === 0) {
return res.status(400).json({ error: 'Query parameter is required' });
}
// Get admin user for Plex API access
const { getAdminUser } = await import(
'@server/lib/collections/core/CollectionUtilities'
);
const admin = await getAdminUser();
if (!admin) {
return res.status(500).json({ error: 'No admin user found' });
}
// Get Plex settings to construct full image URLs
const { getSettings } = await import('@server/lib/settings');
const settings = getSettings();
const plexSettings = settings.plex;
const plexApi = new PlexAPI({ plexToken: admin.plexToken });
// Construct base URL for Plex images
const protocol = plexSettings.useSsl ? 'https' : 'http';
const plexBaseUrl = `${protocol}://${plexSettings.ip}:${plexSettings.port}`;
// Perform search using Plex's global search
// Note: /hubs/search returns results grouped in "Hub" objects, each containing Metadata
const searchResults = await plexApi['plexClient'].query<{
MediaContainer?: {
Hub?: {
Metadata?: PlexLibraryItem[];
}[];
};
}>(`/hubs/search?query=${encodeURIComponent(query)}&limit=${limit * 2}`);
logger.debug('Plex search raw response', {
label: 'PlexSearch',
query,
hasMediaContainer: !!searchResults.MediaContainer,
hubCount: searchResults.MediaContainer?.Hub?.length || 0,
});
// Extract all metadata from all hubs
const rawResults: PlexLibraryItem[] = [];
if (searchResults.MediaContainer?.Hub) {
for (const hub of searchResults.MediaContainer.Hub) {
if (hub.Metadata) {
// Filter out results with a "reason" field - these are related matches, not direct title matches
// Direct matches won't have a reason field
const directMatches = hub.Metadata.filter(
(item) => !(item as { reason?: string }).reason
);
rawResults.push(...directMatches);
}
}
}
// Filter to only movies and shows, and extract library information
const filteredResults: PlexSearchResult[] = [];
for (const item of rawResults) {
// Only include movies and shows (exclude episodes, seasons, artists, etc.)
if (item.type !== 'movie' && item.type !== 'show') {
continue;
}
// Get library information
const libraryId =
(item as { librarySectionID?: string }).librarySectionID?.toString() ||
'';
let libraryName =
(item as { librarySectionTitle?: string }).librarySectionTitle ||
'Unknown Library';
// Try to get library name from section if not available
if (
!(item as { librarySectionTitle?: string }).librarySectionTitle &&
libraryId
) {
try {
const libraries = await plexApi.getLibraries();
const library = libraries.find((lib) => lib.key === libraryId);
libraryName = library?.title || 'Unknown Library';
} catch (error) {
logger.debug('Failed to fetch library name', {
label: 'PlexSearch',
libraryId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Construct full URL for thumb if available
const thumbPath = (item as { thumb?: string }).thumb;
const fullThumbUrl = thumbPath
? `${plexBaseUrl}${thumbPath}?X-Plex-Token=${admin.plexToken}`
: undefined;
filteredResults.push({
ratingKey: item.ratingKey,
title: item.title,
year: item.year,
type: item.type as 'movie' | 'show',
thumb: fullThumbUrl,
libraryId,
libraryName,
});
// Stop if we have enough results
if (filteredResults.length >= limit) {
break;
}
}
logger.info('Plex search completed', {
label: 'PlexSearch',
query,
totalResults: rawResults.length,
filteredResults: filteredResults.length,
});
return res.status(200).json({
results: filteredResults,
totalResults: filteredResults.length,
});
} catch (error) {
logger.error('Failed to search Plex', {
label: 'PlexSearch',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return res.status(500).json({
error: 'Failed to search Plex',
message: error instanceof Error ? error.message : String(error),
});
}
});
export default searchRouter;
+245 -29
View File
@@ -1,3 +1,4 @@
import MaintainerrAPI from '@server/api/maintainerr';
import MDBListAPI from '@server/api/mdblist';
import { getRankedAnime } from '@server/api/myanimelist';
import PlexAPI from '@server/api/plexapi';
@@ -34,6 +35,7 @@ import {
buildTraktRedirectUri,
persistTraktTokens,
} from '@server/utils/traktAuth';
import archiver from 'archiver';
import parser from 'cron-parser';
import type { Request } from 'express';
import { Router } from 'express';
@@ -305,26 +307,16 @@ settingsRoutes.get('/plex/libraries', async (req, res) => {
// Sync libraries to settings so they're available for collection operations
await plexapi.syncLibraries();
const allLibraries = await plexapi.getLibraries();
// Filter to only movie and show libraries
const libraries = allLibraries.filter(
(lib) => lib.type === 'movie' || lib.type === 'show'
);
// Return clean library data directly from Plex (no transformation)
const cleanLibraries = libraries.map((lib) => ({
key: lib.key,
name: lib.title,
type: lib.type, // 'movie' or 'show'
}));
return res.status(200).json(cleanLibraries);
// Return the libraries that were just synced to settings
// This ensures UI and backend always see the same library data
const settings = getSettings();
return res.status(200).json(settings.plex.libraries);
} catch (error) {
logger.error('Failed to fetch Plex libraries', {
logger.error('Failed to sync Plex libraries', {
label: 'Settings Routes',
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({ error: 'Failed to fetch Plex libraries' });
return res.status(500).json({ error: 'Failed to sync Plex libraries' });
}
});
@@ -559,6 +551,128 @@ settingsRoutes.post('/tautulli/test', async (req, res, next) => {
}
});
settingsRoutes.get('/maintainerr', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.maintainerr);
});
settingsRoutes.post('/maintainerr', async (req, res) => {
const settings = getSettings();
Object.assign(settings.maintainerr, req.body);
settings.save();
return res.status(200).json(settings.maintainerr);
});
settingsRoutes.post('/maintainerr/test', async (req, res, next) => {
const startTime = Date.now();
logger.debug('Maintainerr connection test requested', {
label: 'Maintainerr Connection',
hostname: req.body.hostname,
port: req.body.port,
useSsl: req.body.useSsl,
urlBase: req.body.urlBase,
});
try {
const { hostname, port, apiKey, useSsl, urlBase } = req.body;
if (!hostname || !port || !apiKey) {
return next({
status: 400,
message: 'Hostname, port, and API key are required',
});
}
const settings = {
hostname,
port: Number(port),
useSsl: useSsl || false,
urlBase: urlBase || '',
apiKey,
};
const connectionUrl = `${settings.useSsl ? 'https' : 'http'}://${
settings.hostname
}:${settings.port}${settings.urlBase}`;
logger.debug('Testing Maintainerr connection', {
label: 'Maintainerr Connection',
url: connectionUrl,
apiKeyLength: apiKey.length,
});
const maintainerrClient = new MaintainerrAPI(settings);
const collections = await maintainerrClient.getCollections();
if (!Array.isArray(collections)) {
throw new Error('Invalid response from Maintainerr API');
}
logger.info('Maintainerr connection test successful', {
label: 'Maintainerr Connection',
responseTime: Date.now() - startTime,
collectionsFound: collections.length,
});
return res.status(200).json({
success: true,
});
} catch (e) {
const connectionUrl = `${req.body.useSsl ? 'https' : 'http'}://${
req.body.hostname
}:${req.body.port}${req.body.urlBase || ''}`;
let status = 500;
let message = 'Unable to connect to Maintainerr';
if (e.response) {
status = e.response.status;
if (status === 400 || status === 401 || status === 403) {
message = 'Invalid API key - Authentication failed';
} else if (status === 404) {
message = 'Maintainerr API not found - Check URL base and port';
} else {
message = `Maintainerr returned error: ${
e.response.statusText || 'Unknown error'
}`;
}
} else if (e.code === 'ECONNREFUSED') {
message = 'Connection refused - Check hostname and port';
} else if (e.code === 'ENOTFOUND') {
message = 'Host not found - Check hostname';
} else if (e.code === 'ETIMEDOUT') {
message = 'Connection timeout - Check network connectivity';
} else if (e.message) {
message = e.message;
}
logger.error('Maintainerr connection test failed', {
label: 'Maintainerr Connection',
error: e.message,
errorType: e.constructor?.name,
errorCode: e.code,
httpStatus: e.response?.status,
connectionUrl,
responseTime: Date.now() - startTime,
requestedSettings: {
hostname: req.body.hostname,
port: req.body.port,
ssl: req.body.useSsl,
urlBase: req.body.urlBase,
},
});
return next({
status,
message: `${message} (${connectionUrl})`,
});
}
});
settingsRoutes.get('/trakt', (_req, res) => {
const settings = getSettings();
@@ -984,9 +1098,12 @@ settingsRoutes.get('/jobs', (_req, res) => {
if (nextExecution && job.cronSchedule) {
try {
const interval = parser.parse(job.cronSchedule);
interval.next(); // First next (skip it, we already have nextExecution)
followingExecution = interval.next().toDate(); // Second next
// Add 1 second to nextExecution to ensure we get the occurrence AFTER it
const startDate = new Date(new Date(nextExecution).getTime() + 1000);
const interval = parser.parse(job.cronSchedule, {
currentDate: startDate,
});
followingExecution = interval.next().toDate(); // Get execution AFTER nextExecution
} catch (error) {
// If cron parsing fails, followingExecution stays null
}
@@ -1020,9 +1137,12 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
if (nextExecution && scheduledJob.cronSchedule) {
try {
const interval = parser.parse(scheduledJob.cronSchedule);
interval.next(); // First next (skip it, we already have nextExecution)
followingExecution = interval.next().toDate(); // Second next
// Add 1 second to nextExecution to ensure we get the occurrence AFTER it
const startDate = new Date(new Date(nextExecution).getTime() + 1000);
const interval = parser.parse(scheduledJob.cronSchedule, {
currentDate: startDate,
});
followingExecution = interval.next().toDate(); // Get execution AFTER nextExecution
} catch (error) {
// If cron parsing fails, followingExecution stays null
}
@@ -1060,9 +1180,12 @@ settingsRoutes.post<{ jobId: JobId }>(
if (nextExecution && scheduledJob.cronSchedule) {
try {
const interval = parser.parse(scheduledJob.cronSchedule);
interval.next(); // First next (skip it, we already have nextExecution)
followingExecution = interval.next().toDate(); // Second next
// Add 1 second to nextExecution to ensure we get the occurrence AFTER it
const startDate = new Date(new Date(nextExecution).getTime() + 1000);
const interval = parser.parse(scheduledJob.cronSchedule, {
currentDate: startDate,
});
followingExecution = interval.next().toDate(); // Get execution AFTER nextExecution
} catch (error) {
// If cron parsing fails, followingExecution stays null
}
@@ -1106,9 +1229,12 @@ settingsRoutes.post<{ jobId: JobId }>(
if (nextExecution && scheduledJob.cronSchedule) {
try {
const interval = parser.parse(scheduledJob.cronSchedule);
interval.next(); // First next (skip it, we already have nextExecution)
followingExecution = interval.next().toDate(); // Second next
// Add 1 second to nextExecution to ensure we get the occurrence AFTER it
const startDate = new Date(new Date(nextExecution).getTime() + 1000);
const interval = parser.parse(scheduledJob.cronSchedule, {
currentDate: startDate,
});
followingExecution = interval.next().toDate(); // Get execution AFTER nextExecution
} catch (error) {
// If cron parsing fails, followingExecution stays null
}
@@ -1244,7 +1370,7 @@ settingsRoutes.post('/reset', async (_req, res, next) => {
// Delete all unique placeholder files
if (filesToDelete.size > 0) {
const { removePlaceholder } = await import(
'@server/lib/comingsoon/placeholderManager'
'@server/lib/placeholders/placeholderManager'
);
for (const fullPath of filesToDelete) {
@@ -1327,4 +1453,94 @@ settingsRoutes.post('/watchlistsync', (req, res) => {
return res.status(200).json(settings.watchlistSync);
});
settingsRoutes.post('/export-debug', (req, res, next) => {
try {
const { includeDatabase, includeSettings, includeLogs } = req.body;
const configPath = appDataPath();
logger.info('Debug export requested', {
label: 'Settings',
includeDatabase,
includeSettings,
includeLogs,
});
// Set response headers for file download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `agregarr-debug-${timestamp}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Create archiver instance
const archive = archiver('zip', {
zlib: { level: 9 }, // Maximum compression
});
// Handle archiver errors
archive.on('error', (err) => {
logger.error('Error creating debug export archive', {
label: 'Settings',
errorMessage: err.message,
});
next(err);
});
// Pipe archive to response
archive.pipe(res);
// Add database if requested
if (includeDatabase) {
const dbPath = path.join(configPath, 'db', 'db.sqlite3');
if (fs.existsSync(dbPath)) {
archive.file(dbPath, { name: 'db/db.sqlite3' });
logger.debug('Added database to export', { label: 'Settings' });
} else {
logger.warn('Database file not found for export', {
label: 'Settings',
});
}
}
// Add settings.json if requested
if (includeSettings) {
const settingsPath = path.join(configPath, 'settings.json');
if (fs.existsSync(settingsPath)) {
archive.file(settingsPath, { name: 'settings.json' });
logger.debug('Added settings.json to export', { label: 'Settings' });
} else {
logger.warn('settings.json not found for export', {
label: 'Settings',
});
}
}
// Add logs directory if requested
if (includeLogs) {
const logsPath = path.join(configPath, 'logs');
if (fs.existsSync(logsPath)) {
archive.directory(logsPath, 'logs');
logger.debug('Added logs directory to export', { label: 'Settings' });
} else {
logger.warn('Logs directory not found for export', {
label: 'Settings',
});
}
}
// Finalize the archive
archive.finalize();
logger.info('Debug export completed successfully', {
label: 'Settings',
filename,
});
} catch (error) {
logger.error('Error during debug export', {
label: 'Settings',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
next(error);
}
});
export default settingsRoutes;
+201 -12
View File
@@ -3,6 +3,7 @@ import dataSource, { getRepository } from '@server/datasource';
import {
PosterTemplate,
type ContentGridProps,
type PersonElementProps,
type PosterTemplateData,
type SVGElementProps,
type TextElementProps,
@@ -91,6 +92,125 @@ async function seedDefaultTemplate() {
migrated: true,
};
const personTemplateData: PosterTemplateData = {
width: 1000,
height: 1500,
background: {
type: 'gradient',
color: '#2b2a30',
secondaryColor: '#1a1a1d',
intensity: 55,
useSourceColors: false,
},
elements: [
{
id: 'person-backdrop',
layerOrder: 5,
type: 'person',
x: 0,
y: 0,
width: 1000,
height: 1500,
properties: {
imagePath: '',
overlayColor: 'rgba(24,23,27,0.55)', // Subtle charcoal tint similar to reference
overlayOpacity: 0.85,
} as PersonElementProps,
},
{
id: 'person-tagline',
layerOrder: 8,
type: 'text',
x: 76,
y: 396,
width: 420,
height: 70,
properties: {
elementType: 'custom-text',
text: 'Collection',
fontSize: 40,
fontFamily: 'Inter',
fontWeight: 'normal',
fontStyle: 'normal',
color: '#ffffff',
textAlign: 'left',
maxLines: 1,
textTransform: 'uppercase',
} as TextElementProps,
},
{
id: 'person-line',
layerOrder: 9,
type: 'svg',
x: 72,
y: 360,
width: 260,
height: 8,
properties: {
iconType: 'custom-icon',
iconPath: '/api/v1/posters/icons/system/person-spotlight-line.svg',
grayscale: false,
} as SVGElementProps,
},
{
id: 'person-title',
layerOrder: 20,
type: 'text',
x: 72,
y: 96,
width: 760,
height: 180,
properties: {
elementType: 'collection-title',
fontSize: 84,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
color: '#ffffff',
textAlign: 'left',
maxLines: 3,
textTransform: 'uppercase',
} as TextElementProps,
},
],
migrated: true,
};
const separatorTemplateData: PosterTemplateData = {
width: 1000,
height: 1500,
background: {
type: 'gradient',
color: '#2b2d32',
secondaryColor: '#1f2024',
intensity: 55,
useSourceColors: false,
},
elements: [
{
id: 'separator-text',
layerOrder: 10,
type: 'text',
x: 80,
y: 560,
width: 840,
height: 200,
properties: {
elementType: 'collection-title',
fontSize: 96,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
color: '#e2e5e8',
textAlign: 'center',
maxLines: 2,
textTransform: 'uppercase',
} as TextElementProps,
},
],
migrated: true,
};
// Check if default template already exists
const existingTemplate = await templateRepository.findOne({
where: { isDefault: true },
@@ -104,25 +224,94 @@ async function seedDefaultTemplate() {
templateId: existingTemplate.id,
name: existingTemplate.name,
});
return;
} else {
const defaultTemplate = new PosterTemplate({
name: 'Default Agregarr Template',
description:
'The original Agregarr auto-poster design converted to a template',
isDefault: true,
isActive: true,
});
defaultTemplate.setTemplateData(defaultTemplateData);
await templateRepository.save(defaultTemplate);
logger.info('Successfully seeded default poster template', {
templateId: defaultTemplate.id,
name: defaultTemplate.name,
});
}
const defaultTemplate = new PosterTemplate({
name: 'Default Agregarr Template',
description:
'The original Agregarr auto-poster design converted to a template',
isDefault: true,
isActive: true,
// Seed a person-focused template that can be selected for auto posters
const personTemplateName = 'Person Spotlight';
const existingPersonTemplate = await templateRepository.findOne({
where: { name: personTemplateName },
});
defaultTemplate.setTemplateData(defaultTemplateData);
if (existingPersonTemplate) {
existingPersonTemplate.name = personTemplateName;
existingPersonTemplate.setTemplateData(personTemplateData);
existingPersonTemplate.isActive = true;
existingPersonTemplate.description =
'Full-bleed person portrait backdrop with bold title and collection label over a dark gradient, tuned for directors/people.';
await templateRepository.save(existingPersonTemplate);
logger.info('Person poster template refreshed', {
templateId: existingPersonTemplate.id,
name: existingPersonTemplate.name,
});
} else {
const personTemplate = new PosterTemplate({
name: personTemplateName,
description:
'Full-bleed person portrait backdrop with bold title and collection label over a dark gradient, tuned for directors/people.',
isDefault: false,
isActive: true,
});
await templateRepository.save(defaultTemplate);
personTemplate.setTemplateData(personTemplateData);
const savedTemplate = await templateRepository.save(personTemplate);
logger.info('Successfully seeded default poster template', {
templateId: defaultTemplate.id,
name: defaultTemplate.name,
logger.info('Seeded person poster template', {
templateId: savedTemplate.id,
name: savedTemplate.name,
});
}
// Seed separator template for grouping collections
const separatorTemplateName = 'Separator';
const existingSeparatorTemplate = await templateRepository.findOne({
where: { name: separatorTemplateName },
});
if (existingSeparatorTemplate) {
existingSeparatorTemplate.name = separatorTemplateName;
existingSeparatorTemplate.setTemplateData(separatorTemplateData);
existingSeparatorTemplate.isActive = true;
existingSeparatorTemplate.isDefault = false;
existingSeparatorTemplate.description =
'Dark gradient title card for separators that sit before auto-generated collections.';
await templateRepository.save(existingSeparatorTemplate);
logger.info('Separator poster template refreshed', {
templateId: existingSeparatorTemplate.id,
name: existingSeparatorTemplate.name,
});
} else {
const separatorTemplate = new PosterTemplate({
name: separatorTemplateName,
description:
'Dark gradient title card for separators that sit before auto-generated collections.',
isDefault: false,
isActive: true,
});
separatorTemplate.setTemplateData(separatorTemplateData);
const savedTemplate = await templateRepository.save(separatorTemplate);
logger.info('Seeded separator poster template', {
templateId: savedTemplate.id,
name: savedTemplate.name,
});
}
} catch (error) {
logger.error('Failed to seed default template:', error);
throw error;
+20
View File
@@ -165,12 +165,24 @@ export function formatDate(date: Date | string, format: string): string {
'November',
'December',
];
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const dayNamesFull = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
];
const year = dateObj.getFullYear();
const month = dateObj.getMonth() + 1;
const day = dateObj.getDate();
const monthName = monthNames[dateObj.getMonth()];
const monthNameFull = monthNamesFull[dateObj.getMonth()];
const dayName = dayNames[dateObj.getDay()];
const dayNameFull = dayNamesFull[dateObj.getDay()];
// Pad with leading zeros
const pad = (n: number) => String(n).padStart(2, '0');
@@ -186,6 +198,14 @@ export function formatDate(date: Date | string, format: string): string {
return `${pad(day)}/${pad(month)}/${year}`;
case 'MM/DD/YYYY':
return `${pad(month)}/${pad(day)}/${year}`;
case 'DD/MM':
return `${pad(day)}/${pad(month)}`;
case 'MM/DD':
return `${pad(month)}/${pad(day)}`;
case 'DDD DD/MM':
return `${dayName} ${pad(day)}/${pad(month)}`;
case 'DDDD':
return dayNameFull;
case 'MMM DD':
return `${monthName} ${pad(day)}`;
case 'DD MMM':
+2
View File
@@ -133,6 +133,7 @@ export function calculatePosterInputHash(config: {
collectionType?: string;
collectionSubtype?: string;
additionalContext?: Record<string, unknown>;
personImageUrl?: string;
}): string {
return calculateInputHash({
templateId: config.templateId,
@@ -143,6 +144,7 @@ export function calculatePosterInputHash(config: {
collectionType: config.collectionType,
collectionSubtype: config.collectionSubtype,
additionalContext: config.additionalContext,
personImageUrl: config.personImageUrl,
});
}
+24
View File
@@ -5,6 +5,7 @@
* - upload://posters/1765149596
* - /library/metadata/815/thumb/1765149596
* - http://192.168.0.115:32400/library/metadata/815/thumb/1765149596?X-Plex-Token=xxx
* - http://192.168.0.115:32400/library/metadata/815/file?url=upload%3A%2F%2Fposters%2F1765149596&X-Plex-Token=xxx
*
* These helpers normalize URLs to extract the stable thumb ID for comparison.
*/
@@ -19,18 +20,41 @@
* extractThumbId("upload://posters/1765149596") // "1765149596"
* extractThumbId("/library/metadata/815/thumb/1765149596") // "1765149596"
* extractThumbId("http://192.168.0.115:32400/library/metadata/815/thumb/1765149596?X-Plex-Token=xxx") // "1765149596"
* extractThumbId("http://192.168.0.115:32400/library/metadata/815/file?url=upload%3A%2F%2Fposters%2F1765149596&X-Plex-Token=xxx") // "1765149596"
*/
export function extractThumbId(url: string | null | undefined): string | null {
if (!url) {
return null;
}
// Format 4: URL-encoded upload URL embedded in query parameter
// http://192.168.0.115:32400/library/metadata/815/file?url=upload%3A%2F%2Fposters%2F1765149596&X-Plex-Token=xxx
if (url.includes('?url=') || url.includes('&url=')) {
try {
const urlObj = new URL(url);
const embeddedUrl = urlObj.searchParams.get('url');
if (embeddedUrl) {
// Recursively process the embedded URL
return extractThumbId(embeddedUrl);
}
} catch {
// Invalid URL, fall through to other methods
}
}
// Format 1: upload://posters/{id}
if (url.startsWith('upload://posters/')) {
const id = url.replace('upload://posters/', '');
return id || null;
}
// Format 5: metadata://posters/{agent}/{hash}
// e.g., metadata://posters/tv.plex.agents.movie_48a22557dc2290aca2f37f5062f99d6a176bd0c6
if (url.startsWith('metadata://posters/')) {
const id = url.replace('metadata://posters/', '');
return id || null;
}
// Format 2: /library/metadata/{ratingKey}/thumb/{id}
// Format 3: http://.../library/metadata/{ratingKey}/thumb/{id}?X-Plex-Token=xxx
const thumbMatch = url.match(/\/thumb\/(\d+)/);
@@ -58,6 +58,7 @@ const messages = defineMessages({
minimumYear: 'Minimum Year',
minimumImdbRating: 'Minimum IMDb Rating',
minimumRottenTomatoesRating: 'Minimum RT Rating',
minimumRottenTomatoesAudienceRating: 'Minimum RT Audience Rating',
showUnwatchedOnly: 'Unwatched Only',
createPlaceholders: 'Create Placeholders',
editValues: 'Edit Selected',
@@ -104,6 +105,7 @@ type UnifiedCollection = {
minimumYear?: number;
minimumImdbRating?: number;
minimumRottenTomatoesRating?: number;
minimumRottenTomatoesAudienceRating?: number;
showUnwatchedOnly?: boolean;
createPlaceholdersForMissing?: boolean;
// Original config for saving
@@ -154,6 +156,7 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
minimumYear?: number | '';
minimumImdbRating?: number | '';
minimumRottenTomatoesRating?: number | '';
minimumRottenTomatoesAudienceRating?: number | '';
showUnwatchedOnly?: boolean;
createPlaceholdersForMissing?: boolean;
}>({});
@@ -190,6 +193,8 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
minimumYear: config.minimumYear,
minimumImdbRating: config.minimumImdbRating,
minimumRottenTomatoesRating: config.minimumRottenTomatoesRating,
minimumRottenTomatoesAudienceRating:
config.minimumRottenTomatoesAudienceRating,
showUnwatchedOnly: config.showUnwatchedOnly,
createPlaceholdersForMissing: config.createPlaceholdersForMissing,
originalConfig: config,
@@ -343,6 +348,11 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
(a.minimumRottenTomatoesRating || 0) -
(b.minimumRottenTomatoesRating || 0);
break;
case 'minimumRottenTomatoesAudienceRating':
comparison =
(a.minimumRottenTomatoesAudienceRating || 0) -
(b.minimumRottenTomatoesAudienceRating || 0);
break;
case 'showUnwatchedOnly':
comparison =
(a.showUnwatchedOnly ? 1 : 0) - (b.showUnwatchedOnly ? 1 : 0);
@@ -446,6 +456,7 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
'minimumYear',
'minimumImdbRating',
'minimumRottenTomatoesRating',
'minimumRottenTomatoesAudienceRating',
'showUnwatchedOnly',
'createPlaceholdersForMissing',
];
@@ -632,6 +643,19 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
: editValues.minimumRottenTomatoesRating;
}
if (
editValues.minimumRottenTomatoesAudienceRating !== undefined &&
isFieldApplicable(
'minimumRottenTomatoesAudienceRating',
collection.type
)
) {
updatedFields.minimumRottenTomatoesAudienceRating =
editValues.minimumRottenTomatoesAudienceRating === ''
? undefined
: editValues.minimumRottenTomatoesAudienceRating;
}
if (
editValues.showUnwatchedOnly !== undefined &&
isFieldApplicable('showUnwatchedOnly', collection.type)
@@ -959,6 +983,17 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
{intl.formatMessage(messages.minimumRottenTomatoesRating)}
{renderSortIndicator('minimumRottenTomatoesRating')}
</th>
<th
className="w-24 cursor-pointer px-3 py-2 text-center text-xs font-medium text-gray-400 hover:text-gray-300"
onClick={() =>
handleColumnSort('minimumRottenTomatoesAudienceRating')
}
>
{intl.formatMessage(
messages.minimumRottenTomatoesAudienceRating
)}
{renderSortIndicator('minimumRottenTomatoesAudienceRating')}
</th>
<th
className="w-32 cursor-pointer px-3 py-2 text-center text-xs font-medium text-gray-400 hover:text-gray-300"
onClick={() => handleColumnSort('showUnwatchedOnly')}
@@ -1276,6 +1311,18 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
>
{collection.minimumRottenTomatoesRating || '-'}
</td>
<td
className={`px-3 py-2 text-center text-sm ${
!isFieldApplicable(
'minimumRottenTomatoesAudienceRating',
collection.type
)
? 'text-gray-600 opacity-30'
: 'text-gray-300'
}`}
>
{collection.minimumRottenTomatoesAudienceRating || '-'}
</td>
<td
className={`px-3 py-2 text-center ${
!isFieldApplicable(
@@ -1733,6 +1780,27 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
max={100}
/>
</td>
<td className="px-3 py-2">
<input
type="number"
value={
editValues.minimumRottenTomatoesAudienceRating || ''
}
onChange={(e) =>
setEditValues({
...editValues,
minimumRottenTomatoesAudienceRating:
e.target.value === ''
? ''
: Number(e.target.value),
})
}
placeholder="-"
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
min={0}
max={100}
/>
</td>
<td className="px-3 py-2">
<select
value={
@@ -46,6 +46,10 @@ const messages = defineMessages({
minimumRottenTomatoesRating: 'Minimum Rotten Tomatoes rating',
minimumRottenTomatoesRatingHelp:
'Only grab movies/TV shows with a Rotten Tomatoes critics score >= this value (0 = no limit). Items without ratings will be allowed.',
minimumRottenTomatoesAudienceRating:
'Minimum Rotten Tomatoes audience rating',
minimumRottenTomatoesAudienceRatingHelp:
'Only grab movies/TV shows with a Rotten Tomatoes audience score >= this value (0 = no limit). Items without ratings will be allowed.',
// Download method
downloadMethod: 'Download Method',
@@ -107,9 +111,6 @@ interface AutoRequestSectionProps {
downloadMode?: 'overseerr' | 'direct';
searchMissingMovies?: boolean;
searchMissingTV?: boolean;
excludedGenres?: number[];
excludedCountries?: string[];
excludedLanguages?: string[];
filterSettings?: {
genres?: {
mode: 'exclude' | 'include';
@@ -256,6 +257,44 @@ const AutoRequestSection = ({
fetchOverseerrServers();
}, [overseerrSettings]);
// Auto-select single Overseerr Radarr server if only one exists
useEffect(() => {
if (
!overseerrLoading &&
overseerrServerOptions.servers.radarr.length === 1 &&
values.overseerrRadarrServerId === undefined
) {
setFieldValue?.(
'overseerrRadarrServerId',
overseerrServerOptions.servers.radarr[0].id
);
}
}, [
overseerrLoading,
overseerrServerOptions.servers.radarr,
values.overseerrRadarrServerId,
setFieldValue,
]);
// Auto-select single Overseerr Sonarr server if only one exists
useEffect(() => {
if (
!overseerrLoading &&
overseerrServerOptions.servers.sonarr.length === 1 &&
values.overseerrSonarrServerId === undefined
) {
setFieldValue?.(
'overseerrSonarrServerId',
overseerrServerOptions.servers.sonarr[0].id
);
}
}, [
overseerrLoading,
overseerrServerOptions.servers.sonarr,
values.overseerrSonarrServerId,
setFieldValue,
]);
// Get the effective server IDs (only when server data has loaded)
const effectiveRadarrServerId =
values.directDownloadRadarrServerId !== undefined
@@ -521,28 +560,45 @@ const AutoRequestSection = ({
</div>
</div>
{/* Minimum Rotten Tomatoes Audience Rating */}
<div className="mb-6">
<div className="mb-2 text-sm font-medium text-gray-200">
{intl.formatMessage(messages.minimumRottenTomatoesAudienceRating)}
</div>
<div className="form-input-field">
<Field
type="text"
inputMode="decimal"
id="minimumRottenTomatoesAudienceRating"
name="minimumRottenTomatoesAudienceRating"
placeholder="0"
className="short"
/>
</div>
{errors.minimumRottenTomatoesAudienceRating &&
touched.minimumRottenTomatoesAudienceRating && (
<div className="error">
{errors.minimumRottenTomatoesAudienceRating}
</div>
)}
<div className="label-tip mt-2">
{intl.formatMessage(
messages.minimumRottenTomatoesAudienceRatingHelp
)}
</div>
</div>
{/* Genre Filter with Include/Exclude Mode */}
<FilterWithMode
filterType="genres"
mode={values.filterSettings?.genres?.mode || 'exclude'}
selectedValues={
values.filterSettings?.genres?.values ||
values.excludedGenres ||
[]
}
selectedValues={values.filterSettings?.genres?.values || []}
onModeChange={(mode) => {
const currentValues =
values.filterSettings?.genres?.values ||
values.excludedGenres ||
[];
const currentValues = values.filterSettings?.genres?.values || [];
setFieldValue?.('filterSettings', {
...(values.filterSettings || {}),
genres: { mode, values: currentValues },
});
// Clear old format when using new format
if (values.excludedGenres) {
setFieldValue?.('excludedGenres', undefined);
}
}}
onValuesChange={(selectedValues) => {
const currentMode =
@@ -554,10 +610,6 @@ const AutoRequestSection = ({
values: selectedValues as number[],
},
});
// Clear old format when using new format
if (values.excludedGenres) {
setFieldValue?.('excludedGenres', undefined);
}
}}
/>
@@ -565,24 +617,14 @@ const AutoRequestSection = ({
<FilterWithMode
filterType="countries"
mode={values.filterSettings?.countries?.mode || 'exclude'}
selectedValues={
values.filterSettings?.countries?.values ||
values.excludedCountries ||
[]
}
selectedValues={values.filterSettings?.countries?.values || []}
onModeChange={(mode) => {
const currentValues =
values.filterSettings?.countries?.values ||
values.excludedCountries ||
[];
values.filterSettings?.countries?.values || [];
setFieldValue?.('filterSettings', {
...(values.filterSettings || {}),
countries: { mode, values: currentValues },
});
// Clear old format when using new format
if (values.excludedCountries) {
setFieldValue?.('excludedCountries', undefined);
}
}}
onValuesChange={(selectedValues) => {
const currentMode =
@@ -594,10 +636,6 @@ const AutoRequestSection = ({
values: selectedValues as string[],
},
});
// Clear old format when using new format
if (values.excludedCountries) {
setFieldValue?.('excludedCountries', undefined);
}
}}
/>
@@ -605,24 +643,14 @@ const AutoRequestSection = ({
<FilterWithMode
filterType="languages"
mode={values.filterSettings?.languages?.mode || 'exclude'}
selectedValues={
values.filterSettings?.languages?.values ||
values.excludedLanguages ||
[]
}
selectedValues={values.filterSettings?.languages?.values || []}
onModeChange={(mode) => {
const currentValues =
values.filterSettings?.languages?.values ||
values.excludedLanguages ||
[];
values.filterSettings?.languages?.values || [];
setFieldValue?.('filterSettings', {
...(values.filterSettings || {}),
languages: { mode, values: currentValues },
});
// Clear old format when using new format
if (values.excludedLanguages) {
setFieldValue?.('excludedLanguages', undefined);
}
}}
onValuesChange={(selectedValues) => {
const currentMode =
@@ -634,10 +662,6 @@ const AutoRequestSection = ({
values: selectedValues as string[],
},
});
// Clear old format when using new format
if (values.excludedLanguages) {
setFieldValue?.('excludedLanguages', undefined);
}
}}
/>
@@ -68,6 +68,14 @@ const CollectionExclusionSection: React.FC<CollectionExclusionSectionProps> = ({
return null;
}
// Hide for Plex person collections (actors/directors) to avoid exclusions
if (
values.type === 'plex' &&
(values.subtype === 'actors' || values.subtype === 'directors')
) {
return null;
}
// Don't show section if there are no available collections to exclude from
if (availableCollections.length === 0) {
return null;
@@ -82,8 +82,6 @@ const CollectionTypeSection = ({
'/api/v1/settings/sonarr'
);
if (!isVisible) return null;
// Validate API keys for the current collection type
const apiKeyValidation = validateApiKeysForCollectionType(
values.type || '',
@@ -105,6 +103,7 @@ const CollectionTypeSection = ({
{ value: 'overseerr', label: 'Overseerr Requests' },
{ value: 'tautulli', label: 'Tautulli Statistics' },
{ value: 'trakt', label: 'Trakt Lists' },
{ value: 'plex', label: 'Plex Library' },
{ value: 'letterboxd', label: 'Letterboxd Lists' },
{ value: 'tmdb', label: 'TMDB Lists' },
{ value: 'imdb', label: 'IMDb Lists' },
@@ -224,6 +223,21 @@ const CollectionTypeSection = ({
description: 'Randomly select from configured TMDB lists',
},
];
case 'plex':
return [
{
value: 'directors',
label: 'Auto Director Collections',
description:
'Automatically create a smart collection for each top director in this library.',
},
{
value: 'actors',
label: 'Auto Actor Collections',
description:
'Automatically create smart collections for the top 5 actors in this library.',
},
];
case 'imdb':
return [
{
@@ -369,6 +383,12 @@ const CollectionTypeSection = ({
description:
'Replaces Recently Released hub (sorted by release date)',
},
{
value: 'recently_released_episodes',
label: 'Recently Added Episodes',
description:
'Shows sorted by most recent episode added (TV libraries only)',
},
];
default:
return [];
@@ -377,6 +397,10 @@ const CollectionTypeSection = ({
const subtypeOptions = getSubtypeOptions(String(values.type || ''));
if (!isVisible) {
return null;
}
return (
<div className="space-y-4">
{/* Collection Type */}
@@ -406,6 +430,11 @@ const CollectionTypeSection = ({
} else if (oldType === 'multi-source') {
setFieldValue('isMultiSource', false);
}
// Auto-enable placeholders for Coming Soon
if (newType === 'comingsoon') {
setFieldValue('createPlaceholdersForMissing', true);
}
}
// Auto-set media type based on collection type
@@ -525,6 +554,101 @@ const CollectionTypeSection = ({
/>
)}
{/* Person minimum items & separator option for auto person collections */}
{values.type === 'plex' &&
(values.subtype === 'directors' || values.subtype === 'actors') && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label
htmlFor="personMinimumItems"
className="mb-2 block text-sm text-gray-300"
>
Minimum Items
</label>
<Field
type="number"
id="personMinimumItems"
name="personMinimumItems"
placeholder="5"
min="2"
max="50"
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
Only create if this person has at least this many items
(default: 5, minimum allowed: 2)
</p>
</div>
<div className="rounded-md border border-gray-500/20 bg-transparent p-4 md:col-span-2">
<div className="flex items-center justify-between">
<div>
<label
htmlFor="useSeparator"
className="text-sm font-medium text-gray-300"
>
Use Separator
</label>
<p className="text-xs text-gray-400">
Create a simple separator collection to group your auto{' '}
{values.subtype === 'actors' ? 'actor' : 'director'}{' '}
collections.
</p>
</div>
<Field
type="checkbox"
id="useSeparator"
name="useSeparator"
className="h-5 w-5 rounded border-stone-500 bg-stone-700 text-orange-500 focus:ring-2 focus:ring-orange-500"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setFieldValue('useSeparator', checked);
if (
checked &&
(!values.separatorTitle ||
values.separatorTitle.trim().length === 0)
) {
setFieldValue(
'separatorTitle',
values.subtype === 'actors'
? 'Actor Collections'
: 'Director Collections'
);
}
}}
/>
</div>
</div>
{values.useSeparator && (
<div className="md:col-span-2">
<label
htmlFor="separatorTitle"
className="mb-2 block text-sm text-gray-300"
>
Separator Title <span className="text-red-500">*</span>
</label>
<Field
type="text"
id="separatorTitle"
name="separatorTitle"
placeholder={
values.subtype === 'actors'
? 'Actor Collections'
: 'Director Collections'
}
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
Defaults to{' '}
{values.subtype === 'actors'
? 'Actor Collections'
: 'Director Collections'}
. This title is used for the separator collection and poster.
</p>
</div>
)}
</div>
)}
{/* Tautulli Configuration - appears when type='tautulli' and subtype is selected */}
{values.type === 'tautulli' && values.subtype && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -1,129 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import Select, { type MultiValue } from 'react-select';
import useSWR from 'swr';
const messages = defineMessages({
excludedCountries: 'Excluded Countries',
excludedCountriesHelp:
'Exclude items from these countries from being grabbed. Items matching ANY selected country will be skipped.',
selectCountries: 'Select countries to exclude...',
});
interface Country {
code: string;
name: string;
}
interface CountryExclusionProps {
selectedCountries: string[];
onSelectionChange: (selectedCodes: string[]) => void;
disabled?: boolean;
}
const CountryExclusion = ({
selectedCountries,
onSelectionChange,
disabled = false,
}: CountryExclusionProps) => {
const intl = useIntl();
// Fetch combined countries (includes both movie and TV origin countries, deduplicated)
const { data: countries } = useSWR<Country[]>('/api/v1/countries/combined');
const options = (countries || []).map((country) => ({
value: country.code,
label: country.name,
}));
const selectedOptions = options.filter((option) =>
selectedCountries.includes(option.value)
);
const handleChange = (
newSelectedOptions: MultiValue<{ value: string; label: string }>
) => {
const values = newSelectedOptions
? newSelectedOptions.map((option) => option.value)
: [];
onSelectionChange(values);
};
return (
<div className="mb-6">
<label className="mb-2 block text-sm text-gray-300">
{intl.formatMessage(messages.excludedCountries)}
</label>
<Select
isMulti
options={options}
value={selectedOptions}
onChange={handleChange}
isDisabled={disabled || !countries || countries.length === 0}
placeholder={intl.formatMessage(messages.selectCountries)}
menuPlacement="auto"
classNamePrefix="react-select"
closeMenuOnSelect={false}
hideSelectedOptions={false}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (base, state) => ({
...base,
backgroundColor: '#44403c',
borderColor: state.isFocused ? '#ea580c' : '#78716c',
'&:hover': {
borderColor: '#ea580c',
},
boxShadow: state.isFocused ? '0 0 0 1px #ea580c' : 'none',
}),
menu: (base) => ({
...base,
backgroundColor: '#44403c',
border: '1px solid #4b5563',
}),
option: (base, state) => ({
...base,
backgroundColor: state.isFocused ? '#4b5563' : '#374151',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
paddingLeft: '16px',
'&:before': state.isSelected
? {
content: '"✓"',
marginRight: '8px',
color: '#6366f1',
fontWeight: 'bold',
}
: {
content: '""',
marginRight: '20px',
},
}),
multiValue: (base) => ({
...base,
backgroundColor: '#57534e',
color: 'white',
}),
multiValueLabel: (base) => ({
...base,
color: 'white',
}),
multiValueRemove: (base) => ({
...base,
color: '#a8a29e',
'&:hover': {
backgroundColor: '#ef4444',
color: 'white',
},
}),
}}
/>
<p className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.excludedCountriesHelp)}
</p>
</div>
);
};
export default CountryExclusion;
@@ -1,129 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import Select, { type MultiValue } from 'react-select';
import useSWR from 'swr';
const messages = defineMessages({
excludedGenres: 'Excluded Genres',
excludedGenresHelp:
'Exclude items with these genres from being grabbed. Items matching ANY selected genre will be skipped.',
selectGenres: 'Select genres to exclude...',
});
interface Genre {
id: number;
name: string;
}
interface GenreExclusionProps {
selectedGenres: number[];
onSelectionChange: (selectedIds: number[]) => void;
disabled?: boolean;
}
const GenreExclusion = ({
selectedGenres,
onSelectionChange,
disabled = false,
}: GenreExclusionProps) => {
const intl = useIntl();
// Fetch combined genres (includes both movie and TV genres, deduplicated)
const { data: genres } = useSWR<Genre[]>('/api/v1/genres/combined');
const options = (genres || []).map((genre) => ({
value: genre.id,
label: genre.name,
}));
const selectedOptions = options.filter((option) =>
selectedGenres.includes(option.value)
);
const handleChange = (
newSelectedOptions: MultiValue<{ value: number; label: string }>
) => {
const values = newSelectedOptions
? newSelectedOptions.map((option) => option.value)
: [];
onSelectionChange(values);
};
return (
<div className="mb-6">
<label className="mb-2 block text-sm text-gray-300">
{intl.formatMessage(messages.excludedGenres)}
</label>
<Select
isMulti
options={options}
value={selectedOptions}
onChange={handleChange}
isDisabled={disabled || !genres || genres.length === 0}
placeholder={intl.formatMessage(messages.selectGenres)}
menuPlacement="auto"
classNamePrefix="react-select"
closeMenuOnSelect={false}
hideSelectedOptions={false}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (base, state) => ({
...base,
backgroundColor: '#44403c',
borderColor: state.isFocused ? '#ea580c' : '#78716c',
'&:hover': {
borderColor: '#ea580c',
},
boxShadow: state.isFocused ? '0 0 0 1px #ea580c' : 'none',
}),
menu: (base) => ({
...base,
backgroundColor: '#44403c',
border: '1px solid #4b5563',
}),
option: (base, state) => ({
...base,
backgroundColor: state.isFocused ? '#4b5563' : '#374151',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
paddingLeft: '16px',
'&:before': state.isSelected
? {
content: '"✓"',
marginRight: '8px',
color: '#6366f1',
fontWeight: 'bold',
}
: {
content: '""',
marginRight: '20px',
},
}),
multiValue: (base) => ({
...base,
backgroundColor: '#57534e',
color: 'white',
}),
multiValueLabel: (base) => ({
...base,
color: 'white',
}),
multiValueRemove: (base) => ({
...base,
color: '#a8a29e',
'&:hover': {
backgroundColor: '#ef4444',
color: 'white',
},
}),
}}
/>
<p className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.excludedGenresHelp)}
</p>
</div>
);
};
export default GenreExclusion;
@@ -1,129 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import Select, { type MultiValue } from 'react-select';
import useSWR from 'swr';
const messages = defineMessages({
excludedLanguages: 'Excluded Languages',
excludedLanguagesHelp:
'Exclude items with these spoken languages from being grabbed. Items matching ANY selected language will be skipped.',
selectLanguages: 'Select languages to exclude...',
});
interface Language {
code: string;
name: string;
}
interface LanguageExclusionProps {
selectedLanguages: string[];
onSelectionChange: (selectedCodes: string[]) => void;
disabled?: boolean;
}
const LanguageExclusion = ({
selectedLanguages,
onSelectionChange,
disabled = false,
}: LanguageExclusionProps) => {
const intl = useIntl();
// Fetch combined languages (curated list of common languages)
const { data: languages } = useSWR<Language[]>('/api/v1/languages/combined');
const options = (languages || []).map((language) => ({
value: language.code,
label: language.name,
}));
const selectedOptions = options.filter((option) =>
selectedLanguages.includes(option.value)
);
const handleChange = (
newSelectedOptions: MultiValue<{ value: string; label: string }>
) => {
const values = newSelectedOptions
? newSelectedOptions.map((option) => option.value)
: [];
onSelectionChange(values);
};
return (
<div className="mb-6">
<label className="mb-2 block text-sm text-gray-300">
{intl.formatMessage(messages.excludedLanguages)}
</label>
<Select
isMulti
options={options}
value={selectedOptions}
onChange={handleChange}
isDisabled={disabled || !languages || languages.length === 0}
placeholder={intl.formatMessage(messages.selectLanguages)}
menuPlacement="auto"
classNamePrefix="react-select"
closeMenuOnSelect={false}
hideSelectedOptions={false}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (base, state) => ({
...base,
backgroundColor: '#44403c',
borderColor: state.isFocused ? '#ea580c' : '#78716c',
'&:hover': {
borderColor: '#ea580c',
},
boxShadow: state.isFocused ? '0 0 0 1px #ea580c' : 'none',
}),
menu: (base) => ({
...base,
backgroundColor: '#44403c',
border: '1px solid #4b5563',
}),
option: (base, state) => ({
...base,
backgroundColor: state.isFocused ? '#4b5563' : '#374151',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
paddingLeft: '16px',
'&:before': state.isSelected
? {
content: '"✓"',
marginRight: '8px',
color: '#6366f1',
fontWeight: 'bold',
}
: {
content: '""',
marginRight: '20px',
},
}),
multiValue: (base) => ({
...base,
backgroundColor: '#57534e',
color: 'white',
}),
multiValueLabel: (base) => ({
...base,
color: 'white',
}),
multiValueRemove: (base) => ({
...base,
color: '#a8a29e',
'&:hover': {
backgroundColor: '#ef4444',
color: 'white',
},
}),
}}
/>
<p className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.excludedLanguagesHelp)}
</p>
</div>
);
};
export default LanguageExclusion;
@@ -138,7 +138,9 @@ const PosterUploadSection = ({
values.autoPoster ?? (isPreExisting ? false : true);
// Get current selected template - if none selected, use the default template
const defaultTemplate = templates?.find((t) => t.isDefault);
const defaultTemplate =
templates?.find((t) => t.isDefault) ||
templates?.find((t) => t.name === 'Default Agregarr Template');
const selectedTemplateId =
values.autoPosterTemplate || defaultTemplate?.id || null;
const selectedTemplate = templates?.find((t) => t.id === selectedTemplateId);
@@ -153,10 +155,25 @@ const PosterUploadSection = ({
// Auto-select default template when templates load and no template is currently selected
useEffect(() => {
if (templates && !values.autoPosterTemplate && defaultTemplate) {
if (!templates) {
return;
}
// Don't auto-select default template for pre-existing collections
if (isPreExisting) {
return;
}
if (!values.autoPosterTemplate && defaultTemplate) {
setFieldValue('autoPosterTemplate', defaultTemplate.id);
}
}, [templates, values.autoPosterTemplate, defaultTemplate, setFieldValue]);
}, [
templates,
values.autoPosterTemplate,
defaultTemplate,
setFieldValue,
isPreExisting,
]);
const handleRemovePoster = (libraryId: string) => {
const updatedPosters = { ...currentPosters };
@@ -397,9 +397,36 @@ const TemplateSection = ({
<div className="error">{errors.template}</div>
)}
<div className="label-tip">
Available variables: Plex Username - {`{username}`} , Plex Nickname -{' '}
{`{nickname}`} , Plex Server Name - {`{servername}`} , Overseerr Domain
- {`{domain}`} , Overseerr App Title - {`{appTitle}`} .
{(() => {
// Show variables based on collection type
const baseVars = 'Media Type - {mediaType}';
if (values.type === 'overseerr') {
return `Available variables: Plex Username - {username}, Plex Nickname - {nickname}, Overseerr Domain - {domain}, Overseerr App Title - {appTitle}, ${baseVars}.`;
}
if (values.type === 'tautulli') {
return `Available variables: Plex Server Name - {servername}, Number of Days - {customdays}, ${baseVars}.`;
}
if (
values.type === 'plex' &&
(values.subtype === 'actors' || values.subtype === 'directors')
) {
const personType =
values.subtype === 'actors' ? 'Actor' : 'Director';
return `Available variables: ${personType} Name - {${
values.subtype === 'actors' ? 'actor' : 'director'
}}, ${baseVars}.`;
}
if (values.type === 'tmdb' && values.subtype === 'auto_franchise') {
return `Available variables: Franchise Name - {franchiseName}, ${baseVars}.`;
}
// Default for other collection types
return `Available variables: ${baseVars}.`;
})()}
</div>
</>
);

Some files were not shown because too many files have changed in this diff Show More