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",