Add custom plugin command for bespoke re-processing (IProcessPlugin)#791
Merged
Conversation
Enhanced verification checks never run on files whose sidecar is already Verified, and full re-verification is prohibitively expensive. Add a custom command that loads a user-provided plugin assembly and runs it over the media files, reusing the existing file iteration and processing API for targeted re-checks or repairs without recompiling PlexCleaner. - IProcessPlugin / IPluginHost public interfaces with a deterministic PluginApi.Version the plugin checks in Initialize, plus injected logger, app/OS/runtime info for compatibility decisions - PluginLoader / PluginLoadContext (AssemblyLoadContext) sharing the host's loaded assemblies so type identity and static state are single-instance - custom verb (--pluginassembly) wired through CommandLineOptions and Program.CustomCommand - Plugin loading gated behind the PLUGINS constant, compiled only when not building for NativeAOT; PublishAot default flipped to false to match every shipped build - MatroskaHeaderCleanup example plugin, PluginLoaderTests, and README custom plugins section with CLI and Docker Compose examples Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an opt-in plugin mechanism and a new custom CLI verb so users can run bespoke, targeted re-processing over existing libraries (including already-verified files) without deleting sidecars or recompiling PlexCleaner, while keeping NativeAOT builds supported by compile-time gating.
Changes:
- Introduces public plugin contracts (
IProcessPlugin,IPluginHost,PluginApi.Version) and a runtime loader based onAssemblyLoadContext. - Adds the
customcommand (--pluginassembly) wired through CLI parsing andProgram.CustomCommand, with a clear non-AOT error path. - Adds an example plugin (
MatroskaHeaderCleanup), tests for the loader, and documentation updates (README + HISTORY + cspell).
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the new custom plugin command, trust/thread-safety notes, and examples (CLI + Docker Compose). |
| HISTORY.md | Records the new custom plugin command and example plugin in release notes. |
| cspell.json | Adds pluginassembly to avoid spelling false-positives. |
| PlexCleaner/Program.cs | Implements Program.CustomCommand with PLUGINS gating and integrates plugin processing into the standard file iteration. |
| PlexCleaner/PluginLoader.cs | Adds plugin assembly load context/host objects and reflection-based plugin discovery/initialization. |
| PlexCleaner/PlexCleaner.csproj | Flips default PublishAot to false and defines PLUGINS constant when not publishing AOT. |
| PlexCleaner/IProcessPlugin.cs | Adds public plugin API/version contract and host/process interfaces. |
| PlexCleaner/CommandLineOptions.cs | Adds --pluginassembly option and registers the custom subcommand. |
| PlexCleaner.slnx | Includes the example plugin project in the solution. |
| PlexCleanerTests/PluginLoaderTests.cs | Adds tests for loading the example plugin and handling missing plugin assemblies. |
| PlexCleanerTests/PlexCleanerTests.csproj | References the example plugin project so its DLL is available for loader tests. |
| Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj | Adds an example plugin project that compiles against PlexCleaner. |
| Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs | Implements the example plugin that re-runs Matroska structure repair on already-verified files. |
- Dockerfile: copy the Plugins directory into the build context so PlexCleanerTests can resolve its project reference (the smoke-build Docker job failed with the plugin project "not found") - PluginLoadContext: override LoadUnmanagedDll so a plugin's bundled native dependencies are probed from the plugin directory - PluginLoader: log the assembly path on load failure since LogAndHandle only reports the caller member - CustomCommand: isolate plugin exceptions to the current file so a faulty plugin fails that file instead of aborting the whole PLINQ run Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Example plugin now also pins against the tested PlexCleaner application version, not just PluginApi.Version: PluginApi.Version guards only the plugin contract, but the public API a plugin calls can change in any release, so the example warns when host.ApplicationVersion differs from the tested version (a stricter plugin could hard-pin). Documented in the plugin notes. - PluginLoader: guard null/empty/whitespace assembly path before GetFullPath so an empty --pluginassembly does not resolve to the current directory - README: note the plugin needs a public parameterless constructor and that processing helpers are gated by settings (e.g. ProcessOptions.Verify) - PluginLoaderTests: assign to a non-null local after the NotBeNull assertion Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: file processing is serial by default; plugin code must be thread-safe only when --parallel is enabled (it is opt-in, not the default) - ARCHITECTURE.md / AGENTS.md: PublishAot now defaults to false to match the shipped builds and enable the plugin loader; AOT is opt-in via -p:PublishAot=true Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI lint failures (cspell, markdownlint) were only caught after push because the editor extensions are advisory and the pre-commit hook only ran the C# checks. - pre-commit: when Markdown files are staged, run markdownlint and cspell to match CI. These Node tools are unreliable across Windows/WSL/Linux, so run them via Docker. To keep the hook reliable and never a false blocker, only use images already present locally (never pull) and skip when Docker or the image is unavailable; MSYS_NO_PATHCONV keeps the bind mount correct under Git Bash on Windows and is a no-op on Linux/WSL. The commit is gated on lint errors only when the tooling is present; CI enforces regardless. - .vscode/tasks.json: add "Lint: CSharpier", "Lint: Style", "Lint: Markdown", "Lint: Spelling", and a "Lint: All (CI parity)" compound task. The Docker tasks also warm the images the hook reuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The custom plugin command was described in its own section but missing from the 3.20 release-notes summary and the CLI command reference; add both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Path.GetFullPath can throw on invalid path characters; move it and the File.Exists check inside the try/catch so a bad --pluginassembly value is logged and returns null instead of crashing the process. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The custom driver passes every file (mkvFilesOnly=false, matching the flexible TestMediaInfo precedent so plugins can target any container type). The example plugin now skips non-Matroska files and returns true rather than reporting them as errors, and the docs note that a plugin receives all files and should filter by type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A plugin that modifies a file must keep the sidecar in sync or the next normal run invalidates the saved state. Document the example flow (confirm Matroska, GetMediaProps to initialize the sidecar, modify via a ProcessFile helper that refreshes it), verified end to end against a real file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RepairMatroskaStructure combines state flags (State |= ReMuxed) and only clears Verified via SetVerifyFailed if the remux fails to repair the structure, so a successful repair preserves an existing Verified state and avoids re-verifying. Add a comment explaining this in the sample. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In AOT builds the custom command handler emits a clear "requires a non-AOT build" error, but Required=true made argument parsing fail first. Gate the Required flag behind PLUGINS so custom can reach CustomCommand in AOT builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PluginLoadContext deferred to the default context by simple assembly name, which could treat a plugin's private dependency as host-provided when the host loaded a different version or strong name of the same simple name, preventing the plugin from loading its own compatible copy. Compare the full identity (name, version, culture, public key token) so only exact matches are shared; other assemblies resolve through the dependency resolver in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Verification/processing logic is enhanced over time (recently the Matroska seek-index structure check). Files whose sidecar already has
StatesType.Verifiedset are skipped by the conditional verify gate, so newly added checks never run on an already-verified library. The only remedy today is deleting sidecars and full re-verification, which is prohibitively expensive (weeks on a large library).Solution
A new
customcommand loads a user-provided plugin assembly and runs it over the media files, reusing the existing file iteration and processing API to perform a targeted re-check/repair without recompiling PlexCleaner and without full re-verification.IProcessPlugin/IPluginHostpublic interfaces.Initialize(IPluginHost)receives a deterministicPluginApi.Version(bumped on breaking contract changes) the plugin checks for compatibility, an injected Serilog logger (mirroring the existing "caller sets logger" model), and app/OS/runtime info for finer opt-in decisions.PluginLoader/PluginLoadContext(AssemblyLoadContext+AssemblyDependencyResolver) that shares the host's already-loaded assemblies, soIProcessPlugintype identity and the staticProgram.Config/Toolsare single-instance; only the plugin's private dependencies load in isolation. Fails cleanly (missing file / no-or-multiple implementors / load error) with a logged error.customverb (--pluginassembly, plus the usual--settingsfile/--mediafiles/--parallel/--threadcount) wired throughCommandLineOptionsandProgram.CustomCommand.PLUGINSconstant compiled only when not building for NativeAOT. ThePublishAotdefault is flipped tofalseto match every shipped build (CI and Docker already override it). Under an explicit AOT publish the command returns a clear "requires a non-AOT build" error.MatroskaHeaderCleanupexample plugin — re-runsRepairMatroskaStructure(notVerified-gated) on already-verified files, the exact motivating case.Testing
PluginLoaderTests: loads the built example DLL, asserts single type identity andName, and that a missing assembly returns null.Matroska seek index unusable, CuesBeforeTracks, remuxed a copy to repair, and exited 0 (original file untouched).No version bump (part of unreleased 3.20).