From 8e07057081b148241f11158bcb1c6bc4738ec4e1 Mon Sep 17 00:00:00 2001 From: deucebucket Date: Tue, 7 Apr 2026 00:55:31 -0500 Subject: [PATCH 1/2] Docs #203: Plugin system documentation and discoverability Add in-app docs for Python drop-in plugins, secrets management, and real-world API examples (Google Books, Open Library) to the Plugins settings tab. Ship example-logger plugin to examples/plugins/ with comprehensive README. --- CHANGELOG.md | 14 ++ README.md | 2 +- app.py | 2 +- examples/plugins/README.md | 121 ++++++++++++++++ examples/plugins/example-logger/layer.py | 78 +++++++++++ examples/plugins/example-logger/manifest.json | 16 +++ library_manager/hints.py | 6 + templates/plugins_settings.html | 131 ++++++++++++++++++ 8 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 examples/plugins/README.md create mode 100644 examples/plugins/example-logger/layer.py create mode 100644 examples/plugins/example-logger/manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9871940..823ec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Library Manager will be documented in this file. +## [0.9.0-beta.145] - 2026-04-07 + +### Added + +- **Issue #203: Plugin system documentation and discoverability** - Added Python drop-in + plugin guide with manifest.json and BasePlugin interface examples directly in the Plugins + settings tab. Added secrets management card explaining secrets.json usage for Docker and + bare metal. Added ready-to-use API configurations for Google Books and Open Library. + Shipped example-logger plugin to `examples/plugins/` with comprehensive README covering + plugin creation, manifest fields, BasePlugin interface, configuration, and behavior. + Added new hint entries for plugin-related tooltips. + +--- + ## [0.9.0-beta.144] - 2026-04-07 ### Fixed diff --git a/README.md b/README.md index bd1e126..ff83754 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Smart Audiobook Library Organizer with Multi-Source Metadata & AI Verification** -[![Version](https://img.shields.io/badge/version-0.9.0--beta.144-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.9.0--beta.145-blue.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://ghcr.io/deucebucket/library-manager) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) diff --git a/app.py b/app.py index 548fc42..bd4b705 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ - Multi-provider AI (Gemini, OpenRouter, Ollama) """ -APP_VERSION = "0.9.0-beta.144" +APP_VERSION = "0.9.0-beta.145" GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo # Versioning Guide: diff --git a/examples/plugins/README.md b/examples/plugins/README.md new file mode 100644 index 0000000..ac6b573 --- /dev/null +++ b/examples/plugins/README.md @@ -0,0 +1,121 @@ +# Example Plugins + +Drop-in Python plugins for Library Manager. Copy a plugin folder to `/data/plugins/` (Docker) or `plugins/` (bare metal) and restart. + +## example-logger + +A minimal template plugin that logs each book it processes. Use as a starting point for your own plugins. + +**Install:** +```bash +cp -r example-logger /data/plugins/ +# Restart Library Manager +``` + +## Creating Your Own Plugin + +Each plugin needs two files in its own folder: + +### manifest.json + +```json +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "1.0.0", + "description": "What it does", + "type": "layer", + "entry_point": "layer.py", + "class_name": "MyPlugin", + "default_order": 35, + "requires_config": [], + "requires_secrets": ["my_api_key"], + "permissions": { + "network": ["api.example.com"], + "database": "read" + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier (alphanumeric, hyphens, underscores) | +| `name` | Yes | Display name | +| `version` | No | Semver version string | +| `description` | No | What the plugin does | +| `type` | Yes | Must be `layer` | +| `entry_point` | Yes | Python file containing the plugin class | +| `class_name` | No | Class name to load (auto-detected if omitted) | +| `default_order` | No | Pipeline position 1-999 (default: 50). Lower = runs earlier | +| `requires_config` | No | Config keys your plugin reads | +| `requires_secrets` | No | Secret keys your plugin needs (stored in secrets.json) | +| `permissions.network` | No | Domains your plugin connects to | +| `permissions.database` | No | `read` or `write` | + +### layer.py + +```python +from library_manager.plugin_loader import BasePlugin + +class MyPlugin(BasePlugin): + name = "My Plugin" + description = "What it does" + version = "1.0.0" + + def setup(self, config, secrets): + """Called once on startup. Store config/secrets you need.""" + self.api_key = secrets.get('my_api_key') + + def can_process(self, book_data): + """Return True to process this book, False to skip.""" + return True + + def process(self, book_data): + """Main logic. Return dict of matched fields, or empty dict for no match. + + book_data contains: + - current_title: Current title (from path or prior identification) + - current_author: Current author + - current_narrator: Current narrator (if known) + - path: Full filesystem path to the book + - book_id: Database ID + + Return any of: title, author, narrator, series, series_num, year, language + """ + return {} + + def teardown(self): + """Called on shutdown. Clean up resources.""" + pass +``` + +## Plugin Behavior + +- Plugins run with a **30 second timeout** per `process()` call +- Default confidence weight: **60** (configurable via `plugin_configs` in config.json) +- Auto-disabled after **5 consecutive failures** (re-enable from Plugin Health dashboard) +- Plugins never crash the app - exceptions are caught and logged +- Results feed into the book profile system alongside built-in sources + +## Configuration + +Per-plugin settings in `config.json`: + +```json +{ + "plugin_configs": { + "my-plugin": { + "timeout": 30, + "custom_setting": "value" + } + } +} +``` + +Secrets in `secrets.json`: + +```json +{ + "my_api_key": "your-key-here" +} +``` diff --git a/examples/plugins/example-logger/layer.py b/examples/plugins/example-logger/layer.py new file mode 100644 index 0000000..cea74a6 --- /dev/null +++ b/examples/plugins/example-logger/layer.py @@ -0,0 +1,78 @@ +"""Example plugin for Library Manager. + +This is a minimal plugin that demonstrates the BasePlugin interface. +It logs each book it sees and returns empty results (no modifications). + +To use this as a template: +1. Copy this directory to /data/plugins/your-plugin-name/ +2. Edit manifest.json with your plugin's metadata +3. Implement process() with your logic +4. Restart Library Manager + +The plugin loader will discover and load your plugin automatically. +""" + +import logging + +# Import BasePlugin from the plugin loader +from library_manager.plugin_loader import BasePlugin + +logger = logging.getLogger(__name__) + + +class ExampleLoggerPlugin(BasePlugin): + """A simple plugin that logs book information. + + This demonstrates: + - setup() for one-time initialization + - can_process() for filtering books + - process() for the main logic + - teardown() for cleanup + """ + + name = "Example Logger" + description = "Logs book data for debugging" + version = "1.0.0" + + def setup(self, config, secrets): + """Store config for later use.""" + self.log_level = config.get('log_level', 'info') + self.books_seen = 0 + logger.info("[ExamplePlugin] Setup complete") + + def can_process(self, book_data): + """Process all books.""" + return True + + def process(self, book_data): + """Log the book data and return empty (no changes). + + In a real plugin, you would: + 1. Extract info from book_data (title, author, path, etc.) + 2. Query your data source (API, database, file, etc.) + 3. Return a dict with matched fields + + Example return for a match: + return { + 'title': 'The Corrected Title', + 'author': 'Correct Author Name', + 'narrator': 'Narrator Name', + } + """ + self.books_seen += 1 + title = book_data.get('current_title', 'Unknown') + author = book_data.get('current_author', 'Unknown') + + logger.info( + f"[ExamplePlugin] Book #{self.books_seen}: " + f"'{title}' by {author}" + ) + + # Return empty dict = no changes (this is just a logger) + return {} + + def teardown(self): + """Log summary on shutdown.""" + logger.info( + f"[ExamplePlugin] Shutting down. Saw {self.books_seen} books total." + ) diff --git a/examples/plugins/example-logger/manifest.json b/examples/plugins/example-logger/manifest.json new file mode 100644 index 0000000..4c6c417 --- /dev/null +++ b/examples/plugins/example-logger/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "example-logger", + "name": "Example Logger Plugin", + "version": "1.0.0", + "description": "A minimal example plugin that logs book data and returns empty results. Use as a template for building your own plugins.", + "type": "layer", + "entry_point": "layer.py", + "class_name": "ExampleLoggerPlugin", + "default_order": 35, + "requires_config": [], + "requires_secrets": [], + "permissions": { + "network": [], + "database": "read" + } +} diff --git a/library_manager/hints.py b/library_manager/hints.py index 74ed456..8f72c9b 100644 --- a/library_manager/hints.py +++ b/library_manager/hints.py @@ -108,6 +108,12 @@ # === Post-Processing Hooks === 'post_processing': 'Run external scripts or webhooks after a book is successfully renamed. Use for M4B conversion, Audiobookshelf library scans, Discord notifications, backup scripts, etc. Hook failures never undo a successful rename.', + + # === Plugins === + 'custom_api_sources': 'Add your own book metadata APIs as processing layers. Each source queries an HTTP endpoint and maps the response into the book profile system.', + 'python_plugins': 'Drop-in Python plugins for advanced users. Place a plugin folder in /data/plugins/ with a manifest.json and a Python file extending BasePlugin. Plugins are auto-discovered on startup.', + 'plugin_health': 'Monitor the health and performance of your custom API sources and Python plugins. Plugins are auto-disabled after 5 consecutive failures to protect your processing pipeline.', + 'plugin_secrets': 'API keys and passwords are stored in secrets.json (not config.json) so they are never exposed in backups or logs. Add your key as a named entry, then reference the key name here.', } diff --git a/templates/plugins_settings.html b/templates/plugins_settings.html index 9b598af..8906c5c 100644 --- a/templates/plugins_settings.html +++ b/templates/plugins_settings.html @@ -76,6 +76,137 @@ + + +
+
+ Python Drop-in Plugins + ?{{ hints.python_plugins }} +
+
+

For developers who need more than HTTP lookups. Write Python plugins that can do anything: parse files, call complex APIs, run local models, etc.

+ +

Quick Start:

+
    +
  1. Create a folder in /data/plugins/my-plugin/
  2. +
  3. Add a manifest.json with plugin metadata
  4. +
  5. Add a .py file extending BasePlugin
  6. +
  7. Restart Library Manager — plugin loads automatically
  8. +
+ +

manifest.json:

+
{
+  "id": "my-plugin",
+  "name": "My Plugin",
+  "version": "1.0.0",
+  "description": "What it does",
+  "type": "layer",
+  "entry_point": "layer.py",
+  "class_name": "MyPlugin",
+  "default_order": 35,
+  "requires_config": [],
+  "requires_secrets": ["my_api_key"],
+  "permissions": {
+    "network": ["api.example.com"],
+    "database": "read"
+  }
+}
+ +

Plugin Interface:

+
from library_manager.plugin_loader import BasePlugin
+
+class MyPlugin(BasePlugin):
+    def setup(self, config, secrets):
+        self.api_key = secrets.get('my_api_key')
+
+    def can_process(self, book_data):
+        return True  # filter which books to process
+
+    def process(self, book_data):
+        title = book_data.get('current_title')
+        # ... your logic here ...
+        return {
+            'title': 'Corrected Title',
+            'author': 'Author Name',
+            # narrator, series, series_num, year
+        }
+
+    def teardown(self):
+        pass  # cleanup on shutdown
+ +

+ + Plugins run with a 30s timeout per book. Auto-disabled after 5 consecutive failures. + An example plugin ships in examples/plugins/example-logger/. +

+
+
+ + +
+
+ Managing API Secrets + ?{{ hints.plugin_secrets }} +
+
+

API keys and passwords go in secrets.json, not config.json. Reference them by name in the wizard or your plugin code.

+ +

secrets.json example:

+
{
+  "gemini_api_key": "AIza...",
+  "my_bookdb_key": "Bearer abc123",
+  "my_api_password": "secret456"
+}
+

+ + Docker: /config/secrets.json
+ + Bare metal: secrets.json in app directory
+ This file is gitignored and never included in backups or logs. +

+
+
+ + +
+
+ Example API Configurations +
+
+

Ready-to-use configs for popular book APIs. Click "Add Custom API Source" and paste these values.

+ +
+

Google Books

+ + + + + + + + + +
URLhttps://www.googleapis.com/books/v1/volumes?q=intitle:{{"{{"}}title{{"}}"}}}+inauthor:{{"{{"}}author{{"}}"}}&maxResults=1
MethodGET
AuthNone (or API Key Header: key)
Title$.items[0].volumeInfo.title
Author$.items[0].volumeInfo.authors[0]
Year$.items[0].volumeInfo.publishedDate
+
+ +
+

Open Library

+ + + + + + + + + +
URLhttps://openlibrary.org/search.json?title={{"{{"}}title{{"}}"}}&author={{"{{"}}author{{"}}"}}&limit=1
MethodGET
AuthNone
Title$.docs[0].title
Author$.docs[0].author_name[0]
Year$.docs[0].first_publish_year
+

+ Open Library is free with no API key required. +

+
+
+
From a402e485231b383e81034eaef445f51552723898 Mon Sep 17 00:00:00 2001 From: deucebucket Date: Tue, 7 Apr 2026 01:00:28 -0500 Subject: [PATCH 2/2] Fix extra brace in Google Books URL template example --- templates/plugins_settings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/plugins_settings.html b/templates/plugins_settings.html index 8906c5c..a419806 100644 --- a/templates/plugins_settings.html +++ b/templates/plugins_settings.html @@ -179,7 +179,7 @@

Google Books

- +
URLhttps://www.googleapis.com/books/v1/volumes?q=intitle:{{"{{"}}title{{"}}"}}}+inauthor:{{"{{"}}author{{"}}"}}&maxResults=1
URLhttps://www.googleapis.com/books/v1/volumes?q=intitle:{{"{{"}}title{{"}}"}}+inauthor:{{"{{"}}author{{"}}"}}&maxResults=1
MethodGET
AuthNone (or API Key Header: key)
Title$.items[0].volumeInfo.title