fix(placeholders): trigger Plex scan and empty trash after cleanup (#332)

When placeholders are cleaned up because real content arrived, Plex
wasn't notified and ghost entries remained in the library. This adds:

- New emptyTrash() method to PlexAPI
- Fire-and-forget scan + empty trash after placeholder cleanup
- New autoEmptyTrash setting (default: true) with UI toggle
- Setting in Plex settings page with "advanced" badge

The scan runs in the background so it doesn't block the sync process.

Co-authored-by: bitr8 <bitr8@users.noreply.github.com>
This commit is contained in:
bitr8
2026-01-11 20:20:25 +11:00
committed by GitHub
parent 82599a4add
commit 452a2be4a9
4 changed files with 131 additions and 0 deletions

View File

@@ -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<void> {
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
/**

View File

@@ -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',

View File

@@ -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 {

View File

@@ -62,6 +62,9 @@ const messages = defineMessages({
webAppUrl: '<WebAppLink>Web App</WebAppLink> 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) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="autoEmptyTrash" className="checkbox-label">
{intl.formatMessage(messages.autoEmptyTrash)}
<SettingsBadge badgeType="advanced" className="ml-2" />
<span className="label-tip">
{intl.formatMessage(messages.autoEmptyTrashTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="autoEmptyTrash"
name="autoEmptyTrash"
onChange={() => {
setFieldValue('autoEmptyTrash', !values.autoEmptyTrash);
}}
/>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">