mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:30:30 +01:00
LOG_LEVEL never reached the app logger: env.py hardcoded the level to DEBUG or INFO, so INFO lines kept appearing under LOG_LEVEL=error. Read it from the env var and advanced settings, normalize unknown values to INFO, and expose it as a setting. entrypoint.sh now normalizes gunicorn's level too, so a typo falls back to info instead of stopping the container from booting. Z-Library gates the first hit on /md5/<hash> with a 503 whose only payload is a Set-Cookie; echoing that cookie back returns the 302 to the real page. html_get_page dropped it and re-ran the same rejected request on every retry, ending in "No download URL resolved". Retry once with the cookies the 503 issued. Fixes #1185 Fixes #1187
1954 lines
75 KiB
Python
1954 lines
75 KiB
Python
"""Core settings registration and derived configuration values."""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from shelfmark.config import env
|
|
from shelfmark.config.booklore_settings import (
|
|
check_booklore_connection,
|
|
get_booklore_library_options,
|
|
get_booklore_path_options,
|
|
)
|
|
from shelfmark.config.download_settings_handlers import (
|
|
check_audiobook_destination,
|
|
check_books_destination,
|
|
)
|
|
from shelfmark.config.email_settings import check_email_connection
|
|
from shelfmark.core.languages import supported_book_languages
|
|
from shelfmark.core.logger import setup_logger
|
|
from shelfmark.core.settings_registry import (
|
|
ActionButton,
|
|
CheckboxField,
|
|
CustomComponentField,
|
|
HeadingField,
|
|
MultiSelectField,
|
|
NumberField,
|
|
OrderableListField,
|
|
PasswordField,
|
|
SelectField,
|
|
SettingsField,
|
|
TableField,
|
|
TagListField,
|
|
TextField,
|
|
load_config_file,
|
|
register_group,
|
|
register_on_save,
|
|
register_settings,
|
|
)
|
|
|
|
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT = 60
|
|
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX = 3600
|
|
|
|
|
|
def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate advanced settings before persisting."""
|
|
from shelfmark.core.logger import setup_logger
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
timeout_key = "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT"
|
|
if timeout_key in values:
|
|
raw_timeout = values.get(timeout_key)
|
|
if isinstance(raw_timeout, bool):
|
|
return {
|
|
"error": True,
|
|
"message": "Completed Path Wait must be a number of seconds",
|
|
"values": values,
|
|
}
|
|
if raw_timeout is None:
|
|
return {
|
|
"error": True,
|
|
"message": "Completed Path Wait must be a number of seconds",
|
|
"values": values,
|
|
}
|
|
try:
|
|
timeout_seconds = int(raw_timeout)
|
|
except TypeError, ValueError:
|
|
return {
|
|
"error": True,
|
|
"message": "Completed Path Wait must be a number of seconds",
|
|
"values": values,
|
|
}
|
|
if timeout_seconds < 0 or timeout_seconds > _DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX:
|
|
return {
|
|
"error": True,
|
|
"message": (
|
|
"Completed Path Wait must be between 0 and "
|
|
f"{_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX} seconds"
|
|
),
|
|
"values": values,
|
|
}
|
|
values[timeout_key] = timeout_seconds
|
|
|
|
mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS")
|
|
if mappings is None:
|
|
return {"error": False, "values": values}
|
|
|
|
if not isinstance(mappings, list):
|
|
return {
|
|
"error": True,
|
|
"message": "Remote path mappings must be a list",
|
|
"values": values,
|
|
}
|
|
|
|
logger.debug("Processing %d remote path mapping entries", len(mappings))
|
|
|
|
cleaned = []
|
|
for i, entry in enumerate(mappings):
|
|
if not isinstance(entry, dict):
|
|
logger.debug("Skipping entry %d: not a dict", i)
|
|
continue
|
|
|
|
host = str(entry.get("host", "") or "").strip().lower()
|
|
remote_path = str(entry.get("remotePath", "") or "").strip()
|
|
local_path = str(entry.get("localPath", "") or "").strip()
|
|
|
|
if not host or not remote_path or not local_path:
|
|
logger.debug(
|
|
"Skipping entry %d: missing field(s) - host=%s, remotePath=%s, localPath=%s",
|
|
i,
|
|
host,
|
|
remote_path,
|
|
local_path,
|
|
)
|
|
continue
|
|
|
|
if not local_path.startswith("/"):
|
|
return {
|
|
"error": True,
|
|
"message": f"Local Path must be an absolute path (got: {local_path})",
|
|
"values": values,
|
|
}
|
|
|
|
cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path})
|
|
|
|
logger.info("Saved %d remote path mapping(s)", len(cleaned))
|
|
if cleaned:
|
|
for m in cleaned:
|
|
logger.debug(
|
|
" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"]
|
|
)
|
|
|
|
values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned
|
|
return {"error": False, "values": values}
|
|
|
|
|
|
logger = setup_logger(__name__)
|
|
_SMTP_PORT_MAX = 65535
|
|
_EMAIL_ATTACHMENT_LIMIT_MB_MAX = 600
|
|
|
|
# Log bootstrap configuration values at DEBUG level
|
|
logger.debug("Bootstrap configuration:")
|
|
for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMODE"]:
|
|
if hasattr(env, key):
|
|
logger.debug(" %s: %s", key, getattr(env, key))
|
|
|
|
# Selectable book languages, without the resolution aliases clients do not need.
|
|
_SUPPORTED_BOOK_LANGUAGE = supported_book_languages()
|
|
|
|
# Directory settings
|
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
logger.debug("BASE_DIR: %s", BASE_DIR)
|
|
if env.ENABLE_LOGGING:
|
|
env.LOG_DIR.mkdir(exist_ok=True)
|
|
|
|
# Create staging directory (destination is created by orchestrator using config value)
|
|
env.TMP_DIR.mkdir(exist_ok=True)
|
|
|
|
# DNS placeholders - actual values set by network.init() from config/ENV
|
|
CUSTOM_DNS: list[str] = []
|
|
DOH_SERVER: str = ""
|
|
|
|
# Recording directory for debugging internal cloudflare bypasser
|
|
RECORDING_DIR = env.LOG_DIR / "recording"
|
|
|
|
|
|
def _log_external_bypasser_warning() -> None:
|
|
"""Log warning about external bypasser DNS limitations (called after config is available)."""
|
|
from shelfmark.core.config import config
|
|
|
|
if config.get("USING_EXTERNAL_BYPASSER", False) and config.get("USE_CF_BYPASS", True):
|
|
logger.warning(
|
|
"Using external bypasser (FlareSolverr). Note: FlareSolverr uses its own DNS resolution, "
|
|
"not this application's custom DNS settings. If you experience DNS-related blocks, "
|
|
"configure DNS at the Docker/system level for your FlareSolverr container, "
|
|
"or consider using the internal bypasser which integrates with the app's DNS system."
|
|
)
|
|
|
|
|
|
register_group("direct_download", "Direct Download", icon="download", order=20)
|
|
|
|
register_group(
|
|
"metadata_providers",
|
|
"Metadata Providers",
|
|
icon="book",
|
|
order=12, # Between Network (10) and Advanced (15)
|
|
)
|
|
|
|
|
|
# Direct mode sort options
|
|
_AA_SORT_OPTIONS = [
|
|
{"value": "relevance", "label": "Most relevant"},
|
|
{"value": "newest", "label": "Newest (publication year)"},
|
|
{"value": "oldest", "label": "Oldest (publication year)"},
|
|
{"value": "largest", "label": "Largest (filesize)"},
|
|
{"value": "smallest", "label": "Smallest (filesize)"},
|
|
{"value": "newest_added", "label": "Newest (open sourced)"},
|
|
{"value": "oldest_added", "label": "Oldest (open sourced)"},
|
|
]
|
|
|
|
_FORMAT_OPTIONS = [
|
|
{"value": "epub", "label": "EPUB"},
|
|
{"value": "mobi", "label": "MOBI"},
|
|
{"value": "azw3", "label": "AZW3"},
|
|
{"value": "pdf", "label": "PDF"},
|
|
{"value": "fb2", "label": "FB2"},
|
|
{"value": "djvu", "label": "DJVU"},
|
|
{"value": "cbz", "label": "CBZ"},
|
|
{"value": "cbr", "label": "CBR"},
|
|
{"value": "txt", "label": "TXT"},
|
|
{"value": "rtf", "label": "RTF"},
|
|
{"value": "doc", "label": "DOC"},
|
|
{"value": "docx", "label": "DOCX"},
|
|
{"value": "zip", "label": "ZIP"},
|
|
{"value": "rar", "label": "RAR"},
|
|
]
|
|
|
|
_AUDIOBOOK_FORMAT_OPTIONS = [
|
|
{"value": "m4b", "label": "M4B"},
|
|
{"value": "mp3", "label": "MP3"},
|
|
{"value": "m4a", "label": "M4A"},
|
|
{"value": "zip", "label": "ZIP"},
|
|
{"value": "rar", "label": "RAR"},
|
|
]
|
|
|
|
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
|
|
{
|
|
"value": "book",
|
|
"label": "Books",
|
|
"description": "Automatically download completed book files to this browser.",
|
|
},
|
|
{
|
|
"value": "audiobook",
|
|
"label": "Audiobooks",
|
|
"description": "Automatically download completed audiobook files to this browser.",
|
|
},
|
|
]
|
|
|
|
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
|
|
option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS
|
|
}
|
|
|
|
|
|
def _get_metadata_provider_options() -> list[dict[str, str]]:
|
|
"""Build metadata provider options dynamically from enabled providers only."""
|
|
from shelfmark.metadata_providers import is_provider_enabled, list_providers
|
|
|
|
options = [
|
|
{"value": provider["name"], "label": provider["display_name"]}
|
|
for provider in list_providers()
|
|
if is_provider_enabled(provider["name"])
|
|
]
|
|
|
|
# If no providers enabled, show a placeholder option
|
|
if not options:
|
|
options = [
|
|
{"value": "", "label": "No providers enabled"},
|
|
]
|
|
|
|
return options
|
|
|
|
|
|
def _get_metadata_provider_options_with_none() -> list[dict[str, str]]:
|
|
"""Build metadata provider options with a 'Use main provider' option first."""
|
|
return [{"value": "", "label": "Use book provider"}, *_get_metadata_provider_options()]
|
|
|
|
|
|
def _get_release_source_options_for_content_type(content_type: str) -> list[dict[str, str]]:
|
|
"""Build release source options dynamically for a specific content type."""
|
|
from shelfmark.release_sources import list_available_sources
|
|
|
|
return [
|
|
{"value": source["name"], "label": source["display_name"]}
|
|
for source in list_available_sources()
|
|
if source.get("enabled", True)
|
|
and source.get("can_be_default", True)
|
|
and content_type in source.get("supported_content_types", ["ebook", "audiobook"])
|
|
]
|
|
|
|
|
|
def _get_book_release_source_options() -> list[dict[str, str]]:
|
|
"""Build default release source options for book searches."""
|
|
return [
|
|
{"value": "", "label": "Use first available source"},
|
|
*_get_release_source_options_for_content_type("ebook"),
|
|
]
|
|
|
|
|
|
def _get_audiobook_release_source_options() -> list[dict[str, str]]:
|
|
"""Build default release source options for audiobook searches."""
|
|
return [
|
|
{"value": "", "label": "Use book release source"},
|
|
*_get_release_source_options_for_content_type("audiobook"),
|
|
]
|
|
|
|
|
|
_LANGUAGE_OPTIONS = [
|
|
{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE
|
|
]
|
|
|
|
|
|
def _string_setting(value: object) -> str:
|
|
"""Normalize free-form string settings used by select option builders."""
|
|
return value if isinstance(value, str) else str(value or "")
|
|
|
|
|
|
def _get_aa_base_url_options() -> list[dict[str, str]]:
|
|
"""Build AA URL options dynamically from user-supplied mirrors."""
|
|
from shelfmark.core.config import config
|
|
from shelfmark.core.mirrors import get_aa_mirrors
|
|
from shelfmark.core.utils import normalize_http_url
|
|
|
|
options = [{"value": "auto", "label": "Auto (Recommended)"}]
|
|
|
|
all_mirrors = get_aa_mirrors()
|
|
|
|
configured_url = normalize_http_url(
|
|
_string_setting(config.get("AA_BASE_URL", "auto")),
|
|
default_scheme="https",
|
|
allow_special=("auto",),
|
|
)
|
|
if configured_url and configured_url != "auto" and configured_url not in all_mirrors:
|
|
all_mirrors = [configured_url, *all_mirrors]
|
|
|
|
for url in all_mirrors:
|
|
domain = url.replace("https://", "").replace("http://", "")
|
|
label = domain
|
|
if configured_url and url == configured_url:
|
|
label = f"{domain} (configured)"
|
|
options.append({"value": url, "label": label})
|
|
|
|
return options
|
|
|
|
|
|
def _clear_covers_cache(current_values: dict) -> dict:
|
|
"""Clear the cover image cache."""
|
|
try:
|
|
from shelfmark.core.image_cache import get_image_cache, reset_image_cache
|
|
|
|
cache = get_image_cache()
|
|
count = cache.clear()
|
|
|
|
# Reset the singleton so it reinitializes with fresh state
|
|
reset_image_cache()
|
|
|
|
except Exception as e:
|
|
logger.exception("Failed to clear cover cache")
|
|
return {
|
|
"success": False,
|
|
"message": f"Failed to clear cache: {e!s}",
|
|
}
|
|
else:
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleared {count} cached cover images.",
|
|
}
|
|
|
|
|
|
def _clear_metadata_cache(current_values: dict) -> dict:
|
|
"""Clear the in-memory metadata cache."""
|
|
try:
|
|
from shelfmark.core.cache import get_metadata_cache
|
|
|
|
cache = get_metadata_cache()
|
|
stats_before = cache.stats()
|
|
cache.clear()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleared {stats_before['size']} cached entries.",
|
|
}
|
|
except Exception as e:
|
|
logger.exception("Failed to clear metadata cache")
|
|
return {
|
|
"success": False,
|
|
"message": f"Failed to clear cache: {e!s}",
|
|
}
|
|
|
|
|
|
@register_settings("general", "General", icon="settings", order=0)
|
|
def general_settings() -> list[SettingsField]:
|
|
"""Core application settings."""
|
|
return [
|
|
TextField(
|
|
key="SEARCH_PAGE_TITLE",
|
|
label="Search Page Title",
|
|
description="Title shown above the main search box on the homepage.",
|
|
default="Shelfmark",
|
|
placeholder="Shelfmark",
|
|
),
|
|
TextField(
|
|
key="CALIBRE_WEB_URL",
|
|
label="Library URL",
|
|
description="Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc).",
|
|
placeholder="http://calibre-web:8083",
|
|
),
|
|
TextField(
|
|
key="AUDIOBOOK_LIBRARY_URL",
|
|
label="Audiobook Library URL",
|
|
description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.",
|
|
placeholder="http://audiobookshelf:8080",
|
|
),
|
|
HeadingField(
|
|
key="search_defaults_heading",
|
|
title="Default Search Filters",
|
|
description="Default filters applied to searches. Can be overridden using advanced search options.",
|
|
),
|
|
MultiSelectField(
|
|
key="SUPPORTED_FORMATS",
|
|
label="Supported Book Formats",
|
|
description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.",
|
|
options=_FORMAT_OPTIONS,
|
|
default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
|
|
),
|
|
MultiSelectField(
|
|
key="SUPPORTED_AUDIOBOOK_FORMATS",
|
|
label="Supported Audiobook Formats",
|
|
description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.",
|
|
options=_AUDIOBOOK_FORMAT_OPTIONS,
|
|
default=["m4b", "mp3"],
|
|
),
|
|
MultiSelectField(
|
|
key="BOOK_LANGUAGE",
|
|
label="Default Book Languages",
|
|
description="Default language filter for searches.",
|
|
options=_LANGUAGE_OPTIONS,
|
|
default=["en"],
|
|
),
|
|
]
|
|
|
|
|
|
@register_settings("search_mode", "Search Mode", icon="search", order=1)
|
|
def search_mode_settings() -> list[SettingsField]:
|
|
"""Configure how you search for and download books."""
|
|
return [
|
|
HeadingField(
|
|
key="search_mode_heading",
|
|
title="Search Mode",
|
|
description=(
|
|
"Direct mode uses the optional Direct Download source. Universal mode uses "
|
|
"metadata search with whichever release sources you have enabled."
|
|
),
|
|
),
|
|
SelectField(
|
|
key="SEARCH_MODE",
|
|
label="Search Mode",
|
|
description="How you want to search for and download books.",
|
|
options=[
|
|
{
|
|
"value": "direct",
|
|
"label": "Direct",
|
|
"description": (
|
|
"Search with the Direct Download source. Requires enabling the source "
|
|
"and adding your own mirror URLs."
|
|
),
|
|
},
|
|
{
|
|
"value": "universal",
|
|
"label": "Universal",
|
|
"description": "Metadata-based search with downloads from all sources. Book and Audiobook support.",
|
|
},
|
|
],
|
|
default="universal",
|
|
user_overridable=True,
|
|
),
|
|
SelectField(
|
|
key="AA_DEFAULT_SORT",
|
|
label="Default Sort Order",
|
|
description="Default sort order for search results.",
|
|
options=_AA_SORT_OPTIONS,
|
|
default="relevance",
|
|
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
|
),
|
|
CheckboxField(
|
|
key="SHOW_RELEASE_SOURCE_LINKS",
|
|
label="Show Release Source Links",
|
|
description=(
|
|
"Show clickable release-source links in release and details modals. "
|
|
"Metadata provider links stay enabled."
|
|
),
|
|
default=True,
|
|
),
|
|
CheckboxField(
|
|
key="SHOW_COMBINED_SELECTOR",
|
|
label="Show Combined Download Selector",
|
|
description="Show the option to search for and download both a book and audiobook together.",
|
|
default=True,
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
CheckboxField(
|
|
key="FORCE_COMBINED_SEARCH",
|
|
label="Always Use Combined Search",
|
|
description="Force combined search whenever it's available. Locks the combined toggle on.",
|
|
default=False,
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
HeadingField(
|
|
key="universal_mode_heading",
|
|
title="Universal Mode Settings",
|
|
description="Configure metadata providers and release sources for Universal search mode.",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
),
|
|
SelectField(
|
|
key="METADATA_PROVIDER",
|
|
label="Book Metadata Provider",
|
|
description="Choose which metadata provider to use for book searches.",
|
|
options=_get_metadata_provider_options, # Callable - evaluated lazily to avoid circular imports
|
|
default="openlibrary",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
SelectField(
|
|
key="METADATA_PROVIDER_AUDIOBOOK",
|
|
label="Audiobook Metadata Provider",
|
|
description="Metadata provider for audiobook searches. Uses the book provider if not set.",
|
|
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
|
|
default="",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
SelectField(
|
|
key="METADATA_PROVIDER_COMBINED",
|
|
label="Combined Mode Metadata Provider",
|
|
description="Metadata provider for combined mode searches. Uses the book provider if not set.",
|
|
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
|
|
default="",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
SelectField(
|
|
key="DEFAULT_RELEASE_SOURCE",
|
|
label="Default Book Release Source",
|
|
description=(
|
|
"The release source tab to open by default in the release modal for books. "
|
|
"Leave unset to use the first available source."
|
|
),
|
|
options=_get_book_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
|
default="",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
SelectField(
|
|
key="DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
|
label="Default Audiobook Release Source",
|
|
description="The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.",
|
|
options=_get_audiobook_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
|
default="",
|
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
|
user_overridable=True,
|
|
),
|
|
]
|
|
|
|
|
|
@register_settings("network", "Network", icon="globe", order=10)
|
|
def network_settings() -> list[SettingsField]:
|
|
"""Network and connectivity settings."""
|
|
# Avoid querying the live config singleton while settings are still being
|
|
# registered, which can recurse back into this module during import.
|
|
tor_enabled = env.USING_TOR
|
|
|
|
# When Tor is enabled, DNS/proxy settings are overridden by iptables rules
|
|
# Tor uses iptables to force ALL traffic through Tor
|
|
tor_overrides_network = tor_enabled # Only override when Tor is actually active
|
|
|
|
return [
|
|
SelectField(
|
|
key="CERTIFICATE_VALIDATION",
|
|
label="Certificate Validation",
|
|
description="Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).",
|
|
options=[
|
|
{"value": "enabled", "label": "Enabled (Recommended)"},
|
|
{"value": "disabled_local", "label": "Disabled for Local Addresses"},
|
|
{"value": "disabled", "label": "Disabled"},
|
|
],
|
|
default="enabled",
|
|
),
|
|
SelectField(
|
|
key="CUSTOM_DNS",
|
|
label="DNS Provider",
|
|
description=(
|
|
"Managed by Tor when Tor routing is enabled."
|
|
if tor_overrides_network
|
|
else "DNS provider for domain resolution. 'Auto' rotates through providers on failure."
|
|
),
|
|
options=[
|
|
{"value": "auto", "label": "Auto (Recommended)"},
|
|
{"value": "system", "label": "System"},
|
|
{"value": "google", "label": "Google"},
|
|
{"value": "cloudflare", "label": "Cloudflare"},
|
|
{"value": "quad9", "label": "Quad9"},
|
|
{"value": "opendns", "label": "OpenDNS"},
|
|
{"value": "manual", "label": "Manual"},
|
|
],
|
|
default="auto",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="DNS is managed by Tor when Tor routing is enabled.",
|
|
),
|
|
TextField(
|
|
key="CUSTOM_DNS_MANUAL",
|
|
label="Manual DNS Servers",
|
|
description="Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1).",
|
|
placeholder="8.8.8.8, 1.1.1.1",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="DNS is managed by Tor when Tor routing is enabled.",
|
|
show_when={"field": "CUSTOM_DNS", "value": "manual"},
|
|
),
|
|
CheckboxField(
|
|
key="USE_DOH",
|
|
label="Use DNS over HTTPS",
|
|
description=(
|
|
"Not applicable when Tor routing is enabled."
|
|
if tor_overrides_network
|
|
else "Use encrypted DNS queries for improved reliability and privacy."
|
|
),
|
|
default=True,
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="DNS over HTTPS is not used when Tor routing is enabled.",
|
|
# Hide for manual and system (no DoH endpoint available for custom IPs or system DNS)
|
|
show_when={
|
|
"field": "CUSTOM_DNS",
|
|
"value": ["auto", "google", "cloudflare", "quad9", "opendns"],
|
|
},
|
|
# Disable for auto (always uses DoH)
|
|
disabled_when={
|
|
"field": "CUSTOM_DNS",
|
|
"value": "auto",
|
|
"reason": "Auto mode always uses DNS over HTTPS for reliable provider rotation.",
|
|
},
|
|
),
|
|
CheckboxField(
|
|
key="USING_TOR",
|
|
label="Tor Routing",
|
|
description=(
|
|
"All traffic is routed through Tor. Requires container restart to change."
|
|
if tor_enabled
|
|
else "Route all traffic through Tor for enhanced privacy. Requires root startup."
|
|
),
|
|
default=tor_enabled, # Reflects actual state from env var
|
|
disabled=True, # Tor state requires container restart
|
|
disabled_reason=(
|
|
"Tor routing is active. Set USING_TOR=false and restart to disable."
|
|
if tor_enabled
|
|
else "Set USING_TOR=true env var and restart as root."
|
|
),
|
|
),
|
|
SelectField(
|
|
key="PROXY_MODE",
|
|
label="Proxy Mode",
|
|
description=(
|
|
"Not applicable when Tor routing is enabled."
|
|
if tor_overrides_network
|
|
else "Choose proxy type. SOCKS5 handles all traffic through a single proxy."
|
|
),
|
|
options=[
|
|
{"value": "none", "label": "None (Direct Connection)"},
|
|
{"value": "http", "label": "HTTP/HTTPS Proxy"},
|
|
{"value": "socks5", "label": "SOCKS5 Proxy"},
|
|
],
|
|
default="none",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
|
),
|
|
TextField(
|
|
key="HTTP_PROXY",
|
|
label="HTTP Proxy",
|
|
description="HTTP proxy URL (e.g., http://proxy:8080)",
|
|
placeholder="http://proxy:8080",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
|
show_when={"field": "PROXY_MODE", "value": "http"},
|
|
),
|
|
TextField(
|
|
key="HTTPS_PROXY",
|
|
label="HTTPS Proxy",
|
|
description="HTTPS proxy URL (leave empty to use HTTP proxy for HTTPS)",
|
|
placeholder="http://proxy:8080",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
|
show_when={"field": "PROXY_MODE", "value": "http"},
|
|
),
|
|
TextField(
|
|
key="SOCKS5_PROXY",
|
|
label="SOCKS5 Proxy",
|
|
description="SOCKS5 proxy URL. Supports auth: socks5://user:pass@host:port",
|
|
placeholder="socks5://localhost:1080",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
|
show_when={"field": "PROXY_MODE", "value": "socks5"},
|
|
),
|
|
TextField(
|
|
key="NO_PROXY",
|
|
label="No Proxy",
|
|
description="Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)",
|
|
disabled=tor_overrides_network,
|
|
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
|
show_when={"field": "PROXY_MODE", "value": ["http", "socks5"]},
|
|
),
|
|
]
|
|
|
|
|
|
def _contains_path_separators(value: Any) -> bool:
|
|
return isinstance(value, str) and ("/" in value or "\\" in value)
|
|
|
|
|
|
def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate download settings before persisting."""
|
|
existing = load_config_file("downloads")
|
|
effective: dict[str, Any] = dict(existing)
|
|
effective.update(values)
|
|
|
|
if "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" in effective:
|
|
raw_content_types = effective.get("DOWNLOAD_TO_BROWSER_CONTENT_TYPES")
|
|
if raw_content_types is None:
|
|
normalized_content_types: list[str] = []
|
|
elif isinstance(raw_content_types, list):
|
|
normalized_content_types = [
|
|
str(value).strip().lower() for value in raw_content_types if str(value).strip()
|
|
]
|
|
else:
|
|
return {
|
|
"error": True,
|
|
"message": "Download to Browser must be a list.",
|
|
"values": values,
|
|
}
|
|
|
|
deduped_content_types: list[str] = []
|
|
for content_type in normalized_content_types:
|
|
if content_type not in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES:
|
|
allowed = ", ".join(sorted(_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES))
|
|
return {
|
|
"error": True,
|
|
"message": (
|
|
"Download to Browser contains an unsupported content type "
|
|
f"'{content_type}'. Supported values: {allowed}"
|
|
),
|
|
"values": values,
|
|
}
|
|
if content_type not in deduped_content_types:
|
|
deduped_content_types.append(content_type)
|
|
|
|
values["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
|
effective["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
|
|
|
# Books: only validate templates when saving to a folder.
|
|
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
|
|
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
|
|
template = effective.get("TEMPLATE_RENAME", "")
|
|
if _contains_path_separators(template):
|
|
return {
|
|
"error": True,
|
|
"message": "Books Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
|
|
"values": values,
|
|
}
|
|
|
|
# Audiobooks are always folder output.
|
|
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename":
|
|
template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "")
|
|
if _contains_path_separators(template):
|
|
return {
|
|
"error": True,
|
|
"message": "Audiobooks Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
|
|
"values": values,
|
|
}
|
|
|
|
# Email output (SMTP) validation.
|
|
if books_output_mode == "email":
|
|
from email.utils import parseaddr
|
|
|
|
def _is_plain_email_address(addr: str) -> bool:
|
|
parsed = parseaddr(addr or "")[1]
|
|
return bool(parsed) and "@" in parsed and parsed == addr
|
|
|
|
# Preferred model: single recipient for global default and per-user override.
|
|
raw_recipient = str(effective.get("EMAIL_RECIPIENT", "") or "").strip()
|
|
|
|
# Optional global fallback: validate only when a default recipient is provided.
|
|
if raw_recipient and not _is_plain_email_address(raw_recipient):
|
|
return {
|
|
"error": True,
|
|
"message": "Email recipient must be a valid plain email address.",
|
|
"values": values,
|
|
}
|
|
|
|
smtp_host = str(effective.get("EMAIL_SMTP_HOST", "") or "").strip()
|
|
if not smtp_host:
|
|
return {"error": True, "message": "SMTP host is required", "values": values}
|
|
|
|
security = str(effective.get("EMAIL_SMTP_SECURITY", "starttls") or "").strip().lower()
|
|
if security not in {"none", "starttls", "ssl"}:
|
|
return {
|
|
"error": True,
|
|
"message": "SMTP security must be one of: none, starttls, ssl",
|
|
"values": values,
|
|
}
|
|
|
|
try:
|
|
port = int(effective.get("EMAIL_SMTP_PORT", 587))
|
|
except TypeError, ValueError:
|
|
return {"error": True, "message": "SMTP port must be a number", "values": values}
|
|
|
|
if port < 1 or port > _SMTP_PORT_MAX:
|
|
return {
|
|
"error": True,
|
|
"message": f"SMTP port must be between 1 and {_SMTP_PORT_MAX}",
|
|
"values": values,
|
|
}
|
|
|
|
try:
|
|
timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60))
|
|
except TypeError, ValueError:
|
|
return {
|
|
"error": True,
|
|
"message": "SMTP timeout (seconds) must be a number",
|
|
"values": values,
|
|
}
|
|
|
|
if timeout_seconds < 1:
|
|
return {
|
|
"error": True,
|
|
"message": "SMTP timeout (seconds) must be >= 1",
|
|
"values": values,
|
|
}
|
|
|
|
username = str(effective.get("EMAIL_SMTP_USERNAME", "") or "").strip()
|
|
password = effective.get("EMAIL_SMTP_PASSWORD", "") or ""
|
|
if username and not password:
|
|
return {
|
|
"error": True,
|
|
"message": "SMTP password is required when username is set",
|
|
"values": values,
|
|
}
|
|
|
|
try:
|
|
attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25))
|
|
except TypeError, ValueError:
|
|
return {
|
|
"error": True,
|
|
"message": "Attachment size limit (MB) must be a number",
|
|
"values": values,
|
|
}
|
|
|
|
if attachment_limit_mb < 1 or attachment_limit_mb > _EMAIL_ATTACHMENT_LIMIT_MB_MAX:
|
|
return {
|
|
"error": True,
|
|
"message": (
|
|
"Attachment size limit (MB) must be between 1 and "
|
|
f"{_EMAIL_ATTACHMENT_LIMIT_MB_MAX}"
|
|
),
|
|
"values": values,
|
|
}
|
|
|
|
from_addr = str(effective.get("EMAIL_FROM", "") or "").strip()
|
|
if not from_addr:
|
|
# If From is empty, default to the SMTP username when it looks like an email address.
|
|
username_email = parseaddr(username)[1]
|
|
if username_email and "@" in username_email:
|
|
from_addr = f"Shelfmark <{username_email}>"
|
|
values["EMAIL_FROM"] = from_addr
|
|
else:
|
|
return {
|
|
"error": True,
|
|
"message": "From address is required (or set SMTP username to an email address).",
|
|
"values": values,
|
|
}
|
|
else:
|
|
from_email = parseaddr(from_addr)[1]
|
|
if not from_email or "@" not in from_email:
|
|
return {
|
|
"error": True,
|
|
"message": "From address must be a valid email address",
|
|
"values": values,
|
|
}
|
|
|
|
# Persist any normalization/coercion for fields that may have been edited this save.
|
|
if "EMAIL_RECIPIENT" in values:
|
|
values["EMAIL_RECIPIENT"] = raw_recipient
|
|
if "EMAIL_SMTP_SECURITY" in values:
|
|
values["EMAIL_SMTP_SECURITY"] = security
|
|
if "EMAIL_SMTP_PORT" in values:
|
|
values["EMAIL_SMTP_PORT"] = port
|
|
if "EMAIL_SMTP_TIMEOUT_SECONDS" in values:
|
|
values["EMAIL_SMTP_TIMEOUT_SECONDS"] = timeout_seconds
|
|
if "EMAIL_ATTACHMENT_SIZE_LIMIT_MB" in values:
|
|
values["EMAIL_ATTACHMENT_SIZE_LIMIT_MB"] = attachment_limit_mb
|
|
|
|
return {"error": False, "values": values}
|
|
|
|
|
|
def _naming_template_field(
|
|
*,
|
|
key: str,
|
|
label: str,
|
|
description: str,
|
|
default: str,
|
|
placeholder: str,
|
|
show_when: dict[str, Any] | list[dict[str, Any]],
|
|
universal_only: bool = False,
|
|
) -> CustomComponentField:
|
|
return CustomComponentField(
|
|
key=f"{key.lower()}_editor",
|
|
label=label,
|
|
component="naming_template",
|
|
show_when=show_when,
|
|
universal_only=universal_only,
|
|
wrap_in_field_wrapper=True,
|
|
value_fields=[
|
|
TextField(
|
|
key=key,
|
|
label=label,
|
|
description=description,
|
|
default=default,
|
|
placeholder=placeholder,
|
|
show_when=show_when,
|
|
universal_only=universal_only,
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@register_settings("downloads", "Downloads", icon="folder", order=5)
|
|
def download_settings() -> list[SettingsField]:
|
|
"""Configure download behavior and file locations."""
|
|
return [
|
|
# === BOOKS SECTION ===
|
|
# Visible for ALL modes (Direct + Universal)
|
|
HeadingField(
|
|
key="books_heading",
|
|
title="Books",
|
|
description="Configure where ebooks, comics, and magazines are saved.",
|
|
),
|
|
SelectField(
|
|
key="BOOKS_OUTPUT_MODE",
|
|
label="Output Mode",
|
|
description="Choose where completed book files are sent.",
|
|
options=[
|
|
{
|
|
"value": "folder",
|
|
"label": "Folder",
|
|
"description": "Save files to the destination folder",
|
|
},
|
|
{
|
|
"value": "email",
|
|
"label": "Email (SMTP)",
|
|
"description": "Send files as an email attachment",
|
|
},
|
|
{
|
|
"value": "booklore",
|
|
"label": "Grimmory (API)",
|
|
"description": "Upload files directly to Grimmory",
|
|
},
|
|
],
|
|
default="folder",
|
|
user_overridable=True,
|
|
),
|
|
TextField(
|
|
key="DESTINATION",
|
|
label="Destination",
|
|
description="Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}).",
|
|
default="/books",
|
|
required=True,
|
|
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
|
|
user_overridable=True,
|
|
show_when={
|
|
"field": "BOOKS_OUTPUT_MODE",
|
|
"value": "folder",
|
|
},
|
|
),
|
|
ActionButton(
|
|
key="test_destination",
|
|
label="Test Destination",
|
|
description="Check that Shelfmark can create and write to this destination.",
|
|
style="primary",
|
|
callback=check_books_destination,
|
|
show_when={
|
|
"field": "BOOKS_OUTPUT_MODE",
|
|
"value": "folder",
|
|
},
|
|
),
|
|
SelectField(
|
|
key="FILE_ORGANIZATION",
|
|
label="File Organization",
|
|
description="Choose how downloaded book files are named and organized.",
|
|
options=[
|
|
{
|
|
"value": "none",
|
|
"label": "None",
|
|
"description": "Keep original filename from source",
|
|
},
|
|
{
|
|
"value": "rename",
|
|
"label": "Rename Only",
|
|
"description": "Rename single-file downloads; multi-file keeps original names.",
|
|
},
|
|
{
|
|
"value": "organize",
|
|
"label": "Rename and Organize",
|
|
"description": "Create folders and rename files using a template. Do not use with ingest folders.",
|
|
},
|
|
],
|
|
default="rename",
|
|
show_when={
|
|
"field": "BOOKS_OUTPUT_MODE",
|
|
"value": "folder",
|
|
},
|
|
),
|
|
# Rename mode template - filename only
|
|
_naming_template_field(
|
|
key="TEMPLATE_RENAME",
|
|
label="Naming Template",
|
|
description=(
|
|
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
|
"(source filename without extension). Universal adds: {Series}, "
|
|
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
|
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
|
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
|
|
"Applies to single-file downloads."
|
|
),
|
|
default="{Author} - {Title} ({Year})",
|
|
placeholder="{Author} - {Title} ({Year})",
|
|
show_when=[
|
|
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
|
|
{"field": "FILE_ORGANIZATION", "value": "rename"},
|
|
],
|
|
),
|
|
# Organize mode template - folders allowed
|
|
_naming_template_field(
|
|
key="TEMPLATE_ORGANIZE",
|
|
label="Path Template",
|
|
description=(
|
|
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
|
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
|
|
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
|
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
|
),
|
|
default="{Author}/{Title} ({Year})",
|
|
placeholder="{Author}/{Series/}{Title} ({Year})",
|
|
show_when=[
|
|
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
|
|
{"field": "FILE_ORGANIZATION", "value": "organize"},
|
|
],
|
|
),
|
|
CheckboxField(
|
|
key="HARDLINK_TORRENTS",
|
|
label="Hardlink Book Torrents",
|
|
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
|
|
default=False,
|
|
universal_only=True,
|
|
show_when={
|
|
"field": "BOOKS_OUTPUT_MODE",
|
|
"value": "folder",
|
|
},
|
|
),
|
|
HeadingField(
|
|
key="booklore_heading",
|
|
title="Grimmory",
|
|
description="Upload books directly to Grimmory (Formerly Booklore) via API. Audiobooks always use folder mode.",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
TextField(
|
|
key="BOOKLORE_HOST",
|
|
label="Grimmory URL",
|
|
description="Base URL of your Grimmory instance",
|
|
placeholder="http://booklore:6060",
|
|
required=True,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
TextField(
|
|
key="BOOKLORE_USERNAME",
|
|
label="Username",
|
|
description="Grimmory account username",
|
|
required=True,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
PasswordField(
|
|
key="BOOKLORE_PASSWORD",
|
|
label="Password",
|
|
description="Grimmory account password",
|
|
required=True,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
SelectField(
|
|
key="BOOKLORE_DESTINATION",
|
|
label="Upload Destination",
|
|
description="Choose whether uploads go directly to a specific library path or to Bookdrop for review.",
|
|
options=[
|
|
{
|
|
"value": "library",
|
|
"label": "Specific Library",
|
|
"description": "Upload directly into the selected library path.",
|
|
},
|
|
{
|
|
"value": "bookdrop",
|
|
"label": "Bookdrop",
|
|
"description": "Upload into Bookdrop and review metadata before importing to a library.",
|
|
},
|
|
],
|
|
default="library",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
SelectField(
|
|
key="BOOKLORE_LIBRARY_ID",
|
|
label="Library",
|
|
description="Grimmory library to upload into.",
|
|
options=get_booklore_library_options,
|
|
required=True,
|
|
user_overridable=True,
|
|
show_when=[
|
|
{"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
{"field": "BOOKLORE_DESTINATION", "value": "library"},
|
|
],
|
|
),
|
|
SelectField(
|
|
key="BOOKLORE_PATH_ID",
|
|
label="Path",
|
|
description="Grimmory library path for uploads.",
|
|
options=get_booklore_path_options,
|
|
required=True,
|
|
filter_by_field="BOOKLORE_LIBRARY_ID",
|
|
user_overridable=True,
|
|
show_when=[
|
|
{"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
{"field": "BOOKLORE_DESTINATION", "value": "library"},
|
|
],
|
|
),
|
|
ActionButton(
|
|
key="test_booklore",
|
|
label="Test Connection",
|
|
description="Verify your Grimmory configuration",
|
|
style="primary",
|
|
callback=check_booklore_connection,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
|
),
|
|
HeadingField(
|
|
key="email_heading",
|
|
title="Email",
|
|
description="Send books as email attachments via SMTP. Audiobooks always use folder mode.",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
TextField(
|
|
key="EMAIL_RECIPIENT",
|
|
label="Default Email Recipient",
|
|
description="Optional fallback email address when no per-user email recipient override is configured.",
|
|
placeholder="reader@example.com",
|
|
user_overridable=True,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
NumberField(
|
|
key="EMAIL_ATTACHMENT_SIZE_LIMIT_MB",
|
|
label="Attachment Size Limit (MB)",
|
|
description="Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit.",
|
|
default=25,
|
|
min_value=1,
|
|
max_value=600,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
TextField(
|
|
key="EMAIL_SMTP_HOST",
|
|
label="SMTP Host",
|
|
description="SMTP server hostname or IP (e.g., smtp.gmail.com).",
|
|
placeholder="smtp.example.com",
|
|
required=True,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
NumberField(
|
|
key="EMAIL_SMTP_PORT",
|
|
label="SMTP Port",
|
|
description="SMTP server port (587 is typical for STARTTLS, 465 for SSL).",
|
|
default=587,
|
|
min_value=1,
|
|
max_value=65535,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
SelectField(
|
|
key="EMAIL_SMTP_SECURITY",
|
|
label="SMTP Security",
|
|
description="Transport security mode for SMTP.",
|
|
options=[
|
|
{"value": "none", "label": "None", "description": "No TLS (not recommended)."},
|
|
{
|
|
"value": "starttls",
|
|
"label": "STARTTLS",
|
|
"description": "Upgrade to TLS after connecting (recommended).",
|
|
},
|
|
{"value": "ssl", "label": "SSL/TLS", "description": "Connect using TLS (SMTPS)."},
|
|
],
|
|
default="starttls",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
TextField(
|
|
key="EMAIL_SMTP_USERNAME",
|
|
label="Username",
|
|
description="SMTP username (leave empty for no authentication).",
|
|
placeholder="user@example.com",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
PasswordField(
|
|
key="EMAIL_SMTP_PASSWORD",
|
|
label="Password",
|
|
description="SMTP password (required if Username is set).",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
TextField(
|
|
key="EMAIL_FROM",
|
|
label="From Address",
|
|
description="From address used for the email. You can include a display name (e.g., Shelfmark <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address).",
|
|
placeholder="Shelfmark <mail@example.com>",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
TextField(
|
|
key="EMAIL_SUBJECT_TEMPLATE",
|
|
label="Subject Template",
|
|
description="Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.",
|
|
default="{Title}",
|
|
placeholder="{Title}",
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
NumberField(
|
|
key="EMAIL_SMTP_TIMEOUT_SECONDS",
|
|
label="SMTP Timeout (seconds)",
|
|
description="How long to wait for SMTP operations before failing.",
|
|
default=60,
|
|
min_value=1,
|
|
max_value=600,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
CheckboxField(
|
|
key="EMAIL_ALLOW_UNVERIFIED_TLS",
|
|
label="Allow Unverified TLS",
|
|
description="Disable TLS certificate verification (not recommended).",
|
|
default=False,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
ActionButton(
|
|
key="test_email",
|
|
label="Test SMTP Connection",
|
|
description="Verify your SMTP configuration (connect + optional login).",
|
|
style="primary",
|
|
callback=check_email_connection,
|
|
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
|
),
|
|
# === AUDIOBOOKS SECTION ===
|
|
# Universal mode only
|
|
HeadingField(
|
|
key="audiobooks_heading",
|
|
title="Audiobooks",
|
|
description="Configure where audiobooks are saved.",
|
|
universal_only=True,
|
|
),
|
|
TextField(
|
|
key="DESTINATION_AUDIOBOOK",
|
|
label="Destination",
|
|
description="Directory where downloaded audiobook files are saved. Leave empty to use the Books destination.",
|
|
user_overridable=True,
|
|
universal_only=True,
|
|
),
|
|
ActionButton(
|
|
key="test_destination_audiobook",
|
|
label="Test Destination",
|
|
description="Check that Shelfmark can create and write to this audiobook destination.",
|
|
style="primary",
|
|
callback=check_audiobook_destination,
|
|
universal_only=True,
|
|
),
|
|
SelectField(
|
|
key="FILE_ORGANIZATION_AUDIOBOOK",
|
|
label="File Organization",
|
|
description="Choose how downloaded audiobook files are named and organized.",
|
|
options=[
|
|
{
|
|
"value": "none",
|
|
"label": "None",
|
|
"description": "Keep original filename from source",
|
|
},
|
|
{
|
|
"value": "rename",
|
|
"label": "Rename Only",
|
|
"description": "Rename single-file downloads; multi-file keeps original names.",
|
|
},
|
|
{
|
|
"value": "organize",
|
|
"label": "Rename and Organize",
|
|
"description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders.",
|
|
},
|
|
],
|
|
default="rename",
|
|
universal_only=True,
|
|
),
|
|
# Rename mode template - filename only
|
|
_naming_template_field(
|
|
key="TEMPLATE_AUDIOBOOK_RENAME",
|
|
label="Naming Template",
|
|
description=(
|
|
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
|
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
|
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
|
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
|
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
|
|
"Applies to single-file downloads."
|
|
),
|
|
default="{Author} - {Title}",
|
|
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
|
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
|
universal_only=True,
|
|
),
|
|
# Organize mode template - folders allowed
|
|
_naming_template_field(
|
|
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
|
label="Path Template",
|
|
description=(
|
|
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
|
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
|
|
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
|
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
|
),
|
|
default="{Author}/{Title}/{Title}",
|
|
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
|
|
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
|
|
universal_only=True,
|
|
),
|
|
CheckboxField(
|
|
key="HARDLINK_TORRENTS_AUDIOBOOK",
|
|
label="Hardlink Audiobook Torrents",
|
|
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
|
|
default=True,
|
|
universal_only=True,
|
|
),
|
|
# === OPTIONS SECTION ===
|
|
HeadingField(
|
|
key="options_heading",
|
|
title="Options",
|
|
),
|
|
CheckboxField(
|
|
key="AUTO_OPEN_DOWNLOADS_SIDEBAR",
|
|
label="Auto-Open Downloads Sidebar",
|
|
description="Automatically open the downloads sidebar when a new download is queued.",
|
|
default=False,
|
|
),
|
|
MultiSelectField(
|
|
key="DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
|
|
label="Download to Browser",
|
|
description="Automatically download completed files to your browser for the selected content types.",
|
|
options=_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS,
|
|
default=[],
|
|
variant="dropdown",
|
|
user_overridable=True,
|
|
),
|
|
NumberField(
|
|
key="MAX_CONCURRENT_DOWNLOADS",
|
|
label="Max Concurrent Downloads",
|
|
description="Maximum number of simultaneous downloads.",
|
|
default=3,
|
|
min_value=1,
|
|
max_value=10,
|
|
requires_restart=True,
|
|
),
|
|
NumberField(
|
|
key="STATUS_TIMEOUT",
|
|
label="Status Timeout (seconds)",
|
|
description="How long to keep completed/failed downloads in the queue display.",
|
|
default=3600,
|
|
min_value=60,
|
|
max_value=86400,
|
|
),
|
|
]
|
|
|
|
|
|
# Register the on_save handler for this tab
|
|
register_on_save("downloads", _on_save_downloads)
|
|
|
|
|
|
def _get_fast_source_options() -> list[dict[str, str | bool | int | None]]:
|
|
"""Fast download sources - configurable list shown in settings."""
|
|
from shelfmark.core.config import config
|
|
from shelfmark.core.mirrors import get_download_source_missing_mirror_reason
|
|
|
|
has_donator_key = bool(config.get("AA_DONATOR_KEY", ""))
|
|
aa_fast_reason = get_download_source_missing_mirror_reason("aa-fast")
|
|
if not aa_fast_reason and not has_donator_key:
|
|
aa_fast_reason = "Requires Donator Key"
|
|
libgen_reason = get_download_source_missing_mirror_reason("libgen")
|
|
|
|
return [
|
|
{
|
|
"id": "aa-fast",
|
|
"label": "AA Fast Downloads",
|
|
"description": "Fast downloads for donators",
|
|
"isPinned": True,
|
|
"isLocked": aa_fast_reason is not None,
|
|
"disabledReason": aa_fast_reason,
|
|
},
|
|
{
|
|
"id": "libgen",
|
|
"label": "Library Genesis",
|
|
"description": "Instant downloads, no bypass needed",
|
|
"isPinned": True,
|
|
"isLocked": libgen_reason is not None,
|
|
"disabledReason": libgen_reason,
|
|
},
|
|
]
|
|
|
|
|
|
def _get_fast_source_defaults() -> list[dict[str, str | bool]]:
|
|
"""Default values for fast sources display."""
|
|
return [
|
|
{"id": "aa-fast", "enabled": True},
|
|
{"id": "libgen", "enabled": True},
|
|
]
|
|
|
|
|
|
def _get_slow_source_options() -> list[dict[str, str | bool | None]]:
|
|
"""Slow download sources - configurable order. All require bypasser."""
|
|
from shelfmark.core.config import config
|
|
from shelfmark.core.mirrors import get_download_source_missing_mirror_reason
|
|
|
|
bypass_enabled = config.get("USE_CF_BYPASS", True)
|
|
|
|
def _get_reason(source_id: str) -> str | None:
|
|
mirror_reason = get_download_source_missing_mirror_reason(source_id)
|
|
if mirror_reason:
|
|
return mirror_reason
|
|
if not bypass_enabled:
|
|
return "Requires Cloudflare bypass"
|
|
return None
|
|
|
|
return [
|
|
{
|
|
"id": "aa-slow-nowait",
|
|
"label": "AA Slow Downloads (No Waitlist)",
|
|
"description": "Partner servers",
|
|
"isLocked": _get_reason("aa-slow-nowait") is not None,
|
|
"disabledReason": _get_reason("aa-slow-nowait"),
|
|
},
|
|
{
|
|
"id": "aa-slow-wait",
|
|
"label": "AA Slow Downloads (Waitlist)",
|
|
"description": "Partner servers with countdown timer",
|
|
"isLocked": _get_reason("aa-slow-wait") is not None,
|
|
"disabledReason": _get_reason("aa-slow-wait"),
|
|
},
|
|
{
|
|
"id": "welib",
|
|
"label": "Welib",
|
|
"description": "Alternative mirror",
|
|
"isLocked": _get_reason("welib") is not None,
|
|
"disabledReason": _get_reason("welib"),
|
|
},
|
|
{
|
|
"id": "zlib",
|
|
"label": "Zlib",
|
|
"description": "Alternative mirror",
|
|
"isLocked": _get_reason("zlib") is not None,
|
|
"disabledReason": _get_reason("zlib"),
|
|
},
|
|
]
|
|
|
|
|
|
def _get_slow_source_defaults() -> list[dict[str, str | bool]]:
|
|
"""Default source priority order for slow sources."""
|
|
from shelfmark.config.env import _LEGACY_ALLOW_USE_WELIB
|
|
|
|
return [
|
|
{"id": "aa-slow-nowait", "enabled": True},
|
|
{"id": "aa-slow-wait", "enabled": True},
|
|
{"id": "welib", "enabled": _LEGACY_ALLOW_USE_WELIB},
|
|
{"id": "zlib", "enabled": True},
|
|
]
|
|
|
|
|
|
@register_settings(
|
|
"download_sources", "Download Sources", icon="download", order=21, group="direct_download"
|
|
)
|
|
def download_source_settings() -> list[SettingsField]:
|
|
"""Return settings for download source behavior."""
|
|
return [
|
|
CheckboxField(
|
|
key="DIRECT_DOWNLOAD_ENABLED",
|
|
label="Enable Direct Download Source",
|
|
description=(
|
|
"Show Direct Download in release-source lists and allow Direct mode "
|
|
"searches. Add your own mirror URLs in the Mirrors tab before using it."
|
|
),
|
|
default=False,
|
|
),
|
|
CheckboxField(
|
|
key="DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH",
|
|
label="Detect Language From Distant Path",
|
|
description=(
|
|
"When language metadata is missing or unknown, parse the distant path "
|
|
"(file path shown in search results) for language tags like [BD FR] or [En]. "
|
|
"Also enables local language filtering so lgli files without AA language "
|
|
"metadata are not excluded before the distant path can be checked."
|
|
),
|
|
default=False,
|
|
),
|
|
PasswordField(
|
|
key="AA_DONATOR_KEY",
|
|
label="Account Donator Key",
|
|
description="Enables fast download access on AA. Get this from your donator account page.",
|
|
),
|
|
HeadingField(
|
|
key="source_priority_heading",
|
|
title="Source Priority",
|
|
description="Sources are tried in order until a download succeeds. Mirror-backed entries unlock automatically when you configure their mirrors.",
|
|
),
|
|
OrderableListField(
|
|
key="FAST_SOURCES_DISPLAY",
|
|
label="Fast downloads",
|
|
description="Always tried first, no waiting or bypass required.",
|
|
options=_get_fast_source_options,
|
|
default=_get_fast_source_defaults(),
|
|
),
|
|
OrderableListField(
|
|
key="SOURCE_PRIORITY",
|
|
label="Slow downloads",
|
|
description="Fallback sources, may have waiting. Requires bypasser. Drag to reorder.",
|
|
options=_get_slow_source_options,
|
|
default=_get_slow_source_defaults(),
|
|
),
|
|
NumberField(
|
|
key="MAX_RETRY",
|
|
label="Max Retries",
|
|
description="Maximum retry attempts for failed downloads.",
|
|
default=10,
|
|
min_value=1,
|
|
max_value=50,
|
|
),
|
|
NumberField(
|
|
key="DEFAULT_SLEEP",
|
|
label="Retry Delay (seconds)",
|
|
description="Wait time between download retry attempts.",
|
|
default=5,
|
|
min_value=1,
|
|
max_value=60,
|
|
),
|
|
HeadingField(
|
|
key="content_type_routing_heading",
|
|
title="Content-Type Routing",
|
|
description="Route downloads to different folders based on content type. Only applies to Direct download source.",
|
|
),
|
|
CheckboxField(
|
|
key="AA_CONTENT_TYPE_ROUTING",
|
|
label="Enable Content-Type Routing",
|
|
description="Override destination based on content type metadata.",
|
|
default=False,
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_FICTION",
|
|
label="Fiction Books",
|
|
placeholder="/books/fiction",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_NON_FICTION",
|
|
label="Non-Fiction Books",
|
|
placeholder="/books/non-fiction",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_UNKNOWN",
|
|
label="Unknown Books",
|
|
placeholder="/books/unknown",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_MAGAZINE",
|
|
label="Magazines",
|
|
placeholder="/books/magazines",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_COMIC",
|
|
label="Comic Books",
|
|
placeholder="/books/comics",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_STANDARDS",
|
|
label="Standards Documents",
|
|
placeholder="/books/standards",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
|
|
label="Musical Scores",
|
|
placeholder="/books/scores",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
TextField(
|
|
key="AA_CONTENT_TYPE_DIR_OTHER",
|
|
label="Other",
|
|
placeholder="/books/other",
|
|
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
|
|
),
|
|
]
|
|
|
|
|
|
@register_settings(
|
|
"cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download"
|
|
)
|
|
def cloudflare_bypass_settings() -> list[SettingsField]:
|
|
"""Return settings for Cloudflare bypass behavior."""
|
|
return [
|
|
CheckboxField(
|
|
key="USE_CF_BYPASS",
|
|
label="Enable Cloudflare Bypass",
|
|
description="Attempt to bypass Cloudflare protection on download sites.",
|
|
default=True,
|
|
requires_restart=True,
|
|
),
|
|
CheckboxField(
|
|
key="USING_EXTERNAL_BYPASSER",
|
|
label="Use External Bypasser",
|
|
description="Use FlareSolverr or similar external service instead of built-in bypasser. Caution: May have limitations with custom DNS, Tor and proxies. You may experience slower downloads and and poorer reliability compared to the internal bypasser.",
|
|
default=False,
|
|
requires_restart=True,
|
|
),
|
|
TextField(
|
|
key="EXT_BYPASSER_URL",
|
|
label="External Bypasser URL",
|
|
description="URL of the external bypasser service (e.g., FlareSolverr).",
|
|
default="http://flaresolverr:8191",
|
|
placeholder="http://flaresolverr:8191",
|
|
requires_restart=True,
|
|
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
|
),
|
|
TextField(
|
|
key="EXT_BYPASSER_PATH",
|
|
label="External Bypasser Path",
|
|
description="API path for the external bypasser.",
|
|
default="/v1",
|
|
placeholder="/v1",
|
|
requires_restart=True,
|
|
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
|
),
|
|
NumberField(
|
|
key="EXT_BYPASSER_TIMEOUT",
|
|
label="External Bypasser Timeout (ms)",
|
|
description="Timeout for external bypasser requests in milliseconds.",
|
|
default=60000,
|
|
min_value=10000,
|
|
max_value=300000,
|
|
requires_restart=True,
|
|
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
|
),
|
|
]
|
|
|
|
|
|
def _on_save_mirrors(values: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize mirror list settings before persisting."""
|
|
from shelfmark.core.utils import normalize_http_url
|
|
|
|
mirror_list_keys = {
|
|
"AA_MIRROR_URLS",
|
|
"LIBGEN_MIRROR_URLS",
|
|
"ZLIB_MIRROR_URLS",
|
|
"WELIB_MIRROR_URLS",
|
|
}
|
|
|
|
for key in mirror_list_keys:
|
|
raw_urls = values.get(key)
|
|
if raw_urls is None:
|
|
continue
|
|
|
|
if isinstance(raw_urls, str):
|
|
parts = [p.strip() for p in raw_urls.split(",") if p.strip()]
|
|
elif isinstance(raw_urls, list):
|
|
parts = [str(p).strip() for p in raw_urls if str(p).strip()]
|
|
else:
|
|
parts = []
|
|
|
|
normalized: list[str] = []
|
|
for url in parts:
|
|
if url.lower() == "auto":
|
|
continue
|
|
norm = normalize_http_url(url, default_scheme="https")
|
|
if norm and norm not in normalized:
|
|
normalized.append(norm)
|
|
|
|
values[key] = normalized
|
|
|
|
return {"error": False, "values": values}
|
|
|
|
|
|
# Register the on_save handler for this tab
|
|
register_on_save("mirrors", _on_save_mirrors)
|
|
|
|
|
|
@register_settings("mirrors", "Mirrors", icon="globe", order=23, group="direct_download")
|
|
def mirror_settings() -> list[SettingsField]:
|
|
"""Configure download source mirrors."""
|
|
return [
|
|
# === PRIMARY SOURCE ===
|
|
HeadingField(
|
|
key="aa_mirrors_heading",
|
|
title="Anna's Archive",
|
|
description=(
|
|
"Add your own Anna's Archive mirror URLs here. Auto mode will try them in the "
|
|
"order listed below."
|
|
),
|
|
),
|
|
SelectField(
|
|
key="AA_BASE_URL",
|
|
label="Primary Mirror",
|
|
description=(
|
|
"Select Auto to try mirrors from your list on startup and fail over on errors. "
|
|
"Choosing a specific mirror pins Shelfmark to that URL."
|
|
),
|
|
options=_get_aa_base_url_options,
|
|
default="auto",
|
|
),
|
|
TagListField(
|
|
key="AA_MIRROR_URLS",
|
|
label="Mirrors",
|
|
description=(
|
|
"List the Anna's Archive mirror URLs you want Shelfmark to use. Type a URL and "
|
|
"press Enter to add it. Order matters when Auto is selected."
|
|
),
|
|
placeholder="https://your-aa-mirror.example",
|
|
default=[],
|
|
),
|
|
# === LIBGEN ===
|
|
TagListField(
|
|
key="LIBGEN_MIRROR_URLS",
|
|
label="LibGen",
|
|
description="Mirrors are tried in the order you add them until one works.",
|
|
placeholder="https://your-libgen-mirror.example",
|
|
),
|
|
# === Z-LIBRARY ===
|
|
TagListField(
|
|
key="ZLIB_MIRROR_URLS",
|
|
label="Z-Library",
|
|
description="Only the first mirror in the list is used.",
|
|
placeholder="https://your-zlibrary-mirror.example",
|
|
),
|
|
# === WELIB ===
|
|
TagListField(
|
|
key="WELIB_MIRROR_URLS",
|
|
label="Welib",
|
|
description="Only the first mirror in the list is used.",
|
|
placeholder="https://your-welib-mirror.example",
|
|
),
|
|
]
|
|
|
|
|
|
@register_settings("advanced", "Advanced", icon="cog", order=15)
|
|
def advanced_settings() -> list[SettingsField]:
|
|
"""Advanced settings for power users."""
|
|
return [
|
|
TextField(
|
|
key="URL_BASE",
|
|
label="Base Path",
|
|
description="Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root.",
|
|
placeholder="/shelfmark",
|
|
requires_restart=True,
|
|
),
|
|
CheckboxField(
|
|
key="DEBUG",
|
|
label="Debug Mode",
|
|
description="Enable verbose logging to console and file. Not recommended for normal use.",
|
|
default=False,
|
|
requires_restart=True,
|
|
),
|
|
SelectField(
|
|
key="LOG_LEVEL",
|
|
label="Log Level",
|
|
description=(
|
|
"Lowest severity written to the console and log file. "
|
|
"Ignored while Debug Mode is on, which forces Debug."
|
|
),
|
|
options=[
|
|
{"value": "DEBUG", "label": "Debug", "description": "Everything, very noisy."},
|
|
{"value": "INFO", "label": "Info", "description": "Normal activity (default)."},
|
|
{"value": "WARNING", "label": "Warning", "description": "Warnings and problems."},
|
|
{"value": "ERROR", "label": "Error", "description": "Failures only."},
|
|
{"value": "CRITICAL", "label": "Critical", "description": "Fatal errors only."},
|
|
],
|
|
default="INFO",
|
|
requires_restart=True,
|
|
),
|
|
NumberField(
|
|
key="MAIN_LOOP_SLEEP_TIME",
|
|
label="Queue Check Interval (seconds)",
|
|
description="How often the download queue is checked for new items.",
|
|
default=5,
|
|
min_value=1,
|
|
max_value=60,
|
|
requires_restart=True,
|
|
),
|
|
NumberField(
|
|
key="DOWNLOAD_PROGRESS_UPDATE_INTERVAL",
|
|
label="Progress Update Interval (seconds)",
|
|
description="How often download progress is broadcast to the UI.",
|
|
default=1,
|
|
min_value=1,
|
|
max_value=10,
|
|
requires_restart=True,
|
|
),
|
|
TextField(
|
|
key="CUSTOM_SCRIPT",
|
|
label="Custom Script Path",
|
|
description="Path to a script to run after each successful download. Must be executable.",
|
|
placeholder="/path/to/script.sh",
|
|
),
|
|
SelectField(
|
|
key="CUSTOM_SCRIPT_PATH_MODE",
|
|
label="Custom Script Path Mode",
|
|
description="Pass the path to the custom script as an absolute path or relative to the destination folder.",
|
|
options=[
|
|
{
|
|
"value": "absolute",
|
|
"label": "Absolute",
|
|
"description": "Pass the full destination path (default).",
|
|
},
|
|
{
|
|
"value": "relative",
|
|
"label": "Relative",
|
|
"description": "Pass the path relative to the destination folder.",
|
|
},
|
|
],
|
|
default="absolute",
|
|
),
|
|
CheckboxField(
|
|
key="CUSTOM_SCRIPT_JSON_PAYLOAD",
|
|
label="Custom Script JSON Payload",
|
|
description="Send a JSON payload to the script via stdin. Useful for multi-file imports (audiobooks) or richer metadata without relying on path parsing.",
|
|
default=False,
|
|
),
|
|
HeadingField(
|
|
key="remote_path_mappings_heading",
|
|
title="Remote Path Mappings",
|
|
description="Map download client paths to paths inside Shelfmark. Needed when volume mounts differ between containers.",
|
|
),
|
|
NumberField(
|
|
key="DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT",
|
|
label="Completed Path Wait (seconds)",
|
|
description=(
|
|
"How long to wait after a torrent or usenet client reports completion "
|
|
"for the completed file path to become visible to Shelfmark. Increase "
|
|
"this for seedbox or remote-sync workflows."
|
|
),
|
|
default=_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT,
|
|
min_value=0,
|
|
max_value=_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX,
|
|
),
|
|
TableField(
|
|
key="PROWLARR_REMOTE_PATH_MAPPINGS",
|
|
label="Path Mappings",
|
|
columns=[
|
|
{
|
|
"key": "host",
|
|
"label": "Client",
|
|
"type": "select",
|
|
"options": [
|
|
{"value": "qbittorrent", "label": "qBittorrent"},
|
|
{"value": "transmission", "label": "Transmission"},
|
|
{"value": "deluge", "label": "Deluge"},
|
|
{"value": "rtorrent", "label": "rTorrent"},
|
|
{"value": "nzbget", "label": "NZBGet"},
|
|
{"value": "sabnzbd", "label": "SABnzbd"},
|
|
],
|
|
"defaultValue": "qbittorrent",
|
|
},
|
|
{
|
|
"key": "remotePath",
|
|
"label": "Remote Path",
|
|
"type": "path",
|
|
},
|
|
{
|
|
"key": "localPath",
|
|
"label": "Local Path",
|
|
"type": "path",
|
|
},
|
|
],
|
|
default=[],
|
|
add_label="Add Mapping",
|
|
empty_message="No mappings configured.",
|
|
env_supported=False,
|
|
),
|
|
HeadingField(
|
|
key="covers_cache_heading",
|
|
title="Cover Image Cache",
|
|
description="Cache book cover images locally for faster loading. Works for both Direct Download and Universal mode.",
|
|
),
|
|
CheckboxField(
|
|
key="COVERS_CACHE_ENABLED",
|
|
label="Enable Cover Cache",
|
|
description="Cache book covers on the server for faster loading.",
|
|
default=True,
|
|
),
|
|
NumberField(
|
|
key="COVERS_CACHE_TTL",
|
|
label="Cache TTL (days)",
|
|
description="How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork).",
|
|
default=0,
|
|
min_value=0,
|
|
max_value=365,
|
|
),
|
|
NumberField(
|
|
key="COVERS_CACHE_MAX_SIZE_MB",
|
|
label="Max Cache Size (MB)",
|
|
description="Maximum disk space for cached covers. Oldest images are removed when limit is reached.",
|
|
default=500,
|
|
min_value=50,
|
|
max_value=5000,
|
|
),
|
|
ActionButton(
|
|
key="clear_covers_cache",
|
|
label="Clear Cover Cache",
|
|
description="Delete all cached cover images.",
|
|
style="danger",
|
|
callback=_clear_covers_cache,
|
|
),
|
|
HeadingField(
|
|
key="metadata_cache_heading",
|
|
title="Metadata Cache",
|
|
description="Cache book metadata from providers (Hardcover, Open Library) to reduce API calls and speed up repeated searches.",
|
|
),
|
|
CheckboxField(
|
|
key="METADATA_CACHE_ENABLED",
|
|
label="Enable Metadata Caching",
|
|
description="When disabled, all metadata searches hit the provider API directly.",
|
|
default=True,
|
|
),
|
|
NumberField(
|
|
key="METADATA_CACHE_SEARCH_TTL",
|
|
label="Search Results Cache (seconds)",
|
|
description="How long to cache search results. Default: 300 (5 minutes). Max: 604800 (7 days).",
|
|
default=300,
|
|
min_value=60,
|
|
max_value=604800,
|
|
show_when={"field": "METADATA_CACHE_ENABLED", "value": True},
|
|
),
|
|
NumberField(
|
|
key="METADATA_CACHE_BOOK_TTL",
|
|
label="Book Details Cache (seconds)",
|
|
description="How long to cache individual book details. Default: 600 (10 minutes). Max: 604800 (7 days).",
|
|
default=600,
|
|
min_value=60,
|
|
max_value=604800,
|
|
show_when={"field": "METADATA_CACHE_ENABLED", "value": True},
|
|
),
|
|
ActionButton(
|
|
key="clear_metadata_cache",
|
|
label="Clear Metadata Cache",
|
|
description="Clear all cached search results and book details.",
|
|
style="danger",
|
|
callback=_clear_metadata_cache,
|
|
),
|
|
]
|
|
|
|
|
|
register_on_save("advanced", _on_save_advanced)
|