From c301c2584661529cb90f49145890dccf1787ce50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:17:58 -0700 Subject: [PATCH 01/14] Add custom plugin command for bespoke re-processing (IProcessPlugin) 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) --- HISTORY.md | 1 + PlexCleaner.slnx | 1 + PlexCleaner/CommandLineOptions.cs | 24 +++++ PlexCleaner/IProcessPlugin.cs | 33 ++++++ PlexCleaner/PlexCleaner.csproj | 6 +- PlexCleaner/PluginLoader.cs | 101 ++++++++++++++++++ PlexCleaner/Program.cs | 45 ++++++++ PlexCleanerTests/PlexCleanerTests.csproj | 2 + PlexCleanerTests/PluginLoaderTests.cs | 33 ++++++ .../MatroskaCleanupPlugin.cs | 47 ++++++++ .../MatroskaHeaderCleanup.csproj | 10 ++ README.md | 58 ++++++++++ cspell.json | 1 + 13 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 PlexCleaner/IProcessPlugin.cs create mode 100644 PlexCleaner/PluginLoader.cs create mode 100644 PlexCleanerTests/PluginLoaderTests.cs create mode 100644 Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs create mode 100644 Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj diff --git a/HISTORY.md b/HISTORY.md index 89d8fe53..72e7cd66 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Always log the end-of-run summary at information level, even with `--logwarning`, so the modified, error, and verify-failed counts are recorded for every processing run. - Handle the `SIGINT`, `SIGTERM`, and `SIGQUIT` termination signals (`docker stop`, `Ctrl+C`) so processing is interrupted gracefully and the summary and exit code are logged before exit. The custom `Ctrl+Q`/`Ctrl+Z` exit keys are removed in favor of the standard signals. - Normalize `Default` track flags instead of only warning about them: clear the flag on a lone track of a type, keep the preferred audio track as the single default when multiple are flagged, and clear all default flags on subtitle tracks. + - Added a `custom` command that loads a user-provided plugin assembly implementing `IProcessPlugin` and runs it over the media files, reusing the file iteration and processing API for bespoke re-processing or repair. Includes the `MatroskaHeaderCleanup` example plugin. Not available in AOT builds. - Version 3.19: - Reworked the CI/CD pipeline to a branch-scoped self-publishing model: a weekly scheduled run (and manual dispatch) publishes both `main` (stable, Docker `latest`) and `develop` (prerelease, Docker `develop`) - native executables, the multi-arch Docker image, and the GitHub release - while merges accumulate until the next run. No application changes. - Added `WORKFLOW.md` (the canonical CI/CD specification) and `repo-config/` (rulesets and repository settings as code). diff --git a/PlexCleaner.slnx b/PlexCleaner.slnx index abfd876a..223749ed 100644 --- a/PlexCleaner.slnx +++ b/PlexCleaner.slnx @@ -77,5 +77,6 @@ + diff --git a/PlexCleaner/CommandLineOptions.cs b/PlexCleaner/CommandLineOptions.cs index f6d65406..6ca4e52e 100644 --- a/PlexCleaner/CommandLineOptions.cs +++ b/PlexCleaner/CommandLineOptions.cs @@ -19,6 +19,7 @@ public class CommandLineOptions public string ResultsFile { get; set; } = string.Empty; public string SchemaFile { get; set; } = string.Empty; public string SettingsFile { get; set; } = string.Empty; + public string PluginAssembly { get; set; } = string.Empty; } public class CommandLineParser @@ -130,6 +131,13 @@ private class CommandHandler(Func action) : SynchronousCommand HelpName = "boolean", }; + private readonly Option _pluginAssemblyOption = new("--pluginassembly") + { + Description = "Path to a plugin assembly implementing IProcessPlugin", + HelpName = "filepath", + Required = true, + }; + private RootCommand CreateRootCommand() { RootCommand rootCommand = new( @@ -393,6 +401,21 @@ private RootCommand CreateRootCommand() Options = { _schemaFileOption }, } ); + rootCommand.Subcommands.Add( + new("custom") + { + Description = "Process media files using a custom plugin assembly", + Action = new CommandHandler(_ => Program.CustomCommand()), + Options = + { + _settingsFileOption, + _mediaFilesOption, + _pluginAssemblyOption, + _parallelOption, + _threadCountOption, + }, + } + ); return rootCommand; } @@ -414,6 +437,7 @@ public CommandLineOptions Bind() ResultsFile = Result.GetValue(_resultsFileOption) ?? string.Empty, SchemaFile = Result.GetValue(_schemaFileOption) ?? string.Empty, SettingsFile = Result.GetValue(_settingsFileOption) ?? string.Empty, + PluginAssembly = Result.GetValue(_pluginAssemblyOption) ?? string.Empty, }; return options; diff --git a/PlexCleaner/IProcessPlugin.cs b/PlexCleaner/IProcessPlugin.cs new file mode 100644 index 00000000..2b8e1f96 --- /dev/null +++ b/PlexCleaner/IProcessPlugin.cs @@ -0,0 +1,33 @@ +namespace PlexCleaner; + +public static class PluginApi +{ + // Bumped when the IProcessPlugin or IPluginHost contract changes in a breaking way + public const int Version = 1; +} + +public interface IPluginHost +{ + // Compare against the PluginApi.Version the plugin was built against + int PluginApiVersion { get; } + + // Informational host details for finer compatibility decisions + string ApplicationVersion { get; } + string OperatingSystem { get; } + string Runtime { get; } + + // Plugin log events flow to the host sinks and end-of-run summary + Serilog.ILogger Logger { get; } +} + +public interface IProcessPlugin +{ + // Used in logs and as the processing task name + string Name { get; } + + // Called once before processing, return false to abort when incompatible with the host + bool Initialize(IPluginHost host); + + // Called once per media file, reuse the public processing API, return false on failure + bool ProcessFile(string fileName); +} diff --git a/PlexCleaner/PlexCleaner.csproj b/PlexCleaner/PlexCleaner.csproj index fc6e390a..9fbfe703 100644 --- a/PlexCleaner/PlexCleaner.csproj +++ b/PlexCleaner/PlexCleaner.csproj @@ -16,7 +16,7 @@ Exe ptr727.PlexCleaner MIT - true + false https://github.com/ptr727/PlexCleaner README.md 1.1.1.0-prerelease @@ -31,6 +31,10 @@ true $(NoWarn);1591 + + + $(DefineConstants);PLUGINS + True diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs new file mode 100644 index 00000000..e96b2312 --- /dev/null +++ b/PlexCleaner/PluginLoader.cs @@ -0,0 +1,101 @@ +#if PLUGINS +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using Serilog; + +namespace PlexCleaner; + +// Loads a user plugin assembly, sharing the host's already-loaded assemblies so the IProcessPlugin +// type identity and the static Program.Config / Tools are the same instance the plugin binds to +internal sealed class PluginLoadContext(string pluginPath) + : AssemblyLoadContext(isCollectible: false) +{ + private readonly AssemblyDependencyResolver _resolver = new(pluginPath); + + protected override Assembly? Load(AssemblyName assemblyName) + { + // Defer to the default context for anything the host already provides (PlexCleaner, Serilog, + // framework), keeping shared types single-identity; load only the plugin's private dependencies + if (Default.Assemblies.Any(item => item.GetName().Name == assemblyName.Name)) + { + return null; + } + string? path = _resolver.ResolveAssemblyToPath(assemblyName); + return path != null ? LoadFromAssemblyPath(path) : null; + } +} + +internal sealed class PluginHost(ILogger logger) : IPluginHost +{ + public int PluginApiVersion => PluginApi.Version; + public string ApplicationVersion => AssemblyVersion.GetReleaseVersion(); + public string OperatingSystem => RuntimeInformation.OSDescription; + public string Runtime => AssemblyVersion.GetRuntimeVersion(); + public ILogger Logger => logger; +} + +public static class PluginLoader +{ + [RequiresUnreferencedCode( + "Loads a plugin assembly and discovers IProcessPlugin via reflection" + )] + [RequiresDynamicCode("Loads a plugin assembly at runtime")] + public static IProcessPlugin? Load(string assemblyPath) + { + string fullPath = Path.GetFullPath(assemblyPath); + if (!File.Exists(fullPath)) + { + Log.Error("Plugin assembly not found : {AssemblyPath}", fullPath); + return null; + } + + try + { + PluginLoadContext context = new(fullPath); + Assembly assembly = context.LoadFromAssemblyPath(fullPath); + + // Require exactly one concrete IProcessPlugin implementation + List pluginTypes = + [ + .. assembly + .GetTypes() + .Where(type => + typeof(IProcessPlugin).IsAssignableFrom(type) + && type is { IsInterface: false, IsAbstract: false } + ), + ]; + if (pluginTypes.Count != 1) + { + Log.Error( + "Plugin assembly must contain exactly one IProcessPlugin implementation, found {Count} : {AssemblyPath}", + pluginTypes.Count, + fullPath + ); + return null; + } + + if (Activator.CreateInstance(pluginTypes[0]) is not IProcessPlugin plugin) + { + Log.Error("Failed to create plugin instance : {Type}", pluginTypes[0].FullName); + return null; + } + + // The plugin validates host compatibility against PluginApi.Version + if (!plugin.Initialize(new PluginHost(Log.Logger))) + { + Log.Error("Plugin reported incompatible with the host : {Name}", plugin.Name); + return null; + } + + Log.Information("Loaded plugin : {Name} : {AssemblyPath}", plugin.Name, fullPath); + return plugin; + } + catch (Exception e) when (Log.Logger.LogAndHandle(e)) + { + return null; + } + } +} +#endif diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs index daf6615a..a0c27349 100644 --- a/PlexCleaner/Program.cs +++ b/PlexCleaner/Program.cs @@ -276,6 +276,51 @@ out List fileList return MakeExitCode(ExitCode.Success); } +#if PLUGINS + public static int CustomCommand() + { + // Create + if (!Create(true)) + { + return MakeExitCode(ExitCode.Error); + } + + // Load the plugin after config and tools are initialized + IProcessPlugin? plugin = PluginLoader.Load(Options.PluginAssembly); + if (plugin == null) + { + return MakeExitCode(ExitCode.Error); + } + + // Get file and directory list + if ( + !ProcessDriver.GetFiles( + Options.MediaFiles, + out List _, + out List fileList + ) + ) + { + return MakeExitCode(ExitCode.Error); + } + + // Process each file using the plugin + if (!ProcessDriver.ProcessFiles(fileList, plugin.Name, false, plugin.ProcessFile)) + { + return MakeExitCode(ExitCode.Error); + } + + // Done + return MakeExitCode(ExitCode.Success); + } +#else + public static int CustomCommand() + { + Log.Error("Custom plugin support requires a non-AOT build"); + return MakeExitCode(ExitCode.Error); + } +#endif + public static int MonitorCommand() { // Create diff --git a/PlexCleanerTests/PlexCleanerTests.csproj b/PlexCleanerTests/PlexCleanerTests.csproj index a1a3cfcc..0c986898 100644 --- a/PlexCleanerTests/PlexCleanerTests.csproj +++ b/PlexCleanerTests/PlexCleanerTests.csproj @@ -18,5 +18,7 @@ + + diff --git a/PlexCleanerTests/PluginLoaderTests.cs b/PlexCleanerTests/PluginLoaderTests.cs new file mode 100644 index 00000000..3d1140ea --- /dev/null +++ b/PlexCleanerTests/PluginLoaderTests.cs @@ -0,0 +1,33 @@ +using AwesomeAssertions; +using PlexCleaner; +using Xunit; + +namespace PlexCleanerTests; + +public class PluginLoaderTests +{ + private static string ExamplePluginPath => + Path.Combine(AppContext.BaseDirectory, "MatroskaHeaderCleanup.dll"); + + [Fact] + public void Load_ExamplePlugin_ReturnsInitializedPlugin() + { + IProcessPlugin? plugin = PluginLoader.Load(ExamplePluginPath); + + // A non-null typed return proves the loaded type satisfies the host's IProcessPlugin + // (single shared type identity) and that Initialize accepted the host + _ = plugin.Should().NotBeNull(); + _ = plugin.Name.Should().Be("MatroskaHeaderCleanup"); + _ = plugin.GetType().Assembly.GetName().Name.Should().Be("MatroskaHeaderCleanup"); + } + + [Fact] + public void Load_MissingAssembly_ReturnsNull() + { + IProcessPlugin? plugin = PluginLoader.Load( + Path.Combine(AppContext.BaseDirectory, "DoesNotExist.dll") + ); + + _ = plugin.Should().BeNull(); + } +} diff --git a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs new file mode 100644 index 00000000..a04ec042 --- /dev/null +++ b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs @@ -0,0 +1,47 @@ +using Serilog; + +namespace PlexCleaner.Plugins.MatroskaHeaderCleanup; + +// Example plugin: re-check and repair the Matroska seek-index structure on files that were already +// Verified, without a full re-verification. Reuses PlexCleaner.ProcessFile.RepairMatroskaStructure. +public sealed class MatroskaCleanupPlugin : IProcessPlugin +{ + private ILogger _logger = Log.Logger; + + public string Name => "MatroskaHeaderCleanup"; + + public bool Initialize(IPluginHost host) + { + _logger = host.Logger; + + // Refuse to run against an incompatible host contract + if (host.PluginApiVersion != PluginApi.Version) + { + _logger.Error( + "Incompatible plugin API version : host {HostVersion} != plugin {PluginVersion}", + host.PluginApiVersion, + PluginApi.Version + ); + return false; + } + + _logger.Information( + "{Name} initialized : {AppVersion} : {Os}", + Name, + host.ApplicationVersion, + host.OperatingSystem + ); + return true; + } + + public bool ProcessFile(string fileName) + { + ProcessFile processFile = new(fileName); + if (!processFile.GetMediaProps()) + { + return false; + } + bool modified = false; + return processFile.RepairMatroskaStructure(ref modified); + } +} diff --git a/Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj b/Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj new file mode 100644 index 00000000..a991c4a5 --- /dev/null +++ b/Plugins/MatroskaHeaderCleanup/MatroskaHeaderCleanup.csproj @@ -0,0 +1,10 @@ + + + PlexCleaner.Plugins.MatroskaHeaderCleanup + MatroskaHeaderCleanup + + + + + + diff --git a/README.md b/README.md index a5d23ff8..f74d8322 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,8 @@ dotnet publish ./PlexCleaner/PlexCleaner.csproj \ -property:PublishAot=true ``` +> **ℹ️ Note**: The [`custom` plugin command](#custom-plugins) loads assemblies at runtime and is not available in AOT builds; use a standard (JIT) build, which is what the published binaries and Docker images use. + ## Configuration ### Default Settings @@ -814,6 +816,62 @@ Additional commands for specific tasks, organized by category: - `getmediainfo`: - Print media file information and track details. +## Custom Plugins + +The `custom` command runs a user-provided plugin assembly over the media files, reusing PlexCleaner's file iteration and processing. This is useful for bespoke, targeted re-processing that the built-in commands do not cover, for example re-running a newly added or fixed verification check on a library where files are already marked as verified, without paying for a full re-verification. + +A plugin is a .NET class library that references `PlexCleaner.dll` and implements the `IProcessPlugin` interface: + +```csharp +public interface IProcessPlugin +{ + string Name { get; } + bool Initialize(IPluginHost host); // return false when incompatible, see host.PluginApiVersion + bool ProcessFile(string fileName); // called once per file, return false on failure +} +``` + +`Initialize` receives an `IPluginHost` with the deterministic `PluginApiVersion`, the application and OS versions, and a `Serilog.ILogger` to log through. `ProcessFile` reuses the public processing API, for example `new ProcessFile(fileName)` then `RepairMatroskaStructure(...)`. See the [`MatroskaHeaderCleanup`](./Plugins/MatroskaHeaderCleanup/) example, which re-checks and repairs the Matroska seek-index structure on already-verified files. + +Notes: + +- A plugin runs arbitrary code with the same privileges as PlexCleaner. Only run plugins you trust. +- Plugin loading uses runtime assembly loading and is not available in [AOT builds](#aot); use a standard build (the published binaries and Docker images are standard builds). +- A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases; `Initialize` should reject an incompatible `host.PluginApiVersion`. +- Plugins run in parallel unless `--parallel false` is set, so plugin code must be thread-safe. + +CLI example: + +```shell +dotnet PlexCleaner.dll custom \ + --settingsfile /config/PlexCleaner.json \ + --pluginassembly /config/plugins/MatroskaHeaderCleanup.dll \ + --mediafiles /media/Movies \ + --mediafiles /media/Series +``` + +Docker Compose example (one-shot): + +```yaml +services: + + plexcleaner-custom: + image: docker.io/ptr727/plexcleaner:latest + container_name: PlexCleaner-Custom + user: 1000:100 # Change to match your nonroot:users + command: + - /PlexCleaner/PlexCleaner + - custom + - --settingsfile=/media/PlexCleaner/PlexCleaner.json + - --pluginassembly=/media/PlexCleaner/plugins/MatroskaHeaderCleanup.dll + - --mediafiles=/media/Series + - --mediafiles=/media/Movies + environment: + - TZ=America/Los_Angeles + volumes: + - /data/media:/media # Media, config, and the plugin DLL are mounted here +``` + ## Testing PlexCleaner includes multiple testing approaches for different scenarios: diff --git a/cspell.json b/cspell.json index e6d4731d..812c0a76 100644 --- a/cspell.json +++ b/cspell.json @@ -156,6 +156,7 @@ "Plex", "plexcleaner", "PLINQ", + "pluginassembly", "preprocess", "printmediainfo", "printsidecar", From 4ffdfa365151dbac107aca4673384865f15aa373 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:18:40 -0700 Subject: [PATCH 02/14] Add Custom Plugins to README table of contents Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f74d8322..02bb4ebd 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ See [Installation](#installation) for detailed setup instructions and other plat - [Process Command](#process-command) - [Monitor Command](#monitor-command) - [Other Commands](#other-commands) +- [Custom Plugins](#custom-plugins) - [Testing](#testing) - [Unit Testing](#unit-testing) - [Docker Testing](#docker-testing) From e45144a4f6e568a0b469e9ee5915ad0d45046314 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:28:55 -0700 Subject: [PATCH 03/14] Fix Docker build context and address plugin review feedback - 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) --- Docker/Dockerfile | 1 + PlexCleaner/PluginLoader.cs | 9 +++++++++ PlexCleaner/Program.cs | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Docker/Dockerfile b/Docker/Dockerfile index ba10c040..c37a6ed6 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -60,6 +60,7 @@ RUN apt install -y --install-suggests dotnet-sdk-10.0 COPY ./Samples/. ./Samples/. COPY ./PlexCleanerTests/. ./PlexCleanerTests/. COPY ./PlexCleaner/. ./PlexCleaner/. +COPY ./Plugins/. ./Plugins/. COPY ./.editorconfig ./ COPY ./Directory.Build.props ./ COPY ./Directory.Packages.props ./ diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs index e96b2312..7e7238d6 100644 --- a/PlexCleaner/PluginLoader.cs +++ b/PlexCleaner/PluginLoader.cs @@ -25,6 +25,13 @@ internal sealed class PluginLoadContext(string pluginPath) string? path = _resolver.ResolveAssemblyToPath(assemblyName); return path != null ? LoadFromAssemblyPath(path) : null; } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + // Probe the plugin directory for native dependencies the plugin bundles + string? path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return path != null ? LoadUnmanagedDllFromPath(path) : nint.Zero; + } } internal sealed class PluginHost(ILogger logger) : IPluginHost @@ -94,6 +101,8 @@ .. assembly } catch (Exception e) when (Log.Logger.LogAndHandle(e)) { + // Include the assembly path since LogAndHandle only reports the caller member + Log.Error("Failed to load plugin : {AssemblyPath}", fullPath); return null; } } diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs index a0c27349..e745f40b 100644 --- a/PlexCleaner/Program.cs +++ b/PlexCleaner/Program.cs @@ -304,8 +304,21 @@ out List fileList return MakeExitCode(ExitCode.Error); } - // Process each file using the plugin - if (!ProcessDriver.ProcessFiles(fileList, plugin.Name, false, plugin.ProcessFile)) + // Process each file using the plugin, isolating plugin exceptions to the current file so a + // faulty plugin fails that file instead of aborting the entire run + bool ProcessFile(string fileName) + { + try + { + return plugin.ProcessFile(fileName); + } + catch (Exception e) when (Log.Logger.LogAndHandle(e)) + { + return false; + } + } + + if (!ProcessDriver.ProcessFiles(fileList, plugin.Name, false, ProcessFile)) { return MakeExitCode(ExitCode.Error); } From e24f25b9a3c8b0ceb3b740fb6c6f6365f387e3e2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:38:32 -0700 Subject: [PATCH 04/14] Add application-version pinning example and second-round review fixes - 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) --- PlexCleaner/PluginLoader.cs | 7 +++++ PlexCleanerTests/PluginLoaderTests.cs | 5 ++-- .../MatroskaCleanupPlugin.cs | 30 ++++++++++++++++++- README.md | 4 ++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs index 7e7238d6..4cf977c4 100644 --- a/PlexCleaner/PluginLoader.cs +++ b/PlexCleaner/PluginLoader.cs @@ -51,6 +51,13 @@ public static class PluginLoader [RequiresDynamicCode("Loads a plugin assembly at runtime")] public static IProcessPlugin? Load(string assemblyPath) { + // An empty path would resolve to the current directory and report a misleading error + if (string.IsNullOrWhiteSpace(assemblyPath)) + { + Log.Error("Plugin assembly path is empty"); + return null; + } + string fullPath = Path.GetFullPath(assemblyPath); if (!File.Exists(fullPath)) { diff --git a/PlexCleanerTests/PluginLoaderTests.cs b/PlexCleanerTests/PluginLoaderTests.cs index 3d1140ea..5a5d17ef 100644 --- a/PlexCleanerTests/PluginLoaderTests.cs +++ b/PlexCleanerTests/PluginLoaderTests.cs @@ -17,8 +17,9 @@ public void Load_ExamplePlugin_ReturnsInitializedPlugin() // A non-null typed return proves the loaded type satisfies the host's IProcessPlugin // (single shared type identity) and that Initialize accepted the host _ = plugin.Should().NotBeNull(); - _ = plugin.Name.Should().Be("MatroskaHeaderCleanup"); - _ = plugin.GetType().Assembly.GetName().Name.Should().Be("MatroskaHeaderCleanup"); + IProcessPlugin loaded = plugin; + _ = loaded.Name.Should().Be("MatroskaHeaderCleanup"); + _ = loaded.GetType().Assembly.GetName().Name.Should().Be("MatroskaHeaderCleanup"); } [Fact] diff --git a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs index a04ec042..6fb55c75 100644 --- a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs +++ b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs @@ -8,13 +8,20 @@ public sealed class MatroskaCleanupPlugin : IProcessPlugin { private ILogger _logger = Log.Logger; + // The PlexCleaner version whose public API this plugin was built and tested against. PluginApi.Version + // only guards the plugin contract; PlexCleaner's public internals (the ProcessFile methods this plugin + // calls) can change in any release, so a plugin that calls them should also pin against the tested + // application version. This is hard-coded as an example; a real plugin would inject the referenced + // PlexCleaner version at compile time. + private const string TestedApplicationVersion = "3.20"; + public string Name => "MatroskaHeaderCleanup"; public bool Initialize(IPluginHost host) { _logger = host.Logger; - // Refuse to run against an incompatible host contract + // Refuse to run against an incompatible plugin contract if (host.PluginApiVersion != PluginApi.Version) { _logger.Error( @@ -25,6 +32,20 @@ public bool Initialize(IPluginHost host) return false; } + // Warn when the running PlexCleaner version differs from the tested version, since the public API + // this plugin calls could have changed. A stricter plugin could return false here to hard-pin. + if ( + !MajorMinor(host.ApplicationVersion) + .Equals(TestedApplicationVersion, StringComparison.Ordinal) + ) + { + _logger.Warning( + "Plugin tested against PlexCleaner {TestedVersion} but host is {HostVersion}, internal APIs may differ", + TestedApplicationVersion, + host.ApplicationVersion + ); + } + _logger.Information( "{Name} initialized : {AppVersion} : {Os}", Name, @@ -34,6 +55,13 @@ public bool Initialize(IPluginHost host) return true; } + // Reduce a version string like "3.20.1.0" to "3.20" to compare against the tested major.minor + private static string MajorMinor(string version) + { + string[] parts = version.Split('.'); + return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}" : version; + } + public bool ProcessFile(string fileName) { ProcessFile processFile = new(fileName); diff --git a/README.md b/README.md index 02bb4ebd..e4c01240 100644 --- a/README.md +++ b/README.md @@ -837,8 +837,10 @@ public interface IProcessPlugin Notes: - A plugin runs arbitrary code with the same privileges as PlexCleaner. Only run plugins you trust. +- The plugin type is created via reflection, so it must be a concrete class with a public parameterless constructor, and the assembly must contain exactly one `IProcessPlugin` implementation. +- Many processing helpers are gated by the settings file, for example verification only runs when `ProcessOptions.Verify` is enabled, so enable the relevant settings for the operation the plugin performs. - Plugin loading uses runtime assembly loading and is not available in [AOT builds](#aot); use a standard build (the published binaries and Docker images are standard builds). -- A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases; `Initialize` should reject an incompatible `host.PluginApiVersion`. +- A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases. `PluginApiVersion` only guards the plugin contract, but the public API a plugin calls can change in any release, so `Initialize` should also check `host.ApplicationVersion` against the PlexCleaner version the plugin was tested against (see the example). - Plugins run in parallel unless `--parallel false` is set, so plugin code must be thread-safe. CLI example: From 90800be88ccae3dc697c2e0999e31f530793bd16 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:41:57 -0700 Subject: [PATCH 05/14] Add parameterless to cspell dictionary Co-Authored-By: Claude Opus 4.8 (1M context) --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 812c0a76..4bf01f5e 100644 --- a/cspell.json +++ b/cspell.json @@ -150,6 +150,7 @@ "numfmt", "NVENC", "Oughta", + "parameterless", "partitioner", "piete", "Pieter", From e26a17c9a2c2cbad8f39ddf8712121c21e9760c8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:47:52 -0700 Subject: [PATCH 06/14] Fix docs: parallel default wording and AOT-default consistency - 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) --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d84d988..f708f7d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,7 +185,7 @@ The conventions for everything under [`.github/workflows/`](./.github/workflows/ - **PlexCleaner** (`PlexCleaner/PlexCleaner.csproj`) - The CLI application - orchestrates FFmpeg, HandBrake, MkvToolNix, MediaInfo, and 7-Zip to optimize media for Direct Play. - - Target framework: .NET 10.0, AOT compiled (`true`). Internals are exposed to the test project via `InternalsVisibleTo`. + - Target framework: .NET 10.0. AOT is opt-in (`false` by default, matching the shipped builds); the plugin loader compiles only in non-AOT builds. Internals are exposed to the test project via `InternalsVisibleTo`. - **PlexCleanerTests** (`PlexCleanerTests/PlexCleanerTests.csproj`) - xUnit v3 test suite. Assertions via AwesomeAssertions. - **`Docker/`** - multi-arch Linux container build (`ubuntu:rolling`, `linux/amd64` + `linux/arm64`); runs as a `nonroot` user, mounts media under `/media`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4280a9f1..ef3edc61 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -205,7 +205,7 @@ For formatter, EditorConfig, pre-commit hooks, line endings, and charset details ### Code Style - Target: .NET 10.0 (`net10.0`) -- AOT compilation enabled: `true` in executable projects +- AOT compilation is opt-in: `false` by default (matching the shipped builds, which load plugins via reflection); publish with `-p:PublishAot=true` to build an AOT binary, which excludes the plugin loader - Use C# modern features (records, pattern matching, collection expressions, implicit class extensions) - Prefer `Debug.Assert()` for internal invariants - Logging: Serilog with thread IDs (`Log.Information/Warning/Error`) diff --git a/README.md b/README.md index e4c01240..077e6239 100644 --- a/README.md +++ b/README.md @@ -841,7 +841,7 @@ Notes: - Many processing helpers are gated by the settings file, for example verification only runs when `ProcessOptions.Verify` is enabled, so enable the relevant settings for the operation the plugin performs. - Plugin loading uses runtime assembly loading and is not available in [AOT builds](#aot); use a standard build (the published binaries and Docker images are standard builds). - A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases. `PluginApiVersion` only guards the plugin contract, but the public API a plugin calls can change in any release, so `Initialize` should also check `host.ApplicationVersion` against the PlexCleaner version the plugin was tested against (see the example). -- Plugins run in parallel unless `--parallel false` is set, so plugin code must be thread-safe. +- File processing is serial by default; if you enable `--parallel`, plugin code must be thread-safe. CLI example: From d3825e3db7c912174d2018deb75254e31c8c35e7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 09:58:02 -0700 Subject: [PATCH 07/14] Add local markdown/spell lint parity to hook and VS Code tasks 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) --- .husky/pre-commit | 25 +++++++++++++ .vscode/tasks.json | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 818853f5..ab3c3d9e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,29 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +# C# formatting and style (CSharpier + dotnet format), see task-runner.json dotnet husky run + +# Markdown and spelling lint parity with CI, only when Markdown files are staged. The editor +# extensions only warn, so this hook is what actually gates a commit on lint errors. markdownlint and +# cspell are Node tools that do not run reliably across Windows, WSL, and Linux, so run them through +# Docker (the same way the CLIs are run elsewhere). To keep this hook reliable and never a false +# blocker, only use images that are already present locally (never pull) and skip when Docker or the +# image is unavailable; CI enforces these regardless. Warm the images with the VS Code "Lint" tasks. +# MSYS_NO_PATHCONV keeps the bind mount correct under Git Bash on Windows; it is a no-op on Linux/WSL. +docs_lint() { + image="$1" + shift + if docker image inspect "$image" >/dev/null 2>&1; then + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd):/workdir" -w /workdir "$image" "$@" || exit 1 + else + echo "husky: $image not present, skipping lint (run the VS Code Lint tasks or rely on CI)" + fi +} + +if git diff --cached --name-only --diff-filter=ACMR | grep -q '\.md$'; then + if command -v docker >/dev/null 2>&1; then + docs_lint davidanson/markdownlint-cli2:latest "**/*.md" + docs_lint ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md + fi +fi diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6614c4cd..daf0a30e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -209,6 +209,95 @@ "showReuseMessage": false, "clear": false } + }, + { + "label": "Lint: CSharpier", + "type": "process", + "command": "dotnet", + "args": [ + "csharpier", + "check", + "." + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Style", + "type": "process", + "command": "dotnet", + "args": [ + "format", + "style", + "--verify-no-changes", + "--severity=info", + "--verbosity=detailed" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Markdown", + "type": "shell", + "command": "docker", + "args": [ + "run", + "--rm", + "--volume=${workspaceFolder}:/workdir", + "--workdir=/workdir", + "davidanson/markdownlint-cli2:latest", + "**/*.md" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Spelling", + "type": "shell", + "command": "docker", + "args": [ + "run", + "--rm", + "--volume=${workspaceFolder}:/workdir", + "--workdir=/workdir", + "ghcr.io/streetsidesoftware/cspell:latest", + "--no-progress", + "README.md", + "HISTORY.md" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: All (CI parity)", + "dependsOrder": "sequence", + "dependsOn": [ + "Lint: CSharpier", + "Lint: Style", + "Lint: Markdown", + "Lint: Spelling" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } } ] } From 19cc1b3fa61478eaf08cd6fcdc6dd7fe5a18e9f0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:03:01 -0700 Subject: [PATCH 08/14] Document custom command in README summary and command list 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) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 077e6239..f44ddedd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Added per-file stateful log level filtering to enable information level output after a warning or error event is detected, overriding the `--logwarning` warning only filter. - Always log the end-of-run summary, and handle stop signals (`docker stop`, `Ctrl+C`) so processing stops gracefully and the summary and exit code are logged before exit. - Normalize multiple or redundant `Default` track flags instead of only warning about them. +- Added a `custom` command that runs a user-provided plugin assembly over the media files for bespoke re-processing or repair, see [Custom Plugins](#custom-plugins). See [Release History](./HISTORY.md) for complete release notes and older versions. @@ -574,6 +575,7 @@ Commands: testmediainfo Test parsing media tool information getversioninfo Print application and media tool version information createschema Create JSON settings schema file + custom Process media files using a custom plugin assembly ``` ### Global Options From 88726b66fdd612b6a8984ee325958a11dda58aa4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:08:56 -0700 Subject: [PATCH 09/14] Resolve plugin path inside try so a malformed path fails cleanly 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) --- PlexCleaner/PluginLoader.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs index 4cf977c4..3907e02c 100644 --- a/PlexCleaner/PluginLoader.cs +++ b/PlexCleaner/PluginLoader.cs @@ -58,15 +58,17 @@ public static class PluginLoader return null; } - string fullPath = Path.GetFullPath(assemblyPath); - if (!File.Exists(fullPath)) - { - Log.Error("Plugin assembly not found : {AssemblyPath}", fullPath); - return null; - } - + // Resolve inside the try so a malformed path (GetFullPath throws) fails cleanly with a log + string fullPath = assemblyPath; try { + fullPath = Path.GetFullPath(assemblyPath); + if (!File.Exists(fullPath)) + { + Log.Error("Plugin assembly not found : {AssemblyPath}", fullPath); + return null; + } + PluginLoadContext context = new(fullPath); Assembly assembly = context.LoadFromAssemblyPath(fullPath); From f3d5a6b638f279173fee256f20223d8f8bb5147b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:11:24 -0700 Subject: [PATCH 10/14] Filter non-Matroska files in the example plugin 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) --- Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs index 6fb55c75..0ea3c9c7 100644 --- a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs +++ b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs @@ -64,6 +64,13 @@ private static string MajorMinor(string version) public bool ProcessFile(string fileName) { + // The driver passes every file, so skip anything that is not a Matroska file rather than + // reporting it as an error. Plugins that target other container types filter accordingly. + if (!SidecarFile.IsMkvFile(fileName)) + { + return true; + } + ProcessFile processFile = new(fileName); if (!processFile.GetMediaProps()) { diff --git a/README.md b/README.md index f44ddedd..12c34274 100644 --- a/README.md +++ b/README.md @@ -844,6 +844,7 @@ Notes: - Plugin loading uses runtime assembly loading and is not available in [AOT builds](#aot); use a standard build (the published binaries and Docker images are standard builds). - A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases. `PluginApiVersion` only guards the plugin contract, but the public API a plugin calls can change in any release, so `Initialize` should also check `host.ApplicationVersion` against the PlexCleaner version the plugin was tested against (see the example). - File processing is serial by default; if you enable `--parallel`, plugin code must be thread-safe. +- `ProcessFile` is called for every file under `--mediafiles`, not only Matroska files, so a plugin should filter by type (e.g. `SidecarFile.IsMkvFile(fileName)`) and return `true` to skip files it does not handle. CLI example: From 8f192eb1a89fd9787e53f0ee0b060d718370362e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:21:13 -0700 Subject: [PATCH 11/14] Document plugin sidecar consistency requirement 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) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 12c34274..befc5788 100644 --- a/README.md +++ b/README.md @@ -845,6 +845,7 @@ Notes: - A plugin binds to a specific `PlexCleaner.dll` and may need rebuilding across releases. `PluginApiVersion` only guards the plugin contract, but the public API a plugin calls can change in any release, so `Initialize` should also check `host.ApplicationVersion` against the PlexCleaner version the plugin was tested against (see the example). - File processing is serial by default; if you enable `--parallel`, plugin code must be thread-safe. - `ProcessFile` is called for every file under `--mediafiles`, not only Matroska files, so a plugin should filter by type (e.g. `SidecarFile.IsMkvFile(fileName)`) and return `true` to skip files it does not handle. +- A plugin that modifies a file must keep the sidecar in sync or the next normal run will invalidate the saved state. Follow the example flow: confirm the file is Matroska, call `GetMediaProps()` to initialize the sidecar, then modify through a `ProcessFile` helper such as `RepairMatroskaStructure(...)` that refreshes the sidecar to match the changed file. CLI example: From 03d26a759ceed672358c781fe1ba4f215d19cd87 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:25:35 -0700 Subject: [PATCH 12/14] Comment sidecar state-flag behavior in the example plugin 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) --- Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs index 0ea3c9c7..cd3c037c 100644 --- a/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs +++ b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs @@ -76,6 +76,10 @@ public bool ProcessFile(string fileName) { return false; } + + // RepairMatroskaStructure refreshes the sidecar and adds the ReMuxed state while preserving + // existing state (flags are combined, not replaced), so an already Verified file keeps its + // Verified state and is not re-verified; Verified is only cleared if the remux fails to repair. bool modified = false; return processFile.RepairMatroskaStructure(ref modified); } From 33afc72be194814b0962470b81cc43c8990b6778 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:29:50 -0700 Subject: [PATCH 13/14] Require --pluginassembly only in non-AOT builds 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) --- PlexCleaner/CommandLineOptions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PlexCleaner/CommandLineOptions.cs b/PlexCleaner/CommandLineOptions.cs index 6ca4e52e..c55ad54a 100644 --- a/PlexCleaner/CommandLineOptions.cs +++ b/PlexCleaner/CommandLineOptions.cs @@ -135,7 +135,10 @@ private class CommandHandler(Func action) : SynchronousCommand { Description = "Path to a plugin assembly implementing IProcessPlugin", HelpName = "filepath", + // Not required in AOT builds so custom can reach CustomCommand and emit the non-AOT error +#if PLUGINS Required = true, +#endif }; private RootCommand CreateRootCommand() From 86957c6bb0347fb322562c951009cb588573ef0f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 1 Jul 2026 10:46:53 -0700 Subject: [PATCH 14/14] Match full assembly identity when sharing host assemblies 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) --- PlexCleaner/PluginLoader.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/PlexCleaner/PluginLoader.cs b/PlexCleaner/PluginLoader.cs index 3907e02c..6cc3f66f 100644 --- a/PlexCleaner/PluginLoader.cs +++ b/PlexCleaner/PluginLoader.cs @@ -16,9 +16,19 @@ internal sealed class PluginLoadContext(string pluginPath) protected override Assembly? Load(AssemblyName assemblyName) { - // Defer to the default context for anything the host already provides (PlexCleaner, Serilog, - // framework), keeping shared types single-identity; load only the plugin's private dependencies - if (Default.Assemblies.Any(item => item.GetName().Name == assemblyName.Name)) + // Defer to the default context for host-provided assemblies (PlexCleaner, Serilog, framework) + // so shared types keep a single identity. Match the full identity, not just the simple name, so + // a plugin's private dependency that shares a simple name with a host assembly but differs in + // version or strong name still loads its own compatible copy in isolation. + if ( + Default.Assemblies.Any(item => + string.Equals( + item.GetName().FullName, + assemblyName.FullName, + StringComparison.Ordinal + ) + ) + ) { return null; }