Files
Medios-Macina/SYS/config.py
T

1254 lines
46 KiB
Python
Raw Normal View History

2025-12-29 19:00:00 -08:00
""" """
from __future__ import annotations
2026-01-22 11:05:40 -08:00
import json
2026-01-23 16:46:48 -08:00
import sqlite3
import time
2026-01-30 10:47:47 -08:00
import os
import re
2026-01-30 10:47:47 -08:00
import datetime
import shutil
2026-01-30 10:47:47 -08:00
import sys
2026-02-09 17:22:40 -08:00
import tempfile
2026-01-23 18:40:00 -08:00
from copy import deepcopy
2025-12-29 19:00:00 -08:00
from pathlib import Path
2026-03-25 22:39:30 -07:00
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
2025-12-29 19:00:00 -08:00
from SYS.logger import log
2026-01-31 19:57:09 -08:00
import logging
logger = logging.getLogger(__name__)
2026-01-11 00:52:54 -08:00
from SYS.utils import expand_path
2026-02-09 17:45:57 -08:00
from SYS.database import db, get_config_all, rows_to_config
2025-12-29 19:00:00 -08:00
SCRIPT_DIR = Path(__file__).resolve().parent
2026-01-30 10:47:47 -08:00
# 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
2026-01-23 17:12:15 -08:00
_CONFIG_CACHE: Dict[str, Any] = {}
2026-01-23 18:40:00 -08:00
_LAST_SAVED_CONFIG: Dict[str, Any] = {}
2026-04-26 15:08:35 -07:00
_CONFIG_SUMMARY_PENDING = False
2026-01-23 16:46:48 -08:00
_CONFIG_SAVE_MAX_RETRIES = 5
_CONFIG_SAVE_RETRY_DELAY = 0.15
2026-03-25 22:39:30 -07:00
_CONFIG_MISSING = object()
_PATH_ALIAS_TOKEN_RE = re.compile(r"^\$(?:\((?P<braced>[^)]+)\)|(?P<plain>[A-Za-z0-9_.-]+))$")
2025-12-29 19:00:00 -08:00
2026-01-30 10:47:47 -08:00
class ConfigSaveConflict(Exception):
"""Raised when a save would overwrite external changes present on disk."""
pass
2026-01-11 03:56:09 -08:00
def global_config() -> List[Dict[str, Any]]:
"""Return configuration schema for global settings."""
return [
{
"key": "debug",
"label": "Debug Output",
2026-04-21 14:18:52 -07:00
"group": "Runtime",
"type": "boolean",
2026-01-11 03:56:09 -08:00
"default": "false",
2026-04-21 14:18:52 -07:00
"choices": ["true", "false"],
2026-01-11 10:59:50 -08:00
},
{
"key": "auto_update",
"label": "Auto-Update",
2026-04-21 14:18:52 -07:00
"group": "Runtime",
"type": "boolean",
2026-01-11 10:59:50 -08:00
"default": "true",
2026-04-21 14:18:52 -07:00
"choices": ["true", "false"],
2026-04-16 17:18:50 -07:00
},
{
"key": "table_appearance",
"label": "Table Appearance",
2026-04-21 14:18:52 -07:00
"group": "Display",
2026-04-16 17:18:50 -07:00
"default": "rainbow",
2026-04-21 14:18:52 -07:00
"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": {},
2026-01-11 03:56:09 -08:00
}
]
2026-01-11 00:52:54 -08:00
def clear_config_cache() -> None:
2026-01-23 18:40:00 -08:00
"""Clear the configuration cache and baseline snapshot."""
2026-04-26 15:08:35 -07:00
global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING
2026-01-23 17:12:15 -08:00
_CONFIG_CACHE = {}
2026-01-23 18:40:00 -08:00
_LAST_SAVED_CONFIG = {}
2026-04-26 15:08:35 -07:00
_CONFIG_SUMMARY_PENDING = False
def _log_config_load_summary(config: Dict[str, Any]) -> None:
try:
2026-04-26 16:49:23 -07:00
plugin_block = config.get("plugin")
if not isinstance(plugin_block, dict):
plugin_block = config.get("provider")
2026-05-03 21:20:05 -07:00
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
2026-04-26 15:08:35 -07:00
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
2026-05-03 21:20:05 -07:00
plugins_str = ', '.join(plugin_names[:10]) + ('...' if len(plugin_names) > 10 else '')
2026-04-26 15:08:35 -07:00
summary = (
2026-05-03 21:20:05 -07:00
f"Loaded config from {db.db_path.name}: "
f"plugins={len(plugin_names)} ({plugins_str}), "
f"instances={total_instances}, mtime={mtime}"
2026-04-26 15:08:35 -07:00
)
log(summary)
except Exception:
logger.exception("Failed to build config load summary from %s", db.db_path)
2025-12-29 19:00:00 -08:00
2026-03-25 22:39:30 -07:00
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)
2026-03-25 22:39:30 -07:00
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
2025-12-29 19:00:00 -08:00
def get_hydrus_instance(
config: Dict[str, Any], instance_name: str = "home"
) -> Optional[Dict[str, Any]]:
2026-05-03 21:20:05 -07:00
"""Get a specific Hydrus instance config by name from plugin/provider 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
2025-12-29 19:00:00 -08:00
2026-05-03 21:20:05 -07:00
# New format: config["plugin"]["hydrusnetwork"] or config["provider"]["hydrusnetwork"]
# (both point to the same dict after normalization)
for section in ("plugin", "provider"):
section_cfg = config.get(section)
if isinstance(section_cfg, dict):
hydrus_cfg = section_cfg.get("hydrusnetwork")
if isinstance(hydrus_cfg, dict):
result = _lookup_in(hydrus_cfg)
if result is not None:
return result
2026-01-23 16:46:48 -08:00
2025-12-29 19:00:00 -08:00
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:
2026-05-03 21:20:05 -07:00
- config["plugin"]["hydrusnetwork"][name]["API"]
2025-12-29 19:00:00 -08:00
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:
2026-05-03 21:20:05 -07:00
- config["plugin"]["hydrusnetwork"][name]["URL"]
2025-12-29 19:00:00 -08:00
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_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
2026-04-26 16:49:23 -07:00
_normalize_plugin_config_aliases(config)
2025-12-29 19:00:00 -08:00
provider_cfg = config.get("provider")
if not isinstance(provider_cfg, dict):
return {}
2026-01-27 14:56:01 -08:00
normalized = _normalize_provider_name(name)
if normalized:
block = provider_cfg.get(normalized)
if isinstance(block, dict):
return block
for key, block in provider_cfg.items():
if not isinstance(block, dict):
continue
if _normalize_provider_name(key) == normalized:
return block
return {}
2025-12-29 19:00:00 -08:00
def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]:
block = get_provider_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_provider_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
2025-12-29 19:00:00 -08:00
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)
2025-12-29 19:00:00 -08:00
# First try explicit temp setting from config
temp_value = config.get("temp")
if temp_value:
try:
2026-01-11 00:52:54 -08:00
path = expand_path(temp_value)
2025-12-29 19:00:00 -08:00
# Verify we can access it (not a system directory with permission issues)
if path.exists() or path.parent.exists():
return path
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.debug("resolve_output_dir: failed to expand temp value %r: %s", temp_value, exc, exc_info=True)
2025-12-29 19:00:00 -08:00
# Then try outfile setting
outfile_value = config.get("outfile")
if outfile_value:
try:
2026-01-11 00:52:54 -08:00
return expand_path(outfile_value)
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.debug("resolve_output_dir: failed to expand outfile value %r: %s", outfile_value, exc, exc_info=True)
2025-12-29 19:00:00 -08:00
2026-01-11 10:59:50 -08:00
# Fallback to system temp directory
return Path(tempfile.gettempdir())
2025-12-29 19:00:00 -08:00
def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
"""Get local storage path from config.
Supports multiple formats:
- Old: config["storage"]["local"]["path"]
- Old: config["Local"]["path"]
Args:
config: Configuration dict
Returns:
Path object if found, None otherwise
"""
# Fall back to storage.local.path format
storage = config.get("storage", {})
if isinstance(storage, dict):
local_config = storage.get("local", {})
if isinstance(local_config, dict):
path_str = local_config.get("path")
if path_str:
2026-01-11 00:52:54 -08:00
return expand_path(path_str)
2025-12-29 19:00:00 -08:00
# Fall back to old Local format
local_config = config.get("Local", {})
if isinstance(local_config, dict):
path_str = local_config.get("path")
if path_str:
2026-01-11 00:52:54 -08:00
return expand_path(path_str)
2025-12-29 19:00:00 -08:00
return None
def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
"""Get Debrid API key from config.
2026-05-03 21:20:05 -07:00
Checks the plugin/provider block first (canonical format).
2025-12-29 19:00:00 -08:00
Args:
config: Configuration dict
service: Service name (default: "All-debrid")
Returns:
API key string if found, None otherwise
"""
2026-05-03 21:20:05 -07:00
# 1) Canonical plugin/provider block: config["plugin"]["alldebrid"]["api_key"]
provider_block = config.get("provider") or config.get("plugin")
if isinstance(provider_block, dict):
alldebrid_entry = provider_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()
2025-12-29 19:00:00 -08:00
2026-05-03 21:20:05 -07:00
# 2) Migrated legacy debrid plugin entry: config["plugin"]["debrid"]["all-debrid"]["api_key"]
if isinstance(provider_block, dict):
service_key = str(service).strip().lower()
debrid_plugin = provider_block.get("debrid")
if isinstance(debrid_plugin, dict):
entry = debrid_plugin.get(service_key)
if isinstance(entry, dict):
api_key = entry.get("api_key")
return str(api_key).strip() if api_key else None
if isinstance(entry, str):
return entry.strip() or None
2025-12-29 19:00:00 -08:00
return None
def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[Dict[str, str]]:
"""Get provider credentials (email/password) from config.
Supports both formats:
- New: config["provider"][provider] = {"email": "...", "password": "..."}
- Old: config[provider.capitalize()] = {"email": "...", "password": "..."}
Args:
config: Configuration dict
provider: Provider name (e.g., "openlibrary", "soulseek")
Returns:
Dict with credentials if found, None otherwise
"""
# Try new format first
provider_config = config.get("provider", {})
if isinstance(provider_config, dict):
creds = provider_config.get(provider.lower(), {})
if isinstance(creds, dict) and creds:
return creds
# Fall back to old format (capitalized key)
old_key_map = {
"openlibrary": "OpenLibrary",
"archive": "Archive",
"soulseek": "Soulseek",
}
old_key = old_key_map.get(provider.lower())
if old_key:
creds = config.get(old_key, {})
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]:
2026-02-07 14:58:13 -08:00
# Only support modular config style:
2025-12-29 19:00:00 -08:00
# [tool=ytdlp]
# cookies="C:\\path\\cookies.txt"
values: list[Any] = []
try:
tool = config.get("tool")
if isinstance(tool, dict):
ytdlp = tool.get("ytdlp")
if isinstance(ytdlp, dict):
values.append(ytdlp.get("cookies"))
values.append(ytdlp.get("cookiefile"))
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.debug("resolve_cookies_path: failed to read tool.ytdlp cookies: %s", exc, exc_info=True)
2025-12-29 19:00:00 -08:00
2026-04-26 16:49:23 -07:00
base_dir = _resolve_app_root(script_dir)
2025-12-29 19:00:00 -08:00
for value in values:
if not value:
continue
2026-01-11 00:52:54 -08:00
candidate = expand_path(value)
2025-12-29 19:00:00 -08:00
if not candidate.is_absolute():
2026-01-11 00:52:54 -08:00
candidate = expand_path(base_dir / candidate)
2025-12-29 19:00:00 -08:00
if candidate.is_file():
return candidate
plugin_cookie = _resolve_ytdlp_plugin_cookie_path(base_dir)
2026-04-26 16:49:23 -07:00
if plugin_cookie is not None:
return plugin_cookie
2025-12-29 19:00:00 -08:00
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
2025-12-29 19:00:00 -08:00
def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]:
value = config.get("download_debug_log")
if not value:
return None
2026-01-11 00:52:54 -08:00
path = expand_path(value)
2025-12-29 19:00:00 -08:00
if not path.is_absolute():
path = Path.cwd() / path
return path
2026-01-27 14:56:01 -08:00
def _normalize_provider_name(value: Any) -> Optional[str]:
candidate = str(value or "").strip().lower()
return candidate if candidate else None
2026-04-26 16:49:23 -07:00
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 _normalize_plugin_config_aliases(config: Dict[str, Any]) -> None:
if not isinstance(config, dict):
return
plugin_block = config.get("plugin")
provider_block = config.get("provider")
normalized_provider: Dict[str, Any] = {}
if isinstance(provider_block, dict):
for key, value in provider_block.items():
normalized_key = _normalize_provider_name(key)
if normalized_key and normalized_key not in normalized_provider:
normalized_provider[normalized_key] = value
if isinstance(plugin_block, dict):
for key, value in plugin_block.items():
normalized_key = _normalize_provider_name(key)
if normalized_key and normalized_key not in normalized_provider:
normalized_provider[normalized_key] = value
2026-05-03 21:20:05 -07:00
# Fold legacy config["store"] entries into the plugin namespace.
# store format: {type: {instance_name: {key: val}}} — multi-instance.
# After folding, remove config["store"] so it is no longer consulted.
store_block = config.pop("store", None)
if isinstance(store_block, dict):
for store_type, instances in store_block.items():
if not isinstance(instances, dict):
continue
normalized_key = _normalize_provider_name(store_type)
if not normalized_key:
continue
existing = normalized_provider.get(normalized_key)
if not isinstance(existing, dict):
existing = {}
normalized_provider[normalized_key] = existing
for instance_name, settings in instances.items():
if isinstance(settings, dict) and instance_name not in existing:
existing[instance_name] = dict(settings)
2026-04-26 16:49:23 -07:00
if normalized_provider:
config["provider"] = normalized_provider
config["plugin"] = normalized_provider
else:
if isinstance(provider_block, dict):
config["plugin"] = provider_block
elif isinstance(plugin_block, dict):
config["provider"] = plugin_block
2026-01-27 14:56:01 -08:00
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:
2026-05-03 21:20:05 -07:00
"""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.
"""
2026-01-27 14:56:01 -08:00
if not isinstance(config, dict):
return
2026-04-26 16:49:23 -07:00
_normalize_plugin_config_aliases(config)
2026-01-27 14:56:01 -08:00
providers = config.get("provider")
if not isinstance(providers, dict):
providers = {}
config["provider"] = providers
provider_entry = providers.get("alldebrid")
provider_section: Dict[str, Any] | None = None
provider_key = None
if isinstance(provider_entry, dict):
provider_section = provider_entry
provider_key = _extract_api_key(provider_section)
elif isinstance(provider_entry, str):
provider_key = provider_entry.strip()
if provider_key:
provider_section = {"api_key": provider_key}
providers["alldebrid"] = provider_section
2026-05-03 21:20:05 -07:00
# If no key found in provider block, check for a migrated debrid plugin entry.
# (rows_to_config migrates store.debrid.all-debrid → plugin.debrid.all-debrid)
if not provider_key:
plugin_block = config.get("plugin") or providers
debrid_plugin = plugin_block.get("debrid") if isinstance(plugin_block, dict) else None
if isinstance(debrid_plugin, dict):
service_entry = debrid_plugin.get("all-debrid")
legacy_key = _extract_api_key(service_entry) if service_entry else None
if legacy_key:
if provider_section is None:
provider_section = {}
providers["alldebrid"] = provider_section
provider_section.setdefault("api_key", legacy_key)
2026-01-27 14:56:01 -08:00
2026-05-03 21:20:05 -07:00
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())
2026-01-27 14:56:01 -08:00
2025-12-29 19:00:00 -08:00
2026-01-23 18:40:00 -08:00
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
entries: Dict[Tuple[str, str, str, str], Any] = {}
2026-04-26 16:49:23 -07:00
_normalize_plugin_config_aliases(config)
2026-01-23 18:40:00 -08:00
for key, value in config.items():
2026-04-26 16:49:23 -07:00
if key == 'plugin':
2026-05-03 21:20:05 -07:00
# plugin == provider after normalization; skip duplicate
2026-04-26 16:49:23 -07:00
continue
2026-05-03 21:20:05 -07:00
if key == 'provider' 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[('provider', subtype, 'default', k)] = v
elif key in ('store', 'tool') and isinstance(value, dict):
2026-01-23 18:40:00 -08:00
for subtype, instances in value.items():
if not isinstance(instances, dict):
continue
if key == 'store':
2026-05-03 21:20:05 -07:00
# Legacy store: migrate to plugin category
2026-01-23 18:40:00 -08:00
for name, settings in instances.items():
if not isinstance(settings, dict):
continue
for k, v in settings.items():
2026-05-03 21:20:05 -07:00
entries[('plugin', subtype, name, k)] = v
else: # tool
2026-01-23 18:40:00 -08:00
for k, v in instances.items():
entries[(key, 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 == "store":
2026-05-03 21:20:05 -07:00
# Legacy: migrate to plugin namespace at reconstitution time
plugin_block = config.setdefault("plugin", {})
subtype_block = plugin_block.setdefault(subtype, {})
item_block = subtype_block.setdefault(item_name, {})
item_block[key] = value
continue
2026-05-03 21:20:05 -07:00
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 in {"provider", "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
_normalize_plugin_config_aliases(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:
providers = config.get("provider", {}) if isinstance(config, dict) else {}
if isinstance(providers, dict):
entry = providers.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:
2026-05-03 21:20:05 -07:00
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
2026-05-14 20:47:20 -07:00
def load_config(*, emit_summary: bool = False) -> Dict[str, Any]:
2026-04-26 15:08:35 -07:00
global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING
2026-01-23 17:12:15 -08:00
if _CONFIG_CACHE:
2026-04-26 15:08:35 -07:00
if emit_summary and _CONFIG_SUMMARY_PENDING:
_log_config_load_summary(_CONFIG_CACHE)
_CONFIG_SUMMARY_PENDING = False
2026-01-23 17:12:15 -08:00
return _CONFIG_CACHE
2025-12-29 19:00:00 -08:00
2026-05-03 21:20:05 -07:00
# One-time DB migration: move category='store' rows to category='plugin'.
# This is idempotent — a no-op if no store rows exist.
try:
from SYS.database import migrate_store_category_to_plugin
migrate_store_category_to_plugin()
except Exception:
logger.debug("Store→plugin DB migration skipped or failed", exc_info=True)
2026-01-23 17:12:15 -08:00
# Load strictly from database
2026-01-22 01:53:13 -08:00
db_config = get_config_all()
if db_config:
2026-04-26 16:49:23 -07:00
_normalize_plugin_config_aliases(db_config)
2026-01-27 14:56:01 -08:00
_sync_alldebrid_api_key(db_config)
2026-01-23 17:12:15 -08:00
_CONFIG_CACHE = db_config
2026-01-23 18:40:00 -08:00
_LAST_SAVED_CONFIG = deepcopy(db_config)
2026-04-26 15:08:35 -07:00
if emit_summary:
_log_config_load_summary(db_config)
_CONFIG_SUMMARY_PENDING = False
else:
_CONFIG_SUMMARY_PENDING = True
2026-01-30 10:47:47 -08:00
2026-04-26 15:08:35 -07:00
# Forensics disabled: audit/mismatch/backup detection removed to simplify code.
2026-01-22 01:53:13 -08:00
return db_config
2025-12-29 19:00:00 -08:00
2026-01-23 18:40:00 -08:00
_LAST_SAVED_CONFIG = {}
2026-01-22 01:53:13 -08:00
return {}
2025-12-29 19:00:00 -08:00
2026-01-23 17:12:15 -08:00
def reload_config() -> Dict[str, Any]:
clear_config_cache()
return load_config()
2025-12-29 19:00:00 -08:00
2026-01-30 10:47:47 -08:00
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),
}))
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.exception("Failed to write save lock owner metadata %s: %s", lock_dir, exc)
2026-01-30 10:47:47 -08:00
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
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.exception("Failed to remove stale save lock dir %s", lock_dir)
2026-01-30 10:47:47 -08:00
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
2026-01-31 19:57:09 -08:00
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)
2026-01-30 10:47:47 -08:00
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:
2026-01-31 19:57:09 -08:00
logger.exception("Failed to remove save lock owner file %s", owner)
2026-01-30 10:47:47 -08:00
lock_dir.rmdir()
except Exception:
2026-01-31 19:57:09 -08:00
logger.exception("Failed to release save lock directory %s", lock_dir)
2026-01-30 10:47:47 -08:00
2026-01-23 17:12:15 -08:00
def save_config(config: Dict[str, Any]) -> int:
2026-01-23 18:40:00 -08:00
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
2026-04-26 16:49:23 -07:00
_normalize_plugin_config_aliases(config)
2026-01-27 14:56:01 -08:00
_sync_alldebrid_api_key(config)
2026-01-30 10:47:47 -08:00
# 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
2026-01-23 18:40:00 -08:00
previous_config = deepcopy(_LAST_SAVED_CONFIG)
changed_count = _count_changed_entries(previous_config, config)
2026-01-23 16:46:48 -08:00
def _write_entries() -> int:
2026-01-30 10:47:47 -08:00
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
2026-01-23 16:46:48 -08:00
count = 0
config_to_write = config
2026-01-30 10:47:47 -08:00
# 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,
2026-01-30 10:47:47 -08:00
)
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.")
2026-01-30 10:47:47 -08:00
# Proceed with writing when no conflicting external changes detected
conn.execute("DELETE FROM config")
for key, value in config_to_write.items():
2026-01-30 10:47:47 -08:00
if key in ('store', 'provider', 'tool') and isinstance(value, dict):
for subtype, instances in value.items():
if not isinstance(instances, dict):
continue
if key == 'store':
for name, settings in instances.items():
if isinstance(settings, dict):
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 (?, ?, ?, ?, ?)",
(key, subtype, name, k, val_str),
)
count += 1
else:
normalized_subtype = subtype
if key == 'provider':
normalized_subtype = _normalize_provider_name(subtype)
if not normalized_subtype:
continue
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 (?, ?, ?, ?, ?)",
(key, normalized_subtype, "default", k, val_str),
)
count += 1
2026-01-22 11:05:40 -08:00
else:
2026-01-23 16:46:48 -08:00
if not key.startswith("_") and value is not None:
2026-01-30 10:47:47 -08:00
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),
)
2026-01-23 16:46:48 -08:00
count += 1
_CONFIG_CACHE = config_to_write
_LAST_SAVED_CONFIG = deepcopy(config_to_write)
2026-01-23 16:46:48 -08:00
return count
2026-01-30 10:47:47 -08:00
2026-01-23 16:46:48 -08:00
saved_entries = 0
attempts = 0
while True:
try:
saved_entries = _write_entries()
2026-01-30 10:47:47 -08:00
# Central log entry
2026-01-23 18:40:00 -08:00
log(
f"Synced {saved_entries} entries to {db.db_path} "
f"({changed_count} changed entries)"
)
2026-01-30 10:47:47 -08:00
# 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}")
2026-01-30 12:04:37 -08:00
# Forensics disabled: audit/logs/backups removed to keep save lean.
# Release the save lock we acquired earlier
2026-01-30 10:47:47 -08:00
try:
2026-01-30 12:04:37 -08:00
if lock_dir is not None and lock_dir.exists():
_release_save_lock(lock_dir)
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.exception("Failed to release save lock during save flow: %s", exc)
2026-01-30 10:47:47 -08:00
2026-01-23 16:46:48 -08:00
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:
2026-01-23 17:12:15 -08:00
log(f"CRITICAL: Database write failed: {exc}")
2026-01-30 10:47:47 -08:00
# 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)
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.exception("Failed to release save lock after DB write failure: %s", exc)
2026-01-23 16:46:48 -08:00
raise
delay = _CONFIG_SAVE_RETRY_DELAY * attempts
2026-01-23 17:12:15 -08:00
log(f"Database locked; retry {attempts}/{_CONFIG_SAVE_MAX_RETRIES} in {delay:.2f}s")
2026-01-23 16:46:48 -08:00
time.sleep(delay)
except Exception as exc:
2026-01-23 17:12:15 -08:00
log(f"CRITICAL: Configuration save failed: {exc}")
2026-01-30 10:47:47 -08:00
try:
if lock_dir is not None and lock_dir.exists():
_release_save_lock(lock_dir)
2026-01-31 19:57:09 -08:00
except Exception as exc:
logger.exception("Failed to release save lock after CRITICAL configuration save failure: %s", exc)
2026-01-23 16:46:48 -08:00
raise
2026-01-22 02:45:08 -08:00
2026-01-23 16:46:48 -08:00
return saved_entries
2025-12-29 19:00:00 -08:00
def load() -> Dict[str, Any]:
"""Return the parsed downlow configuration."""
return load_config()
2026-01-23 16:46:48 -08:00
def save(config: Dict[str, Any]) -> int:
2025-12-29 19:00:00 -08:00
"""Persist *config* back to disk."""
2026-01-23 16:46:48 -08:00
return save_config(config)
2026-01-30 10:47:47 -08:00
2026-01-31 15:37:17 -08:00
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:
2026-01-31 15:37:17 -08:00
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 debrid/provider keys
clear_config_cache()
reloaded = load_config()
# Provider-level key
prov_block = reloaded.get("provider", {}) if isinstance(reloaded, dict) else {}
prov_key = None
if isinstance(prov_block, dict):
aentry = prov_block.get("alldebrid")
if isinstance(aentry, dict):
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
v = aentry.get(k)
if isinstance(v, str) and v.strip():
prov_key = v.strip()
break
elif isinstance(aentry, str) and aentry.strip():
prov_key = aentry.strip()
# Store-level key
try:
store_key = get_debrid_api_key(reloaded, service="All-debrid")
except Exception:
store_key = None
if prov_key == expected_key or store_key == expected_key:
2026-01-31 21:32:51 -08:00
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
2026-01-31 15:37:17 -08:00
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}")
2026-01-30 10:47:47 -08:00
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)