mirror of
https://github.com/agregarr/agregarr.git
synced 2026-04-28 14:09:29 -05:00
Merge branch 'develop' into latest
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
buy_me_a_coffee: agregarr
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user