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** -[](CHANGELOG.md) +[](CHANGELOG.md) [](https://ghcr.io/deucebucket/library-manager) [](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..a419806 100644 --- a/templates/plugins_settings.html +++ b/templates/plugins_settings.html @@ -76,6 +76,137 @@ + + +
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:
+/data/plugins/my-plugin/manifest.json with plugin metadata.py file extending BasePluginmanifest.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/.
+
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.
+
Ready-to-use configs for popular book APIs. Click "Add Custom API Source" and paste these values.
+ +Google Books
+| URL | https://www.googleapis.com/books/v1/volumes?q=intitle:{{"{{"}}title{{"}}"}}+inauthor:{{"{{"}}author{{"}}"}}&maxResults=1 |
| Method | GET |
| Auth | None (or API Key Header: key) |
| Title | $.items[0].volumeInfo.title |
| Author | $.items[0].volumeInfo.authors[0] |
| Year | $.items[0].volumeInfo.publishedDate |
Open Library
+| URL | https://openlibrary.org/search.json?title={{"{{"}}title{{"}}"}}&author={{"{{"}}author{{"}}"}}&limit=1 |
| Method | GET |
| Auth | None |
| 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. +
+