feat: add plugin system for extensibility#1
Conversation
- Add IBookyPlugin interface with pre/post conversion and format plugins - Add PluginService to load plugins from Plugins folder - Integrate plugin hooks into conversion pipeline - Add example plugin templates and documentation
There was a problem hiding this comment.
Pull request overview
This PR adds a comprehensive plugin system to enable extending Booky's ebook conversion functionality without modifying core code. The system supports three plugin types: pre-conversion processors (e.g., DRM removal), post-conversion processors (e.g., metadata enhancement), and format converters (e.g., AZW3, KFX support).
Changes:
- Introduced plugin interfaces (IBookyPlugin, IPreConversionPlugin, IPostConversionPlugin, IFormatPlugin) with PluginResult class for standardized plugin responses
- Implemented PluginService for loading, managing, and executing plugins from a Plugins directory
- Integrated plugin hooks into ConversionService workflow (pre-conversion, format plugin conversion, post-conversion)
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| Plugins/IBookyPlugin.cs | Defines plugin interfaces and result types for the three plugin categories |
| Services/PluginService.cs | Implements plugin discovery, loading, lifecycle management, and execution orchestration |
| Services/ConversionService.cs | Integrates plugin execution into the conversion pipeline at appropriate stages |
| MainWindow.xaml.cs | Instantiates PluginService, enables plugin-based file type detection and descriptions |
| Plugins/README.md | Provides comprehensive documentation on plugin types, creation, and usage guidelines |
| Plugins/Examples/ExamplePreConversionPlugin.cs | Template demonstrating pre-conversion plugin implementation |
| Plugins/Examples/ExampleFormatPlugin.cs | Template demonstrating format converter plugin implementation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _pluginService = new PluginService(); | ||
| _pluginService.LoadPlugins(); |
There was a problem hiding this comment.
The plugin service is never unloaded when the application closes. This means plugin Shutdown methods are never called, potentially leaving resources unreleased. Consider adding a handler for window closing event in MainWindow that calls _pluginService.UnloadPlugins() to ensure proper cleanup.
| public async Task ConvertAsync(ConversionOptions options, PluginService? pluginService = null) | ||
| { | ||
| var ext = Path.GetExtension(options.InputPath).ToLowerInvariant(); | ||
| var inputPath = options.InputPath; | ||
| var ext = Path.GetExtension(inputPath).ToLowerInvariant(); | ||
|
|
||
| // Run pre-conversion plugins (e.g., DRM removal) | ||
| if (pluginService != null) | ||
| { | ||
| inputPath = await pluginService.RunPreConversionPluginsAsync(inputPath); | ||
| } | ||
|
|
||
| // Try format plugins first | ||
| if (pluginService != null) | ||
| { | ||
| var pluginResult = await pluginService.ConvertWithPluginAsync(inputPath, options.OutputPath, options.Title, options.Author); | ||
| if (pluginResult != null) | ||
| { | ||
| if (!pluginResult.Success) | ||
| throw new Exception(pluginResult.ErrorMessage ?? "Plugin conversion failed"); | ||
|
|
||
| // Run post-conversion plugins | ||
| await pluginService.RunPostConversionPluginsAsync(options.OutputPath); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Built-in conversion | ||
| if (ext != ".mobi") | ||
| throw new NotSupportedException($"Unsupported format: {ext}. Only MOBI files are supported."); | ||
|
|
||
| await ConvertMobiToEpubAsync(options); | ||
| var modifiedOptions = new ConversionOptions | ||
| { | ||
| InputPath = inputPath, | ||
| OutputPath = options.OutputPath, | ||
| Title = options.Title, | ||
| Author = options.Author | ||
| }; | ||
|
|
||
| await ConvertMobiToEpubAsync(modifiedOptions); | ||
|
|
||
| // Run post-conversion plugins | ||
| if (pluginService != null) | ||
| { | ||
| await pluginService.RunPostConversionPluginsAsync(options.OutputPath); | ||
| } |
There was a problem hiding this comment.
When inputPath is modified by pre-conversion plugins and differs from options.InputPath, the temporary file is never cleaned up after the conversion completes. The ConversionService should track if inputPath was modified and ensure cleanup of the temporary file in a finally block or after processing completes.
| foreach (var type in pluginTypes) | ||
| { | ||
| try | ||
| { | ||
| if (Activator.CreateInstance(type) is IBookyPlugin plugin) | ||
| { | ||
| plugin.Initialize(); | ||
| _plugins.Add(plugin); | ||
| System.Diagnostics.Debug.WriteLine($"Loaded plugin: {plugin.Name} v{plugin.Version}"); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| System.Diagnostics.Debug.WriteLine($"Failed to initialize plugin {type.Name}: {ex.Message}"); | ||
| } |
There was a problem hiding this comment.
If a plugin's Initialize method throws an exception during plugin loading, the plugin instance may be created but not added to the _plugins list, yet its resources are not cleaned up. Consider calling Shutdown on the plugin instance if it was successfully created but Initialize failed, or wrap the entire creation and initialization in a try-catch that disposes properly.
| var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(extension.ToLowerInvariant())); | ||
| return formatPlugin?.GetFileTypeDescription(extension); |
There was a problem hiding this comment.
The extension comparison in GetPluginFileTypeDescription passes the extension parameter without normalizing it to lowercase before the comparison, but the Contains check uses extension.ToLowerInvariant(). This is inconsistent - the extension should be normalized once at the start of the method. Additionally, the normalized extension should be passed to GetFileTypeDescription for consistency.
| var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(extension.ToLowerInvariant())); | |
| return formatPlugin?.GetFileTypeDescription(extension); | |
| var normalizedExtension = extension.ToLowerInvariant(); | |
| var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(normalizedExtension)); | |
| return formatPlugin?.GetFileTypeDescription(normalizedExtension); |
| public async Task<PluginResult> ProcessAsync(string inputPath, string outputPath) | ||
| { | ||
| // This is where you would implement your processing logic | ||
| // | ||
| // For example: | ||
| // 1. Read the input file | ||
| // 2. Process it (decrypt, normalize, etc.) | ||
| // 3. Write the result to outputPath | ||
| // 4. Return PluginResult.Ok(outputPath) on success | ||
| // | ||
| // If you can't process this file, return PluginResult.Skip() | ||
| // If there's an error, return PluginResult.Fail("error message") | ||
|
|
||
| await Task.CompletedTask; // Placeholder for async operations | ||
|
|
||
| // Default: skip processing (pass through unchanged) | ||
| return PluginResult.Skip(); |
There was a problem hiding this comment.
The example shows returning PluginResult.Skip() as the default behavior, but this could be confusing for plugin developers. When a plugin returns Skip with no OutputPath, the pre-conversion logic in PluginService doesn't modify currentPath, effectively passing through the original file. However, the tempOutput file that was pre-created is never deleted, causing a resource leak. Consider updating the example to show proper cleanup or changing the PluginService to not create tempOutput until the plugin confirms it will process the file.
| ## Plugin Guidelines | ||
|
|
||
| - Plugins should fail gracefully and not crash Booky | ||
| - Return `PluginResult.Skip()` if the plugin can't process a file |
There was a problem hiding this comment.
The documentation states that plugins return PluginResult.Skip() if they can't process a file, but the actual behavior in RunPreConversionPluginsAsync treats Skip() the same as a failure - the plugin just doesn't modify the file. The documentation should clarify that Skip() means "plugin chose not to process this file, continue with the original file" rather than implying it's an error condition.
| - Return `PluginResult.Skip()` if the plugin can't process a file | |
| - Return `PluginResult.Skip()` when the plugin chooses not to process a file (Booky will continue using the original, unmodified file) |
| public void LoadPlugins() | ||
| { | ||
| if (!Directory.Exists(_pluginsPath)) | ||
| { | ||
| Directory.CreateDirectory(_pluginsPath); | ||
| return; | ||
| } | ||
|
|
||
| var dllFiles = Directory.GetFiles(_pluginsPath, "*.dll"); | ||
|
|
||
| foreach (var dllPath in dllFiles) | ||
| { | ||
| try | ||
| { | ||
| LoadPluginFromDll(dllPath); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| System.Diagnostics.Debug.WriteLine($"Failed to load plugin {dllPath}: {ex.Message}"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Loading all DLL files from the Plugins directory without validation could be a security risk. Malicious DLLs could be placed in this directory and executed with the application's privileges. Consider implementing plugin signature verification, a whitelist of trusted plugins, or at minimum, logging loaded plugin information to help users identify unexpected plugins.
| catch (Exception ex) | ||
| { | ||
| System.Diagnostics.Debug.WriteLine($"Plugin {plugin.Name} failed: {ex.Message}"); | ||
| } | ||
| } | ||
|
|
||
| return currentPath; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Run post-conversion plugins on an EPUB file | ||
| /// </summary> | ||
| public async Task RunPostConversionPluginsAsync(string epubPath) | ||
| { | ||
| foreach (var plugin in PostConversionPlugins) | ||
| { | ||
| try | ||
| { | ||
| await plugin.ProcessAsync(epubPath); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| System.Diagnostics.Debug.WriteLine($"Plugin {plugin.Name} failed: {ex.Message}"); | ||
| } |
There was a problem hiding this comment.
Using Debug.WriteLine means plugin processing errors are invisible in production. Users won't know why their plugins aren't working. Consider using a proper logging mechanism or at minimum providing user-visible feedback when plugins fail, especially since plugins are a user-facing feature where troubleshooting information is valuable.
| /// </summary> | ||
| /// <param name="inputPath">Path to the input file</param> | ||
| /// <param name="outputPath">Path where processed file should be written</param> | ||
| /// <returns>True if processing succeeded, false to skip this plugin</returns> |
There was a problem hiding this comment.
The documentation comment for ProcessAsync says it returns "True if processing succeeded, false to skip this plugin" but the actual return type is Task<PluginResult>, not a boolean. This outdated documentation is misleading. Update the comment to describe the PluginResult return value and its Success, ErrorMessage, and OutputPath properties.
| /// <returns>True if processing succeeded, false to skip this plugin</returns> | |
| /// <returns> | |
| /// A <see cref="PluginResult"/> indicating the outcome of processing. | |
| /// <list type="bullet"> | |
| /// <item><description><see cref="PluginResult.Success"/> is true if processing completed successfully or the plugin chose to skip processing.</description></item> | |
| /// <item><description><see cref="PluginResult.ErrorMessage"/> contains details if processing failed.</description></item> | |
| /// <item><description><see cref="PluginResult.OutputPath"/> is the path to the processed file, if different from the input.</description></item> | |
| /// </list> | |
| /// </returns> |
| { | ||
| var assembly = Assembly.LoadFrom(dllPath); | ||
|
|
||
| var pluginTypes = assembly.GetTypes() |
There was a problem hiding this comment.
The plugin loading iterates through all types in every DLL assembly, checking each type against IBookyPlugin. For large plugin DLLs with many types, this could be slow. Consider documenting that plugins should be kept small, or implement a more efficient discovery mechanism such as requiring plugins to have a specific attribute or export a known type.
| var pluginTypes = assembly.GetTypes() | |
| var pluginTypes = assembly.GetExportedTypes() |
|
Thanks for the detailed review! Here's the response to the feedback: Fixed:
Won't fix (over-engineering for this use case):
|
- Add plugin cleanup on window close (UnloadPlugins) - Add temp file cleanup in ConvertAsync finally block - Fix extension normalization consistency in GetPluginFileTypeDescription - Add Debug.WriteLine for plugin shutdown errors - Update ProcessAsync comment to describe PluginResult return type
452e0ed to
a21086d
Compare
Summary
Adds a plugin system to allow extending Booky's functionality without modifying the core application.
Plugin Types
Changes
Plugins/IBookyPlugin.cs- Plugin interfacesServices/PluginService.cs- Plugin loader and managerConversionServiceMainWindowto use plugin servicePlugins/Examples/Plugins/README.mdUsage
Pluginsfolder