This is the biggest of the docs batch, and this time I don't think it was my PRs that caused it. The plugin guides fell behind as the plugin API changed over several releases, and nobody updated them along the way. Same arrangement as the others: Claude compared the guides to the code and wrote the changes, so another human look before this merges would be good. Someone who knows the plugin API well would catch things I would not. These are the parts I would read first: the search plan table, the `SourceUnavailableError` advice in the error handling section, and the `locating` status row. What was wrong and is now fixed, all checked against the code: Release sources guide - `search` takes `(book, plan, *, expand_search, content_type)`. Every sample had the old one-argument signature, so a source written from the guide raised a TypeError on its first search. There is now a short table of the search plan fields. - A source is registered by adding its module to `_BUILTIN_SOURCE_MODULES`. The guide said to add an import at the bottom of `__init__.py`. - `get_column_config` is an instance method, not a classmethod, and `ColumnRenderType.SEEDERS` does not exist (the real members are listed). - `download` can return a `HandoffResult`. `list_available_sources` lists every source with an `enabled` flag, it does not filter. - `group="sources"` is gone, since only `direct_download` and `metadata_providers` exist. - Field lists for `Release`, `DownloadTask`, `BookMetadata`, `ColumnSchema` and `ReleaseColumnConfig` are brought up to date, with the protocol enum and the `locating` status. - The optional hooks that were not mentioned (`post_process_cleanup`, `build_retry_resolution_fields`, `list_authors`, `seed_complaints`, `get_record`, `get_destination_override`, `search_results_are_releases`) are documented. Plugin settings guide - The examples imported from `shelfmark.metadata_providers.base` and `shelfmark.release_sources.base`, which do not exist. The metadata example also skipped the abstract `search_by_isbn` and the kwargs factory. - Value priority now starts with the environment variable, and `search_mode` is listed next to `general` as using `settings.json`. The `metadata_providers` group order is 12, not 50. - Documented `TagListField`, `OrderableListField`, `TableField`, `env_var`, `env_supported`, `user_overridable`, `universal_only`, `filter_by_field`, `register_on_save` and the `current_values` argument for action callbacks. Also in here: `docs/dev/index.md` was a TODO stub and is now a short index linked from `docs/index.md`, a dead link in the metadata providers README is fixed, and the README table gains Databáze knih and Moly.hu. To check the samples were not just plausible, I ran the quick start and the full BookNet example against the real registry. Both register, instantiate and return their column config. The old BookNet example failed on import with a NameError, because the Test Connection callback was defined below the settings function that references it. The decorator calls that function straight away, so both settings snippets now define the callback first.
22 KiB
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:
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.
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.
PasswordField(
key="API_KEY",
label="API Key",
description="Your secret API key",
placeholder="sk-...",
required=True,
)
NumberField
Numeric input with optional min/max constraints.
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.
CheckboxField(
key="ENABLE_FEATURE",
label="Enable Feature",
description="Turn this feature on or off",
default=False,
)
SelectField
Dropdown for single-choice selection.
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"},
],
)
SelectField also takes filter_by_field, the key of another field whose value filters this field's options through each option's childOf property. options can be a list or a function that returns one, evaluated when the schema is built.
MultiSelectField
Multi-choice selection from a list of options. variant is "pills" (default) or "dropdown" for a checkbox dropdown.
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"},
],
)
TagListField
Editable list of free-form strings, shown as tags. The value is a list of strings.
TagListField(
key="MY_PLUGIN_MIRROR_URLS",
label="Mirror URLs",
description="Type a URL and press Enter to add it",
placeholder="https://mirror.example",
normalize_urls=True, # default: entries are cleaned up as URLs
)
OrderableListField
A list of options that can be toggled on and off and reordered. The value is a list of {"id": ..., "enabled": ...} objects in priority order.
OrderableListField(
key="MY_PLUGIN_SOURCE_PRIORITY",
label="Source priority",
options=[
{"id": "a", "label": "Source A"},
{"id": "b", "label": "Source B", "isLocked": True, "disabledReason": "Needs an API key"},
],
default=[{"id": "a", "enabled": True}, {"id": "b", "enabled": False}],
)
An option can set isLocked (the toggle is disabled), isPinned (it cannot be reordered), description and section.
TableField
Editable table of structured rows. The value is a list of objects, one per row.
TableField(
key="MY_PLUGIN_RULES",
label="Rules",
columns=[
{"key": "name", "label": "Name", "type": "text", "placeholder": "Rule name"},
{"key": "mode", "label": "Mode", "type": "select", "options": [...]},
],
add_label="Add rule",
empty_message="No rules yet",
default=[],
)
ActionButton
Button that executes a callback function. Does not store a value.
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)}"}
A callback that declares a current_values parameter receives the form's current, unsaved values as a dict, so a "Test connection" button can test what the user just typed instead of what was last saved:
def my_callback_fn(current_values: dict):
api_key = current_values.get("MY_PLUGIN_API_KEY", "")
...
The decorator calls your settings function as soon as the module is imported, so define the callback above @register_settings, not below it.
HeadingField
Display-only section heading with optional link. Does not store a value.
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.
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 |
env_var |
str |
None |
Environment variable name to use instead of key |
env_supported |
bool |
True |
Set False for a UI-only setting that cannot come from an environment variable |
user_overridable |
bool |
False |
Let an admin set a per-user override of this field |
universal_only |
bool |
False |
Only show the field in Universal search mode |
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:
# 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:
# 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:
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 settingsmetadata_providers(order=12): For metadata provider plugins
Value Resolution Priority
Settings values are resolved in this order (highest priority first):
- Environment variable - The field's key, or
env_varwhen set. Skipped for a field withenv_supported=False. A value that comes from an environment variable is shown locked in the UI - Config File - Stored in
CONFIG_DIR/plugins/<tab_name>.json - Field Default - Value specified in the field definition
The general and search_mode tabs use CONFIG_DIR/settings.json instead of the plugins subdirectory.
Validating or Transforming on Save
register_on_save(tab_name, handler) runs a function each time the tab is saved. The handler receives a copy of the values being saved and returns a dict. Return {"error": True, "message": "..."} to refuse the save and show the message, or {"values": {...}} to save changed values.
from shelfmark.core.settings_registry import register_on_save
def _validate_my_plugin(values: dict) -> dict:
if values.get("MY_PLUGIN_TIMEOUT", 30) < 5:
return {"error": True, "message": "Timeout must be at least 5 seconds"}
return {"values": values}
register_on_save("my_plugin", _validate_my_plugin)
Reading Setting Values
Use the config singleton to read setting values in your plugin code:
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:
- Resolves values in the order above, with the field default as the last 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:
# shelfmark/metadata_providers/my_provider.py
from shelfmark.metadata_providers import (
BookMetadata,
MetadataProvider,
MetadataSearchOptions,
register_provider,
register_provider_kwargs,
)
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_kwargs("my_provider")
def _my_provider_kwargs() -> dict:
"""Keyword arguments for MyProvider(...), read from settings each time one is created."""
return {"api_key": config.get("MY_PROVIDER_API_KEY", "")}
@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 = ""):
self.api_key = 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, options: MetadataSearchOptions) -> list[BookMetadata]:
# Implementation...
return []
def get_book(self, book_id: str) -> BookMetadata | None:
# Implementation...
return None
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
# Implementation...
return None
All four methods above are abstract, so a provider that leaves one out cannot be instantiated. The provider module also has to be imported once so its decorators run: add it to the list of imports at the bottom of shelfmark/metadata_providers/__init__.py.
Complete Example: Release Source
Here's a complete example for a release source plugin:
# shelfmark/release_sources/my_source.py
from shelfmark.release_sources import (
DownloadHandler,
ReleaseSource,
register_handler,
register_source,
)
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, plan, *, expand_search=False, content_type="ebook"):
# Implementation...
return []
@register_handler("my_source")
class MySourceHandler(DownloadHandler):
"""Handler for downloading from My Source."""
def download(self, task, cancel_flag, progress_callback, status_callback):
# Implementation...
return None
def cancel(self, task_id):
return False
The full source and handler interfaces, and how to register the module, are in the Release Sources Plugin Guide.
Best Practices
-
Use descriptive keys: Keys should be uppercase and prefixed with your plugin name (e.g.,
MY_PLUGIN_API_KEY) -
Provide helpful descriptions: Include enough detail in descriptions to help users understand what each setting does
-
Set sensible defaults: Users should be able to get started without configuring everything
-
Use conditional visibility: Hide advanced options behind enabling checkboxes to reduce UI clutter
-
Include a test button: ActionButtons that test connections help users verify their configuration
-
Mark restart-required settings: Use
requires_restart=Truefor settings that can't be applied live -
Group related settings: Use HeadingField to visually separate sections, and put plugins in appropriate groups
-
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
{
"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>
// 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>
// Response
{
"success": true,
"message": "Connection successful!"
}