Merge branch 'develop' into latest

This commit is contained in:
Tom Wheeler
2025-10-22 05:53:23 +13:00
27 changed files with 1664 additions and 482 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
buy_me_a_coffee: agregarr
+2 -30
View File
@@ -1,6 +1,6 @@
name: 🐛 Bug Report
description: Report a problem
labels: ['type:bug', 'awaiting-triage']
labels: ['awaiting-triage']
body:
- type: markdown
attributes:
@@ -41,36 +41,8 @@ body:
id: logs
attributes:
label: Logs
description: Please copy and paste any relevant log output. (This will be automatically formatted into code, so no need for backticks.)
description: Please copy and paste any relevant log output. The logs file can be located in config/logs/agregarr-YYYY-MM-DD. (This will be automatically formatted into code, so no need for backticks.)
render: shell
- type: dropdown
id: platform
attributes:
label: Platform
options:
- desktop
- smartphone
- tablet
validations:
required: true
- type: input
id: device
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
description: e.g., Ubuntu 20.04, Windows 10, MacOS 11.2
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: e.g., Chrome, Safari, Edge, Firefox
validations:
required: true
- type: textarea
id: additional-context
attributes:
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Support via Discord
url: discord.gg/RfEPPRQJQ2
about: Chat with other users and the Overseerr dev team
- name: 💬 Support via GitHub Discussions
url: https://github.com/agregarr/agregarr/discussions
about: Ask questions and discuss with other community members
+1 -1
View File
@@ -5160,7 +5160,7 @@ paths:
type:
type: string
description: Collection source type
enum: [trakt, tmdb, imdb, letterboxd, mdblist, tautulli, networks, originals, overseerr, multi-source]
enum: [trakt, tmdb, imdb, letterboxd, mdblist, tautulli, networks, originals, overseerr, anilist, myanimelist, multi-source]
example: imdb
subtype:
type: string
+202 -1
View File
@@ -373,7 +373,7 @@ export async function getTopRatedAnime(
// ---- Custom Lists (per user) ----
export async function getUserCustomLists(
userName: string,
type: 'ANIME' | 'MANGA' = 'ANIME'
type: 'ANIME' = 'ANIME'
) {
const query = `
${MEDIA_FIELDS}
@@ -398,6 +398,207 @@ export async function getUserCustomLists(
return data.MediaListCollection.lists || [];
}
// ---- Search with custom filters ----
export async function searchAnime(
page = 1,
perPage = 20,
searchParams: {
genres?: string[];
tags?: string[];
season?: string;
seasonYear?: number;
year?: number;
startDateGreater?: number; // year range start
startDateLesser?: number; // year range end
format?: string;
formatIn?: string[];
sort?: string;
isAdult?: boolean;
status?: string; // airing status: FINISHED, RELEASING, NOT_YET_RELEASED, CANCELLED
licensedById?: number; // streaming service ID
countryOfOrigin?: string; // JP, KR, CN, TW
source?: string; // source material: ORIGINAL, MANGA, LIGHT_NOVEL, etc.
search?: string; // search query
isLicensed?: boolean; // doujin filter
episodes_greater?: number;
episodes_lesser?: number;
duration_greater?: number;
duration_lesser?: number;
} = {}
) {
// Map URL parameter names to AniList GraphQL parameter names
const {
genres,
tags,
season,
seasonYear,
year,
startDateGreater,
startDateLesser,
format,
formatIn,
sort = 'TRENDING_DESC',
isAdult = false,
status,
licensedById,
countryOfOrigin,
source,
search,
isLicensed,
episodes_greater,
episodes_lesser,
duration_greater,
duration_lesser,
} = searchParams;
// Build query dynamically based on provided filters
const queryParams: string[] = ['$page: Int', '$perPage: Int'];
const mediaParams: string[] = ['type: ANIME'];
const variables: Record<
string,
string | number | boolean | string[] | null | undefined
> = {
page,
perPage,
};
if (genres && genres.length > 0) {
queryParams.push('$genreIn: [String]');
mediaParams.push('genre_in: $genreIn');
variables.genreIn = genres;
}
if (tags && tags.length > 0) {
queryParams.push('$tagIn: [String]');
mediaParams.push('tag_in: $tagIn');
variables.tagIn = tags;
}
if (season) {
queryParams.push('$season: MediaSeason');
mediaParams.push('season: $season');
variables.season = season;
}
if (seasonYear) {
queryParams.push('$seasonYear: Int');
mediaParams.push('seasonYear: $seasonYear');
variables.seasonYear = seasonYear;
}
if (year) {
queryParams.push('$year: Int');
mediaParams.push('seasonYear: $year');
variables.year = year;
}
if (startDateGreater) {
queryParams.push('$startDateGreater: FuzzyDateInt');
mediaParams.push('startDate_greater: $startDateGreater');
variables.startDateGreater = startDateGreater;
}
if (startDateLesser) {
queryParams.push('$startDateLesser: FuzzyDateInt');
mediaParams.push('startDate_lesser: $startDateLesser');
variables.startDateLesser = startDateLesser;
}
if (format) {
queryParams.push('$format: MediaFormat');
mediaParams.push('format: $format');
variables.format = format;
} else if (formatIn && formatIn.length > 0) {
queryParams.push('$formatIn: [MediaFormat]');
mediaParams.push('format_in: $formatIn');
variables.formatIn = formatIn;
}
if (status) {
queryParams.push('$status: MediaStatus');
mediaParams.push('status: $status');
variables.status = status;
}
if (licensedById !== undefined) {
queryParams.push('$licensedById: Int');
mediaParams.push('licensedById: $licensedById');
variables.licensedById = licensedById;
}
if (countryOfOrigin) {
queryParams.push('$countryOfOrigin: CountryCode');
mediaParams.push('countryOfOrigin: $countryOfOrigin');
variables.countryOfOrigin = countryOfOrigin;
}
if (source) {
queryParams.push('$source: MediaSource');
mediaParams.push('source: $source');
variables.source = source;
}
if (search) {
queryParams.push('$search: String');
mediaParams.push('search: $search');
variables.search = search;
}
if (isLicensed !== undefined) {
queryParams.push('$isLicensed: Boolean');
mediaParams.push('isLicensed: $isLicensed');
variables.isLicensed = isLicensed;
}
if (episodes_greater !== undefined) {
queryParams.push('$episodesGreater: Int');
mediaParams.push('episodes_greater: $episodesGreater');
variables.episodesGreater = episodes_greater;
}
if (episodes_lesser !== undefined) {
queryParams.push('$episodesLesser: Int');
mediaParams.push('episodes_lesser: $episodesLesser');
variables.episodesLesser = episodes_lesser;
}
if (duration_greater !== undefined) {
queryParams.push('$durationGreater: Int');
mediaParams.push('duration_greater: $durationGreater');
variables.durationGreater = duration_greater;
}
if (duration_lesser !== undefined) {
queryParams.push('$durationLesser: Int');
mediaParams.push('duration_lesser: $durationLesser');
variables.durationLesser = duration_lesser;
}
if (sort) {
queryParams.push('$sort: [MediaSort]');
mediaParams.push('sort: $sort');
variables.sort = sort;
}
queryParams.push('$isAdult: Boolean');
mediaParams.push('isAdult: $isAdult');
variables.isAdult = isAdult;
const query = `
${MEDIA_FIELDS}
query (${queryParams.join(', ')}) {
Page(page: $page, perPage: $perPage) {
pageInfo { total perPage currentPage lastPage hasNextPage }
media(${mediaParams.join(', ')}) {
...MediaFields
}
}
}
`;
return fetchAniListData<PageMediaResponse>(query, variables);
}
// ---- Convenience ----
export async function getFeedsFirstPage(perPage = 20, isAdult = false) {
// Make requests sequential instead of concurrent to avoid rate limiting
+12
View File
@@ -511,6 +511,18 @@ class FlixPatrolAPI extends ExternalAPI {
if (currentElement.tagName === 'H3') {
const h3Text = currentElement.textContent?.toLowerCase() || '';
// Filter out "Kids" content sections
if (h3Text.includes('kids')) {
logger.debug(`Skipping Kids content section: "${h3Text}"`, {
label: 'FlixPatrol API',
platform,
section: h3Text,
reason: 'kids_content_filter',
});
currentElement = currentElement.nextElementSibling;
continue;
}
// Find the table that follows this H3
let tableElement = currentElement.nextElementSibling;
let searchDepth = 0;
+190 -27
View File
@@ -1317,25 +1317,69 @@ class PlexAPI {
return; // Just skip arrangement for smart collections, don't throw error
}
// Fetch current order once
const currentOrder = await this.getCollectionItems(collectionRatingKey);
const desiredOrder = orderedItems.map((item) => item.ratingKey);
// Early return optimization: Check if already in correct order
if (
currentOrder.length === desiredOrder.length &&
currentOrder.every(
(ratingKey, index) => ratingKey === desiredOrder[index]
)
) {
logger.debug(
`Collection ${collectionRatingKey} is already in correct order. Skipping reordering.`,
{
label: 'Plex API',
collectionRatingKey,
itemCount: orderedItems.length,
}
);
return;
}
let moveCount = 0;
let failCount = 0;
// Move each item to its correct position (skip the first item as it's already in position)
// Items are ordered newest first, so we position each subsequent item after the previous one
for (let i = 1; i < orderedItems.length; i++) {
const currentItem = orderedItems[i];
const previousItem = orderedItems[i - 1];
// Selective reordering: Only move items that are out of position
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;
const success = await this.moveItemInCollection(
collectionRatingKey,
currentItem.ratingKey,
previousItem.ratingKey
);
if (afterItem) {
const success = await this.moveItemInCollection(
collectionRatingKey,
itemToMove,
afterItem
);
if (!success) {
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++;
}
}
}
}
if (moveCount > 0) {
logger.debug(
`Selectively moved ${moveCount} items in collection ${collectionRatingKey}`,
{
label: 'Plex API',
collectionRatingKey,
totalItems: orderedItems.length,
movedItems: moveCount,
}
);
}
if (failCount > 0) {
logger.warn(
`Failed to arrange ${failCount} items in collection ${collectionRatingKey}`,
@@ -2328,6 +2372,7 @@ class PlexAPI {
}
// Check subsequent items - move after their immediate predecessor if wrong
let repromoCount = 0;
for (let i = 1; i < completeDesiredOrder.length; i++) {
const currentItem = completeDesiredOrder[i];
const expectedPredecessor = completeDesiredOrder[i - 1];
@@ -2357,12 +2402,98 @@ class PlexAPI {
await this.moveHub(sectionId, currentItem, expectedPredecessor);
moveCount++;
// Update our tracking of current order after the move
// Remove item from old position and insert after predecessor
const itemToMove = currentOrder.splice(currentPosition, 1)[0];
const predecessorNewPosition =
currentOrder.indexOf(expectedPredecessor);
currentOrder.splice(predecessorNewPosition + 1, 0, itemToMove);
// CONVERGENCE SOLUTION: Verify the move worked by fetching actual order
const verificationHubManagement = await this.getHubManagement(
sectionId
);
const actualOrder =
verificationHubManagement.MediaContainer.Hub.map(
(h: { identifier: string }) => h.identifier
);
// Check if item landed immediately after predecessor
const actualPredecessorIndex =
actualOrder.indexOf(expectedPredecessor);
const actualCurrentIndex = actualOrder.indexOf(currentItem);
const placementSuccess =
actualPredecessorIndex !== -1 &&
actualCurrentIndex === actualPredecessorIndex + 1;
if (!placementSuccess) {
// Placement failed - likely due to float precision convergence
logger.warn(
`Placement verification failed for ${currentItem} - attempting unpromote/re-promote recovery`,
{
label: 'Plex API',
sectionId,
hubId: currentItem,
expectedAfter: expectedPredecessor,
actualPredecessorIndex,
actualCurrentIndex,
convergenceDetected: true,
}
);
// Extract rating key from identifier for unpromote/re-promote
const ratingKey =
this.extractRatingKeyFromIdentifier(currentItem);
if (ratingKey) {
// Unpromote the collection (delete from hub management)
await this.deleteHubItem(sectionId, currentItem);
repromoCount++;
logger.debug(
`Unpromoted collection ${currentItem}, re-promoting with fresh spacing`,
{
label: 'Plex API',
sectionId,
hubId: currentItem,
ratingKey,
}
);
// Re-promote it (gets fresh 1000-unit spacing at the end)
await this.promoteCollectionToHub(ratingKey, sectionId);
// Update tracking: item is now at the end
actualOrder.splice(actualCurrentIndex, 1);
actualOrder.push(currentItem);
currentOrder.length = 0;
currentOrder.push(...actualOrder);
logger.info(
`Successfully recovered from convergence via unpromote/re-promote for ${currentItem}`,
{
label: 'Plex API',
sectionId,
hubId: currentItem,
ratingKey,
repromoCount,
}
);
} else {
// Can't unpromote built-in hubs or items without rating keys
logger.warn(
`Cannot unpromote/re-promote ${currentItem} - no rating key available`,
{
label: 'Plex API',
sectionId,
hubId: currentItem,
note: 'Built-in hubs or invalid identifiers cannot be re-promoted',
}
);
// Update tracking with actual order anyway
currentOrder.length = 0;
currentOrder.push(...actualOrder);
}
} else {
// Move succeeded - update our tracking of current order
const itemToMove = currentOrder.splice(currentPosition, 1)[0];
const predecessorNewPosition =
currentOrder.indexOf(expectedPredecessor);
currentOrder.splice(predecessorNewPosition + 1, 0, itemToMove);
}
} catch (error) {
logger.error(
`Failed to move item ${currentItem} after predecessor ${expectedPredecessor}`,
@@ -2378,13 +2509,21 @@ class PlexAPI {
}
}
logger.info(`Selective reordering completed: ${moveCount} items moved`, {
label: 'Plex API',
sectionId,
moveCount,
totalItems: completeDesiredOrder.length,
efficiency: `${moveCount}/${completeDesiredOrder.length} moves`,
});
logger.info(
`Selective reordering completed: ${moveCount} items moved${
repromoCount > 0
? `, ${repromoCount} items re-promoted for convergence recovery`
: ''
}`,
{
label: 'Plex API',
sectionId,
moveCount,
repromoCount,
totalItems: completeDesiredOrder.length,
efficiency: `${moveCount}/${completeDesiredOrder.length} moves`,
}
);
// Verify order after moves - detect precision convergence
if (moveCount > 0) {
@@ -2400,28 +2539,32 @@ class PlexAPI {
if (!orderMatches) {
logger.error(
`Order verification failed after ${moveCount} moves - precision convergence detected`,
`Order verification failed after ${moveCount} moves and ${repromoCount} re-promotions - falling back to reset`,
{
label: 'Plex API',
sectionId,
moveCount,
repromoCount,
expectedOrder: completeDesiredOrder,
actualOrder,
convergenceDetected: true,
note: 'Unpromote/re-promote recovery was attempted but final order still incorrect',
}
);
// Throw a specific error that can be caught and handled with reset
const convergenceError = new Error(
`Precision convergence detected in library ${sectionId}`
`Precision convergence detected in library ${sectionId} - unpromote/re-promote recovery failed`
) as Error & {
isPrecisionConvergence: boolean;
sectionId: string;
moveCount: number;
repromoCount: number;
};
convergenceError.isPrecisionConvergence = true;
convergenceError.sectionId = sectionId;
convergenceError.moveCount = moveCount;
convergenceError.repromoCount = repromoCount;
throw convergenceError;
} else {
logger.info(
@@ -2511,6 +2654,26 @@ class PlexAPI {
}
}
/**
* Extract rating key from a hub identifier for unpromote/re-promote operations
* @param identifier Hub identifier (e.g., "custom.collection.1.35954")
* @returns Rating key if identifier is a custom collection, null otherwise
*/
private extractRatingKeyFromIdentifier(identifier: string): string | null {
// Check if this is a custom collection identifier
if (!identifier.startsWith('custom.collection.')) {
return null;
}
// Extract rating key from "custom.collection.{libraryId}.{ratingKey}"
const parts = identifier.split('.');
if (parts.length >= 4) {
return parts[3];
}
return null;
}
/**
* Get Plex user display name (plexTitle) for a given Plex user ID
* Uses the Plex users API to get user details with actual display names
+3 -1
View File
@@ -331,7 +331,9 @@ class SonarrAPI extends ServarrBase<{
public getExclusions = async (): Promise<SonarrExclusion[]> => {
try {
const response = await this.axios.get<SonarrExclusion[]>('/exclusions');
const response = await this.axios.get<SonarrExclusion[]>(
'/importlistexclusion'
);
return response.data;
} catch (e) {
logger.error('Error retrieving exclusions from Sonarr', {
@@ -81,14 +81,67 @@ export function generateGlobalCollectionName(): string {
/**
* Clean Agregarr-specific labels from filter strings
* Used to remove auto-generated labels when updating user filters
*
* Plex filter syntax: filter1&filter2&filter3
* Each filter can be: key=value1,value2|key=value3
* Examples:
* - "label!=X,Y,Z" (negative labels)
* - "contentRating=G&label!=X,Y" (content rating + negative labels)
* - "contentRating=G|label=kids&label!=X,Y" (OR content/label + negative labels)
*/
export function cleanOverseerrLabels(filterStr: string): string {
if (!filterStr) return '';
return filterStr
.replace(/Agregarr[^,]*/gi, '')
.replace(/,,+/g, ',')
.replace(/^,|,$/g, '')
.replace(/^label!=$/, '');
// Split by & to get individual filter groups
const filterGroups = filterStr.split('&');
// Process each filter group
const cleanedGroups = filterGroups
.map((group) => {
// Check if this is a label filter (either label= or label!=)
if (group.includes('label=') || group.includes('label!=')) {
// Split by | to handle OR conditions within the group
const orParts = group.split('|');
const cleanedOrParts = orParts
.map((part) => {
// Only process label!= (negative filters), leave label= (positive filters) unchanged
if (!part.startsWith('label!=')) {
return part; // Keep label= and other filters unchanged
}
// Extract the values after label!=
const valuesStr = part.substring('label!='.length);
if (!valuesStr) return ''; // Empty values
// Split by comma to get individual labels
const labels = valuesStr.split(',');
// Filter out Agregarr user/owner labels only
const nonAgregarrLabels = labels.filter(
(label) => !label.toLowerCase().startsWith('agregarr')
);
// Reconstruct the label filter if there are remaining labels
if (nonAgregarrLabels.length > 0) {
return `label!=${nonAgregarrLabels.join(',')}`;
}
return ''; // All labels were Agregarr labels
})
.filter((part) => part !== ''); // Remove empty parts
// Rejoin OR parts if any remain
return cleanedOrParts.join('|');
}
// Not a label filter, keep as-is
return group;
})
.filter((group) => group !== ''); // Remove empty groups
// Rejoin all filter groups
return cleanedGroups.join('&');
}
/**
@@ -157,8 +210,11 @@ export async function cleanupOrphanedCollections(
let deletedCount = 0;
try {
// Get all libraries
const libraries = await plexClient.getLibraries();
// Get all libraries - filter to only movie and show libraries
const allLibraries = await plexClient.getLibraries();
const libraries = allLibraries.filter(
(library) => library.type === 'movie' || library.type === 'show'
);
for (const library of libraries) {
// Get all collections - they're filtered by library key internally
@@ -1147,19 +1203,33 @@ export function validateDownloadModeConfig(config: CollectionConfig): {
);
}
// Position limit validation
if (config.maxPositionToProcess && config.maxPositionToProcess < 1) {
errors.push('Position limit must be at least 1 if specified');
// Position limit validation (0 = no limit)
if (
config.maxPositionToProcess !== undefined &&
config.maxPositionToProcess !== null &&
config.maxPositionToProcess < 0
) {
errors.push('Position limit must be 0 or greater (0 = no limit)');
}
// Season limit validation
if (config.maxSeasonsToRequest && config.maxSeasonsToRequest < 1) {
errors.push('Season limit must be at least 1 if specified');
// Season limit validation (0 = no limit)
if (
config.maxSeasonsToRequest !== undefined &&
config.maxSeasonsToRequest !== null &&
config.maxSeasonsToRequest < 0
) {
errors.push('Season limit must be 0 or greater (0 = no limit)');
}
// Seasons per show limit validation
if (config.seasonsPerShowLimit && config.seasonsPerShowLimit < 1) {
errors.push('Seasons per show limit must be at least 1 if specified');
// Seasons per show limit validation (0 = all seasons)
if (
config.seasonsPerShowLimit !== undefined &&
config.seasonsPerShowLimit !== null &&
config.seasonsPerShowLimit < 0
) {
errors.push(
'Seasons per show limit must be 0 or greater (0 = all seasons)'
);
}
// Mode-specific validations
@@ -1288,7 +1358,11 @@ export async function prefetchAllLibraryItems(
const cache: LibraryItemsCache = {};
try {
const libraries = await plexClient.getLibraries();
const allLibraries = await plexClient.getLibraries();
// Filter to only movie and show libraries
const libraries = allLibraries.filter(
(library) => library.type === 'movie' || library.type === 'show'
);
let librariesToCache = libraries;
// If targetLibraryId is specified, only cache that library
@@ -1406,7 +1480,11 @@ export async function findPlexItemsByTmdbIds(
);
} else {
// Fallback to fresh API call if no cache
libraries = await plexClient.getLibraries();
const allLibraries = await plexClient.getLibraries();
// Filter to only movie and show libraries
libraries = allLibraries.filter(
(library) => library.type === 'movie' || library.type === 'show'
);
// No library cache available, fetching fresh data
}
+219 -32
View File
@@ -4,6 +4,7 @@ import {
getTopRatedAnime,
getTrendingAnime,
getUserCustomLists,
searchAnime,
type AniListCustomList,
type AniListMedia,
} from '@server/api/anilist';
@@ -177,6 +178,7 @@ export class AnilistCollectionSync extends BaseCollectionSync {
/**
* Count how many items from the provided media array would match items in the library.
* This is a lightweight version of the full mapping logic used for early termination checks.
* Deduplicates by ratingKey to match the final preview behavior.
*/
private countMatchedItems(
media: AniListMedia[],
@@ -196,13 +198,13 @@ export class AnilistCollectionSync extends BaseCollectionSync {
},
mediaType: 'movie' | 'tv'
): number {
let matchedCount = 0;
const seenRatingKeys = new Set<string>();
for (const m of media) {
const anilistId = m?.id;
let matched = false;
let matchedRatingKey: string | undefined;
// Check Kometa mapping first
// Check PlexAniBridge mapping first
if (anilistId) {
let map = lookupByAniList(anilistId);
if (!map && m?.idMal) {
@@ -228,21 +230,24 @@ export class AnilistCollectionSync extends BaseCollectionSync {
const imdbFirst = getFirstValue(map.imdb_id);
const imdb = imdbFirst?.toLowerCase();
// Check if any of these IDs exist in library
// Check if any of these IDs exist in library and get ratingKey
if (mediaType === 'tv') {
if (tvdb && libraryIndex.tvdb.has(tvdb)) matched = true;
else if (tmdbShow && libraryIndex.tmdb.has(tmdbShow))
matched = true;
else if (imdb && libraryIndex.imdb.has(imdb)) matched = true;
if (tvdb && libraryIndex.tvdb.has(tvdb)) {
matchedRatingKey = libraryIndex.tvdb.get(tvdb)?.ratingKey;
} else if (tmdbShow && libraryIndex.tmdb.has(tmdbShow)) {
matchedRatingKey = libraryIndex.tmdb.get(tmdbShow)?.ratingKey;
} else if (imdb && libraryIndex.imdb.has(imdb)) {
matchedRatingKey = libraryIndex.imdb.get(imdb)?.ratingKey;
}
}
if (!matched) {
if (!matchedRatingKey) {
const tryTmdbIds = [tmdbMovie, tmdbShow].filter(
Boolean
) as string[];
for (const tid of tryTmdbIds) {
if (libraryIndex.tmdb.has(tid)) {
matched = true;
matchedRatingKey = libraryIndex.tmdb.get(tid)?.ratingKey;
break;
}
}
@@ -250,23 +255,26 @@ export class AnilistCollectionSync extends BaseCollectionSync {
}
}
if (!matched) {
if (!matchedRatingKey) {
// Check external links (TMDb/IMDb)
const tmdbId = this.extractTmdbId(m?.externalLinks ?? undefined);
if (tmdbId && libraryIndex.tmdb.has(tmdbId)) {
matched = true;
matchedRatingKey = libraryIndex.tmdb.get(tmdbId)?.ratingKey;
} else {
const imdbId = this.extractImdbId(m?.externalLinks ?? undefined);
if (imdbId && libraryIndex.imdb.has(imdbId)) {
matched = true;
matchedRatingKey = libraryIndex.imdb.get(imdbId)?.ratingKey;
}
}
}
if (matched) matchedCount++;
// Only count if we haven't seen this ratingKey before (deduplication)
if (matchedRatingKey && !seenRatingKeys.has(matchedRatingKey)) {
seenRatingKeys.add(matchedRatingKey);
}
}
return matchedCount;
return seenRatingKeys.size;
}
private buildProviderIndex(libraryCache?: LibraryItemsCache) {
@@ -459,6 +467,9 @@ export class AnilistCollectionSync extends BaseCollectionSync {
options?: CollectionSyncOptions,
libraryCache?: LibraryItemsCache
): Promise<CollectionSourceData[]> {
// Ensure anime IDs are loaded for mapping (needed for preview which bypasses processConfiguration)
await ensureAnimeIdsLoaded();
const rawSubtype = (config.subtype || 'trending').toString();
const subtype = rawSubtype.toLowerCase();
const perPage = 50; // AniList API maximum per page
@@ -645,11 +656,164 @@ export class AnilistCollectionSync extends BaseCollectionSync {
// Legacy support: use config.anilistCustomListUrl when subtype === 'custom'
const customUrl = config.anilistCustomListUrl;
if (typeof customUrl === 'string' && customUrl.length > 0) {
// parse patterns like /user/{username}/animelist/{ListName}
// parse patterns like /user/{username}/animelist/{ListName} or /search/anime?params
try {
const u = new URL(customUrl);
const parts = u.pathname.split('/').filter(Boolean);
// Expecting ['user', username, 'animelist', maybeList]
// Handle search URLs: /search/anime?genres=Comedy&sort=TRENDING_DESC or /search/anime/top-100
if (parts[0] === 'search' && parts[1] === 'anime') {
const searchParams: Parameters<typeof searchAnime>[2] = {};
// Handle path-based shortcuts (e.g., /search/anime/top-100, /search/anime/this-season)
if (parts[2]) {
const shortcut = parts[2].toLowerCase();
const now = new Date();
const currentMonth = now.getMonth() + 1; // 1-12
const currentYear = now.getFullYear();
// Helper to get current season
const getCurrentSeason = (): string => {
if (currentMonth >= 1 && currentMonth <= 3) return 'WINTER';
if (currentMonth >= 4 && currentMonth <= 6) return 'SPRING';
if (currentMonth >= 7 && currentMonth <= 9) return 'SUMMER';
return 'FALL';
};
// Helper to get next season
const getNextSeason = (): { season: string; year: number } => {
const currentSeason = getCurrentSeason();
if (currentSeason === 'WINTER')
return { season: 'SPRING', year: currentYear };
if (currentSeason === 'SPRING')
return { season: 'SUMMER', year: currentYear };
if (currentSeason === 'SUMMER')
return { season: 'FALL', year: currentYear };
return { season: 'WINTER', year: currentYear + 1 };
};
switch (shortcut) {
case 'trending':
searchParams.sort = 'TRENDING_DESC';
break;
case 'this-season':
searchParams.season = getCurrentSeason();
searchParams.seasonYear = currentYear;
searchParams.sort = 'POPULARITY_DESC';
break;
case 'next-season': {
const next = getNextSeason();
searchParams.season = next.season;
searchParams.seasonYear = next.year;
searchParams.sort = 'POPULARITY_DESC';
break;
}
case 'popular':
searchParams.sort = 'POPULARITY_DESC';
break;
case 'top-100':
searchParams.sort = 'SCORE_DESC';
break;
}
}
// Parse query parameters from URL (these override path-based shortcuts)
const genresParam = u.searchParams.get('genres');
if (genresParam) {
searchParams.genres = genresParam.split(',');
}
const tagsParam = u.searchParams.get('tags');
if (tagsParam) {
searchParams.tags = tagsParam.split(',');
}
const seasonParam = u.searchParams.get('season');
if (seasonParam) {
searchParams.season = seasonParam.toUpperCase();
}
const seasonYearParam = u.searchParams.get('seasonYear');
if (seasonYearParam) {
searchParams.seasonYear = parseInt(seasonYearParam);
}
const yearParam = u.searchParams.get('year');
if (yearParam) {
searchParams.year = parseInt(yearParam);
}
const sortParam = u.searchParams.get('sort');
if (sortParam) {
searchParams.sort = sortParam.toUpperCase();
}
const formatParam = u.searchParams.get('format');
if (formatParam) {
searchParams.format = formatParam.toUpperCase();
}
const statusParam = u.searchParams.get('airing status');
if (statusParam) {
searchParams.status = statusParam.toUpperCase();
}
const streamingParam = u.searchParams.get('streaming on');
if (streamingParam) {
searchParams.licensedById = parseInt(streamingParam);
}
const countryParam = u.searchParams.get('country of origin');
if (countryParam) {
searchParams.countryOfOrigin = countryParam.toUpperCase();
}
const sourceParam = u.searchParams.get('source material');
if (sourceParam) {
searchParams.source = sourceParam.toUpperCase();
}
const searchParam = u.searchParams.get('search');
if (searchParam) {
searchParams.search = searchParam;
}
if (u.searchParams.get('doujin')) {
searchParams.isLicensed = u.searchParams.get('doujin') === 'true';
}
// Handle year range (appears as two separate parameters)
const yearRanges = u.searchParams.getAll('year range');
if (yearRanges.length >= 2) {
searchParams.startDateGreater = parseInt(yearRanges[0]) * 10000; // Convert year to FuzzyDateInt (YYYYMMDD)
searchParams.startDateLesser = parseInt(yearRanges[1]) * 10000;
}
// Handle episodes range (appears as two separate parameters)
const episodesRanges = u.searchParams.getAll('episodes');
if (episodesRanges.length >= 2) {
searchParams.episodes_greater = parseInt(episodesRanges[0]);
searchParams.episodes_lesser = parseInt(episodesRanges[1]);
}
// Handle duration range (appears as two separate parameters)
const durationRanges = u.searchParams.getAll('duration');
if (durationRanges.length >= 2) {
searchParams.duration_greater = parseInt(durationRanges[0]);
searchParams.duration_lesser = parseInt(durationRanges[1]);
}
// Apply format filters based on collection media type (only if not already specified)
if (!searchParams.format && !searchParams.formatIn) {
if (mediaType === 'movie') {
searchParams.format = 'MOVIE';
} else {
searchParams.formatIn = [
'TV',
'TV_SHORT',
'ONA',
'OVA',
'SPECIAL',
];
}
}
// Fetch with pagination (same pattern as trending/popular)
const allMedia = await paginateResults((page, perPage) =>
searchAnime(page, perPage, searchParams)
);
return adapt(allMedia);
}
// Handle user list URLs: /user/{username}/animelist/{ListName}
if (parts[0] === 'user' && parts[1]) {
const userName = parts[1];
const maybeList = parts[3];
@@ -673,10 +837,11 @@ export class AnilistCollectionSync extends BaseCollectionSync {
return adapt(medias.slice(0, perPage));
}
} catch (e) {
logger.debug('Failed to parse AniList custom URL', {
logger.error('Failed to parse AniList custom URL', {
label: 'AniList Collections',
url: customUrl,
error: String(e),
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
}
}
@@ -749,10 +914,10 @@ export class AnilistCollectionSync extends BaseCollectionSync {
let matched = false;
// --- A) Kometa mapping first ---
// --- A) PlexAniBridge mapping first ---
if (anilistId) {
let map = lookupByAniList(anilistId);
// If Kometa has no entry for this AniList id, but AniList provides idMal, try MAL -> Kometa fallback
// If no entry for this AniList id, but AniList provides idMal, try MAL fallback
if (!map && raw?.idMal) {
try {
const malId = Number(raw.idMal);
@@ -804,11 +969,15 @@ export class AnilistCollectionSync extends BaseCollectionSync {
ratingKey: hit.ratingKey,
title: hit.title,
type: 'tv',
tmdbId: tmdbShow ? Number(tmdbShow) : undefined,
posterUrl:
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: hit.libraryKey },
metadata: {
libraryKey: hit.libraryKey,
originalPosition: i + 1,
},
});
matched = true;
}
@@ -824,7 +993,10 @@ export class AnilistCollectionSync extends BaseCollectionSync {
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: hit.libraryKey },
metadata: {
libraryKey: hit.libraryKey,
originalPosition: i + 1,
},
});
matched = true;
}
@@ -835,11 +1007,15 @@ export class AnilistCollectionSync extends BaseCollectionSync {
ratingKey: hit.ratingKey,
title: hit.title,
type: 'tv',
tmdbId: tmdbShow ? Number(tmdbShow) : undefined,
posterUrl:
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: hit.libraryKey },
metadata: {
libraryKey: hit.libraryKey,
originalPosition: i + 1,
},
});
matched = true;
}
@@ -875,7 +1051,10 @@ export class AnilistCollectionSync extends BaseCollectionSync {
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: normalizedLibraryId },
metadata: {
libraryKey: normalizedLibraryId,
originalPosition: i + 1,
},
});
matched = true;
}
@@ -974,7 +1153,7 @@ export class AnilistCollectionSync extends BaseCollectionSync {
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: hit.libraryKey },
metadata: { libraryKey: hit.libraryKey, originalPosition: i + 1 },
});
continue;
}
@@ -993,7 +1172,7 @@ export class AnilistCollectionSync extends BaseCollectionSync {
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: hit.libraryKey },
metadata: { libraryKey: hit.libraryKey, originalPosition: i + 1 },
});
continue;
}
@@ -1023,7 +1202,10 @@ export class AnilistCollectionSync extends BaseCollectionSync {
raw?.coverImage?.extraLarge ||
raw?.coverImage?.large ||
undefined,
metadata: { libraryKey: normalizedLibraryId },
metadata: {
libraryKey: normalizedLibraryId,
originalPosition: i + 1,
},
});
continue;
}
@@ -1032,6 +1214,7 @@ export class AnilistCollectionSync extends BaseCollectionSync {
// --- C) No match found - try to get TMDB ID for auto-request ---
// Try to get TMDB ID from anime mapping or external links
let tmdbId = 0;
let tvdbId: number | undefined;
if (anilistId) {
let map = lookupByAniList(anilistId);
@@ -1051,6 +1234,9 @@ export class AnilistCollectionSync extends BaseCollectionSync {
// PRIORITY 2: Only if PlexAniBridge doesn't have TMDB ID, try TVDB → TMDB API lookup
const tvdb = map.tvdb_id != null ? String(map.tvdb_id) : undefined;
if (tvdb) {
tvdbId = parseInt(tvdb); // Save TVDB ID for Sonarr
}
if (tmdbId === 0 && tvdb) {
try {
const TheMovieDb = (await import('@server/api/themoviedb'))
@@ -1091,11 +1277,12 @@ export class AnilistCollectionSync extends BaseCollectionSync {
const itemMediaType: 'movie' | 'tv' =
raw?.format === 'MOVIE' ? 'movie' : 'tv';
// Only add to missing if we have a valid TMDB ID
// For anime, we ONLY send TMDB ID to Overseerr (no TVDB, to avoid extra TMDB API calls)
if (tmdbId > 0) {
// Add to missing if we have either TMDB ID or TVDB ID
// TMDB ID preferred for Overseerr/Radarr, TVDB ID works for Sonarr
if (tmdbId > 0 || tvdbId) {
missing.push({
tmdbId,
tmdbId: tmdbId > 0 ? tmdbId : 0, // Use 0 if no TMDB ID (Sonarr will use TVDB)
tvdbId,
mediaType: itemMediaType,
title: displayTitle,
originalPosition: i + 1,
+1 -1
View File
@@ -887,7 +887,7 @@ export class NetworksCollectionSync extends BaseCollectionSync {
/**
* Extract individual platform logo from FlixPatrol sprite sheet
*/
private async extractPlatformLogoFromSprite(
public async extractPlatformLogoFromSprite(
spriteUrl: string,
positionPercent: string,
platformName: string
+171 -73
View File
@@ -1,6 +1,5 @@
import {
cleanOverseerrLabels,
createFormData,
extractErrorMessage,
getAdminUser,
} from '@server/lib/collections/core/CollectionUtilities';
@@ -27,7 +26,10 @@ export interface SharedServerData {
allowTuners: string;
allowSubtitleAdmin: string;
owned: string;
filterAll?: string;
filterMovies?: string;
filterMusic?: string;
filterPhotos?: string;
filterTelevision?: string;
};
}
@@ -35,6 +37,89 @@ export interface SharedServerData {
// Simple cache for shared server responses
const sharedServerCache = new Map<string, SharedServerData[]>();
/**
* Merge Agregarr labels into an existing Plex filter string
* Preserves all existing filter components (contentRating, etc.) and only adds/updates label!= section
*
* Plex filter syntax: filter1&filter2&filter3
* Each filter can be: key=value1,value2|key=value3
*
* @param existingFilter The current filter string (already cleaned of old Agregarr labels)
* @param agregarrLabels Array of Agregarr label names to add to label!= section
* @returns Complete filter string with Agregarr labels merged in
*/
function mergeAgregarrLabelsIntoFilter(
existingFilter: string,
agregarrLabels: string[]
): string {
if (agregarrLabels.length === 0) {
return existingFilter;
}
// If no existing filter, just create a simple label!= filter
if (!existingFilter) {
return `label!=${agregarrLabels.join(',')}`;
}
// Split by & to get individual filter groups
const filterGroups = existingFilter.split('&');
// Find if there's already a label!= group and track other groups
let labelNotEqualGroup: string | null = null;
let labelNotEqualIndex = -1;
const otherGroups: string[] = [];
filterGroups.forEach((group, index) => {
// Check if this group contains label!=
if (group.includes('label!=')) {
// We need to be more careful - check if label!= appears in any OR part
const orParts = group.split('|');
const hasLabelNotEqual = orParts.some((part) =>
part.startsWith('label!=')
);
if (hasLabelNotEqual) {
labelNotEqualGroup = group;
labelNotEqualIndex = index;
} else {
otherGroups.push(group);
}
} else {
otherGroups.push(group);
}
});
// If there's an existing label!= group, merge our labels into it
if (labelNotEqualGroup) {
// TypeScript narrowing: we know it's a string now
const groupStr: string = labelNotEqualGroup;
const orParts = groupStr.split('|');
const updatedOrParts = orParts.map((part: string) => {
if (part.startsWith('label!=')) {
// Extract existing labels
const existingLabelsStr = part.substring('label!='.length);
const existingLabels = existingLabelsStr
? existingLabelsStr.split(',')
: [];
// Merge with Agregarr labels
const allLabels = [...existingLabels, ...agregarrLabels];
return `label!=${allLabels.join(',')}`;
}
return part; // Keep non-label!= OR parts unchanged
});
// Replace the old label!= group with the updated one
const mergedLabelGroup = updatedOrParts.join('|');
otherGroups.splice(labelNotEqualIndex, 0, mergedLabelGroup);
} else {
// No existing label!= group, add one at the end
otherGroups.push(`label!=${agregarrLabels.join(',')}`);
}
return otherGroups.join('&');
}
/**
* Get shared servers data with simple caching
* Fetches user sharing data from Plex.tv API
@@ -185,7 +270,7 @@ export async function updateUserFilterSettings(
currentTvFilter = decodeURIComponent(userServer.$.filterTelevision || '');
}
// Clean existing Agregarr labels
// Clean existing Agregarr labels from Movies and TV only
const cleanedMovieFilter = cleanOverseerrLabels(currentMovieFilter);
const cleanedTvFilter = cleanOverseerrLabels(currentTvFilter);
@@ -208,64 +293,72 @@ export async function updateUserFilterSettings(
agregarrLabels.push(`AgregarrOverseerrOwner${adminUser.plexId}`);
}
// Combine filters
// Combine filters - merge Agregarr labels into existing filter structure for Movies and TV only
let finalMovieFilter = cleanedMovieFilter;
let finalTvFilter = cleanedTvFilter;
if (agregarrLabels.length > 0) {
const labelFilter = `label!=${agregarrLabels.join(',')}`;
if (!finalMovieFilter) {
finalMovieFilter = labelFilter;
} else if (finalMovieFilter.startsWith('label!=')) {
const existingLabels = finalMovieFilter.split('!=')[1];
finalMovieFilter = `label!=${existingLabels},${agregarrLabels.join(
','
)}`;
} else {
logger.warn(
`Non-label filter detected for user ${targetUserPlexId}: "${finalMovieFilter}". Using only Agregarr labels.`
);
finalMovieFilter = labelFilter;
}
if (!finalTvFilter) {
finalTvFilter = labelFilter;
} else if (finalTvFilter.startsWith('label!=')) {
const existingLabels = finalTvFilter.split('!=')[1];
finalTvFilter = `label!=${existingLabels},${agregarrLabels.join(',')}`;
} else {
logger.warn(
`Non-label filter detected for user ${targetUserPlexId}: "${finalTvFilter}". Using only Agregarr labels.`
);
finalTvFilter = labelFilter;
}
finalMovieFilter = mergeAgregarrLabelsIntoFilter(
cleanedMovieFilter,
agregarrLabels
);
finalTvFilter = mergeAgregarrLabelsIntoFilter(
cleanedTvFilter,
agregarrLabels
);
}
// Use persistent server client identifier (following Overseerr pattern)
const plexClientIdentifier = settings.clientId;
// Update user restrictions
const url = `https://plex.tv/api/friends/${targetUserPlexId}`;
const headers = {
'X-Plex-Token': admin.plexToken,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Plex-Client-Identifier': plexClientIdentifier,
// CRITICAL: Must send ALL fields or they will be reset to defaults
if (!userServer) {
throw new Error(
`Cannot update filters: userServer is undefined for user ${targetUserPlexId}`
);
}
// Build v2 API payload with complete settings
const settingsPayload = {
allowChannels: userServer.$.allowChannels === '1',
allowSync: userServer.$.allowSync === '1',
allowCameraUpload: userServer.$.allowCameraUpload === '1',
allowSubtitleAdmin: userServer.$.allowSubtitleAdmin === '1',
allowTuners: parseInt(userServer.$.allowTuners || '0', 10),
filterAll: userServer.$.filterAll || null,
filterMovies: finalMovieFilter || '',
filterMusic: userServer.$.filterMusic || '',
filterPhotos: userServer.$.filterPhotos || '',
filterTelevision: finalTvFilter || '',
};
const formData = createFormData({
server_id: settings.plex.machineId,
filterMovies: finalMovieFilter,
filterTelevision: finalTvFilter,
});
const payload = {
settings: settingsPayload,
invitedEmail: userServer.$.email || userServer.$.username,
};
logger.debug(
`Updating user filters (v2 API) - payload for user ${targetUserPlexId}`,
{
label: 'Plex User Manager',
userPlexId: targetUserPlexId,
payload,
}
);
// Use v2 API endpoint
const url = `https://clients.plex.tv/api/v2/sharing_settings?X-Plex-Product=Agregarr&X-Plex-Client-Identifier=${plexClientIdentifier}&X-Plex-Token=${admin.plexToken}`;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
// Individual user filter updates logged in batch summary
const response = await fetch(url, {
method: 'PUT',
method: 'POST',
headers,
body: formData,
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -674,50 +767,55 @@ export async function clearUserFilters(
// Use persistent server client identifier (following Overseerr pattern)
const plexClientIdentifier = settings.clientId;
// Update user restrictions with cleaned filters
const url = `https://plex.tv/api/friends/${targetUserPlexId}`;
const headers = {
'X-Plex-Token': admin.plexToken,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Plex-Client-Identifier': plexClientIdentifier,
// CRITICAL: Must send ALL fields or they will be reset to defaults
if (!userServer) {
throw new Error(
`Cannot clear filters: userServer is undefined for user ${targetUserPlexId}`
);
}
// Build v2 API payload with complete settings
const settingsPayload = {
allowChannels: userServer.$.allowChannels === '1',
allowSync: userServer.$.allowSync === '1',
allowCameraUpload: userServer.$.allowCameraUpload === '1',
allowSubtitleAdmin: userServer.$.allowSubtitleAdmin === '1',
allowTuners: parseInt(userServer.$.allowTuners || '0', 10),
filterAll: userServer.$.filterAll || null,
filterMovies: cleanedMovieFilter || '',
filterMusic: userServer.$.filterMusic || '',
filterPhotos: userServer.$.filterPhotos || '',
filterTelevision: cleanedTvFilter || '',
};
const formData = createFormData({
server_id: settings.plex.machineId,
filterMovies: cleanedMovieFilter,
filterTelevision: cleanedTvFilter,
});
const payload = {
settings: settingsPayload,
invitedEmail: userServer.$.email || userServer.$.username,
};
// Log the exact HTTP request data being sent for clear operation
logger.debug(
`Sending Plex.tv CLEAR API request for user ${targetUserPlexId}`,
`Sending Plex.tv CLEAR API request (v2) for user ${targetUserPlexId}`,
{
label: 'Plex User Manager',
userPlexId: targetUserPlexId,
url,
method: 'PUT',
headers: {
'X-Plex-Token': '[REDACTED]',
Accept: headers.Accept,
'X-Plex-Client-Identifier': headers['X-Plex-Client-Identifier'],
},
formDataString: formData,
formDataLength: formData.length,
rawFormData: {
server_id: settings.plex.machineId,
filterMovies: cleanedMovieFilter,
filterTelevision: cleanedTvFilter,
},
payload,
movieFilter: cleanedMovieFilter,
tvFilter: cleanedTvFilter,
}
);
// Use v2 API endpoint
const url = `https://clients.plex.tv/api/v2/sharing_settings?X-Plex-Product=Agregarr&X-Plex-Client-Identifier=${plexClientIdentifier}&X-Plex-Token=${admin.plexToken}`;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
const response = await fetch(url, {
method: 'PUT',
method: 'POST',
headers,
body: formData,
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -269,6 +269,7 @@ export async function applyUnifiedOrderingToPlex(
isPrecisionConvergence?: boolean;
sectionId?: string;
moveCount?: number;
repromoCount?: number;
};
if (
@@ -276,13 +277,15 @@ export async function applyUnifiedOrderingToPlex(
convergenceError.sectionId === libraryId
) {
logger.warn(
`Precision convergence detected in library ${libraryId}, initiating reset and rebuild`,
`Precision convergence detected in library ${libraryId} after unpromote/re-promote recovery attempts failed, initiating reset and rebuild as fallback`,
{
label: 'Unified Ordering Service',
libraryId,
libraryType,
moveCount: convergenceError.moveCount,
repromoCount: convergenceError.repromoCount || 0,
action: 'reset_and_rebuild',
note: 'This should be rare - unpromote/re-promote recovery handles most convergence cases',
}
);
@@ -331,7 +334,8 @@ export async function applyUnifiedOrderingToPlex(
/**
* Rebuild hub management for a specific library after reset
* This restores exactly what was there before the reset
* Key optimization: Promote collections in the DESIRED ORDER so they get sequential fresh spacing
* Then only need to move default hubs into position
*/
async function rebuildLibraryHubManagement(
plexClient: PlexAPI,
@@ -353,81 +357,87 @@ async function rebuildLibraryHubManagement(
const preExistingCollectionConfigs =
settings.plex.preExistingCollectionConfigs || [];
// Filter configs for this specific library
const libraryCollectionConfigs = collectionConfigs.filter(
(config) => config.libraryId === libraryId
);
const libraryPreExistingConfigs = preExistingCollectionConfigs.filter(
(config) => config.libraryId === libraryId
);
// Step 1: Re-promote collections that should be in hub management
// Our created collections
for (const config of libraryCollectionConfigs) {
// Use the same logic as HubSyncService - check if collection should be promoted
const shouldBePromoted = shouldCollectionBePromotedToHub(config);
if (config.collectionRatingKey && shouldBePromoted) {
try {
await plexClient.promoteCollectionToHub(
config.collectionRatingKey,
libraryId
);
logger.debug(
`Re-promoted collection ${config.name} to hub management`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
}
);
} catch (error) {
logger.warn(
`Failed to re-promote collection ${
config.name
} to hub: ${extractErrorMessage(error)}`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
}
);
}
// Build a map of identifier -> config for quick lookup
const configsByRatingKey = new Map<
string,
CollectionConfig | PreExistingCollectionConfig
>();
for (const config of collectionConfigs) {
if (config.collectionRatingKey && config.libraryId === libraryId) {
configsByRatingKey.set(config.collectionRatingKey, config);
}
}
for (const config of preExistingCollectionConfigs) {
if (config.collectionRatingKey && config.libraryId === libraryId) {
configsByRatingKey.set(config.collectionRatingKey, config);
}
}
// Pre-existing collections that were promoted
for (const config of libraryPreExistingConfigs) {
// Use the same logic as HubSyncService to determine if this should be promoted
const shouldBePromoted = shouldCollectionBePromotedToHub(config);
if (config.collectionRatingKey && shouldBePromoted) {
try {
await plexClient.promoteCollectionToHub(
config.collectionRatingKey,
libraryId
);
logger.debug(
`Re-promoted pre-existing collection ${config.name} to hub management`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
// Helper to extract rating key from identifier
const extractRatingKey = (identifier: string): string | null => {
if (!identifier.startsWith('custom.collection.')) {
return null;
}
const parts = identifier.split('.');
return parts.length >= 4 ? parts[3] : null;
};
// Step 1: Re-promote collections IN DESIRED ORDER
// This gives them sequential fresh 1000-unit spacing automatically
const defaultHubIdentifiers: string[] = [];
let promotedCount = 0;
for (const identifier of orderedIdentifiers) {
const ratingKey = extractRatingKey(identifier);
if (ratingKey) {
// This is a custom collection - promote it
const config = configsByRatingKey.get(ratingKey);
if (config) {
const shouldBePromoted = shouldCollectionBePromotedToHub(config);
if (shouldBePromoted) {
try {
await plexClient.promoteCollectionToHub(ratingKey, libraryId);
promotedCount++;
logger.debug(
`Re-promoted collection ${config.name} in order position ${promotedCount}`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
orderPosition: promotedCount,
}
);
} catch (error) {
logger.warn(
`Failed to re-promote collection ${
config.name
} to hub: ${extractErrorMessage(error)}`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
}
);
}
);
} catch (error) {
logger.warn(
`Failed to re-promote pre-existing collection ${
config.name
} to hub: ${extractErrorMessage(error)}`,
{
label: 'Unified Ordering Service',
collectionName: config.name,
libraryId,
}
);
}
}
} else {
// This is a default hub - track it for positioning later
defaultHubIdentifiers.push(identifier);
}
}
logger.info(
`Promoted ${promotedCount} collections in desired order with fresh spacing`,
{
label: 'Unified Ordering Service',
libraryId,
promotedCount,
defaultHubsToPosition: defaultHubIdentifiers.length,
}
);
// Step 2: Set visibility settings for each hub
const { HubSyncService } = await import('./HubSyncService');
const hubSyncService = new HubSyncService();
@@ -436,19 +446,94 @@ async function rebuildLibraryHubManagement(
// Step 3: Wait for Plex to process the hub setup
await new Promise((resolve) => setTimeout(resolve, 2000));
// Step 4: Apply clean ordering (this will use fresh 1000-interval spacing)
await plexClient.reorderHubs(
libraryId,
orderedIdentifiers,
undefined,
libraryType
// Note: No sync counter here - we want clean positioning after reset
);
// Step 4: Position default hubs only
// Collections are already in the right order from sequential promotion
if (defaultHubIdentifiers.length > 0) {
logger.info(
`Positioning ${defaultHubIdentifiers.length} default hubs around collections`,
{
label: 'Unified Ordering Service',
libraryId,
defaultHubs: defaultHubIdentifiers,
}
);
// Position each default hub
for (const defaultHub of defaultHubIdentifiers) {
// Find where this hub should be in the desired order
const desiredIndex = orderedIdentifiers.indexOf(defaultHub);
if (desiredIndex === -1) continue;
// Find what should come before it in the desired order
const predecessorIdentifier =
desiredIndex > 0 ? orderedIdentifiers[desiredIndex - 1] : null;
if (predecessorIdentifier) {
// Move this default hub after its predecessor
try {
await plexClient.moveHub(
libraryId,
defaultHub,
predecessorIdentifier
);
logger.debug(
`Positioned default hub ${defaultHub} after ${predecessorIdentifier}`,
{
label: 'Unified Ordering Service',
libraryId,
hubId: defaultHub,
afterId: predecessorIdentifier,
}
);
} catch (error) {
logger.warn(
`Failed to position default hub ${defaultHub}: ${extractErrorMessage(
error
)}`,
{
label: 'Unified Ordering Service',
libraryId,
hubId: defaultHub,
}
);
}
} else {
// This is the first item - position after the anchor
const anchor =
libraryType === 'show' ? 'tv.ondeck' : 'movie.inprogress';
try {
await plexClient.moveHub(libraryId, defaultHub, anchor);
logger.debug(
`Positioned default hub ${defaultHub} as first item after anchor ${anchor}`,
{
label: 'Unified Ordering Service',
libraryId,
hubId: defaultHub,
anchor,
}
);
} catch (error) {
logger.warn(
`Failed to position default hub ${defaultHub} as first item: ${extractErrorMessage(
error
)}`,
{
label: 'Unified Ordering Service',
libraryId,
hubId: defaultHub,
}
);
}
}
}
}
logger.info(`Hub management rebuild completed for library ${libraryId}`, {
label: 'Unified Ordering Service',
libraryId,
result: 'rebuild_successful',
collectionsPromoted: promotedCount,
defaultHubsPositioned: defaultHubIdentifiers.length,
});
} catch (error) {
logger.error(
@@ -178,7 +178,11 @@ export class AutoRequestService {
let manualApprovalRequests = 0;
let alreadyRequestedCount = 0;
let skippedRequests = 0;
const maxSeasons = Number(config.maxSeasonsToRequest) || 3;
const maxSeasons =
config.maxSeasonsToRequest !== undefined &&
config.maxSeasonsToRequest !== null
? Number(config.maxSeasonsToRequest)
: 0; // 0 = no limit
// Track declined items for summary logging
const previouslyDeclinedItems: string[] = [];
@@ -228,7 +232,8 @@ export class AutoRequestService {
}
// Check season limit for ALL TV shows first (regardless of auto-approve setting)
if (item.mediaType === 'tv') {
// Only skip if maxSeasons is set (> 0)
if (item.mediaType === 'tv' && maxSeasons > 0) {
const seasonCount = await this.getTvSeasonCount(item.tmdbId);
if (seasonCount > maxSeasons) {
@@ -284,7 +284,11 @@ export class DirectDownloadService {
let autoApprovedRequests = 0;
let skippedRequests = 0;
let alreadyDownloadedCount = 0;
const maxSeasons = Number(config.maxSeasonsToRequest) || 3;
const maxSeasons =
config.maxSeasonsToRequest !== undefined &&
config.maxSeasonsToRequest !== null
? Number(config.maxSeasonsToRequest)
: 0; // 0 = no limit
// Track excluded items for summary logging
const excludedGenreItems: string[] = [];
@@ -297,19 +301,21 @@ export class DirectDownloadService {
if (item.mediaType === 'movie') {
// Movies are always downloaded
} else if (item.mediaType === 'tv') {
// For TV shows, check season count limit only
const seasonCount = await this.getTvSeasonCount(item.tmdbId);
// For TV shows, check season count limit only if maxSeasons is set (> 0)
if (maxSeasons > 0) {
const seasonCount = await this.getTvSeasonCount(item.tmdbId);
if (seasonCount > maxSeasons) {
logger.debug(
`Skipping ${item.title}: Too many seasons (${seasonCount} > ${maxSeasons})`,
{
label: 'Direct Download Service',
collection: config.name,
}
);
skippedRequests++;
continue;
if (seasonCount > maxSeasons) {
logger.debug(
`Skipping ${item.title}: Too many seasons (${seasonCount} > ${maxSeasons})`,
{
label: 'Direct Download Service',
collection: config.name,
}
);
skippedRequests++;
continue;
}
}
} else {
// Unknown media type
@@ -101,10 +101,16 @@ export class DiscoveryService {
});
const startTime = Date.now();
const libraries = await plexClient.getLibraries();
const allLibraries = await plexClient.getLibraries();
// Filter to only movie and show libraries - we don't manage music, photo, or other library types
const libraries = allLibraries.filter(
(library) => library.type === 'movie' || library.type === 'show'
);
logger.info('Libraries loaded for discovery', {
label: 'Hub Discovery',
libraryCount: libraries.length,
totalLibraryCount: allLibraries.length,
filteredLibraryCount: libraries.length,
libraryNames: libraries.map((l) => `${l.title} (${l.type})`),
});
@@ -114,6 +120,27 @@ export class DiscoveryService {
// Get existing configs to check for duplicates
const settings = getSettings();
// Clean up orphaned configs from non-movie/TV libraries
// This removes configs referencing libraries that are now filtered out (music, photos, etc.)
const cleanupResult = this.cleanupOrphanedLibraryConfigs(
settings,
libraries
);
if (cleanupResult.removed > 0) {
settings.save();
logger.info(
`Cleaned up ${cleanupResult.removed} orphaned configs from non-movie/TV libraries`,
{
label: 'Discovery Service - Cleanup',
collectionsRemoved: cleanupResult.collectionsRemoved,
hubsRemoved: cleanupResult.hubsRemoved,
preExistingRemoved: cleanupResult.preExistingRemoved,
orphanedLibraries: cleanupResult.orphanedLibraries,
}
);
}
let collectionConfigs = settings.plex.collectionConfigs || [];
const existingHubConfigs = settings.plex.hubConfigs || [];
const existingPreExistingConfigs =
@@ -176,10 +203,7 @@ export class DiscoveryService {
// STEP 4: Promote collections that should be visible but aren't in hub management
await this.promoteCollectionsThatShouldBeVisible(plexClient, libraries);
// STEP 5: Report on collections removed from hub management
this.reportRemovedFromHubManagement();
// STEP 3: Sync configs with Plex collections to fix any out-of-sync rating keys/labels
// STEP 5: Sync configs with Plex collections to fix any out-of-sync rating keys/labels
logger.info(
'Starting config sync process to fix out-of-sync collections',
{
@@ -1238,47 +1262,6 @@ export class DiscoveryService {
}
}
/**
* Report on pre-existing collections that were removed from hub management
* These collections still exist in Plex but are no longer promoted to hubs
*/
private reportRemovedFromHubManagement(): void {
const settings = getSettings();
const preExistingConfigs = settings.plex.preExistingCollectionConfigs || [];
const removedFromHubs = preExistingConfigs.filter(
(config) => config.isPromotedToHub === false
);
if (removedFromHubs.length > 0) {
logger.info(
`Detected ${removedFromHubs.length} pre-existing collections removed from hub management`,
{
label: 'Discovery Service',
removedCount: removedFromHubs.length,
removedCollections: removedFromHubs.map((config) => ({
name: config.name,
libraryId: config.libraryId,
ratingKey: config.collectionRatingKey,
})),
}
);
// These collections will now be handled via DELETE instead of visibility updates
removedFromHubs.forEach((config) => {
logger.debug(
`Collection "${config.name}" is no longer promoted to hub - will be removed from hub management on next sync`,
{
label: 'Discovery Service',
collectionId: config.id,
libraryId: config.libraryId,
ratingKey: config.collectionRatingKey,
}
);
});
}
}
/**
* Discover all Plex collections first (source of truth for titles)
*/
@@ -1533,6 +1516,138 @@ export class DiscoveryService {
return status;
}
/**
* Clean up configs that reference libraries no longer in the valid library list
* This removes orphaned configs from non-movie/TV libraries (music, photos, etc.)
*/
private cleanupOrphanedLibraryConfigs(
settings: ReturnType<typeof getSettings>,
validLibraries: PlexLibrary[]
): {
removed: number;
collectionsRemoved: number;
hubsRemoved: number;
preExistingRemoved: number;
orphanedLibraries: string[];
} {
const validLibraryIds = new Set(validLibraries.map((lib) => lib.key));
const orphanedLibraryIds = new Set<string>();
let collectionsRemoved = 0;
let hubsRemoved = 0;
let preExistingRemoved = 0;
// Clean up collection configs
if (settings.plex.collectionConfigs) {
settings.plex.collectionConfigs = settings.plex.collectionConfigs.filter(
(config) => {
// For configs with array of library IDs, check if any are valid
if (Array.isArray(config.libraryId)) {
const validIds = config.libraryId.filter((id) =>
validLibraryIds.has(id)
);
if (validIds.length === 0) {
orphanedLibraryIds.add(config.libraryId.join(','));
collectionsRemoved++;
logger.debug(
`Removing collection config from non-movie/TV libraries: ${config.name}`,
{
label: 'Discovery Service - Cleanup',
configId: config.id,
libraryIds: config.libraryId,
}
);
return false;
}
// If some libraries are valid but some aren't, log but keep the config
// (we can't mutate the libraryId array since it's readonly)
if (validIds.length !== config.libraryId.length) {
logger.debug(
`Collection config references some non-movie/TV libraries: ${config.name}`,
{
label: 'Discovery Service - Cleanup',
configId: config.id,
allLibraries: config.libraryId,
validLibraries: validIds,
}
);
}
return true;
}
// For configs with single library ID
if (!validLibraryIds.has(config.libraryId)) {
orphanedLibraryIds.add(config.libraryId);
collectionsRemoved++;
logger.debug(
`Removing collection config from non-movie/TV library: ${config.name}`,
{
label: 'Discovery Service - Cleanup',
configId: config.id,
libraryId: config.libraryId,
}
);
return false;
}
return true;
}
);
}
// Clean up hub configs
if (settings.plex.hubConfigs) {
settings.plex.hubConfigs = settings.plex.hubConfigs.filter((config) => {
if (!validLibraryIds.has(config.libraryId)) {
orphanedLibraryIds.add(config.libraryId);
hubsRemoved++;
logger.debug(
`Removing hub config from non-movie/TV library: ${config.name}`,
{
label: 'Discovery Service - Cleanup',
configId: config.id,
libraryId: config.libraryId,
hubIdentifier: config.hubIdentifier,
}
);
return false;
}
return true;
});
}
// Clean up pre-existing collection configs
if (settings.plex.preExistingCollectionConfigs) {
settings.plex.preExistingCollectionConfigs =
settings.plex.preExistingCollectionConfigs.filter((config) => {
if (!validLibraryIds.has(config.libraryId)) {
orphanedLibraryIds.add(config.libraryId);
preExistingRemoved++;
logger.debug(
`Removing pre-existing collection config from non-movie/TV library: ${config.name}`,
{
label: 'Discovery Service - Cleanup',
configId: config.id,
libraryId: config.libraryId,
ratingKey: config.collectionRatingKey,
}
);
return false;
}
return true;
});
}
const totalRemoved = collectionsRemoved + hubsRemoved + preExistingRemoved;
return {
removed: totalRemoved,
collectionsRemoved,
hubsRemoved,
preExistingRemoved,
orphanedLibraries: Array.from(orphanedLibraryIds),
};
}
/**
* Check if a collection is an Overseerr user collection that should be filtered from discovery
* Only filters collections created by Overseerr "users" subtype (individual user collections)
@@ -537,18 +537,6 @@ export class IndividualCollectionScheduler {
collectionId: string
): Promise<void> {
try {
// Check if full sync is running
if (this.fullSyncRunning) {
logger.info(
`Skipping individual collection sync: full sync is running`,
{
label: 'Individual Collection Scheduler',
collectionId,
}
);
return;
}
// Get collection configuration
const settings = getSettings();
const collectionConfig = settings.plex.collectionConfigs?.find(
@@ -618,15 +606,29 @@ export class IndividualCollectionScheduler {
libraryQueue.queue.push(queuedSync);
libraryQueue.queue.sort((a, b) => a.priority - b.priority);
logger.info(
`Queued collection sync: ${collectionConfig.name} (queue position: ${libraryQueue.queue.length})`,
{
label: 'Individual Collection Scheduler',
collectionId,
libraryId,
queueSize: libraryQueue.queue.length,
}
);
// Log differently if queuing during main sync
if (this.fullSyncRunning) {
logger.info(
`Queued collection sync (will process after main sync completes): ${collectionConfig.name} (queue position: ${libraryQueue.queue.length})`,
{
label: 'Individual Collection Scheduler',
collectionId,
libraryId,
queueSize: libraryQueue.queue.length,
deferredUntilMainSyncComplete: true,
}
);
} else {
logger.info(
`Queued collection sync: ${collectionConfig.name} (queue position: ${libraryQueue.queue.length})`,
{
label: 'Individual Collection Scheduler',
collectionId,
libraryId,
queueSize: libraryQueue.queue.length,
}
);
}
// Process queue for this library if not already running
await this.processLibraryQueue(libraryId);
@@ -1029,15 +1031,17 @@ export class IndividualCollectionScheduler {
/**
* Wait for individual syncs to complete before allowing full sync
* No timeout - waits indefinitely (consistent with main sync behavior)
* Main sync flag will cause individual syncs to exit their processing loops
*/
public static async waitForIndividualSyncsToComplete(
timeoutMs = 300000
): Promise<void> {
const startTime = Date.now();
public static async waitForIndividualSyncsToComplete(): Promise<void> {
const checkInterval = 1000; // Check every second
let lastLogTime = Date.now();
const logIntervalMs = 10000; // Log status every 10 seconds to avoid spam
while (Date.now() - startTime < timeoutMs) {
const anyRunning = Array.from(this.libraryQueues.values()).some(
let anyRunning = true;
while (anyRunning) {
anyRunning = Array.from(this.libraryQueues.values()).some(
(queue) => queue.running || queue.queue.length > 0
);
@@ -1048,30 +1052,73 @@ export class IndividualCollectionScheduler {
return;
}
logger.debug('Waiting for individual collection syncs to complete...', {
label: 'Individual Collection Scheduler',
runningQueues: Array.from(this.libraryQueues.values())
.filter((q) => q.running || q.queue.length > 0)
.map((q) => ({
libraryId: q.libraryId,
queueSize: q.queue.length,
running: q.running,
})),
});
// Log status periodically (not every second to avoid spam)
const now = Date.now();
if (now - lastLogTime >= logIntervalMs) {
logger.debug('Waiting for individual collection syncs to complete...', {
label: 'Individual Collection Scheduler',
runningQueues: Array.from(this.libraryQueues.values())
.filter((q) => q.running || q.queue.length > 0)
.map((q) => ({
libraryId: q.libraryId,
queueSize: q.queue.length,
running: q.running,
currentCollection: q.currentCollection,
})),
});
lastLogTime = now;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
}
}
logger.warn('Timeout waiting for individual collection syncs to complete', {
/**
* Process all pending queues that accumulated during main sync
* Called after main sync completes to handle collections that were queued
*/
public static async processPendingQueues(): Promise<void> {
const queuesWithPendingItems = Array.from(this.libraryQueues.entries())
.filter(([, queue]) => queue.queue.length > 0 && !queue.running)
.map(([libraryId]) => libraryId);
if (queuesWithPendingItems.length === 0) {
logger.debug('No pending individual collection syncs to process', {
label: 'Individual Collection Scheduler',
});
return;
}
logger.info(
`Processing ${queuesWithPendingItems.length} library queues with pending individual syncs`,
{
label: 'Individual Collection Scheduler',
librariesWithPendingItems: queuesWithPendingItems.length,
totalPendingCollections: Array.from(this.libraryQueues.values()).reduce(
(sum, queue) => sum + queue.queue.length,
0
),
}
);
// Process each library queue with pending items
for (const libraryId of queuesWithPendingItems) {
try {
await this.processLibraryQueue(libraryId);
} catch (error) {
logger.error(
`Failed to process pending queue for library ${libraryId}: ${error}`,
{
label: 'Individual Collection Scheduler',
libraryId,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
logger.info('Finished processing pending individual collection syncs', {
label: 'Individual Collection Scheduler',
timeoutMs,
stillRunning: Array.from(this.libraryQueues.values())
.filter((q) => q.running || q.queue.length > 0)
.map((q) => ({
libraryId: q.libraryId,
queueSize: q.queue.length,
running: q.running,
})),
});
}
}
@@ -1408,13 +1408,105 @@ export class MultiSourceOrchestrator {
// For cycle_lists mode, use the active source's type for the poster
// For other modes, use 'multi-source' type
let collectionType = 'multi-source';
let activeSource = null;
if (config.combineMode === 'cycle_lists') {
// Get the active source
const activeSourceIndex =
getCollectionSyncCounter(config.id) % config.sources.length;
const activeSource = config.sources[activeSourceIndex];
activeSource = config.sources[activeSourceIndex];
if (activeSource) {
collectionType = activeSource.type;
// For networks sources, extract the specific platform name from subtype
// (e.g., "netflix_top_10" -> "netflix") for correct logo and colors
if (
activeSource.type === 'networks' &&
activeSource.subtype &&
activeSource.subtype.endsWith('_top_10')
) {
const platformName = activeSource.subtype
.replace(/_top_10$/, '') // Remove "_top_10" suffix
.replace(/_/g, '-'); // Convert underscores to hyphens for logo compatibility
collectionType = platformName;
logger.debug(
`Using platform-specific type for Networks source in cycle_lists mode`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
originalType: activeSource.type,
subtype: activeSource.subtype,
resolvedPlatform: platformName,
}
);
}
}
}
// Extract dynamic platform logo for network sources
let dynamicPlatformLogo: string | undefined;
if (
collectionType !== 'multi-source' &&
activeSource?.type === 'networks' &&
items.length > 0
) {
const firstItem = items[0];
if (
firstItem.metadata?.platformLogo &&
typeof firstItem.metadata.platformLogo === 'object' &&
'spriteUrl' in firstItem.metadata.platformLogo &&
'position' in firstItem.metadata.platformLogo
) {
try {
// Extract platform name from active source subtype
const platformName = activeSource.subtype
? activeSource.subtype.replace(/_top_10$/, '').replace(/_/g, '-')
: 'unknown';
logger.debug(
`Extracting dynamic platform logo for cycle_lists mode`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
platform: platformName,
spriteUrl: firstItem.metadata.platformLogo.spriteUrl,
position: firstItem.metadata.platformLogo.position,
}
);
// Use NetworksCollectionSync to extract the logo
const networksSync = this.getSyncService(
'networks'
) as NetworksCollectionSync;
dynamicPlatformLogo =
await networksSync.extractPlatformLogoFromSprite(
firstItem.metadata.platformLogo.spriteUrl as string,
firstItem.metadata.platformLogo.position as string,
platformName
);
logger.info(
`Successfully extracted dynamic platform logo for multi-source collection`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
platform: platformName,
logoPath: dynamicPlatformLogo,
}
);
} catch (logoError) {
logger.warn(
`Failed to extract dynamic platform logo, will use static logo`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
error:
logoError instanceof Error
? logoError.message
: String(logoError),
}
);
}
}
}
@@ -1450,6 +1542,7 @@ export class MultiSourceOrchestrator {
mediaType,
items: posterItems,
autoPosterTemplate: config.autoPosterTemplate, // Use configured template or default
...(dynamicPlatformLogo && { dynamicLogo: dynamicPlatformLogo }), // Pass dynamic logo if available
},
`${
config.combineMode === 'cycle_lists'
@@ -1478,9 +1571,39 @@ export class MultiSourceOrchestrator {
posterFilename,
collectionType,
combineMode: config.combineMode,
usedDynamicLogo: !!dynamicPlatformLogo,
}
);
}
// Clean up the temporary dynamic logo file if created
if (dynamicPlatformLogo) {
try {
const fs = await import('fs');
if (fs.existsSync(dynamicPlatformLogo)) {
await fs.promises.unlink(dynamicPlatformLogo);
logger.debug(
`Cleaned up temporary dynamic logo file: ${dynamicPlatformLogo}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
}
);
}
} catch (cleanupError) {
logger.warn(
`Failed to cleanup dynamic logo file: ${dynamicPlatformLogo}`,
{
label: 'Multi-Source Orchestrator',
configId: config.id,
error:
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError),
}
);
}
}
} catch (error) {
logger.error(
`Failed to generate poster for multi-source collection: ${config.name}`,
+13
View File
@@ -427,6 +427,19 @@ class CollectionsSync {
);
IndividualCollectionScheduler.setFullSyncRunning(false);
// Process any individual syncs that were queued during main sync
try {
await IndividualCollectionScheduler.processPendingQueues();
} catch (error) {
logger.warn(
'Failed to process pending individual collection syncs after main sync',
{
label: 'Collections Sync',
error: error instanceof Error ? error.message : String(error),
}
);
}
// Reset progress tracking
this.currentStage = '';
this.totalCollections = 0;
+60 -51
View File
@@ -578,29 +578,17 @@ async function processMultiSourcePreview(
// Assign positions to matched items
let nextPosition = 1;
const matchedItemsWithPosition = limitedItems
.filter((item) => {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number) || 0;
if (!tmdbId || tmdbId === 0) {
logger.debug('Filtering out matched item with invalid tmdbId', {
label: 'Collections Preview API - Multi-Source',
title: item.title,
});
return false;
}
return true;
})
.map((item) => {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number) || 0;
while (Array.from(tmdbToPosition.values()).includes(nextPosition)) {
nextPosition++;
}
const position = tmdbToPosition.has(tmdbId)
? tmdbToPosition.get(tmdbId) || nextPosition++
: nextPosition++;
const matchedItemsWithPosition = limitedItems.map((item) => {
const tmdbId = item.tmdbId || (item.metadata?.tmdbId as number) || 0;
while (Array.from(tmdbToPosition.values()).includes(nextPosition)) {
nextPosition++;
}
const position = tmdbToPosition.has(tmdbId)
? tmdbToPosition.get(tmdbId) || nextPosition++
: nextPosition++;
return { ...item, tmdbId, originalPosition: position };
});
return { ...item, tmdbId, originalPosition: position };
});
type EnrichedItem = {
ratingKey?: string;
@@ -905,8 +893,11 @@ async function processPreviewAsync(
progress: 10,
});
// Get library info
const libraries = await plexClient.getLibraries();
// Get library info - filter to only movie and show libraries
const allLibraries = await plexClient.getLibraries();
const libraries = allLibraries.filter(
(lib) => lib.type === 'movie' || lib.type === 'show'
);
const library = libraries.find((lib) => lib.key === libraryId);
if (!library) {
@@ -946,6 +937,10 @@ async function processPreviewAsync(
previewConfigRecord.letterboxdCustomListUrl = customUrl;
else if (type === 'mdblist')
previewConfigRecord.mdblistCustomListUrl = customUrl;
else if (type === 'anilist')
previewConfigRecord.anilistCustomListUrl = customUrl;
else if (type === 'myanimelist')
previewConfigRecord.myanilistCustomListUrl = customUrl;
}
if (type === 'tautulli') {
@@ -1115,39 +1110,20 @@ async function processPreviewAsync(
return { ...item, tmdbId };
});
// Filter out items with invalid tmdbId (0 or undefined) - these can't have posters fetched
const validMatchedItems = itemsWithTmdbId.filter((item) => {
if (!item.tmdbId || item.tmdbId === 0) {
logger.debug(`Filtering out matched item with invalid tmdbId`, {
label: 'Collections Preview API',
title: item.title,
tmdbId: item.tmdbId,
ratingKey: item.ratingKey,
});
return false;
}
return true;
});
// Keep all matched items
// Only missing items need TMDB IDs for poster fetching
const validMatchedItems = itemsWithTmdbId;
// Assume matched items fill in the gaps - assign them sequential positions
// This is a best-effort approach since CollectionItem doesn't track originalPosition
let nextPosition = 1;
// Extract originalPosition from metadata (AniList and other sources provide this)
const matchedItemsWithPosition = validMatchedItems.map((item) => {
// If we know the position from missing items map, skip those positions
while (Array.from(tmdbToPosition.values()).includes(nextPosition)) {
nextPosition++;
}
const position =
item.tmdbId && tmdbToPosition.has(item.tmdbId)
? tmdbToPosition.get(item.tmdbId) || nextPosition++
: nextPosition++;
return { ...item, originalPosition: position };
const originalPosition = (item.metadata?.originalPosition as number) || 0;
return { ...item, originalPosition };
});
type EnrichedItem = {
ratingKey?: string;
tmdbId?: number;
tvdbId?: number;
title: string;
year?: number;
mediaType?: 'movie' | 'tv';
@@ -1335,10 +1311,30 @@ async function processPreviewAsync(
});
// Sort all items by original position to maintain source list order
const enrichedItems = allItemsWithPosition.sort(
const sortedItems = allItemsWithPosition.sort(
(a, b) => a.originalPosition - b.originalPosition
);
// Deduplicate by TMDB ID - keep first occurrence (earliest position)
// This prevents multiple seasons/variants of the same show from appearing
const seenTmdbIds = new Set<number>();
const seenRatingKeys = new Set<string>();
const enrichedItems = sortedItems.filter((item) => {
// For matched items, deduplicate by ratingKey
if (item.ratingKey) {
if (seenRatingKeys.has(item.ratingKey)) return false;
seenRatingKeys.add(item.ratingKey);
return true;
}
// For missing items, deduplicate by tmdbId
if (item.tmdbId && item.tmdbId > 0) {
if (seenTmdbIds.has(item.tmdbId)) return false;
seenTmdbIds.add(item.tmdbId);
return true;
}
return true;
});
const matchedCount = enrichedItems.filter((i) => i.inLibrary).length;
const missingCount = enrichedItems.filter((i) => !i.inLibrary).length;
@@ -1351,6 +1347,19 @@ async function processPreviewAsync(
}
);
logger.debug('Preview final item list', {
label: 'Collections Preview API',
items: enrichedItems.map((item) => ({
title: item.title,
tmdbId: item.tmdbId,
tvdbId: item.tvdbId,
ratingKey: item.ratingKey,
inLibrary: item.inLibrary,
mediaType: item.mediaType,
position: item.originalPosition,
})),
});
updatePreviewStatus(sessionId, {
running: false,
completed: true,
+3 -3
View File
@@ -156,10 +156,10 @@ function validateExternalUrl(
// Accept a wide range of AniList URL patterns including:
// - https://anilist.co/user/:username/animelist/:listname (personal animelists)
// - https://anilist.co/list/:listname
// - https://anilist.co/search/anime?... or /search/manga?...
// - single item pages: /anime/:id and /manga/:id
// - https://anilist.co/search/anime?... (with optional additional path segments like /this-season, /popular)
// - single item pages: /anime/:id
const anilistPattern =
/^(?:\/user\/[^/]+\/(?:animelist|list)\/[^/?]+|\/(?:animelist|list)\/[^/?]+|\/search\/(?:anime|manga)(?:\/.*)?|\/anime\/?\d+|\/manga\/?\d+)(?:\/)?$/;
/^(?:\/user\/[^/]+\/(?:animelist|list)\/[^/?]+|\/(?:animelist|list)\/[^/?]+|\/search\/anime(?:\/[^/?]+)?|\/anime\/?\d+)(?:\/)?$/;
// Allow the pattern to match either the pathname or a search path with query params
if (!urlObj.pathname.match(anilistPattern)) {
+5 -1
View File
@@ -283,7 +283,11 @@ settingsRoutes.get('/plex/libraries', async (req, res) => {
}
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
const libraries = await plexapi.getLibraries();
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) => ({
@@ -350,8 +350,8 @@ const TimeRestrictionsSection = ({
</div>
)}
{/* Custom Sync Schedule Section - Available for all collections */}
{true && (
{/* Custom Sync Schedule Section - Only for Agregarr collections (not hubs or pre-existing) */}
{!isDefaultPlexHub && !isPreExisting && (
<div className="mt-6">
<label className="mb-4 block text-sm font-medium text-gray-200">
{intl.formatMessage(messages.customSyncSchedule)}
@@ -327,8 +327,8 @@ const CollectionFormConfigForm = ({
schema
.required('AniList list URL is required')
.matches(
/anilist\.co\/(?:user\/[^/]+\/animelist\/[^/?]+|anime\/[^/?]+)/,
'Please enter a valid AniList list URL (e.g., https://anilist.co/anime/listname or https://anilist.co/user/username/animelist/listname)'
/anilist\.co\/(?:user\/[^/]+\/(?:animelist|list)\/[^/?]+|(?:animelist|list)\/[^/?]+|search\/anime(?:\/[^/?]+)?|anime\/?\d+)/,
'Please enter a valid AniList URL (e.g., user lists, search pages, or anime pages)'
),
otherwise: (schema) => schema,
}),
@@ -2344,11 +2344,11 @@ const CollectionFormConfigForm = ({
autoApproveTV:
(config as CollectionFormConfig).autoApproveTV ?? false,
maxSeasonsToRequest:
(config as CollectionFormConfig).maxSeasonsToRequest ?? 3,
(config as CollectionFormConfig).maxSeasonsToRequest ?? 0,
seasonsPerShowLimit:
(config as CollectionFormConfig).seasonsPerShowLimit || 0,
(config as CollectionFormConfig).seasonsPerShowLimit ?? 0,
maxPositionToProcess:
(config as CollectionFormConfig).maxPositionToProcess || 0,
(config as CollectionFormConfig).maxPositionToProcess ?? 0,
minimumYear: (config as CollectionFormConfig).minimumYear || 0,
excludedGenres: (config as CollectionFormConfig).excludedGenres || [],
excludedCountries:
@@ -3599,6 +3599,10 @@ const CollectionFormConfigForm = ({
? (valuesRecord.mdblistCustomListUrl as
| string
| undefined)
: values.type === 'anilist'
? (valuesRecord.anilistCustomListUrl as
| string
| undefined)
: undefined,
maxItems: values.maxItems,
timePeriod: values.timePeriod,
@@ -41,6 +41,7 @@ const messages = defineMessages({
interface PreviewItem {
ratingKey?: string;
tmdbId: number;
tvdbId?: number;
title: string;
year?: number;
mediaType?: 'movie' | 'tv';
@@ -985,7 +986,7 @@ const PreviewCollectionModal = ({
{/* Download Buttons - Bottom of poster with logos */}
{!item.inLibrary && hoveredItem === item.tmdbId && (
<div className="absolute bottom-2 left-2 right-2 z-10 flex justify-center gap-2">
{item.mediaType === 'movie' && (
{item.mediaType === 'movie' && item.tmdbId > 0 && (
<>
<button
onClick={() =>
@@ -1138,55 +1139,58 @@ const PreviewCollectionModal = ({
</>
)}
</button>
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'tv',
'overseerr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaOverseerr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">
...
</span>
) : (
<>
<img
src="/services/overseerr.svg"
alt="Overseerr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-overseerr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
{/* Only show Overseerr button if item has TMDB ID */}
{item.tmdbId > 0 && (
<button
onClick={() =>
handleDownload(
item.tmdbId,
item.title,
'tv',
'overseerr',
item.backdropPath
)
}
disabled={downloadingItems.has(item.tmdbId)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-black bg-opacity-70 p-2 transition hover:bg-opacity-90 disabled:opacity-50"
title={intl.formatMessage(
messages.downloadViaOverseerr
)}
>
{downloadingItems.has(item.tmdbId) ? (
<span className="text-xs text-white">
...
</span>
) : (
<>
<img
src="/services/overseerr.svg"
alt="Overseerr"
className="h-full w-full"
/>
{requestedItems.has(
`${item.tmdbId}-overseerr`
) && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-green-600 bg-opacity-80">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</>
)}
</button>
)}
</>
)}
</div>
@@ -200,6 +200,9 @@ const CollectionSettings = ({
isLinked: preExistingConfig.isLinked,
linkId: preExistingConfig.linkId,
isUnlinked: preExistingConfig.isUnlinked,
...(preExistingConfig.randomizeHomeOrder !== undefined && {
randomizeHomeOrder: preExistingConfig.randomizeHomeOrder,
}),
...(preExistingConfig.timeRestriction && {
timeRestriction: preExistingConfig.timeRestriction,
}),
@@ -228,6 +231,9 @@ const CollectionSettings = ({
isLinked: hubConfig.isLinked,
linkId: hubConfig.linkId,
isUnlinked: hubConfig.isUnlinked,
...(hubConfig.randomizeHomeOrder !== undefined && {
randomizeHomeOrder: hubConfig.randomizeHomeOrder,
}),
...(hubConfig.timeRestriction && {
timeRestriction: hubConfig.timeRestriction,
}),
@@ -1075,6 +1081,9 @@ const CollectionSettings = ({
isLinked: hubConfig.isLinked,
linkId: hubConfig.linkId,
isUnlinked: hubConfig.isUnlinked,
...(hubConfig.randomizeHomeOrder !== undefined && {
randomizeHomeOrder: hubConfig.randomizeHomeOrder,
}),
...(hubConfig.timeRestriction && {
timeRestriction: hubConfig.timeRestriction,
}),
@@ -1118,6 +1127,9 @@ const CollectionSettings = ({
isLinked: preExistingConfig.isLinked,
linkId: preExistingConfig.linkId,
isUnlinked: preExistingConfig.isUnlinked,
...(preExistingConfig.randomizeHomeOrder !== undefined && {
randomizeHomeOrder: preExistingConfig.randomizeHomeOrder,
}),
...(preExistingConfig.timeRestriction && {
timeRestriction: preExistingConfig.timeRestriction,
}),
+30
View File
@@ -128,6 +128,32 @@ const customUrlValidations = {
),
otherwise: (schema) => schema,
}),
anilistCustomListUrl: Yup.string().when(['type', 'subtype'], {
is: (type: string, subtype: string) =>
type === 'anilist' && subtype === 'custom',
then: (schema) =>
schema
.required('AniList list URL is required')
.matches(
/anilist\.co\/(?:user\/[^/]+\/(?:animelist|list)\/[^/?]+|(?:animelist|list)\/[^/?]+|search\/anime(?:\/[^/?]+)?|anime\/?\d+)/,
'Please enter a valid AniList URL (e.g., user lists, search pages, or anime pages)'
),
otherwise: (schema) => schema,
}),
myanilistCustomListUrl: Yup.string().when(['type', 'subtype'], {
is: (type: string, subtype: string) =>
type === 'myanimelist' && subtype === 'custom',
then: (schema) =>
schema
.required('MyAnimeList list URL is required')
.matches(
/myanimelist\.net\/(?:animelist\/[^/?]+|anime\.php)/,
'Please enter a valid MyAnimeList URL'
),
otherwise: (schema) => schema,
}),
};
// Auto-request validation
@@ -299,6 +325,8 @@ export const ValidationHelpers = {
tmdb: 'tmdbCustomCollectionUrl',
imdb: 'imdbCustomListUrl',
letterboxd: 'letterboxdCustomListUrl',
anilist: 'anilistCustomListUrl',
myanimelist: 'myanilistCustomListUrl',
};
const urlField = urlFieldMap[values.type];
@@ -424,6 +452,8 @@ export const ValidationHelpers = {
tmdb: 'tmdbCustomCollectionUrl',
imdb: 'imdbCustomListUrl',
letterboxd: 'letterboxdCustomListUrl',
anilist: 'anilistCustomListUrl',
myanimelist: 'myanilistCustomListUrl',
};
const field = values.type ? urlFieldMap[values.type] : undefined;
if (field) {