mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-19 21:39:17 -04:00
- Adds a comprehensive multi-user request system to the existing download flow - Request configuration is policy based. Configure global settings for content type, or narrow down policy for specific sources (E.g. allow direct downloads, set prowlarr to request only, block IRC completely, etc). - Global policy configuration and per-user overrides for tailored configs - Replaced downloads sidebar with ActivitySidebar, combining active downloads with requests. Admin management of user requests is done here, and admins have view of downloads from all users. Sidebar can now be pinned. - Request either a standard book or a specific release. Release-requests are used if you permit one source differently than the other. On book-level requests, admins pick the specific file to be attached to the fulfilled request. - Users can request books with a note This is WIP so some features are still not complete (notifications, more automatic release selection, among others).
655 lines
18 KiB
Markdown
655 lines
18 KiB
Markdown
# Plugin Settings Integration Guide
|
|
|
|
This guide explains how to add configuration settings to plugins (Metadata Providers and Release Sources) so they appear in the Settings UI.
|
|
|
|
## Overview
|
|
|
|
The settings system uses a decorator-based registration pattern. Plugins register their settings when their module is imported, and the frontend dynamically renders the appropriate UI based on the schema provided by the backend.
|
|
|
|
**Key features:**
|
|
- Settings are defined in Python and automatically rendered in the React frontend
|
|
- Values persist across container restarts via JSON config files
|
|
- Changes take effect immediately without restart (unless marked otherwise)
|
|
|
|
## Quick Start
|
|
|
|
Add settings to your plugin in 3 steps:
|
|
|
|
```python
|
|
from shelfmark.core.settings_registry import (
|
|
register_settings,
|
|
TextField,
|
|
PasswordField,
|
|
ActionButton,
|
|
)
|
|
|
|
@register_settings(
|
|
name="my_plugin", # Unique identifier
|
|
display_name="My Plugin", # Shown in sidebar
|
|
icon="wrench", # Icon name
|
|
order=100, # Sort order (lower = higher in list)
|
|
group="metadata_providers" # Optional: group in sidebar
|
|
)
|
|
def my_plugin_settings():
|
|
return [
|
|
PasswordField(
|
|
key="MY_PLUGIN_API_KEY",
|
|
label="API Key",
|
|
description="Your API key from the provider",
|
|
required=True,
|
|
),
|
|
ActionButton(
|
|
key="test_connection",
|
|
label="Test Connection",
|
|
style="primary",
|
|
callback=_test_connection,
|
|
),
|
|
]
|
|
|
|
def _test_connection():
|
|
# Perform connection test
|
|
return {"success": True, "message": "Connected successfully!"}
|
|
```
|
|
|
|
## Available Field Types
|
|
|
|
### TextField
|
|
|
|
Single-line text input for strings.
|
|
|
|
```python
|
|
TextField(
|
|
key="MY_SETTING", # Config key
|
|
label="Setting Name", # Display label
|
|
description="Help text", # Optional description below field
|
|
default="", # Default value
|
|
placeholder="Enter value", # Placeholder text
|
|
max_length=100, # Optional max characters
|
|
required=False, # Is this field required?
|
|
requires_restart=False, # Does changing this need a restart?
|
|
show_when=None, # Conditional visibility (see below)
|
|
disabled_when=None, # Conditional disable (see below)
|
|
)
|
|
```
|
|
|
|
### PasswordField
|
|
|
|
Masked input for sensitive values (API keys, passwords). Values are never echoed back to the frontend.
|
|
|
|
```python
|
|
PasswordField(
|
|
key="API_KEY",
|
|
label="API Key",
|
|
description="Your secret API key",
|
|
placeholder="sk-...",
|
|
required=True,
|
|
)
|
|
```
|
|
|
|
### NumberField
|
|
|
|
Numeric input with optional min/max constraints.
|
|
|
|
```python
|
|
NumberField(
|
|
key="TIMEOUT",
|
|
label="Timeout (seconds)",
|
|
description="Connection timeout in seconds",
|
|
default=30,
|
|
min_value=5,
|
|
max_value=300,
|
|
step=1, # Increment step
|
|
required=False,
|
|
)
|
|
```
|
|
|
|
### CheckboxField
|
|
|
|
Toggle switch for boolean values.
|
|
|
|
```python
|
|
CheckboxField(
|
|
key="ENABLE_FEATURE",
|
|
label="Enable Feature",
|
|
description="Turn this feature on or off",
|
|
default=False,
|
|
)
|
|
```
|
|
|
|
### SelectField
|
|
|
|
Dropdown for single-choice selection.
|
|
|
|
```python
|
|
SelectField(
|
|
key="LOG_LEVEL",
|
|
label="Log Level",
|
|
description="Logging verbosity",
|
|
default="info",
|
|
options=[
|
|
{"value": "debug", "label": "Debug"},
|
|
{"value": "info", "label": "Info"},
|
|
{"value": "warning", "label": "Warning"},
|
|
{"value": "error", "label": "Error"},
|
|
],
|
|
)
|
|
```
|
|
|
|
### MultiSelectField
|
|
|
|
Multi-choice selection from a list of options.
|
|
|
|
```python
|
|
MultiSelectField(
|
|
key="SUPPORTED_FORMATS",
|
|
label="Supported Formats",
|
|
description="Select which formats to support",
|
|
default=["epub", "mobi"],
|
|
options=[
|
|
{"value": "epub", "label": "EPUB"},
|
|
{"value": "mobi", "label": "MOBI"},
|
|
{"value": "pdf", "label": "PDF"},
|
|
{"value": "azw3", "label": "AZW3"},
|
|
],
|
|
)
|
|
```
|
|
|
|
### ActionButton
|
|
|
|
Button that executes a callback function. Does not store a value.
|
|
|
|
```python
|
|
ActionButton(
|
|
key="test_connection", # Unique key for the action
|
|
label="Test Connection", # Button text
|
|
description="Test the API connection",
|
|
style="primary", # "default", "primary", or "danger"
|
|
callback=my_callback_fn, # Function to execute
|
|
)
|
|
|
|
def my_callback_fn():
|
|
"""Callback must return dict with 'success' and 'message' keys."""
|
|
try:
|
|
# Perform action
|
|
return {"success": True, "message": "Connection successful!"}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"Failed: {str(e)}"}
|
|
```
|
|
|
|
### HeadingField
|
|
|
|
Display-only section heading with optional link. Does not store a value.
|
|
|
|
```python
|
|
HeadingField(
|
|
key="section_heading", # Unique key
|
|
title="Configuration", # Heading text
|
|
description="Configure the plugin settings below",
|
|
link_url="https://example.com/docs", # Optional link
|
|
link_text="View Documentation", # Link text
|
|
)
|
|
```
|
|
|
|
### CustomComponentField
|
|
|
|
Render a frontend-registered custom settings component while still using the
|
|
decorator-based schema.
|
|
|
|
```python
|
|
from shelfmark.core.settings_registry import CustomComponentField
|
|
|
|
CustomComponentField(
|
|
key="request_policy_editor",
|
|
component="request_policy_grid", # frontend registry key
|
|
label="Request Policy Rules",
|
|
description="Custom editor for policy defaults and matrix rules.",
|
|
value_fields=[
|
|
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
|
|
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
|
|
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
|
|
],
|
|
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
|
|
)
|
|
```
|
|
|
|
When `value_fields` is provided, those backing fields are included in
|
|
serialization/save/validation automatically and are hidden from the default renderer.
|
|
|
|
## Common Field Properties
|
|
|
|
All field types support these common properties:
|
|
|
|
| Property | Type | Default | Description |
|
|
|----------|------|---------|-------------|
|
|
| `key` | `str` | Required | Unique identifier for this setting |
|
|
| `label` | `str` | Required | Display label in the UI |
|
|
| `description` | `str` | `""` | Help text shown below the field |
|
|
| `default` | `Any` | `None` | Default value if not set |
|
|
| `required` | `bool` | `False` | Whether the field must have a value |
|
|
| `disabled` | `bool` | `False` | Disable the field (greyed out) |
|
|
| `disabled_reason` | `str` | `""` | Explanation shown when disabled |
|
|
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
|
|
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
|
|
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
|
|
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
|
|
|
|
## Conditional Visibility
|
|
|
|
Fields can be shown/hidden based on other field values using `show_when`:
|
|
|
|
```python
|
|
# Only show DNS servers field when custom DNS is selected
|
|
TextField(
|
|
key="CUSTOM_DNS_SERVERS",
|
|
label="DNS Servers",
|
|
description="Comma-separated DNS server IPs",
|
|
show_when={"field": "DNS_PROVIDER", "value": "manual"},
|
|
)
|
|
```
|
|
|
|
The field will only be visible when the referenced field has the specified value.
|
|
|
|
## Conditional Disable
|
|
|
|
Fields can be enabled/disabled based on other field values using `disabled_when`:
|
|
|
|
```python
|
|
# Disable timeout field when feature is disabled
|
|
NumberField(
|
|
key="FEATURE_TIMEOUT",
|
|
label="Timeout (seconds)",
|
|
description="Request timeout",
|
|
default=30,
|
|
disabled_when={
|
|
"field": "FEATURE_ENABLED",
|
|
"value": False,
|
|
"reason": "Enable the feature first"
|
|
},
|
|
)
|
|
```
|
|
|
|
The field will be greyed out with the specified reason when the condition is met.
|
|
|
|
## Settings Groups
|
|
|
|
Register a group to organize related settings tabs in the sidebar:
|
|
|
|
```python
|
|
from shelfmark.core.settings_registry import register_group
|
|
|
|
# Register a group (do this once, usually in a central config file)
|
|
register_group(
|
|
name="my_group",
|
|
display_name="My Group",
|
|
icon="folder",
|
|
order=50,
|
|
)
|
|
|
|
# Then register settings to the group
|
|
@register_settings(
|
|
name="plugin_a",
|
|
display_name="Plugin A",
|
|
icon="puzzle",
|
|
order=51,
|
|
group="my_group", # Assigns to the group
|
|
)
|
|
def plugin_a_settings():
|
|
return [...]
|
|
```
|
|
|
|
**Existing groups:**
|
|
- `direct_download` (order=20): For download-related settings
|
|
- `metadata_providers` (order=50): For metadata provider plugins
|
|
|
|
## Value Resolution Priority
|
|
|
|
Settings values are resolved in this order (highest priority first):
|
|
|
|
1. **Config File** - Stored in `CONFIG_DIR/plugins/<tab_name>.json`
|
|
2. **Field Default** - Value specified in the field definition
|
|
|
|
The `general` tab uses `CONFIG_DIR/settings.json` instead of the plugins subdirectory.
|
|
|
|
## Reading Setting Values
|
|
|
|
Use the `config` singleton to read setting values in your plugin code:
|
|
|
|
```python
|
|
from shelfmark.core.config import config
|
|
|
|
# Get a setting value with default fallback
|
|
api_key = config.get("MY_PLUGIN_API_KEY", "")
|
|
timeout = config.get("MY_PLUGIN_TIMEOUT", 30)
|
|
|
|
# Or access as attributes (raises AttributeError if not found)
|
|
api_key = config.MY_PLUGIN_API_KEY
|
|
|
|
# Check all cached settings
|
|
all_settings = config.get_all()
|
|
```
|
|
|
|
The config singleton:
|
|
- Automatically resolves values from config files with field defaults as fallback
|
|
- Caches values for performance
|
|
- Refreshes automatically when settings are updated via the UI
|
|
|
|
## Complete Example: Metadata Provider
|
|
|
|
Here's a complete example for a metadata provider plugin:
|
|
|
|
```python
|
|
# shelfmark/metadata_providers/my_provider.py
|
|
|
|
from shelfmark.metadata_providers.base import (
|
|
MetadataProvider,
|
|
register_provider,
|
|
)
|
|
from shelfmark.core.settings_registry import (
|
|
register_settings,
|
|
HeadingField,
|
|
TextField,
|
|
PasswordField,
|
|
CheckboxField,
|
|
ActionButton,
|
|
)
|
|
from shelfmark.core.config import config
|
|
|
|
|
|
def _test_connection():
|
|
"""Test API connection callback."""
|
|
api_key = config.get("MY_PROVIDER_API_KEY", "")
|
|
if not api_key:
|
|
return {"success": False, "message": "API key not configured"}
|
|
|
|
try:
|
|
# Perform actual connection test
|
|
# response = requests.get(...)
|
|
return {"success": True, "message": "Connected to My Provider API"}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
|
|
|
|
|
@register_settings(
|
|
name="my_provider",
|
|
display_name="My Provider",
|
|
icon="book",
|
|
order=53,
|
|
group="metadata_providers",
|
|
)
|
|
def my_provider_settings():
|
|
"""Define settings for this metadata provider."""
|
|
return [
|
|
HeadingField(
|
|
key="my_provider_heading",
|
|
title="My Provider",
|
|
description="A metadata provider for book information",
|
|
link_url="https://myprovider.com",
|
|
link_text="Visit My Provider",
|
|
),
|
|
PasswordField(
|
|
key="MY_PROVIDER_API_KEY",
|
|
label="API Key",
|
|
description="Your My Provider API key",
|
|
placeholder="Enter your API key",
|
|
required=True,
|
|
),
|
|
CheckboxField(
|
|
key="MY_PROVIDER_INCLUDE_COVERS",
|
|
label="Include Cover Images",
|
|
description="Fetch cover images when searching",
|
|
default=True,
|
|
),
|
|
TextField(
|
|
key="MY_PROVIDER_BASE_URL",
|
|
label="API Base URL",
|
|
description="Override the default API endpoint",
|
|
default="https://api.myprovider.com/v1",
|
|
required=False,
|
|
),
|
|
ActionButton(
|
|
key="test_connection",
|
|
label="Test Connection",
|
|
description="Verify your API key works",
|
|
style="primary",
|
|
callback=_test_connection,
|
|
),
|
|
]
|
|
|
|
|
|
@register_provider("my_provider")
|
|
class MyProvider(MetadataProvider):
|
|
"""My Provider metadata implementation."""
|
|
|
|
name = "my_provider"
|
|
display_name = "My Provider"
|
|
requires_auth = True
|
|
|
|
def __init__(self, api_key: str = None):
|
|
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
|
|
self.base_url = config.get(
|
|
"MY_PROVIDER_BASE_URL",
|
|
"https://api.myprovider.com/v1"
|
|
)
|
|
|
|
def is_available(self) -> bool:
|
|
return bool(self.api_key)
|
|
|
|
def search(self, query: str):
|
|
# Implementation...
|
|
pass
|
|
|
|
def get_book(self, book_id: str):
|
|
# Implementation...
|
|
pass
|
|
```
|
|
|
|
## Complete Example: Release Source
|
|
|
|
Here's a complete example for a release source plugin:
|
|
|
|
```python
|
|
# shelfmark/release_sources/my_source.py
|
|
|
|
from shelfmark.release_sources.base import (
|
|
ReleaseSource,
|
|
DownloadHandler,
|
|
register_source,
|
|
register_handler,
|
|
)
|
|
from shelfmark.core.settings_registry import (
|
|
register_settings,
|
|
HeadingField,
|
|
TextField,
|
|
NumberField,
|
|
CheckboxField,
|
|
SelectField,
|
|
ActionButton,
|
|
)
|
|
from shelfmark.core.config import config
|
|
|
|
|
|
def _test_source():
|
|
"""Test source availability callback."""
|
|
base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
|
|
try:
|
|
# Test connectivity
|
|
return {"success": True, "message": f"Source available at {base_url}"}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"Source unavailable: {str(e)}"}
|
|
|
|
|
|
@register_settings(
|
|
name="my_source",
|
|
display_name="My Source",
|
|
icon="download",
|
|
order=25,
|
|
group="direct_download",
|
|
)
|
|
def my_source_settings():
|
|
"""Define settings for this release source."""
|
|
return [
|
|
HeadingField(
|
|
key="my_source_heading",
|
|
title="My Source Configuration",
|
|
description="Configure the My Source download provider",
|
|
),
|
|
CheckboxField(
|
|
key="MY_SOURCE_ENABLED",
|
|
label="Enable My Source",
|
|
description="Include My Source in download fallback chain",
|
|
default=True,
|
|
),
|
|
TextField(
|
|
key="MY_SOURCE_URL",
|
|
label="Source URL",
|
|
description="Base URL for the source",
|
|
default="https://mysource.com",
|
|
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
|
),
|
|
NumberField(
|
|
key="MY_SOURCE_TIMEOUT",
|
|
label="Timeout (seconds)",
|
|
description="Request timeout",
|
|
default=30,
|
|
min_value=10,
|
|
max_value=120,
|
|
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
|
),
|
|
SelectField(
|
|
key="MY_SOURCE_PRIORITY",
|
|
label="Priority",
|
|
description="Where in the fallback chain to try this source",
|
|
default="normal",
|
|
options=[
|
|
{"value": "high", "label": "High (try first)"},
|
|
{"value": "normal", "label": "Normal"},
|
|
{"value": "low", "label": "Low (try last)"},
|
|
],
|
|
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
|
),
|
|
ActionButton(
|
|
key="test_source",
|
|
label="Test Source",
|
|
description="Check if the source is accessible",
|
|
style="primary",
|
|
callback=_test_source,
|
|
),
|
|
]
|
|
|
|
|
|
@register_source("my_source")
|
|
class MySource(ReleaseSource):
|
|
"""My Source release source implementation."""
|
|
|
|
name = "my_source"
|
|
display_name = "My Source"
|
|
|
|
def __init__(self):
|
|
self.enabled = config.get("MY_SOURCE_ENABLED", True)
|
|
self.base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
|
|
self.timeout = config.get("MY_SOURCE_TIMEOUT", 30)
|
|
|
|
def is_available(self) -> bool:
|
|
return self.enabled
|
|
|
|
def search(self, book):
|
|
# Implementation...
|
|
pass
|
|
|
|
|
|
@register_handler("my_source")
|
|
class MySourceHandler(DownloadHandler):
|
|
"""Handler for downloading from My Source."""
|
|
|
|
name = "my_source"
|
|
|
|
def download(self, release, output_path):
|
|
# Implementation...
|
|
pass
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use descriptive keys**: Keys should be uppercase and prefixed with your plugin name (e.g., `MY_PLUGIN_API_KEY`)
|
|
|
|
2. **Provide helpful descriptions**: Include enough detail in descriptions to help users understand what each setting does
|
|
|
|
3. **Set sensible defaults**: Users should be able to get started without configuring everything
|
|
|
|
4. **Use conditional visibility**: Hide advanced options behind enabling checkboxes to reduce UI clutter
|
|
|
|
5. **Include a test button**: ActionButtons that test connections help users verify their configuration
|
|
|
|
6. **Mark restart-required settings**: Use `requires_restart=True` for settings that can't be applied live
|
|
|
|
7. **Group related settings**: Use HeadingField to visually separate sections, and put plugins in appropriate groups
|
|
|
|
8. **Handle missing values gracefully**: Always provide fallbacks when reading settings in your code
|
|
|
|
## API Reference
|
|
|
|
### Backend Routes
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/api/settings` | Get all settings tabs, groups, and values |
|
|
| GET | `/api/settings/<tab_name>` | Get a specific settings tab |
|
|
| PUT | `/api/settings/<tab_name>` | Update settings for a tab |
|
|
| POST | `/api/settings/<tab_name>/action/<action_key>` | Execute an action button callback |
|
|
|
|
### Response Format
|
|
|
|
**GET /api/settings**
|
|
```json
|
|
{
|
|
"groups": [
|
|
{"name": "direct_download", "displayName": "Direct Download", "icon": "download", "order": 20}
|
|
],
|
|
"tabs": [
|
|
{
|
|
"name": "my_plugin",
|
|
"displayName": "My Plugin",
|
|
"icon": "book",
|
|
"order": 53,
|
|
"group": "metadata_providers",
|
|
"fields": [
|
|
{
|
|
"type": "password",
|
|
"key": "MY_PLUGIN_API_KEY",
|
|
"label": "API Key",
|
|
"description": "Your API key",
|
|
"hasValue": true,
|
|
"value": "",
|
|
"required": true,
|
|
"disabled": false,
|
|
"requiresRestart": false
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**PUT /api/settings/<tab_name>**
|
|
```json
|
|
// Request
|
|
{"MY_PLUGIN_API_KEY": "new-value", "MY_PLUGIN_TIMEOUT": 60}
|
|
|
|
// Response
|
|
{
|
|
"success": true,
|
|
"message": "Settings updated",
|
|
"updated": ["MY_PLUGIN_API_KEY", "MY_PLUGIN_TIMEOUT"],
|
|
"requiresRestart": false
|
|
}
|
|
```
|
|
|
|
**POST /api/settings/<tab_name>/action/<action_key>**
|
|
```json
|
|
// Response
|
|
{
|
|
"success": true,
|
|
"message": "Connection successful!"
|
|
}
|
|
```
|