mirror of
https://github.com/agregarr/agregarr.git
synced 2026-04-29 22:49:27 -05:00
Merge branch 'develop' into latest
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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 |
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -153,6 +153,7 @@ export type CollectionSource =
|
||||
| 'originals'
|
||||
| 'anilist'
|
||||
| 'myanimelist'
|
||||
| 'plex'
|
||||
| 'radarrtag'
|
||||
| 'sonarrtag'
|
||||
| 'comingsoon'
|
||||
|
||||
@@ -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)}§ionId=${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)}§ionId=${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;
|
||||
}
|
||||
|
||||
|
||||
+30
-32
@@ -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,
|
||||
+8
-18
@@ -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,
|
||||
+8
-4
@@ -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) {
|
||||
+15
-36
@@ -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) {
|
||||
+14
-31
@@ -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) {
|
||||
+30
-36
@@ -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,
|
||||
+23
-42
@@ -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
|
||||
}
|
||||
|
||||
+14
-31
@@ -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
+14
-31
@@ -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) {
|
||||
+18
-3
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
+14
-31
@@ -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) {
|
||||
+5
-8
@@ -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) {
|
||||
+14
-33
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+222
-992
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+98
-170
@@ -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;
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user