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:
Tom Wheeler
2026-01-09 18:11:42 +13:00
parent 33ac103895
commit 5ae10be8ce
15 changed files with 362 additions and 106 deletions
+5 -1
View File
@@ -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 {
+2 -1
View File
@@ -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', {
+2 -1
View File
@@ -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', {
+2 -1
View File
@@ -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
+2 -1
View File
@@ -140,7 +140,8 @@ export class NetworksCollectionSync extends BaseCollectionSync<'networks'> {
allCollections,
processedCollectionKeys,
undefined, // userInfo
libraryCache
libraryCache,
missingItems
);
} catch (error) {
throw this.createSyncError(
+2 -1
View File
@@ -169,7 +169,8 @@ export class OriginalsCollectionSync extends BaseCollectionSync<'originals'> {
allCollections,
processedCollectionKeys,
undefined, // userInfo
libraryCache
libraryCache,
missingItems
);
} catch (error) {
throw this.createSyncError(
+88 -42
View File
@@ -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;
}
+2 -1
View File
@@ -129,7 +129,8 @@ export class RadarrTagCollectionSync extends BaseCollectionSync<'radarrtag'> {
allCollections,
processedCollectionKeys,
undefined, // userInfo
libraryCache
libraryCache,
missingItems
);
} catch (error) {
throw this.createSyncError(
+2 -1
View File
@@ -131,7 +131,8 @@ export class SonarrTagCollectionSync extends BaseCollectionSync<'sonarrtag'> {
allCollections,
processedCollectionKeys,
undefined, // userInfo
libraryCache
libraryCache,
missingItems
);
} catch (error) {
throw this.createSyncError(
+11 -4
View File
@@ -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;
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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
+48 -36
View File
@@ -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")`
);
}
}