diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index d1f2900..21da993 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -2115,6 +2115,34 @@ class PlexAPI { } } + /** + * Empty trash for a Plex library section + * Removes items that Plex has detected as missing/unavailable + * @param libraryId - The library section ID to empty trash for + */ + public async emptyTrash(libraryId: string): Promise { + try { + logger.debug('Emptying Plex library trash', { + label: 'Plex API', + libraryId, + }); + + await this.safePutQuery(`/library/sections/${libraryId}/emptyTrash`); + + logger.info('Plex library trash emptied', { + label: 'Plex API', + libraryId, + }); + } catch (error) { + logger.error('Failed to empty Plex library trash', { + label: 'Plex API', + libraryId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + // PLEX.TV METHODS - Delegated to PlexTvAPI /** diff --git a/server/lib/collections/services/CollectionSyncService.ts b/server/lib/collections/services/CollectionSyncService.ts index 8613c54..b523191 100644 --- a/server/lib/collections/services/CollectionSyncService.ts +++ b/server/lib/collections/services/CollectionSyncService.ts @@ -186,6 +186,45 @@ export class CollectionSyncService { cleanedUp, titlesFixes, }); + + // Trigger Plex library scan + empty trash to remove ghost entries (fire-and-forget) + if (cleanedUp > 0 && tvLibraryId) { + const libraryId = tvLibraryId; + logger.info( + 'Triggering Plex scan to clean up deleted TV placeholders', + { + label: 'Collection Sync Service', + libraryId, + placeholdersDeleted: cleanedUp, + } + ); + // Fire-and-forget: don't block sync while Plex processes + const autoEmptyTrash = getSettings().plex.autoEmptyTrash !== false; + void (async () => { + try { + await plexClient.scanLibrary(libraryId); + if (autoEmptyTrash) { + // Brief delay for scan to detect missing files + await new Promise((resolve) => setTimeout(resolve, 3000)); + await plexClient.emptyTrash(libraryId); + } + logger.info('Plex placeholder cleanup complete', { + label: 'Collection Sync Service', + libraryId, + trashedEmptied: autoEmptyTrash, + }); + } catch (cleanupError) { + logger.warn('Failed to complete Plex placeholder cleanup', { + label: 'Collection Sync Service', + libraryId, + error: + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError), + }); + } + })(); + } } catch (error) { logger.warn('Failed to run global TV placeholder discovery', { label: 'Collection Sync Service', @@ -246,6 +285,45 @@ export class CollectionSyncService { label: 'Collection Sync Service', cleanedUp: moviesCleanedUp, }); + + // Trigger Plex library scan + empty trash to remove ghost entries (fire-and-forget) + if (moviesCleanedUp > 0 && movieLibraryId) { + const libraryId = movieLibraryId; + logger.info( + 'Triggering Plex scan to clean up deleted movie placeholders', + { + label: 'Collection Sync Service', + libraryId, + placeholdersDeleted: moviesCleanedUp, + } + ); + // Fire-and-forget: don't block sync while Plex processes + const autoEmptyTrash = getSettings().plex.autoEmptyTrash !== false; + void (async () => { + try { + await plexClient.scanLibrary(libraryId); + if (autoEmptyTrash) { + // Brief delay for scan to detect missing files + await new Promise((resolve) => setTimeout(resolve, 3000)); + await plexClient.emptyTrash(libraryId); + } + logger.info('Plex placeholder cleanup complete', { + label: 'Collection Sync Service', + libraryId, + trashedEmptied: autoEmptyTrash, + }); + } catch (cleanupError) { + logger.warn('Failed to complete Plex placeholder cleanup', { + label: 'Collection Sync Service', + libraryId, + error: + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError), + }); + } + })(); + } } catch (error) { logger.warn('Failed to run global movie placeholder discovery', { label: 'Collection Sync Service', diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 763d7a4..720d945 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -407,6 +407,7 @@ export interface PlexSettings { hubConfigs?: PlexHubConfig[]; // Plex built-in hub configurations preExistingCollectionConfigs?: PreExistingCollectionConfig[]; // Pre-existing Plex collections discovered by hub discovery usersHomeUnlocked?: boolean; // Secret unlock for Users Home collections + autoEmptyTrash?: boolean; // Auto-empty Plex trash after placeholder cleanup (default: true) } export interface TraktSettings { diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 17b404f..03ac13c 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -62,6 +62,9 @@ const messages = defineMessages({ webAppUrl: 'Web App URL', webAppUrlTip: 'Optionally direct users to the web app on your server instead of the "hosted" web app', + autoEmptyTrash: 'Auto Empty Trash', + autoEmptyTrashTip: + 'Automatically empty Plex library trash after placeholder cleanup to remove ghost entries', }); interface Library { @@ -246,6 +249,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { useSsl: data?.useSsl, selectedPreset: undefined, webAppUrl: data?.webAppUrl, + autoEmptyTrash: data?.autoEmptyTrash !== false, }} validationSchema={PlexSettingsSchema} onSubmit={async (values) => { @@ -266,6 +270,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { port: Number(values.port), useSsl: values.useSsl, webAppUrl: values.webAppUrl, + autoEmptyTrash: values.autoEmptyTrash, } as PlexSettings); syncLibraries(); @@ -476,6 +481,25 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { )} +
+ +
+ { + setFieldValue('autoEmptyTrash', !values.autoEmptyTrash); + }} + /> +
+