1148 lines
42 KiB
Python
1148 lines
42 KiB
Python
""" """
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
import os
|
|
import re
|
|
import datetime
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
from SYS.logger import log
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
from SYS.utils import expand_path
|
|
from SYS.database import db, get_config_all, rows_to_config
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
|
|
# Save lock settings (cross-process)
|
|
_SAVE_LOCK_DIRNAME = ".medios_save_lock"
|
|
_SAVE_LOCK_TIMEOUT = 30.0 # seconds to wait for save lock
|
|
_SAVE_LOCK_STALE_SECONDS = 3600 # consider lock stale after 1 hour
|
|
|
|
_CONFIG_CACHE: Dict[str, Any] = {}
|
|
_LAST_SAVED_CONFIG: Dict[str, Any] = {}
|
|
_CONFIG_SUMMARY_PENDING = False
|
|
_CONFIG_SAVE_MAX_RETRIES = 5
|
|
_CONFIG_SAVE_RETRY_DELAY = 0.15
|
|
_CONFIG_MISSING = object()
|
|
_PATH_ALIAS_TOKEN_RE = re.compile(r"^\$(?:\((?P<braced>[^)]+)\)|(?P<plain>[A-Za-z0-9_.-]+))$")
|
|
|
|
|
|
class ConfigSaveConflict(Exception):
|
|
"""Raised when a save would overwrite external changes present on disk."""
|
|
pass
|
|
|
|
|
|
def global_config() -> List[Dict[str, Any]]:
|
|
"""Return configuration schema for global settings."""
|
|
return [
|
|
{
|
|
"key": "debug",
|
|
"label": "Debug Output",
|
|
"group": "Runtime",
|
|
"type": "boolean",
|
|
"default": "false",
|
|
"choices": ["true", "false"],
|
|
},
|
|
{
|
|
"key": "auto_update",
|
|
"label": "Auto-Update",
|
|
"group": "Runtime",
|
|
"type": "boolean",
|
|
"default": "true",
|
|
"choices": ["true", "false"],
|
|
},
|
|
{
|
|
"key": "table_appearance",
|
|
"label": "Table Appearance",
|
|
"group": "Display",
|
|
"default": "rainbow",
|
|
"choices": ["plain", "bw-striped", "rainbow"],
|
|
},
|
|
{
|
|
"key": "download_path_default",
|
|
"label": "Default Download Path",
|
|
"group": "Downloads",
|
|
"type": "string",
|
|
"default": "",
|
|
},
|
|
{
|
|
"key": "path_aliases",
|
|
"label": "Path Aliases",
|
|
"group": "Downloads",
|
|
"type": "json",
|
|
"default": {},
|
|
}
|
|
]
|
|
|
|
|
|
def clear_config_cache() -> None:
|
|
"""Clear the configuration cache and baseline snapshot."""
|
|
global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING
|
|
_CONFIG_CACHE = {}
|
|
_LAST_SAVED_CONFIG = {}
|
|
_CONFIG_SUMMARY_PENDING = False
|
|
|
|
|
|
def _log_config_load_summary(config: Dict[str, Any]) -> None:
|
|
try:
|
|
plugin_block = config.get("plugin")
|
|
if isinstance(plugin_block, dict):
|
|
# Count distinct plugin names; note multi-instance plugins appear once per name
|
|
plugin_names = list(plugin_block.keys())
|
|
# Count total configured instances across all plugins
|
|
total_instances = sum(
|
|
len(v) if isinstance(v, dict) and all(isinstance(x, dict) for x in v.values()) else 1
|
|
for v in plugin_block.values()
|
|
if isinstance(v, dict)
|
|
)
|
|
else:
|
|
plugin_names, total_instances = [], 0
|
|
mtime = None
|
|
try:
|
|
mtime = datetime.datetime.fromtimestamp(db.db_path.stat().st_mtime, datetime.timezone.utc).isoformat().replace('+00:00', 'Z')
|
|
except Exception:
|
|
mtime = None
|
|
plugins_str = ', '.join(plugin_names[:10]) + ('...' if len(plugin_names) > 10 else '')
|
|
summary = (
|
|
f"Loaded config from {db.db_path.name}: "
|
|
f"plugins={len(plugin_names)} ({plugins_str}), "
|
|
f"instances={total_instances}, mtime={mtime}"
|
|
)
|
|
log(summary)
|
|
except Exception:
|
|
logger.exception("Failed to build config load summary from %s", db.db_path)
|
|
|
|
|
|
def get_nested_config_value(config: Dict[str, Any], *path: str) -> Any:
|
|
cur: Any = config
|
|
for key in path:
|
|
if not isinstance(cur, dict):
|
|
return None
|
|
cur = cur.get(key)
|
|
return cur
|
|
|
|
|
|
def _normalize_path_alias_name(value: Any) -> Optional[str]:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return None
|
|
|
|
match = _PATH_ALIAS_TOKEN_RE.match(raw)
|
|
if match:
|
|
raw = str(match.group("braced") or match.group("plain") or "").strip()
|
|
|
|
candidate = raw.strip().strip("()")
|
|
if not candidate:
|
|
return None
|
|
return candidate.lower()
|
|
|
|
|
|
def get_path_aliases(config: Dict[str, Any]) -> Dict[str, str]:
|
|
aliases: Dict[str, str] = {}
|
|
if not isinstance(config, dict):
|
|
return aliases
|
|
|
|
for block_name in ("path_aliases", "download_paths"):
|
|
block = config.get(block_name)
|
|
if not isinstance(block, dict):
|
|
continue
|
|
for key, value in block.items():
|
|
alias = _normalize_path_alias_name(key)
|
|
if not alias:
|
|
continue
|
|
if isinstance(value, str) and value.strip():
|
|
aliases[alias] = value.strip()
|
|
|
|
return aliases
|
|
|
|
|
|
def resolve_path_alias(config: Dict[str, Any], value: Any) -> Optional[Path]:
|
|
raw = str(value or "").strip()
|
|
if not raw.startswith("$"):
|
|
return None
|
|
|
|
alias = _normalize_path_alias_name(raw)
|
|
if not alias:
|
|
return None
|
|
|
|
target = get_path_aliases(config).get(alias)
|
|
if not target:
|
|
return None
|
|
|
|
return expand_path(target)
|
|
|
|
|
|
def coerce_config_value(
|
|
value: Any,
|
|
existing_value: Any = _CONFIG_MISSING,
|
|
*,
|
|
on_error: Optional[Callable[[str], None]] = None,
|
|
) -> Any:
|
|
if not isinstance(value, str):
|
|
return value
|
|
|
|
text = value.strip()
|
|
lowered = text.lower()
|
|
|
|
if existing_value is _CONFIG_MISSING:
|
|
if lowered in {"true", "false"}:
|
|
return lowered == "true"
|
|
if text.isdigit():
|
|
return int(text)
|
|
return value
|
|
|
|
if isinstance(existing_value, bool):
|
|
if lowered in {"true", "yes", "1", "on"}:
|
|
return True
|
|
if lowered in {"false", "no", "0", "off"}:
|
|
return False
|
|
if on_error is not None:
|
|
on_error(f"Warning: Could not convert '{value}' to boolean. Using string.")
|
|
return value
|
|
|
|
if isinstance(existing_value, int) and not isinstance(existing_value, bool):
|
|
try:
|
|
return int(text)
|
|
except ValueError:
|
|
if on_error is not None:
|
|
on_error(f"Warning: Could not convert '{value}' to int. Using string.")
|
|
return value
|
|
|
|
if isinstance(existing_value, float):
|
|
try:
|
|
return float(text)
|
|
except ValueError:
|
|
if on_error is not None:
|
|
on_error(f"Warning: Could not convert '{value}' to float. Using string.")
|
|
return value
|
|
|
|
return value
|
|
|
|
|
|
def set_nested_config_value(
|
|
config: Dict[str, Any],
|
|
key_path: str | Sequence[str],
|
|
value: Any,
|
|
*,
|
|
on_error: Optional[Callable[[str], None]] = None,
|
|
) -> bool:
|
|
if not isinstance(config, dict):
|
|
return False
|
|
|
|
if isinstance(key_path, str):
|
|
keys = [part for part in key_path.split(".") if part]
|
|
else:
|
|
keys = [str(part) for part in (key_path or []) if str(part)]
|
|
|
|
if not keys:
|
|
return False
|
|
|
|
current = config
|
|
for key in keys[:-1]:
|
|
next_value = current.get(key)
|
|
if not isinstance(next_value, dict):
|
|
next_value = {}
|
|
current[key] = next_value
|
|
current = next_value
|
|
|
|
last_key = keys[-1]
|
|
existing_value = current[last_key] if last_key in current else _CONFIG_MISSING
|
|
current[last_key] = coerce_config_value(value, existing_value, on_error=on_error)
|
|
return True
|
|
|
|
|
|
def get_hydrus_instance(
|
|
config: Dict[str, Any], instance_name: str = "home"
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get a specific Hydrus instance config by name from plugin config."""
|
|
_canonicalize_plugin_config(config)
|
|
|
|
def _lookup_in(source: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not isinstance(source, dict) or not source:
|
|
return None
|
|
instance = source.get(instance_name)
|
|
if isinstance(instance, dict):
|
|
return instance
|
|
target = str(instance_name or "").lower()
|
|
for name, conf in source.items():
|
|
if isinstance(conf, dict) and str(name).lower() == target:
|
|
return conf
|
|
keys = sorted(source.keys())
|
|
for key in keys:
|
|
if not str(key or "").startswith("new_"):
|
|
candidate = source.get(key)
|
|
if isinstance(candidate, dict):
|
|
return candidate
|
|
first_key = keys[0] if keys else None
|
|
candidate = source.get(first_key) if first_key else None
|
|
return candidate if isinstance(candidate, dict) else None
|
|
|
|
plugin_cfg = config.get("plugin")
|
|
if isinstance(plugin_cfg, dict):
|
|
hydrus_cfg = plugin_cfg.get("hydrusnetwork")
|
|
if isinstance(hydrus_cfg, dict):
|
|
result = _lookup_in(hydrus_cfg)
|
|
if result is not None:
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
def get_hydrus_access_key(config: Dict[str, Any], instance_name: str = "home") -> Optional[str]:
|
|
"""Get Hydrus access key for an instance.
|
|
|
|
Config format:
|
|
- config["plugin"]["hydrusnetwork"][name]["API"]
|
|
|
|
Args:
|
|
config: Configuration dict
|
|
instance_name: Name of the Hydrus instance (default: "home")
|
|
|
|
Returns:
|
|
Access key string, or None if not found
|
|
"""
|
|
instance = get_hydrus_instance(config, instance_name)
|
|
if instance:
|
|
key = instance.get("API")
|
|
return str(key).strip() if key else None
|
|
|
|
return None
|
|
|
|
|
|
def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optional[str]:
|
|
"""Get Hydrus URL for an instance.
|
|
|
|
Config format:
|
|
- config["plugin"]["hydrusnetwork"][name]["URL"]
|
|
|
|
Args:
|
|
config: Configuration dict
|
|
instance_name: Name of the Hydrus instance (default: "home")
|
|
|
|
Returns:
|
|
URL string, or None if not found
|
|
"""
|
|
instance = get_hydrus_instance(config, instance_name)
|
|
url = instance.get("URL") if instance else None
|
|
return str(url).strip() if url else None
|
|
|
|
|
|
def get_plugin_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
|
|
_canonicalize_plugin_config(config)
|
|
plugin_cfg = config.get("plugin")
|
|
if not isinstance(plugin_cfg, dict):
|
|
return {}
|
|
normalized = _normalize_provider_name(name)
|
|
if normalized:
|
|
block = plugin_cfg.get(normalized)
|
|
if isinstance(block, dict):
|
|
return block
|
|
for key, block in plugin_cfg.items():
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if _normalize_provider_name(key) == normalized:
|
|
return block
|
|
return {}
|
|
|
|
|
|
def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]:
|
|
block = get_plugin_block(config, "soulseek")
|
|
val = block.get("username") or block.get("USERNAME")
|
|
return str(val).strip() if val else None
|
|
|
|
|
|
def get_soulseek_password(config: Dict[str, Any]) -> Optional[str]:
|
|
block = get_plugin_block(config, "soulseek")
|
|
val = block.get("password") or block.get("PASSWORD")
|
|
return str(val).strip() if val else None
|
|
|
|
|
|
def resolve_output_dir(config: Dict[str, Any]) -> Path:
|
|
"""Resolve output directory from config with single source of truth.
|
|
|
|
Priority:
|
|
1. config["download_path_default"] - default download/output directory
|
|
2. config["temp"] - explicitly set temp/output directory
|
|
3. config["outfile"] - fallback to outfile setting
|
|
4. System Temp - default fallback directory
|
|
|
|
Returns:
|
|
Path to output directory
|
|
"""
|
|
default_output = config.get("download_path_default")
|
|
if default_output:
|
|
try:
|
|
aliased = resolve_path_alias(config, default_output)
|
|
path = aliased if aliased is not None else expand_path(default_output)
|
|
if path.exists() or path.parent.exists():
|
|
return path
|
|
except Exception as exc:
|
|
logger.debug("resolve_output_dir: failed to expand download_path_default value %r: %s", default_output, exc, exc_info=True)
|
|
|
|
# First try explicit temp setting from config
|
|
temp_value = config.get("temp")
|
|
if temp_value:
|
|
try:
|
|
path = expand_path(temp_value)
|
|
# Verify we can access it (not a system directory with permission issues)
|
|
if path.exists() or path.parent.exists():
|
|
return path
|
|
except Exception as exc:
|
|
logger.debug("resolve_output_dir: failed to expand temp value %r: %s", temp_value, exc, exc_info=True)
|
|
|
|
# Then try outfile setting
|
|
outfile_value = config.get("outfile")
|
|
if outfile_value:
|
|
try:
|
|
return expand_path(outfile_value)
|
|
except Exception as exc:
|
|
logger.debug("resolve_output_dir: failed to expand outfile value %r: %s", outfile_value, exc, exc_info=True)
|
|
|
|
# Fallback to system temp directory
|
|
return Path(tempfile.gettempdir())
|
|
|
|
|
|
def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
|
|
"""Return the configured default local plugin destination path.
|
|
|
|
This helper is intentionally narrow: it reports a real local library/export
|
|
root only when the canonical `plugin.local` config defines one. Callers that
|
|
want a staging/output directory should use `resolve_output_dir(...)` instead.
|
|
"""
|
|
local_block = get_plugin_block(config, "local")
|
|
if not isinstance(local_block, dict) or not local_block:
|
|
return None
|
|
|
|
if _is_multi_instance_plugin_config(local_block):
|
|
if "default" in local_block and isinstance(local_block.get("default"), dict):
|
|
local_config = local_block.get("default")
|
|
else:
|
|
local_config = next(
|
|
(value for value in local_block.values() if isinstance(value, dict)),
|
|
None,
|
|
)
|
|
else:
|
|
local_config = local_block
|
|
|
|
if not isinstance(local_config, dict):
|
|
return None
|
|
|
|
path_str = local_config.get("path") or local_config.get("PATH")
|
|
if path_str:
|
|
return expand_path(path_str)
|
|
|
|
return None
|
|
|
|
|
|
def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
|
|
"""Get Debrid API key from config.
|
|
|
|
Checks the plugin block first (canonical format).
|
|
|
|
Args:
|
|
config: Configuration dict
|
|
service: Service name (default: "All-debrid")
|
|
|
|
Returns:
|
|
API key string if found, None otherwise
|
|
"""
|
|
_canonicalize_plugin_config(config)
|
|
|
|
# 1) Canonical plugin block: config["plugin"]["alldebrid"]["api_key"]
|
|
plugin_block = config.get("plugin")
|
|
if isinstance(plugin_block, dict):
|
|
alldebrid_entry = plugin_block.get("alldebrid")
|
|
if isinstance(alldebrid_entry, dict):
|
|
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
|
val = alldebrid_entry.get(k)
|
|
if isinstance(val, str) and val.strip():
|
|
return val.strip()
|
|
|
|
return None
|
|
|
|
|
|
def get_plugin_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]:
|
|
"""Get plugin credentials (email/password) from config.
|
|
|
|
Args:
|
|
config: Configuration dict
|
|
provider: Provider name (e.g., "openlibrary", "soulseek")
|
|
|
|
Returns:
|
|
Dict with credentials if found, None otherwise
|
|
"""
|
|
_canonicalize_plugin_config(config)
|
|
|
|
plugin_config = config.get("plugin", {})
|
|
if isinstance(plugin_config, dict):
|
|
creds = plugin_config.get(provider.lower(), {})
|
|
if isinstance(creds, dict) and creds:
|
|
return creds
|
|
|
|
return None
|
|
|
|
|
|
def resolve_cookies_path(
|
|
config: Dict[str, Any], script_dir: Optional[Path] = None
|
|
) -> Optional[Path]:
|
|
# Only support plugin config style:
|
|
# [plugin=ytdlp]
|
|
# cookies="C:\\path\\cookies.txt"
|
|
values: list[Any] = []
|
|
try:
|
|
plugin = config.get("plugin")
|
|
if isinstance(plugin, dict):
|
|
ytdlp = plugin.get("ytdlp")
|
|
if isinstance(ytdlp, dict):
|
|
values.append(ytdlp.get("cookies"))
|
|
values.append(ytdlp.get("cookiefile"))
|
|
except Exception as exc:
|
|
logger.debug("resolve_cookies_path: failed to read plugin.ytdlp cookies: %s", exc, exc_info=True)
|
|
|
|
base_dir = _resolve_app_root(script_dir)
|
|
for value in values:
|
|
if not value:
|
|
continue
|
|
candidate = expand_path(value)
|
|
if not candidate.is_absolute():
|
|
candidate = expand_path(base_dir / candidate)
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
plugin_cookie = _resolve_ytdlp_plugin_cookie_path(base_dir)
|
|
if plugin_cookie is not None:
|
|
return plugin_cookie
|
|
|
|
default_path = base_dir / "cookies.txt"
|
|
if default_path.is_file():
|
|
return default_path
|
|
return None
|
|
|
|
|
|
def _resolve_ytdlp_plugin_cookie_path(base_dir: Path) -> Optional[Path]:
|
|
plugin_cookie = resolve_plugin_asset_path("ytdlp", "cookies.txt", script_dir=base_dir)
|
|
if plugin_cookie is not None:
|
|
return plugin_cookie
|
|
|
|
plugin_dir = _resolve_app_root(base_dir) / "plugins" / "ytdlp"
|
|
if not plugin_dir.is_dir():
|
|
return None
|
|
|
|
plugin_cookie = plugin_dir / "cookies.txt"
|
|
legacy_cookie = _resolve_app_root(base_dir) / "cookies.txt"
|
|
try:
|
|
if legacy_cookie.is_file() and not plugin_cookie.exists():
|
|
plugin_cookie.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(legacy_cookie, plugin_cookie)
|
|
return plugin_cookie
|
|
except Exception:
|
|
return None
|
|
|
|
if plugin_cookie.is_file():
|
|
return plugin_cookie
|
|
return None
|
|
|
|
|
|
def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]:
|
|
value = config.get("download_debug_log")
|
|
if not value:
|
|
return None
|
|
path = expand_path(value)
|
|
if not path.is_absolute():
|
|
path = Path.cwd() / path
|
|
return path
|
|
|
|
def _normalize_provider_name(value: Any) -> Optional[str]:
|
|
candidate = str(value or "").strip().lower()
|
|
return candidate if candidate else None
|
|
|
|
|
|
def _resolve_app_root(script_dir: Optional[Path] = None) -> Path:
|
|
if script_dir is not None:
|
|
try:
|
|
candidate = expand_path(script_dir)
|
|
except Exception:
|
|
candidate = Path(script_dir)
|
|
return candidate if candidate.is_dir() else candidate.parent
|
|
return SCRIPT_DIR.parent
|
|
|
|
|
|
def resolve_plugin_asset_path(
|
|
plugin_name: str,
|
|
*relative_parts: str,
|
|
script_dir: Optional[Path] = None,
|
|
) -> Optional[Path]:
|
|
normalized = _normalize_provider_name(plugin_name)
|
|
if not normalized:
|
|
return None
|
|
|
|
plugin_dir = _resolve_app_root(script_dir) / "plugins" / normalized
|
|
if not plugin_dir.is_dir():
|
|
return None
|
|
|
|
for part in relative_parts:
|
|
text = str(part or "").strip().strip("/\\")
|
|
if not text:
|
|
continue
|
|
candidate = plugin_dir / text
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _canonicalize_plugin_config(config: Dict[str, Any]) -> None:
|
|
if not isinstance(config, dict):
|
|
return
|
|
|
|
config.pop("provider", None)
|
|
config.pop("store", None)
|
|
plugin_block = config.get("plugin")
|
|
|
|
normalized_plugin: Dict[str, Any] = {}
|
|
|
|
if isinstance(plugin_block, dict):
|
|
for key, value in plugin_block.items():
|
|
normalized_key = _normalize_provider_name(key)
|
|
if normalized_key:
|
|
normalized_plugin[normalized_key] = value
|
|
|
|
if normalized_plugin or isinstance(plugin_block, dict):
|
|
config["plugin"] = normalized_plugin
|
|
else:
|
|
config.pop("plugin", None)
|
|
|
|
def _extract_api_key(value: Any) -> Optional[str]:
|
|
if isinstance(value, dict):
|
|
for key in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
|
candidate = value.get(key)
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
return candidate.strip()
|
|
elif isinstance(value, str):
|
|
trimmed = value.strip()
|
|
if trimmed:
|
|
return trimmed
|
|
return None
|
|
|
|
|
|
def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None:
|
|
"""Ensure AllDebrid API key is consistently stored in config[\"plugin\"][\"alldebrid\"].
|
|
|
|
Previously this function also synced to config[\"store\"][\"debrid\"]. That path
|
|
is no longer used; only the plugin namespace is written.
|
|
"""
|
|
if not isinstance(config, dict):
|
|
return
|
|
|
|
_canonicalize_plugin_config(config)
|
|
|
|
plugins = config.get("plugin")
|
|
if not isinstance(plugins, dict):
|
|
plugins = {}
|
|
config["plugin"] = plugins
|
|
|
|
plugin_entry = plugins.get("alldebrid")
|
|
plugin_section: Dict[str, Any] | None = None
|
|
plugin_key = None
|
|
if isinstance(plugin_entry, dict):
|
|
plugin_section = plugin_entry
|
|
plugin_key = _extract_api_key(plugin_section)
|
|
elif isinstance(plugin_entry, str):
|
|
plugin_key = plugin_entry.strip()
|
|
if plugin_key:
|
|
plugin_section = {"api_key": plugin_key}
|
|
plugins["alldebrid"] = plugin_section
|
|
|
|
def _is_multi_instance_plugin_config(value: Any) -> bool:
|
|
"""Return True if `value` looks like a multi-instance plugin config (dict-of-dicts).
|
|
|
|
Multi-instance plugins store their configuration as::
|
|
|
|
{<instance_name>: {key: value, ...}, ...}
|
|
|
|
Single-instance plugins store their config as a flat dict::
|
|
|
|
{key: value, ...}
|
|
|
|
We detect multi-instance by checking whether ALL values are themselves dicts
|
|
(and the outer dict is non-empty). An empty dict is treated as single-instance.
|
|
"""
|
|
if not isinstance(value, dict) or not value:
|
|
return False
|
|
return all(isinstance(v, dict) for v in value.values())
|
|
|
|
|
|
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
|
|
entries: Dict[Tuple[str, str, str, str], Any] = {}
|
|
_canonicalize_plugin_config(config)
|
|
for key, value in config.items():
|
|
if key == 'plugin' and isinstance(value, dict):
|
|
for subtype, plugin_cfg in value.items():
|
|
if not isinstance(plugin_cfg, dict):
|
|
continue
|
|
if _is_multi_instance_plugin_config(plugin_cfg):
|
|
# Multi-instance: {instance_name: {key: val}}
|
|
for instance_name, settings in plugin_cfg.items():
|
|
if not isinstance(settings, dict):
|
|
continue
|
|
for k, v in settings.items():
|
|
entries[('plugin', subtype, instance_name, k)] = v
|
|
else:
|
|
# Single-instance: {key: val}
|
|
for k, v in plugin_cfg.items():
|
|
entries[('plugin', subtype, 'default', k)] = v
|
|
elif key == 'tool' and isinstance(value, dict):
|
|
for subtype, instances in value.items():
|
|
if not isinstance(instances, dict):
|
|
continue
|
|
for k, v in instances.items():
|
|
entries[('tool', subtype, 'default', k)] = v
|
|
elif not key.startswith('_') and value is not None:
|
|
entries[('global', 'none', 'none', key)] = value
|
|
return entries
|
|
|
|
|
|
def _count_changed_entries(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> int:
|
|
old_entries = _flatten_config_entries(old_config or {})
|
|
new_entries = _flatten_config_entries(new_config or {})
|
|
changed = {k for k, v in new_entries.items() if old_entries.get(k) != v}
|
|
removed = {k for k in old_entries if k not in new_entries}
|
|
return len(changed) + len(removed)
|
|
|
|
|
|
def _changed_entry_keys(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> set[Tuple[str, str, str, str]]:
|
|
old_entries = _flatten_config_entries(old_config or {})
|
|
new_entries = _flatten_config_entries(new_config or {})
|
|
keys = set(old_entries) | set(new_entries)
|
|
return {key for key in keys if old_entries.get(key, _CONFIG_MISSING) != new_entries.get(key, _CONFIG_MISSING)}
|
|
|
|
|
|
def _config_from_flattened_entries(
|
|
entries: Dict[Tuple[str, str, str, str], Any],
|
|
) -> Dict[str, Any]:
|
|
config: Dict[str, Any] = {}
|
|
for (category, subtype, item_name, key), value in entries.items():
|
|
if category == "global":
|
|
config[key] = value
|
|
continue
|
|
|
|
if category == "plugin":
|
|
plugin_block = config.setdefault("plugin", {})
|
|
subtype_block = plugin_block.setdefault(subtype, {})
|
|
if item_name == "default":
|
|
subtype_block[key] = value
|
|
else:
|
|
item_block = subtype_block.setdefault(item_name, {})
|
|
item_block[key] = value
|
|
continue
|
|
|
|
if category == "tool":
|
|
category_block = config.setdefault(category, {})
|
|
subtype_block = category_block.setdefault(subtype, {})
|
|
subtype_block[key] = value
|
|
continue
|
|
|
|
category_block = config.setdefault(category, {})
|
|
if isinstance(category_block, dict):
|
|
subtype_block = category_block.setdefault(subtype, {})
|
|
if isinstance(subtype_block, dict):
|
|
item_block = subtype_block.setdefault(item_name, {})
|
|
if isinstance(item_block, dict):
|
|
item_block[key] = value
|
|
|
|
_canonicalize_plugin_config(config)
|
|
_sync_alldebrid_api_key(config)
|
|
return config
|
|
|
|
|
|
def _merge_non_conflicting_config_changes(
|
|
base_config: Dict[str, Any],
|
|
disk_config: Dict[str, Any],
|
|
local_config: Dict[str, Any],
|
|
) -> Optional[Dict[str, Any]]:
|
|
local_changed = _changed_entry_keys(base_config, local_config)
|
|
if not local_changed:
|
|
return deepcopy(disk_config)
|
|
|
|
disk_changed = _changed_entry_keys(base_config, disk_config)
|
|
if local_changed & disk_changed:
|
|
return None
|
|
|
|
merged_entries = dict(_flatten_config_entries(disk_config or {}))
|
|
local_entries = _flatten_config_entries(local_config or {})
|
|
for key in local_changed:
|
|
if key in local_entries:
|
|
merged_entries[key] = local_entries[key]
|
|
else:
|
|
merged_entries.pop(key, None)
|
|
return _config_from_flattened_entries(merged_entries)
|
|
|
|
|
|
def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]:
|
|
expected_key = None
|
|
try:
|
|
plugins = config.get("plugin", {}) if isinstance(config, dict) else {}
|
|
if isinstance(plugins, dict):
|
|
entry = plugins.get("alldebrid")
|
|
if entry is not None:
|
|
if isinstance(entry, dict):
|
|
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
|
v = entry.get(k)
|
|
if isinstance(v, str) and v.strip():
|
|
expected_key = v.strip()
|
|
break
|
|
elif isinstance(entry, str) and entry.strip():
|
|
expected_key = entry.strip()
|
|
if not expected_key:
|
|
expected_key = get_debrid_api_key(config, service="All-debrid")
|
|
except Exception as exc:
|
|
logger.debug("Failed to determine expected AllDebrid key: %s", exc, exc_info=True)
|
|
expected_key = None
|
|
return expected_key
|
|
|
|
|
|
def load_config(*, emit_summary: bool = False) -> Dict[str, Any]:
|
|
global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING
|
|
if _CONFIG_CACHE:
|
|
if emit_summary and _CONFIG_SUMMARY_PENDING:
|
|
_log_config_load_summary(_CONFIG_CACHE)
|
|
_CONFIG_SUMMARY_PENDING = False
|
|
return _CONFIG_CACHE
|
|
|
|
# Load strictly from database
|
|
db_config = get_config_all()
|
|
if db_config:
|
|
_canonicalize_plugin_config(db_config)
|
|
_sync_alldebrid_api_key(db_config)
|
|
_CONFIG_CACHE = db_config
|
|
_LAST_SAVED_CONFIG = deepcopy(db_config)
|
|
if emit_summary:
|
|
_log_config_load_summary(db_config)
|
|
_CONFIG_SUMMARY_PENDING = False
|
|
else:
|
|
_CONFIG_SUMMARY_PENDING = True
|
|
|
|
# Forensics disabled: audit/mismatch/backup detection removed to simplify code.
|
|
return db_config
|
|
|
|
_LAST_SAVED_CONFIG = {}
|
|
return {}
|
|
|
|
|
|
def reload_config() -> Dict[str, Any]:
|
|
clear_config_cache()
|
|
return load_config()
|
|
|
|
|
|
def _acquire_save_lock(timeout: float = _SAVE_LOCK_TIMEOUT):
|
|
"""Acquire a cross-process save lock implemented as a directory.
|
|
|
|
Returns the Path to the created lock directory. Raises ConfigSaveConflict
|
|
if the lock cannot be acquired within the timeout.
|
|
"""
|
|
lock_dir = Path(db.db_path).with_name(_SAVE_LOCK_DIRNAME)
|
|
start = time.time()
|
|
while True:
|
|
try:
|
|
lock_dir.mkdir(exist_ok=False)
|
|
# Write owner metadata for diagnostics
|
|
try:
|
|
(lock_dir / "owner.json").write_text(json.dumps({
|
|
"pid": os.getpid(),
|
|
"ts": time.time(),
|
|
"cmdline": " ".join(sys.argv),
|
|
}))
|
|
except Exception as exc:
|
|
logger.exception("Failed to write save lock owner metadata %s: %s", lock_dir, exc)
|
|
return lock_dir
|
|
except FileExistsError:
|
|
# Check for stale lock
|
|
try:
|
|
owner = lock_dir / "owner.json"
|
|
if owner.exists():
|
|
data = json.loads(owner.read_text())
|
|
ts = data.get("ts") or 0
|
|
if time.time() - ts > _SAVE_LOCK_STALE_SECONDS:
|
|
try:
|
|
import shutil
|
|
shutil.rmtree(lock_dir)
|
|
continue
|
|
except Exception as exc:
|
|
logger.exception("Failed to remove stale save lock dir %s", lock_dir)
|
|
else:
|
|
# No owner file; if directory is old enough consider it stale
|
|
try:
|
|
if time.time() - lock_dir.stat().st_mtime > _SAVE_LOCK_STALE_SECONDS:
|
|
import shutil
|
|
shutil.rmtree(lock_dir)
|
|
continue
|
|
except Exception as exc:
|
|
logger.exception("Failed to stat/remove stale save lock dir %s", lock_dir)
|
|
except Exception as exc:
|
|
logger.exception("Failed to inspect save lock directory %s: %s", lock_dir, exc)
|
|
if time.time() - start > timeout:
|
|
raise ConfigSaveConflict("Save lock busy; could not acquire in time")
|
|
time.sleep(0.1)
|
|
|
|
|
|
def _release_save_lock(lock_dir: Path) -> None:
|
|
try:
|
|
owner = lock_dir / "owner.json"
|
|
try:
|
|
if owner.exists():
|
|
owner.unlink()
|
|
except Exception:
|
|
logger.exception("Failed to remove save lock owner file %s", owner)
|
|
lock_dir.rmdir()
|
|
except Exception:
|
|
logger.exception("Failed to release save lock directory %s", lock_dir)
|
|
|
|
|
|
def save_config(config: Dict[str, Any]) -> int:
|
|
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
|
|
_canonicalize_plugin_config(config)
|
|
_sync_alldebrid_api_key(config)
|
|
|
|
# Acquire cross-process save lock to avoid concurrent saves from different
|
|
# processes which can lead to race conditions and DB-level overwrite.
|
|
lock_dir = None
|
|
try:
|
|
lock_dir = _acquire_save_lock()
|
|
except ConfigSaveConflict:
|
|
# Surface a clear exception to callers so they can retry or handle it.
|
|
raise
|
|
|
|
previous_config = deepcopy(_LAST_SAVED_CONFIG)
|
|
changed_count = _count_changed_entries(previous_config, config)
|
|
|
|
def _write_entries() -> int:
|
|
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
|
|
count = 0
|
|
config_to_write = config
|
|
# Use the transaction-provided connection directly to avoid re-acquiring
|
|
# the connection lock via db.* helpers which can lead to deadlock.
|
|
with db.transaction() as conn:
|
|
# Detect concurrent changes by reading the current DB state inside the
|
|
# same transaction before mutating it. Use the transaction connection
|
|
# directly to avoid acquiring the connection lock again (deadlock).
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT category, subtype, item_name, key, value FROM config")
|
|
rows = cur.fetchall()
|
|
current_disk = rows_to_config(rows)
|
|
cur.close()
|
|
except Exception:
|
|
current_disk = {}
|
|
|
|
if current_disk != _LAST_SAVED_CONFIG:
|
|
# If we have no local changes, refresh caches and skip the write.
|
|
if changed_count == 0:
|
|
log("Skip save: disk configuration changed since last load and no local changes; not writing to DB.")
|
|
# Refresh local caches to match the disk
|
|
_CONFIG_CACHE = current_disk
|
|
_LAST_SAVED_CONFIG = deepcopy(current_disk)
|
|
return 0
|
|
merged_config = _merge_non_conflicting_config_changes(
|
|
previous_config,
|
|
current_disk,
|
|
config,
|
|
)
|
|
if merged_config is None:
|
|
# Otherwise, abort to avoid overwriting external changes
|
|
raise ConfigSaveConflict(
|
|
"Configuration on disk changed since you started editing; save aborted to prevent overwrite. Reload and reapply your changes."
|
|
)
|
|
config_to_write = merged_config
|
|
log("Config save rebased local changes onto newer disk configuration.")
|
|
|
|
# Proceed with writing when no conflicting external changes detected
|
|
conn.execute("DELETE FROM config")
|
|
for key, value in config_to_write.items():
|
|
if key in ('plugin', 'tool') and isinstance(value, dict):
|
|
for subtype, instances in value.items():
|
|
if not isinstance(instances, dict):
|
|
continue
|
|
if key == 'plugin':
|
|
normalized_subtype = _normalize_provider_name(subtype)
|
|
if not normalized_subtype:
|
|
continue
|
|
if _is_multi_instance_plugin_config(instances):
|
|
for name, settings in instances.items():
|
|
if not isinstance(settings, dict):
|
|
continue
|
|
for k, v in settings.items():
|
|
val_str = json.dumps(v) if not isinstance(v, str) else v
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
|
("plugin", normalized_subtype, name, k, val_str),
|
|
)
|
|
count += 1
|
|
else:
|
|
for k, v in instances.items():
|
|
val_str = json.dumps(v) if not isinstance(v, str) else v
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
|
("plugin", normalized_subtype, "default", k, val_str),
|
|
)
|
|
count += 1
|
|
else:
|
|
for k, v in instances.items():
|
|
val_str = json.dumps(v) if not isinstance(v, str) else v
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
|
("tool", subtype, "default", k, val_str),
|
|
)
|
|
count += 1
|
|
else:
|
|
if not key.startswith("_") and value is not None:
|
|
val_str = json.dumps(value) if not isinstance(value, str) else value
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
|
|
("global", "none", "none", key, val_str),
|
|
)
|
|
count += 1
|
|
_CONFIG_CACHE = config_to_write
|
|
_LAST_SAVED_CONFIG = deepcopy(config_to_write)
|
|
return count
|
|
|
|
|
|
saved_entries = 0
|
|
attempts = 0
|
|
while True:
|
|
try:
|
|
saved_entries = _write_entries()
|
|
# Central log entry
|
|
log(
|
|
f"Synced {saved_entries} entries to {db.db_path} "
|
|
f"({changed_count} changed entries)"
|
|
)
|
|
|
|
# Try to checkpoint WAL to ensure main DB file reflects latest state.
|
|
# Use a separate short-lived connection to perform the checkpoint so
|
|
# we don't contend with our main connection lock or active transactions.
|
|
try:
|
|
try:
|
|
with sqlite3.connect(str(db.db_path), timeout=5.0) as _con:
|
|
_con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
except Exception:
|
|
with sqlite3.connect(str(db.db_path), timeout=5.0) as _con:
|
|
_con.execute("PRAGMA wal_checkpoint")
|
|
except Exception as exc:
|
|
log(f"Warning: WAL checkpoint failed: {exc}")
|
|
|
|
# Forensics disabled: audit/logs/backups removed to keep save lean.
|
|
# Release the save lock we acquired earlier
|
|
try:
|
|
if lock_dir is not None and lock_dir.exists():
|
|
_release_save_lock(lock_dir)
|
|
except Exception as exc:
|
|
logger.exception("Failed to release save lock during save flow: %s", exc)
|
|
|
|
break
|
|
except sqlite3.OperationalError as exc:
|
|
attempts += 1
|
|
locked_error = "locked" in str(exc).lower()
|
|
if not locked_error or attempts >= _CONFIG_SAVE_MAX_RETRIES:
|
|
log(f"CRITICAL: Database write failed: {exc}")
|
|
# Ensure we release potential save lock before bubbling error
|
|
try:
|
|
if lock_dir is not None and lock_dir.exists():
|
|
_release_save_lock(lock_dir)
|
|
except Exception as exc:
|
|
logger.exception("Failed to release save lock after DB write failure: %s", exc)
|
|
raise
|
|
delay = _CONFIG_SAVE_RETRY_DELAY * attempts
|
|
log(f"Database locked; retry {attempts}/{_CONFIG_SAVE_MAX_RETRIES} in {delay:.2f}s")
|
|
time.sleep(delay)
|
|
except Exception as exc:
|
|
log(f"CRITICAL: Configuration save failed: {exc}")
|
|
try:
|
|
if lock_dir is not None and lock_dir.exists():
|
|
_release_save_lock(lock_dir)
|
|
except Exception as exc:
|
|
logger.exception("Failed to release save lock after CRITICAL configuration save failure: %s", exc)
|
|
raise
|
|
|
|
return saved_entries
|
|
|
|
|
|
def load() -> Dict[str, Any]:
|
|
"""Return the parsed downlow configuration."""
|
|
return load_config()
|
|
|
|
|
|
def save(config: Dict[str, Any]) -> int:
|
|
"""Persist *config* back to disk."""
|
|
return save_config(config)
|
|
|
|
|
|
def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: float = 0.15) -> int:
|
|
"""Save configuration and verify crucial keys persisted to disk.
|
|
|
|
This helper performs a best-effort verification loop that reloads the
|
|
configuration from disk and confirms that modified API key entries (e.g.
|
|
AllDebrid) were written successfully. If verification fails after the
|
|
configured number of retries, a RuntimeError is raised.
|
|
"""
|
|
# Only perform the extra verification loop when the AllDebrid key actually changed.
|
|
expected_key = _extract_expected_alldebrid_key(config)
|
|
baseline_key = _extract_expected_alldebrid_key(_LAST_SAVED_CONFIG)
|
|
if expected_key == baseline_key:
|
|
expected_key = None
|
|
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, max(1, int(retries)) + 1):
|
|
try:
|
|
saved = save_config(config)
|
|
if not expected_key:
|
|
# Nothing special to verify; return success.
|
|
return saved
|
|
|
|
# Reload directly from disk and compare the canonical plugin key.
|
|
clear_config_cache()
|
|
reloaded = load_config()
|
|
try:
|
|
reloaded_key = _extract_expected_alldebrid_key(reloaded)
|
|
except Exception:
|
|
reloaded_key = None
|
|
|
|
if reloaded_key == expected_key:
|
|
try:
|
|
# Log a short, masked fingerprint to aid debugging without exposing the key itself
|
|
import hashlib
|
|
|
|
fp = hashlib.sha256(expected_key.encode("utf-8")).hexdigest()[:8]
|
|
log(f"Verified AllDebrid API key persisted (fingerprint={fp})")
|
|
except Exception:
|
|
# If hashing/logging fails, don't abort the save
|
|
pass
|
|
return saved
|
|
|
|
# Not yet persisted; log and retry
|
|
log(f"Warning: Post-save verification attempt {attempt} failed (expected key not found in DB). Retrying...")
|
|
time.sleep(delay * attempt)
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
log(f"Warning: save and verify attempt {attempt} failed: {exc}")
|
|
time.sleep(delay * attempt)
|
|
|
|
# All retries exhausted
|
|
raise RuntimeError(f"Post-save verification failed after {retries} attempts: {last_exc}")
|
|
|
|
|
|
def count_changed_entries(config: Dict[str, Any]) -> int:
|
|
"""Return the number of changed configuration entries compared to the last saved snapshot.
|
|
|
|
This is useful for user-facing messages that want to indicate how many entries
|
|
were actually modified, not the total number of rows persisted to the database.
|
|
"""
|
|
return _count_changed_entries(_LAST_SAVED_CONFIG, config)
|