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
+ }
}
]
}
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/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/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..c55ad54a 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,16 @@ private class CommandHandler(Func action) : SynchronousCommand
HelpName = "boolean",
};
+ private readonly Option _pluginAssemblyOption = new("--pluginassembly")
+ {
+ 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()
{
RootCommand rootCommand = new(
@@ -393,6 +404,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 +440,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..6cc3f66f
--- /dev/null
+++ b/PlexCleaner/PluginLoader.cs
@@ -0,0 +1,129 @@
+#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 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;
+ }
+ 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
+{
+ 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)
+ {
+ // 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;
+ }
+
+ // 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);
+
+ // 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))
+ {
+ // Include the assembly path since LogAndHandle only reports the caller member
+ Log.Error("Failed to load plugin : {AssemblyPath}", fullPath);
+ return null;
+ }
+ }
+}
+#endif
diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs
index daf6615a..e745f40b 100644
--- a/PlexCleaner/Program.cs
+++ b/PlexCleaner/Program.cs
@@ -276,6 +276,64 @@ 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, 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);
+ }
+
+ // 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..5a5d17ef
--- /dev/null
+++ b/PlexCleanerTests/PluginLoaderTests.cs
@@ -0,0 +1,34 @@
+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();
+ IProcessPlugin loaded = plugin;
+ _ = loaded.Name.Should().Be("MatroskaHeaderCleanup");
+ _ = loaded.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..cd3c037c
--- /dev/null
+++ b/Plugins/MatroskaHeaderCleanup/MatroskaCleanupPlugin.cs
@@ -0,0 +1,86 @@
+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;
+
+ // 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 plugin contract
+ if (host.PluginApiVersion != PluginApi.Version)
+ {
+ _logger.Error(
+ "Incompatible plugin API version : host {HostVersion} != plugin {PluginVersion}",
+ host.PluginApiVersion,
+ PluginApi.Version
+ );
+ 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,
+ host.ApplicationVersion,
+ host.OperatingSystem
+ );
+ 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)
+ {
+ // 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())
+ {
+ 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);
+ }
+}
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..befc5788 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.
@@ -86,6 +87,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)
@@ -370,6 +372,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
@@ -571,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
@@ -814,6 +819,66 @@ 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.
+- 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. `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:
+
+```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..4bf01f5e 100644
--- a/cspell.json
+++ b/cspell.json
@@ -150,12 +150,14 @@
"numfmt",
"NVENC",
"Oughta",
+ "parameterless",
"partitioner",
"piete",
"Pieter",
"Plex",
"plexcleaner",
"PLINQ",
+ "pluginassembly",
"preprocess",
"printmediainfo",
"printsidecar",