mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-05 09:49:34 -05:00
fix(collections-quick-sync): add overseerr and tmdb autoFranchise support. refactor to use ratingKey
refactors collection-missing-item schema to use plex ratingKey as primary key, allowing for support for configs that create multiple collections fix #295
This commit is contained in:
@@ -23,7 +23,11 @@ export class CollectionMissingItems {
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
@Index()
|
||||
public collectionId: string; // Links to CollectionConfig.id
|
||||
public collectionRatingKey: string; // Plex collection rating key (primary identifier)
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
@Index()
|
||||
public configId: string; // Parent CollectionConfig.id (same for multi-collection patterns)
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
@Index()
|
||||
|
||||
@@ -540,8 +540,9 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
*/
|
||||
protected async storeMissingItems(
|
||||
missingItems: MissingItem[],
|
||||
collectionId: string,
|
||||
libraryId: string | string[]
|
||||
collectionRatingKey: string,
|
||||
libraryId: string | string[],
|
||||
configId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { getRepository } = await import('@server/datasource');
|
||||
@@ -558,11 +559,12 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
: libraryId;
|
||||
|
||||
// Delete existing entries for this collection (replace strategy)
|
||||
await repository.delete({ collectionId });
|
||||
await repository.delete({ collectionRatingKey });
|
||||
|
||||
// Insert new missing items
|
||||
const entities = missingItems.map((item) => ({
|
||||
collectionId,
|
||||
collectionRatingKey,
|
||||
configId,
|
||||
libraryId: targetLibraryId,
|
||||
tmdbId: item.tmdbId,
|
||||
tvdbId: item.tvdbId,
|
||||
@@ -578,7 +580,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
await repository.insert(entities);
|
||||
logger.debug(`Stored ${entities.length} missing items for Quick Sync`, {
|
||||
label: `${this.source} Collections`,
|
||||
collectionId,
|
||||
collectionRatingKey,
|
||||
configId,
|
||||
missingItemCount: entities.length,
|
||||
});
|
||||
}
|
||||
@@ -586,12 +589,35 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
// Don't fail the sync if storage fails - just log the error
|
||||
logger.warn('Failed to store missing items for Quick Sync', {
|
||||
label: `${this.source} Collections`,
|
||||
collectionId,
|
||||
collectionRatingKey,
|
||||
configId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to store missing items after collection creation (when rating key is available)
|
||||
* Call this after creating/updating a collection to enable Quick Sync
|
||||
*/
|
||||
protected async storeCollectionMissingItems(
|
||||
missingItems: MissingItem[] | undefined,
|
||||
collectionRatingKey: string,
|
||||
libraryId: string | string[],
|
||||
configId: string
|
||||
): Promise<void> {
|
||||
if (!missingItems || missingItems.length === 0 || !collectionRatingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.storeMissingItems(
|
||||
missingItems,
|
||||
collectionRatingKey,
|
||||
libraryId,
|
||||
configId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle placeholder cleanup and process missing items in one step
|
||||
* This combines the cleanup phase (remove old placeholders) with creation phase (add new ones)
|
||||
@@ -671,8 +697,9 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
return [];
|
||||
}
|
||||
|
||||
// Store missing items for Quick Sync feature
|
||||
await this.storeMissingItems(missingItems, config.id, config.libraryId);
|
||||
// NOTE: Missing items are now stored AFTER collection creation
|
||||
// when we have the collectionRatingKey available.
|
||||
// See storeCollectionMissingItems() calls after createOrUpdateCollection()
|
||||
|
||||
let placeholderItems: CollectionItem[] = [];
|
||||
|
||||
@@ -944,7 +971,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
plexClient: PlexAPI,
|
||||
allCollections: PlexCollection[],
|
||||
processedCollectionKeys?: Set<string>,
|
||||
userInfo?: { userId?: number | string; customLabel?: string }
|
||||
userInfo?: { userId?: number | string; customLabel?: string },
|
||||
missingItems?: MissingItem[]
|
||||
): Promise<CollectionOperationResult> {
|
||||
// Support user-specific collections for services like Overseerr
|
||||
const customLabel =
|
||||
@@ -992,6 +1020,16 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
this.updateConfigWithRatingKey(config, updateResult.collectionRatingKey);
|
||||
}
|
||||
|
||||
// Store missing items for Quick Sync (now that we have collectionRatingKey)
|
||||
if (updateResult.collectionRatingKey && missingItems) {
|
||||
await this.storeCollectionMissingItems(
|
||||
missingItems,
|
||||
updateResult.collectionRatingKey,
|
||||
config.libraryId,
|
||||
config.id // Always store parent config ID, even for multi-collection patterns
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate poster if enabled (available for all collection types)
|
||||
// Default to true for existing collections that don't have this field set
|
||||
const shouldGeneratePoster = config.autoPoster ?? true;
|
||||
@@ -3054,7 +3092,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
allCollections: PlexCollection[],
|
||||
processedCollectionKeys?: Set<string>,
|
||||
userInfo?: { userId?: number | string; customLabel?: string },
|
||||
libraryCache?: LibraryItemsCache
|
||||
libraryCache?: LibraryItemsCache,
|
||||
missingItems?: MissingItem[]
|
||||
): Promise<MediaProcessingResult> {
|
||||
const mediaType = getCollectionMediaType(config);
|
||||
|
||||
@@ -3069,7 +3108,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
userInfo,
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Media type processing failed`, {
|
||||
@@ -3100,7 +3140,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
allCollections: PlexCollection[],
|
||||
processedCollectionKeys?: Set<string>,
|
||||
userInfo?: { userId?: number | string; customLabel?: string },
|
||||
libraryCache?: LibraryItemsCache
|
||||
libraryCache?: LibraryItemsCache,
|
||||
missingItems?: MissingItem[]
|
||||
): Promise<MediaProcessingResult> {
|
||||
// Filter items by the specified media type
|
||||
let filteredItems = items.filter((item) => item.type === mediaType);
|
||||
@@ -3165,7 +3206,8 @@ export abstract class BaseCollectionSync<TSource extends CollectionSource>
|
||||
plexClient,
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
userInfo
|
||||
userInfo,
|
||||
missingItems
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -186,7 +186,8 @@ export class ComingSoonCollectionSync extends BaseCollectionSync<'comingsoon'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined,
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Coming Soon collection processing failed', {
|
||||
|
||||
@@ -982,7 +982,8 @@ export class ImdbCollectionSync extends BaseCollectionSync<'imdb'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error in IMDb processConfiguration', {
|
||||
|
||||
@@ -135,7 +135,8 @@ export class MDBListCollectionSync extends BaseCollectionSync<'mdblist'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
// Log detailed error information before rethrowing
|
||||
|
||||
@@ -140,7 +140,8 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
throw this.createSyncError(
|
||||
|
||||
@@ -169,7 +169,8 @@ export class OriginalsCollectionSync extends BaseCollectionSync<'originals'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
throw this.createSyncError(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CollectionSyncError,
|
||||
CollectionSyncOptions,
|
||||
FilteringStats,
|
||||
ItemProducingSource,
|
||||
MissingItem,
|
||||
OverseerrMediaRequest,
|
||||
OverseerrTemplateContext,
|
||||
@@ -17,7 +18,6 @@ import type {
|
||||
PlexCollection,
|
||||
PlexLabel,
|
||||
SyncResult,
|
||||
UserCollections,
|
||||
} from '@server/lib/collections/core/types';
|
||||
import { CollectionSyncErrorType } from '@server/lib/collections/core/types';
|
||||
import type { CollectionConfig } from '@server/lib/settings';
|
||||
@@ -30,10 +30,15 @@ interface OverseerrCollectionItem extends CollectionItem {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface OverseerrMissingItem extends MissingItem {
|
||||
userId: number; // Track which user requested this item
|
||||
}
|
||||
|
||||
interface OverseerrUserCollections {
|
||||
user: OverseerrUser;
|
||||
movies: OverseerrCollectionItem[];
|
||||
tv: OverseerrCollectionItem[];
|
||||
missingItems: OverseerrMissingItem[]; // Missing items for this specific user
|
||||
}
|
||||
|
||||
interface UserCollectionsMap {
|
||||
@@ -100,16 +105,15 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
options,
|
||||
libraryCache
|
||||
);
|
||||
const { items: allItems } = await this.mapSourceDataToItems(
|
||||
requests,
|
||||
config
|
||||
);
|
||||
const { items: allItems, missingItems: allMissingItems } =
|
||||
await this.mapSourceDataToItems(requests, config);
|
||||
|
||||
let result: SyncResult;
|
||||
switch (config.subtype) {
|
||||
case 'users':
|
||||
result = await this.processUserCollections(
|
||||
allItems as OverseerrCollectionItem[],
|
||||
(allMissingItems as OverseerrMissingItem[]) || [],
|
||||
config,
|
||||
plexClient,
|
||||
allCollections,
|
||||
@@ -246,16 +250,15 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
options,
|
||||
libraryCache
|
||||
);
|
||||
const { items: allItems } = await this.mapSourceDataToItems(
|
||||
requests,
|
||||
config
|
||||
);
|
||||
const { items: allItems, missingItems: allMissingItems } =
|
||||
await this.mapSourceDataToItems(requests, config);
|
||||
|
||||
// Process based on subtype using pre-fetched data
|
||||
switch (config.subtype) {
|
||||
case 'users':
|
||||
return await this.processUserCollections(
|
||||
allItems as OverseerrCollectionItem[],
|
||||
(allMissingItems as OverseerrMissingItem[]) || [],
|
||||
config,
|
||||
plexClient,
|
||||
allCollections,
|
||||
@@ -366,18 +369,10 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid rating keys
|
||||
const hasValidRatingKey = request.is4k
|
||||
? request.media.ratingKey4k &&
|
||||
request.media.ratingKey4k !== '' &&
|
||||
request.media.ratingKey4k !== 'null' &&
|
||||
request.media.ratingKey4k !== 'undefined'
|
||||
: request.media.ratingKey &&
|
||||
request.media.ratingKey !== '' &&
|
||||
request.media.ratingKey !== 'null' &&
|
||||
request.media.ratingKey !== 'undefined';
|
||||
|
||||
if (!hasValidRatingKey) return false;
|
||||
// Don't filter by ratingKey here - let mapSourceDataToItems decide
|
||||
// Items WITH ratingKey → go to collection
|
||||
// Items WITHOUT ratingKey → go to missing items for Quick Sync
|
||||
// This allows PENDING/APPROVED requests to become missing items
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -427,43 +422,59 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
config: CollectionConfig
|
||||
): Promise<{
|
||||
items: OverseerrCollectionItem[];
|
||||
missingItems?: MissingItem[];
|
||||
missingItems?: OverseerrMissingItem[];
|
||||
stats?: FilteringStats;
|
||||
}> {
|
||||
const mappedItems: OverseerrCollectionItem[] = [];
|
||||
const missingItems: OverseerrMissingItem[] = [];
|
||||
|
||||
for (const request of sourceData) {
|
||||
const ratingKey = request.is4k
|
||||
? request.media?.ratingKey4k
|
||||
: request.media?.ratingKey;
|
||||
|
||||
if (!ratingKey || !request.requestedBy) continue;
|
||||
if (!request.requestedBy) continue;
|
||||
|
||||
mappedItems.push({
|
||||
ratingKey: ratingKey.toString(),
|
||||
title: request.media?.title || 'Unknown',
|
||||
type: request.type as 'movie' | 'tv',
|
||||
requestId: request.id,
|
||||
userId: request.requestedBy.id,
|
||||
tmdbId: request.media?.tmdbId,
|
||||
createdAt: request.createdAt,
|
||||
});
|
||||
// Item is in Plex - add to collection
|
||||
if (ratingKey) {
|
||||
mappedItems.push({
|
||||
ratingKey: ratingKey.toString(),
|
||||
title: request.media?.title || 'Unknown',
|
||||
type: request.type as 'movie' | 'tv',
|
||||
requestId: request.id,
|
||||
userId: request.requestedBy.id,
|
||||
tmdbId: request.media?.tmdbId,
|
||||
createdAt: request.createdAt,
|
||||
});
|
||||
}
|
||||
// Item not in Plex yet - track as missing for quick sync
|
||||
else if (request.media?.tmdbId) {
|
||||
missingItems.push({
|
||||
tmdbId: request.media.tmdbId,
|
||||
tvdbId: request.media.tvdbId,
|
||||
mediaType: request.type as 'movie' | 'tv',
|
||||
title: request.media.title || 'Unknown',
|
||||
year: request.media.year,
|
||||
originalPosition: missingItems.length + 1,
|
||||
source: 'tmdb' as ItemProducingSource, // Use TMDB as the source since Overseerr requests are TMDB-based
|
||||
userId: request.requestedBy.id, // Track which user requested this item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Don't limit here - apply limits later during collection creation
|
||||
const stats = this.createFilteringStats(
|
||||
sourceData.length,
|
||||
mappedItems.length,
|
||||
{
|
||||
'missing rating key or user': sourceData.length - mappedItems.length,
|
||||
'missing rating key or user':
|
||||
sourceData.length - mappedItems.length - missingItems.length,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
items: mappedItems,
|
||||
stats,
|
||||
// Overseerr doesn't have missing items (all items are already available)
|
||||
missingItems: [],
|
||||
missingItems,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -478,7 +489,8 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
allCollections: PlexCollection[],
|
||||
config: CollectionConfig,
|
||||
processedCollectionKeys?: Set<string>,
|
||||
userOverride?: OverseerrUser
|
||||
userOverride?: OverseerrUser,
|
||||
missingItems?: MissingItem[]
|
||||
): Promise<CollectionOperationResult> {
|
||||
try {
|
||||
// Use userOverride for user collections, otherwise create context based on subtype
|
||||
@@ -544,7 +556,8 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
{
|
||||
userId: userContext?.plexId || userContext?.id,
|
||||
customLabel,
|
||||
}
|
||||
},
|
||||
missingItems // Pass missing items for Quick Sync
|
||||
);
|
||||
|
||||
// Handle smart collection cleanup if user disabled showUnwatchedOnly
|
||||
@@ -577,6 +590,7 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
|
||||
private async processUserCollections(
|
||||
allItems: OverseerrCollectionItem[],
|
||||
allMissingItems: OverseerrMissingItem[],
|
||||
config: CollectionConfig,
|
||||
plexClient: PlexAPI,
|
||||
allCollections: PlexCollection[],
|
||||
@@ -585,7 +599,10 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
options?: CollectionSyncOptions
|
||||
): Promise<SyncResult> {
|
||||
// Group by user first
|
||||
const userCollectionsMap = await this.groupItemsByUser(allItems);
|
||||
const userCollectionsMap = await this.groupItemsByUser(
|
||||
allItems,
|
||||
allMissingItems
|
||||
);
|
||||
|
||||
let totalCreated = 0;
|
||||
let totalUpdated = 0;
|
||||
@@ -737,7 +754,7 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
}
|
||||
|
||||
private async processUserCollection(
|
||||
userCollections: UserCollections,
|
||||
userCollections: OverseerrUserCollections,
|
||||
config: CollectionConfig,
|
||||
plexClient: PlexAPI,
|
||||
allCollections: PlexCollection[],
|
||||
@@ -772,6 +789,11 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
// Apply maxItems
|
||||
const limitedItems = mediaItems.slice(0, this.getMaxItems(config));
|
||||
|
||||
// Filter missing items for this media type
|
||||
const userMissingItems = userCollections.missingItems.filter(
|
||||
(item) => item.mediaType === collectionMediaType
|
||||
);
|
||||
|
||||
// Process the collection (simple, direct approach)
|
||||
if (limitedItems.length > 0) {
|
||||
const collectionName = await this.createUserCollectionName(
|
||||
@@ -788,7 +810,8 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
allCollections,
|
||||
config,
|
||||
processedCollectionKeys,
|
||||
userCollections.user // Pass the real user for user collections
|
||||
userCollections.user, // Pass the real user for user collections
|
||||
userMissingItems // Pass user-specific missing items for Quick Sync
|
||||
);
|
||||
|
||||
totalCreated += result.created;
|
||||
@@ -1451,7 +1474,8 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
}
|
||||
|
||||
private async groupItemsByUser(
|
||||
items: OverseerrCollectionItem[]
|
||||
items: OverseerrCollectionItem[],
|
||||
missingItems: OverseerrMissingItem[]
|
||||
): Promise<UserCollectionsMap> {
|
||||
const userCollectionsMap: UserCollectionsMap = {};
|
||||
|
||||
@@ -1461,6 +1485,7 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
// Create map for efficient lookup
|
||||
const usersById = new Map(allUsers.map((user) => [user.id, user]));
|
||||
|
||||
// Group items by user
|
||||
for (const item of items) {
|
||||
if (!userCollectionsMap[item.userId]) {
|
||||
const user = usersById.get(item.userId);
|
||||
@@ -1473,6 +1498,7 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
user: user,
|
||||
movies: [],
|
||||
tv: [],
|
||||
missingItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1484,6 +1510,26 @@ export class OverseerrCollectionSync extends BaseCollectionSync<'overseerr'> {
|
||||
}
|
||||
}
|
||||
|
||||
// Group missing items by user
|
||||
for (const missingItem of missingItems) {
|
||||
if (!userCollectionsMap[missingItem.userId]) {
|
||||
const user = usersById.get(missingItem.userId);
|
||||
if (!user) {
|
||||
// Skip missing items for users that don't exist
|
||||
continue;
|
||||
}
|
||||
|
||||
userCollectionsMap[missingItem.userId] = {
|
||||
user: user,
|
||||
movies: [],
|
||||
tv: [],
|
||||
missingItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
userCollectionsMap[missingItem.userId].missingItems.push(missingItem);
|
||||
}
|
||||
|
||||
return userCollectionsMap;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ export class RadarrTagCollectionSync extends BaseCollectionSync<'radarrtag'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
throw this.createSyncError(
|
||||
|
||||
@@ -131,7 +131,8 @@ export class SonarrTagCollectionSync extends BaseCollectionSync<'sonarrtag'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
throw this.createSyncError(
|
||||
|
||||
@@ -391,7 +391,8 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
plexClient: PlexAPI,
|
||||
allCollections: PlexCollection[],
|
||||
config: CollectionConfig,
|
||||
processedCollectionKeys?: Set<string>
|
||||
processedCollectionKeys?: Set<string>,
|
||||
missingItems?: MissingItem[]
|
||||
): Promise<CollectionOperationResult> {
|
||||
try {
|
||||
// Use the new standardized approach via BaseCollectionSync
|
||||
@@ -402,7 +403,9 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
config,
|
||||
plexClient,
|
||||
allCollections,
|
||||
processedCollectionKeys
|
||||
processedCollectionKeys,
|
||||
undefined,
|
||||
missingItems
|
||||
);
|
||||
|
||||
// Update config with rating key if we got one
|
||||
@@ -490,6 +493,7 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
);
|
||||
const {
|
||||
items: movieItems,
|
||||
missingItems: movieMissingItems,
|
||||
mappingStats: movieMappingStats,
|
||||
filteringStats: movieFilteringStats,
|
||||
} = await this.applyFilteringToMappedItems(movieMappedResult, config);
|
||||
@@ -506,7 +510,8 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
plexClient,
|
||||
allCollections,
|
||||
config,
|
||||
processedCollectionKeys
|
||||
processedCollectionKeys,
|
||||
movieMissingItems
|
||||
);
|
||||
|
||||
totalCreated += movieResult.created;
|
||||
@@ -561,6 +566,7 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
);
|
||||
const {
|
||||
items: tvItems,
|
||||
missingItems: tvMissingItems,
|
||||
mappingStats: tvMappingStats,
|
||||
filteringStats: tvFilteringStats,
|
||||
} = await this.applyFilteringToMappedItems(tvMappedResult, config);
|
||||
@@ -577,7 +583,8 @@ export class TautulliCollectionSync extends BaseCollectionSync<'tautulli'> {
|
||||
plexClient,
|
||||
allCollections,
|
||||
config,
|
||||
processedCollectionKeys
|
||||
processedCollectionKeys,
|
||||
tvMissingItems
|
||||
);
|
||||
|
||||
totalCreated += tvResult.created;
|
||||
|
||||
@@ -726,7 +726,8 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1210,7 +1211,8 @@ export class TmdbCollectionSync extends BaseCollectionSync<'tmdb'> {
|
||||
processedCollectionKeys,
|
||||
{
|
||||
customLabel, // Enables findExistingCollection() to track by label
|
||||
}
|
||||
},
|
||||
missingItems // Enable Quick Sync for franchise collections
|
||||
);
|
||||
|
||||
// Handle poster upload and collection mode
|
||||
|
||||
@@ -139,7 +139,8 @@ export class TraktCollectionSync extends BaseCollectionSync<'trakt'> {
|
||||
allCollections,
|
||||
processedCollectionKeys,
|
||||
undefined, // userInfo
|
||||
libraryCache
|
||||
libraryCache,
|
||||
missingItems
|
||||
);
|
||||
} catch (error) {
|
||||
// Log detailed error information before rethrowing
|
||||
|
||||
@@ -186,12 +186,33 @@ class CollectionsQuickSync {
|
||||
itemCount: recentItems.length,
|
||||
});
|
||||
|
||||
// Fetch full metadata for each item to get external GUIDs (TMDB, IMDB, TVDB)
|
||||
// The recentlyAdded endpoint only returns internal Plex GUIDs
|
||||
logger.debug('Fetching full metadata for external GUIDs...', {
|
||||
label: 'Collections Quick Sync',
|
||||
libraryName: library.name,
|
||||
});
|
||||
const itemsWithMetadata: PlexLibraryItem[] = [];
|
||||
for (const item of recentItems) {
|
||||
try {
|
||||
const fullMetadata = await plexClient.getMetadata(item.ratingKey);
|
||||
itemsWithMetadata.push(fullMetadata);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch metadata for item, skipping', {
|
||||
label: 'Collections Quick Sync',
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up placeholders for recently added real items
|
||||
this.setStage(
|
||||
`Checking placeholders for library: ${library.name}...`
|
||||
);
|
||||
const cleanupResult = await this.cleanupPlaceholdersForRecentItems(
|
||||
recentItems,
|
||||
itemsWithMetadata,
|
||||
library.key
|
||||
);
|
||||
|
||||
@@ -202,7 +223,7 @@ class CollectionsQuickSync {
|
||||
|
||||
// Process these items (match and add to collections)
|
||||
const result = await this.processRecentItems(
|
||||
recentItems,
|
||||
itemsWithMetadata,
|
||||
library.key,
|
||||
plexClient
|
||||
);
|
||||
@@ -527,20 +548,23 @@ class CollectionsQuickSync {
|
||||
// Group matches by collection for efficient processing
|
||||
const matchesByCollection = new Map<string, MissingItemMatch[]>();
|
||||
for (const match of matches) {
|
||||
const collectionId = match.missingItem.collectionId;
|
||||
if (!matchesByCollection.has(collectionId)) {
|
||||
matchesByCollection.set(collectionId, []);
|
||||
const collectionRatingKey = match.missingItem.collectionRatingKey;
|
||||
if (!matchesByCollection.has(collectionRatingKey)) {
|
||||
matchesByCollection.set(collectionRatingKey, []);
|
||||
}
|
||||
matchesByCollection.get(collectionId)?.push(match);
|
||||
matchesByCollection.get(collectionRatingKey)?.push(match);
|
||||
}
|
||||
|
||||
// Process each collection
|
||||
for (const [collectionId, collectionMatches] of matchesByCollection) {
|
||||
for (const [
|
||||
collectionRatingKey,
|
||||
collectionMatches,
|
||||
] of matchesByCollection) {
|
||||
if (this.cancelled) break;
|
||||
|
||||
try {
|
||||
const added = await this.addItemsToCollection(
|
||||
collectionId,
|
||||
collectionRatingKey,
|
||||
collectionMatches,
|
||||
plexClient
|
||||
);
|
||||
@@ -552,7 +576,7 @@ class CollectionsQuickSync {
|
||||
} catch (error) {
|
||||
logger.error('Failed to add items to collection', {
|
||||
label: 'Collections Quick Sync',
|
||||
collectionId,
|
||||
collectionRatingKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Continue with next collection
|
||||
@@ -670,32 +694,18 @@ class CollectionsQuickSync {
|
||||
* Add matched items to a collection at correct position
|
||||
*/
|
||||
private async addItemsToCollection(
|
||||
collectionId: string,
|
||||
collectionRatingKey: string,
|
||||
matches: MissingItemMatch[],
|
||||
plexClient: PlexAPI
|
||||
): Promise<number> {
|
||||
// Get collection config
|
||||
// Optionally get collection config for logging (configId may be null for multi-collection patterns)
|
||||
const settings = getSettings();
|
||||
const config = settings.plex.collectionConfigs?.find(
|
||||
(c) => c.id === collectionId
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
logger.warn('Collection config not found, skipping', {
|
||||
label: 'Collections Quick Sync',
|
||||
collectionId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!config.collectionRatingKey) {
|
||||
logger.warn('Collection has no rating key, skipping', {
|
||||
label: 'Collections Quick Sync',
|
||||
collectionName: config.name,
|
||||
collectionId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
const firstMatch = matches[0]?.missingItem;
|
||||
const config = firstMatch?.configId
|
||||
? settings.plex.collectionConfigs?.find(
|
||||
(c) => c.id === firstMatch.configId
|
||||
)
|
||||
: null;
|
||||
|
||||
// Sort matches by original position for correct ordering
|
||||
const sortedMatches = matches.sort(
|
||||
@@ -708,12 +718,13 @@ class CollectionsQuickSync {
|
||||
title: m.plexItem.title,
|
||||
}));
|
||||
|
||||
await plexClient.addItemsToCollection(config.collectionRatingKey, newItems);
|
||||
await plexClient.addItemsToCollection(collectionRatingKey, newItems);
|
||||
|
||||
logger.info('Added items to collection', {
|
||||
label: 'Collections Quick Sync',
|
||||
collectionName: config.name,
|
||||
collectionId,
|
||||
collectionName: config?.name || 'Unknown Collection',
|
||||
collectionRatingKey,
|
||||
configId: firstMatch?.configId,
|
||||
itemsAdded: newItems.length,
|
||||
titles: sortedMatches.map((m) => m.missingItem.title),
|
||||
});
|
||||
@@ -760,7 +771,7 @@ class CollectionsQuickSync {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete items for collections that no longer exist
|
||||
// Delete items for deleted configs (both single and multi-collection patterns)
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const activeCollectionIds =
|
||||
@@ -770,7 +781,7 @@ class CollectionsQuickSync {
|
||||
const orphanResult = await repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('collectionId NOT IN (:...activeIds)', {
|
||||
.where('configId NOT IN (:...activeIds)', {
|
||||
activeIds: activeCollectionIds,
|
||||
})
|
||||
.execute();
|
||||
@@ -782,6 +793,7 @@ class CollectionsQuickSync {
|
||||
logger.info('Deleted orphaned missing items', {
|
||||
label: 'Collections Quick Sync',
|
||||
count: orphanDeleted,
|
||||
note: 'Removed items for deleted parent configs',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Refactor CollectionMissingItems to use collectionRatingKey as primary identifier
|
||||
*
|
||||
* Changes:
|
||||
* - Add collectionRatingKey column (non-null, indexed) - primary identifier for the Plex collection
|
||||
* - Keep configId (non-null, indexed) - references parent config (same for multi-collection patterns)
|
||||
* - Drop existing data (will be regenerated on next full sync)
|
||||
*
|
||||
* This enables quick sync to work with "one config multiple collections" patterns
|
||||
* where a single config generates multiple Plex collections (e.g., per-user collections,
|
||||
* per-franchise collections). All collections share the same configId but have unique
|
||||
* collectionRatingKey values.
|
||||
*/
|
||||
export class RefactorCollectionMissingItemsSchema1767929221162
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RefactorCollectionMissingItemsSchema1767929221162';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Step 1: Create temporary table with new schema
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_collection_missing_items" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"collectionRatingKey" varchar NOT NULL,
|
||||
"configId" varchar NOT NULL,
|
||||
"libraryId" varchar NOT NULL,
|
||||
"tmdbId" integer NOT NULL,
|
||||
"tvdbId" integer,
|
||||
"mediaType" varchar(10) NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"year" integer,
|
||||
"originalPosition" integer NOT NULL,
|
||||
"source" varchar,
|
||||
"fullSyncTimestamp" datetime NOT NULL,
|
||||
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
|
||||
"updatedAt" datetime NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
);
|
||||
|
||||
// Step 2: Drop old data
|
||||
// We intentionally don't migrate existing records because:
|
||||
// - Old collectionId was the config ID, not the Plex collection rating key
|
||||
// - We can't reliably look up the actual collectionRatingKey during migration
|
||||
// - Missing items will be regenerated correctly on next full sync
|
||||
// This is safe because missing items are ephemeral data for Quick Sync optimization
|
||||
|
||||
// Step 3: Drop old table
|
||||
await queryRunner.query(`DROP INDEX "IDX_f9edfd4988befaae4cced8cfb2"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_082f893274e0755e0b2a8c1891"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_23e094cd6d150084d54145cd05"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_168fd2baea6e73ff0276eac52d"`);
|
||||
await queryRunner.query(`DROP TABLE "collection_missing_items"`);
|
||||
|
||||
// Step 4: Rename temporary table
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_collection_missing_items" RENAME TO "collection_missing_items"`
|
||||
);
|
||||
|
||||
// Step 5: Create indexes on new schema
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_collection_missing_items_collectionRatingKey" ON "collection_missing_items" ("collectionRatingKey")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_collection_missing_items_configId" ON "collection_missing_items" ("configId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_collection_missing_items_libraryId" ON "collection_missing_items" ("libraryId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_collection_missing_items_tmdbId" ON "collection_missing_items" ("tmdbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_collection_missing_items_fullSyncTimestamp" ON "collection_missing_items" ("fullSyncTimestamp")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Rollback: Restore original schema
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "collection_missing_items" RENAME TO "temporary_collection_missing_items"`
|
||||
);
|
||||
|
||||
// Create original table structure
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "collection_missing_items" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"collectionId" varchar NOT NULL,
|
||||
"libraryId" varchar NOT NULL,
|
||||
"tmdbId" integer NOT NULL,
|
||||
"tvdbId" integer,
|
||||
"mediaType" varchar(10) NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"year" integer,
|
||||
"originalPosition" integer NOT NULL,
|
||||
"source" varchar,
|
||||
"fullSyncTimestamp" datetime NOT NULL,
|
||||
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
|
||||
"updatedAt" datetime NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
);
|
||||
|
||||
// Copy data back (using configId as collectionId)
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "collection_missing_items"(
|
||||
"id", "collectionId", "libraryId", "tmdbId", "tvdbId",
|
||||
"mediaType", "title", "year", "originalPosition", "source",
|
||||
"fullSyncTimestamp", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
"id", COALESCE("configId", "collectionRatingKey"), "libraryId", "tmdbId", "tvdbId",
|
||||
"mediaType", "title", "year", "originalPosition", "source",
|
||||
"fullSyncTimestamp", "createdAt", "updatedAt"
|
||||
FROM "temporary_collection_missing_items"`
|
||||
);
|
||||
|
||||
// Drop temporary table
|
||||
await queryRunner.query(`DROP TABLE "temporary_collection_missing_items"`);
|
||||
|
||||
// Recreate original indexes
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_168fd2baea6e73ff0276eac52d" ON "collection_missing_items" ("collectionId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_23e094cd6d150084d54145cd05" ON "collection_missing_items" ("libraryId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_082f893274e0755e0b2a8c1891" ON "collection_missing_items" ("tmdbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f9edfd4988befaae4cced8cfb2" ON "collection_missing_items" ("fullSyncTimestamp")`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user