mirror of
https://github.com/agregarr/agregarr.git
synced 2026-02-04 20:50:57 +08:00
add "Include all released items" toggle, fix orphaned cleanup to use fixed 7-day grace period, fix placeholder quick sync deleting tv items, other minor changes re #336, re #268, re #253, re #307
819 lines
25 KiB
TypeScript
819 lines
25 KiB
TypeScript
import PlexAPI, { type PlexLibraryItem } from '@server/api/plexapi';
|
|
import { getRepository } from '@server/datasource';
|
|
import { CollectionMissingItems } from '@server/entity/CollectionMissingItems';
|
|
import { ComingSoonItem } from '@server/entity/ComingSoonItem';
|
|
import { getSettings } from '@server/lib/settings';
|
|
import logger from '@server/logger';
|
|
import path from 'path';
|
|
|
|
/**
|
|
* Match between a recently added Plex item and a stored missing item
|
|
*/
|
|
interface MissingItemMatch {
|
|
plexItem: PlexLibraryItem;
|
|
missingItem: CollectionMissingItems;
|
|
}
|
|
|
|
/**
|
|
* Collections Quick Sync Job
|
|
* Efficiently adds recently downloaded items to collections without full sync
|
|
*
|
|
* Process:
|
|
* 1. Get items recently added to Plex (since last run)
|
|
* 2. Match against stored missing items from previous full sync
|
|
* 3. Add matched items to collections at correct position
|
|
* 4. Cleanup old missing items data (>30 days)
|
|
*/
|
|
class CollectionsQuickSync {
|
|
public running = false;
|
|
private cancelled = false;
|
|
private currentStage = '';
|
|
|
|
/**
|
|
* Get current status for UI display
|
|
*/
|
|
public get status() {
|
|
return {
|
|
running: this.running,
|
|
cancelled: this.cancelled,
|
|
currentStage: this.currentStage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cancel the currently running job
|
|
*/
|
|
public cancel(): void {
|
|
this.cancelled = true;
|
|
logger.info('Collections Quick Sync cancellation requested', {
|
|
label: 'Collections Quick Sync',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set current stage for progress tracking
|
|
*/
|
|
private setStage(stage: string): void {
|
|
this.currentStage = stage;
|
|
logger.debug(stage, { label: 'Collections Quick Sync' });
|
|
}
|
|
|
|
/**
|
|
* Get Plex client with admin token
|
|
*/
|
|
private async getPlexClient(): Promise<PlexAPI> {
|
|
const { getAdminUser } = await import(
|
|
'@server/lib/collections/core/CollectionUtilities'
|
|
);
|
|
const localAdmin = await getAdminUser();
|
|
|
|
if (!localAdmin?.plexToken) {
|
|
throw new Error('No local admin Plex token found');
|
|
}
|
|
|
|
const settings = getSettings().load();
|
|
return new PlexAPI({
|
|
plexToken: localAdmin.plexToken,
|
|
plexSettings: settings.plex,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Main job execution
|
|
*/
|
|
public async run(): Promise<void> {
|
|
if (this.running) {
|
|
logger.warn('Collections Quick Sync is already running', {
|
|
label: 'Collections Quick Sync',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skip if full collections sync is running to prevent conflicts
|
|
const collectionsSync = (await import('@server/lib/collectionsSync'))
|
|
.default;
|
|
if (collectionsSync.status.running) {
|
|
logger.info(
|
|
'Full Collections Sync is currently running, skipping Quick Sync',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.running = true;
|
|
this.cancelled = false;
|
|
this.currentStage = '';
|
|
|
|
const startTime = Date.now();
|
|
let itemsMatched = 0;
|
|
let collectionsUpdated = 0;
|
|
let itemsAdded = 0;
|
|
let placeholdersDeleted = 0;
|
|
const librariesNeedingScan = new Set<string>();
|
|
|
|
try {
|
|
logger.info('Starting Collections Quick Sync', {
|
|
label: 'Collections Quick Sync',
|
|
});
|
|
|
|
// Get last run timestamp (or default to 24 hours ago)
|
|
const settings = getSettings();
|
|
const lastRunStr = settings.main.lastCollectionsQuickSyncAt;
|
|
const cutoffTime = lastRunStr
|
|
? new Date(lastRunStr).getTime()
|
|
: Date.now() - 24 * 60 * 60 * 1000; // 24 hours default
|
|
|
|
logger.info('Checking for items added since last run', {
|
|
label: 'Collections Quick Sync',
|
|
cutoffTime: new Date(cutoffTime).toISOString(),
|
|
isFirstRun: !lastRunStr,
|
|
});
|
|
|
|
// Get Plex client
|
|
this.setStage('Connecting to Plex...');
|
|
const plexClient = await this.getPlexClient();
|
|
|
|
// Test connection
|
|
const isConnected = await plexClient.getStatus();
|
|
if (!isConnected) {
|
|
throw new Error('Could not connect to Plex server');
|
|
}
|
|
|
|
// Get libraries from settings
|
|
const libraries = settings.plex.libraries;
|
|
|
|
if (!libraries || libraries.length === 0) {
|
|
logger.warn('No libraries configured', {
|
|
label: 'Collections Quick Sync',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Process each library
|
|
for (const library of libraries) {
|
|
if (this.cancelled) {
|
|
logger.info('Collections Quick Sync cancelled by user', {
|
|
label: 'Collections Quick Sync',
|
|
});
|
|
break;
|
|
}
|
|
|
|
this.setStage(`Checking library: ${library.name}...`);
|
|
|
|
try {
|
|
// Get recently added items for this library
|
|
const mediaType = library.type === 'show' ? 'show' : 'movie';
|
|
const recentItems = await plexClient.getRecentlyAdded(
|
|
library.key,
|
|
{ addedAt: cutoffTime },
|
|
mediaType
|
|
);
|
|
|
|
if (!recentItems || recentItems.length === 0) {
|
|
logger.debug('No recently added items in library', {
|
|
label: 'Collections Quick Sync',
|
|
libraryName: library.name,
|
|
libraryKey: library.key,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
logger.info('Found recently added items', {
|
|
label: 'Collections Quick Sync',
|
|
libraryName: library.name,
|
|
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[] = [];
|
|
const isShowLibrary = library.type === 'show';
|
|
for (const item of recentItems) {
|
|
try {
|
|
// For TV shows, include children so isPlaceholderItem() can check Season 00
|
|
const fullMetadata = await plexClient.getMetadata(
|
|
item.ratingKey,
|
|
{ includeChildren: isShowLibrary }
|
|
);
|
|
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(
|
|
itemsWithMetadata,
|
|
library.key
|
|
);
|
|
|
|
placeholdersDeleted += cleanupResult.deletedCount;
|
|
for (const libId of cleanupResult.affectedLibraries) {
|
|
librariesNeedingScan.add(libId);
|
|
}
|
|
|
|
// Process these items (match and add to collections)
|
|
const result = await this.processRecentItems(
|
|
itemsWithMetadata,
|
|
library.key,
|
|
plexClient
|
|
);
|
|
|
|
itemsMatched += result.matched;
|
|
collectionsUpdated += result.collectionsUpdated;
|
|
itemsAdded += result.itemsAdded;
|
|
} catch (error) {
|
|
logger.error('Failed to process library', {
|
|
label: 'Collections Quick Sync',
|
|
libraryName: library.name,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
// Continue with next library
|
|
}
|
|
}
|
|
|
|
// Trigger Plex scans for libraries where placeholders were deleted
|
|
if (librariesNeedingScan.size > 0) {
|
|
this.setStage('Triggering Plex scans for placeholder cleanup...');
|
|
for (const libraryId of librariesNeedingScan) {
|
|
try {
|
|
await plexClient.scanLibrary(libraryId);
|
|
logger.info('Triggered Plex scan after placeholder deletion', {
|
|
label: 'Collections Quick Sync',
|
|
libraryId,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
'Failed to trigger Plex scan after placeholder deletion',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
libraryId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup old missing items (>30 days)
|
|
this.setStage('Cleaning up old missing items...');
|
|
const cleanedCount = await this.cleanupOldMissingItems();
|
|
|
|
// Update last run timestamp
|
|
settings.main.lastCollectionsQuickSyncAt = new Date().toISOString();
|
|
settings.save();
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.info('Collections Quick Sync completed', {
|
|
label: 'Collections Quick Sync',
|
|
duration: `${Math.round(duration / 1000)}s`,
|
|
itemsMatched,
|
|
collectionsUpdated,
|
|
itemsAdded,
|
|
placeholdersDeleted,
|
|
oldItemsCleaned: cleanedCount,
|
|
});
|
|
|
|
this.setStage('Quick sync completed');
|
|
} catch (error) {
|
|
logger.error('Collections Quick Sync failed', {
|
|
label: 'Collections Quick Sync',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
} finally {
|
|
this.running = false;
|
|
this.cancelled = false;
|
|
this.currentStage = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check recently added Plex items against placeholders and delete placeholders for real items
|
|
* This prevents placeholder accumulation when real content is added
|
|
*/
|
|
private async cleanupPlaceholdersForRecentItems(
|
|
recentItems: PlexLibraryItem[],
|
|
libraryId: string
|
|
): Promise<{ deletedCount: number; affectedLibraries: Set<string> }> {
|
|
const placeholderRepository = getRepository(ComingSoonItem);
|
|
const { placeholderContextService } = await import(
|
|
'@server/lib/placeholders/services/PlaceholderContextService'
|
|
);
|
|
const { removePlaceholder } = await import(
|
|
'@server/lib/placeholders/placeholderManager'
|
|
);
|
|
|
|
let deletedCount = 0;
|
|
const affectedLibraries = new Set<string>();
|
|
|
|
// Get all placeholders for this library
|
|
const settings = getSettings();
|
|
const collectionsForLibrary = settings.plex.collectionConfigs?.filter(
|
|
(c) => c.libraryId === libraryId && c.createPlaceholdersForMissing
|
|
);
|
|
|
|
if (!collectionsForLibrary || collectionsForLibrary.length === 0) {
|
|
return { deletedCount: 0, affectedLibraries };
|
|
}
|
|
|
|
const configIds = collectionsForLibrary.map((c) => c.id);
|
|
const placeholders = await placeholderRepository
|
|
.createQueryBuilder('placeholder')
|
|
.where('placeholder.configId IN (:...configIds)', { configIds })
|
|
.getMany();
|
|
|
|
if (placeholders.length === 0) {
|
|
return { deletedCount: 0, affectedLibraries };
|
|
}
|
|
|
|
logger.info('Checking recently added items against placeholders', {
|
|
label: 'Collections Quick Sync',
|
|
recentItemsCount: recentItems.length,
|
|
placeholdersCount: placeholders.length,
|
|
});
|
|
|
|
// Create lookup map of placeholders by TMDB ID
|
|
const placeholdersByTmdbId = new Map<number, ComingSoonItem[]>();
|
|
for (const placeholder of placeholders) {
|
|
if (!placeholdersByTmdbId.has(placeholder.tmdbId)) {
|
|
placeholdersByTmdbId.set(placeholder.tmdbId, []);
|
|
}
|
|
placeholdersByTmdbId.get(placeholder.tmdbId)?.push(placeholder);
|
|
}
|
|
|
|
// Check each recent item
|
|
for (const recentItem of recentItems) {
|
|
const tmdbId = this.extractTmdbId(recentItem);
|
|
if (!tmdbId) continue;
|
|
|
|
const matchedPlaceholders = placeholdersByTmdbId.get(tmdbId);
|
|
if (!matchedPlaceholders || matchedPlaceholders.length === 0) continue;
|
|
|
|
// Cast to extended type to access optional properties used by placeholder detection
|
|
const itemExtended = recentItem as PlexLibraryItem & {
|
|
childCount?: number;
|
|
Children?: { Metadata?: unknown[] };
|
|
seasonCount?: number;
|
|
leafCount?: number;
|
|
};
|
|
|
|
// Verify this is real content and not still a placeholder
|
|
const isStillPlaceholder = placeholderContextService.isPlaceholderItem({
|
|
type: itemExtended.type,
|
|
guid: itemExtended.guid,
|
|
editionTitle: itemExtended.editionTitle,
|
|
Guid: itemExtended.Guid,
|
|
childCount: itemExtended.childCount,
|
|
Children: itemExtended.Children,
|
|
seasonCount: itemExtended.seasonCount,
|
|
leafCount: itemExtended.leafCount,
|
|
});
|
|
|
|
if (isStillPlaceholder) {
|
|
logger.debug(
|
|
'Skipping placeholder cleanup - Plex item is still a placeholder',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
tmdbId,
|
|
}
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Real content detected - delete placeholder for ALL collections
|
|
logger.info(
|
|
'Real content detected - deleting placeholder(s) for all collections',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
tmdbId,
|
|
placeholderCount: matchedPlaceholders.length,
|
|
}
|
|
);
|
|
|
|
// Get ALL placeholder records for this TMDB ID across all collections
|
|
const allPlaceholderRecords = await placeholderRepository.find({
|
|
where: { tmdbId },
|
|
});
|
|
|
|
if (allPlaceholderRecords.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Get the placeholder file path (should be same for all records)
|
|
const placeholderPath = allPlaceholderRecords[0].placeholderPath;
|
|
const mediaType = allPlaceholderRecords[0].mediaType;
|
|
|
|
// Delete the placeholder file once
|
|
let fileDeleted = false;
|
|
if (placeholderPath) {
|
|
const { getPlaceholderRootFolder } = await import(
|
|
'@server/lib/placeholders/helpers/placeholderPathHelpers'
|
|
);
|
|
const libraryPath = getPlaceholderRootFolder(libraryId, mediaType);
|
|
|
|
if (!libraryPath) {
|
|
logger.warn('Library path not configured - skipping file deletion', {
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
mediaType,
|
|
libraryId,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Construct full path
|
|
const fullPath = path.join(libraryPath, placeholderPath);
|
|
|
|
try {
|
|
await removePlaceholder(fullPath, mediaType);
|
|
fileDeleted = true;
|
|
affectedLibraries.add(libraryId);
|
|
logger.info('Deleted placeholder file (real content exists)', {
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
path: placeholderPath,
|
|
affectedCollections: allPlaceholderRecords.length,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes('ENOENT')) {
|
|
// File doesn't exist - that's fine, proceed with database cleanup
|
|
fileDeleted = true;
|
|
logger.debug('Placeholder file already deleted', {
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
path: fullPath,
|
|
});
|
|
} else {
|
|
logger.error(
|
|
'Failed to delete placeholder file - keeping all database records',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
path: fullPath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
);
|
|
continue; // Keep ALL database records if file deletion failed
|
|
}
|
|
}
|
|
} else {
|
|
fileDeleted = true; // No file to delete
|
|
}
|
|
|
|
// Delete ALL database records for this placeholder across ALL collections
|
|
if (fileDeleted) {
|
|
try {
|
|
await placeholderRepository.remove(allPlaceholderRecords);
|
|
deletedCount += allPlaceholderRecords.length;
|
|
logger.info(
|
|
'Deleted placeholder records for all collections (real content exists)',
|
|
{
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
tmdbId,
|
|
recordsDeleted: allPlaceholderRecords.length,
|
|
collections: allPlaceholderRecords.map((r) => r.configId),
|
|
}
|
|
);
|
|
} catch (error) {
|
|
logger.error('Failed to delete placeholder database records', {
|
|
label: 'Collections Quick Sync',
|
|
title: recentItem.title,
|
|
tmdbId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (deletedCount > 0) {
|
|
logger.info('Placeholder cleanup completed', {
|
|
label: 'Collections Quick Sync',
|
|
deletedCount,
|
|
affectedLibraries: Array.from(affectedLibraries),
|
|
});
|
|
}
|
|
|
|
return { deletedCount, affectedLibraries };
|
|
}
|
|
|
|
/**
|
|
* Process recently added items - match and add to collections
|
|
*/
|
|
private async processRecentItems(
|
|
recentItems: PlexLibraryItem[],
|
|
libraryId: string,
|
|
plexClient: PlexAPI
|
|
): Promise<{
|
|
matched: number;
|
|
collectionsUpdated: number;
|
|
itemsAdded: number;
|
|
}> {
|
|
let matched = 0;
|
|
let collectionsUpdated = 0;
|
|
let itemsAdded = 0;
|
|
|
|
// Match recent items against missing items database
|
|
const matches = await this.matchAgainstMissingItems(recentItems, libraryId);
|
|
|
|
if (matches.length === 0) {
|
|
logger.debug('No matches found for recently added items', {
|
|
label: 'Collections Quick Sync',
|
|
libraryId,
|
|
recentItemCount: recentItems.length,
|
|
});
|
|
return { matched, collectionsUpdated, itemsAdded };
|
|
}
|
|
|
|
logger.info('Matched recently added items to collections', {
|
|
label: 'Collections Quick Sync',
|
|
libraryId,
|
|
matchCount: matches.length,
|
|
});
|
|
|
|
matched = matches.length;
|
|
|
|
// Group matches by collection for efficient processing
|
|
const matchesByCollection = new Map<string, MissingItemMatch[]>();
|
|
for (const match of matches) {
|
|
const collectionRatingKey = match.missingItem.collectionRatingKey;
|
|
if (!matchesByCollection.has(collectionRatingKey)) {
|
|
matchesByCollection.set(collectionRatingKey, []);
|
|
}
|
|
matchesByCollection.get(collectionRatingKey)?.push(match);
|
|
}
|
|
|
|
// Process each collection
|
|
for (const [
|
|
collectionRatingKey,
|
|
collectionMatches,
|
|
] of matchesByCollection) {
|
|
if (this.cancelled) break;
|
|
|
|
try {
|
|
const added = await this.addItemsToCollection(
|
|
collectionRatingKey,
|
|
collectionMatches,
|
|
plexClient
|
|
);
|
|
|
|
if (added > 0) {
|
|
collectionsUpdated++;
|
|
itemsAdded += added;
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to add items to collection', {
|
|
label: 'Collections Quick Sync',
|
|
collectionRatingKey,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
// Continue with next collection
|
|
}
|
|
}
|
|
|
|
return { matched, collectionsUpdated, itemsAdded };
|
|
}
|
|
|
|
/**
|
|
* Match recent Plex items against stored missing items
|
|
*/
|
|
private async matchAgainstMissingItems(
|
|
recentItems: PlexLibraryItem[],
|
|
libraryId: string
|
|
): Promise<MissingItemMatch[]> {
|
|
const repository = getRepository(CollectionMissingItems);
|
|
const matches: MissingItemMatch[] = [];
|
|
|
|
// Get all missing items for this library
|
|
const missingItems = await repository.find({
|
|
where: { libraryId },
|
|
});
|
|
|
|
if (missingItems.length === 0) {
|
|
return matches;
|
|
}
|
|
|
|
// Create lookup maps for efficient matching
|
|
const missingByTmdbId = new Map<number, CollectionMissingItems[]>();
|
|
const missingByTvdbId = new Map<number, CollectionMissingItems[]>();
|
|
|
|
for (const missing of missingItems) {
|
|
if (missing.tmdbId) {
|
|
if (!missingByTmdbId.has(missing.tmdbId)) {
|
|
missingByTmdbId.set(missing.tmdbId, []);
|
|
}
|
|
missingByTmdbId.get(missing.tmdbId)?.push(missing);
|
|
}
|
|
|
|
if (missing.tvdbId) {
|
|
if (!missingByTvdbId.has(missing.tvdbId)) {
|
|
missingByTvdbId.set(missing.tvdbId, []);
|
|
}
|
|
missingByTvdbId.get(missing.tvdbId)?.push(missing);
|
|
}
|
|
}
|
|
|
|
// Match each recent item
|
|
for (const plexItem of recentItems) {
|
|
// Extract TMDB/TVDB IDs from Plex item GUIDs
|
|
const tmdbId = this.extractTmdbId(plexItem);
|
|
const tvdbId = this.extractTvdbId(plexItem);
|
|
|
|
let matchedMissingItems: CollectionMissingItems[] = [];
|
|
|
|
// Try matching by TMDB ID first
|
|
if (tmdbId && missingByTmdbId.has(tmdbId)) {
|
|
matchedMissingItems = missingByTmdbId.get(tmdbId) || [];
|
|
}
|
|
// Fallback to TVDB ID (for anime)
|
|
else if (tvdbId && missingByTvdbId.has(tvdbId)) {
|
|
matchedMissingItems = missingByTvdbId.get(tvdbId) || [];
|
|
}
|
|
|
|
// Create matches for all collections that were missing this item
|
|
for (const missingItem of matchedMissingItems) {
|
|
matches.push({
|
|
plexItem,
|
|
missingItem,
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Extract TMDB ID from Plex item GUIDs
|
|
*/
|
|
private extractTmdbId(plexItem: PlexLibraryItem): number | undefined {
|
|
if (!plexItem.Guid) return undefined;
|
|
|
|
for (const guid of plexItem.Guid) {
|
|
if (guid.id.startsWith('tmdb://')) {
|
|
const match = guid.id.match(/tmdb:\/\/(\d+)/);
|
|
if (match) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extract TVDB ID from Plex item GUIDs
|
|
*/
|
|
private extractTvdbId(plexItem: PlexLibraryItem): number | undefined {
|
|
if (!plexItem.Guid) return undefined;
|
|
|
|
for (const guid of plexItem.Guid) {
|
|
if (guid.id.startsWith('tvdb://')) {
|
|
const match = guid.id.match(/tvdb:\/\/(\d+)/);
|
|
if (match) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Add matched items to a collection at correct position
|
|
*/
|
|
private async addItemsToCollection(
|
|
collectionRatingKey: string,
|
|
matches: MissingItemMatch[],
|
|
plexClient: PlexAPI
|
|
): Promise<number> {
|
|
// Optionally get collection config for logging (configId may be null for multi-collection patterns)
|
|
const settings = getSettings();
|
|
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(
|
|
(a, b) => a.missingItem.originalPosition - b.missingItem.originalPosition
|
|
);
|
|
|
|
// Add items to collection
|
|
const newItems = sortedMatches.map((m) => ({
|
|
ratingKey: m.plexItem.ratingKey,
|
|
title: m.plexItem.title,
|
|
}));
|
|
|
|
await plexClient.addItemsToCollection(collectionRatingKey, newItems);
|
|
|
|
logger.info('Added items to collection', {
|
|
label: 'Collections Quick Sync',
|
|
collectionName: config?.name || 'Unknown Collection',
|
|
collectionRatingKey,
|
|
configId: firstMatch?.configId,
|
|
itemsAdded: newItems.length,
|
|
titles: sortedMatches.map((m) => m.missingItem.title),
|
|
});
|
|
|
|
// Delete matched missing items from database (they're no longer missing)
|
|
const repository = getRepository(CollectionMissingItems);
|
|
const missingItemIds = matches.map((m) => m.missingItem.id);
|
|
await repository.delete(missingItemIds);
|
|
|
|
return newItems.length;
|
|
}
|
|
|
|
/**
|
|
* Cleanup old missing items (>30 days) and orphaned items
|
|
*/
|
|
private async cleanupOldMissingItems(): Promise<number> {
|
|
const repository = getRepository(CollectionMissingItems);
|
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
let totalDeleted = 0;
|
|
|
|
// Delete items older than 30 days
|
|
try {
|
|
const oldResult = await repository
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.where('fullSyncTimestamp < :cutoff', { cutoff: thirtyDaysAgo })
|
|
.execute();
|
|
|
|
const oldDeleted = oldResult.affected || 0;
|
|
totalDeleted += oldDeleted;
|
|
|
|
if (oldDeleted > 0) {
|
|
logger.info('Deleted old missing items', {
|
|
label: 'Collections Quick Sync',
|
|
count: oldDeleted,
|
|
olderThan: '30 days',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to delete old missing items', {
|
|
label: 'Collections Quick Sync',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
// Delete items for deleted configs (both single and multi-collection patterns)
|
|
try {
|
|
const settings = getSettings();
|
|
const activeCollectionIds =
|
|
settings.plex.collectionConfigs?.map((c) => c.id) || [];
|
|
|
|
if (activeCollectionIds.length > 0) {
|
|
const orphanResult = await repository
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.where('configId NOT IN (:...activeIds)', {
|
|
activeIds: activeCollectionIds,
|
|
})
|
|
.execute();
|
|
|
|
const orphanDeleted = orphanResult.affected || 0;
|
|
totalDeleted += orphanDeleted;
|
|
|
|
if (orphanDeleted > 0) {
|
|
logger.info('Deleted orphaned missing items', {
|
|
label: 'Collections Quick Sync',
|
|
count: orphanDeleted,
|
|
note: 'Removed items for deleted parent configs',
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to delete orphaned missing items', {
|
|
label: 'Collections Quick Sync',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
return totalDeleted;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
const collectionsQuickSync = new CollectionsQuickSync();
|
|
export default collectionsQuickSync;
|