update and cleanup repo

This commit is contained in:
2026-05-26 15:32:01 -07:00
parent 5041d9fbb9
commit 0db899d0c3
72 changed files with 788 additions and 1884 deletions
+122 -228
View File
@@ -95,8 +95,6 @@ def clear_config_cache() -> None:
def _log_config_load_summary(config: Dict[str, Any]) -> None:
try:
plugin_block = config.get("plugin")
if not isinstance(plugin_block, dict):
plugin_block = config.get("provider")
if isinstance(plugin_block, dict):
# Count distinct plugin names; note multi-instance plugins appear once per name
plugin_names = list(plugin_block.keys())
@@ -265,7 +263,9 @@ def set_nested_config_value(
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/provider config."""
"""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
@@ -286,16 +286,13 @@ def get_hydrus_instance(
candidate = source.get(first_key) if first_key else None
return candidate if isinstance(candidate, dict) else None
# 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
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
@@ -339,17 +336,17 @@ def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optio
return str(url).strip() if url else None
def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
_normalize_plugin_config_aliases(config)
provider_cfg = config.get("provider")
if not isinstance(provider_cfg, dict):
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 = provider_cfg.get(normalized)
block = plugin_cfg.get(normalized)
if isinstance(block, dict):
return block
for key, block in provider_cfg.items():
for key, block in plugin_cfg.items():
if not isinstance(block, dict):
continue
if _normalize_provider_name(key) == normalized:
@@ -358,13 +355,13 @@ def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
def get_soulseek_username(config: Dict[str, Any]) -> Optional[str]:
block = get_provider_block(config, "soulseek")
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_provider_block(config, "soulseek")
block = get_plugin_block(config, "soulseek")
val = block.get("password") or block.get("PASSWORD")
return str(val).strip() if val else None
@@ -415,33 +412,33 @@ def resolve_output_dir(config: Dict[str, Any]) -> Path:
def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
"""Get local storage path from config.
"""Return the configured default local plugin destination path.
Supports multiple formats:
- Old: config["storage"]["local"]["path"]
- Old: config["Local"]["path"]
Args:
config: Configuration dict
Returns:
Path object if found, None otherwise
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.
"""
# 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:
return expand_path(path_str)
local_block = get_plugin_block(config, "local")
if not isinstance(local_block, dict) or not local_block:
return None
# 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:
return expand_path(path_str)
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
@@ -449,7 +446,7 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
"""Get Debrid API key from config.
Checks the plugin/provider block first (canonical format).
Checks the plugin block first (canonical format).
Args:
config: Configuration dict
@@ -458,37 +455,23 @@ def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> O
Returns:
API key string if found, None otherwise
"""
# 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")
_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()
# 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
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": "..."}
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
@@ -497,22 +480,11 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
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
_canonicalize_plugin_config(config)
# 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, {})
plugin_config = config.get("plugin", {})
if isinstance(plugin_config, dict):
creds = plugin_config.get(provider.lower(), {})
if isinstance(creds, dict) and creds:
return creds
@@ -522,19 +494,19 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
def resolve_cookies_path(
config: Dict[str, Any], script_dir: Optional[Path] = None
) -> Optional[Path]:
# Only support modular config style:
# [tool=ytdlp]
# Only support plugin config style:
# [plugin=ytdlp]
# cookies="C:\\path\\cookies.txt"
values: list[Any] = []
try:
tool = config.get("tool")
if isinstance(tool, dict):
ytdlp = tool.get("ytdlp")
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 tool.ytdlp cookies: %s", exc, exc_info=True)
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:
@@ -627,54 +599,26 @@ def resolve_plugin_asset_path(
return None
def _normalize_plugin_config_aliases(config: Dict[str, Any]) -> 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")
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
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 and normalized_key not in normalized_provider:
normalized_provider[normalized_key] = value
if normalized_key:
normalized_plugin[normalized_key] = value
# 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)
if normalized_provider:
config["provider"] = normalized_provider
config["plugin"] = normalized_provider
if normalized_plugin or isinstance(plugin_block, dict):
config["plugin"] = normalized_plugin
else:
if isinstance(provider_block, dict):
config["plugin"] = provider_block
elif isinstance(plugin_block, dict):
config["provider"] = plugin_block
config.pop("plugin", None)
def _extract_api_key(value: Any) -> Optional[str]:
if isinstance(value, dict):
@@ -698,40 +642,24 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None:
if not isinstance(config, dict):
return
_normalize_plugin_config_aliases(config)
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
# 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)
_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).
@@ -754,12 +682,9 @@ def _is_multi_instance_plugin_config(value: Any) -> bool:
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
entries: Dict[Tuple[str, str, str, str], Any] = {}
_normalize_plugin_config_aliases(config)
_canonicalize_plugin_config(config)
for key, value in config.items():
if key == 'plugin':
# plugin == provider after normalization; skip duplicate
continue
if key == 'provider' and isinstance(value, dict):
if key == 'plugin' and isinstance(value, dict):
for subtype, plugin_cfg in value.items():
if not isinstance(plugin_cfg, dict):
continue
@@ -773,21 +698,13 @@ def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str,
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):
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
if key == 'store':
# Legacy store: migrate to plugin category
for name, settings in instances.items():
if not isinstance(settings, dict):
continue
for k, v in settings.items():
entries[('plugin', subtype, name, k)] = v
else: # tool
for k, v in instances.items():
entries[(key, subtype, 'default', k)] = v
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
@@ -817,14 +734,6 @@ def _config_from_flattened_entries(
config[key] = value
continue
if category == "store":
# 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
if category == "plugin":
plugin_block = config.setdefault("plugin", {})
subtype_block = plugin_block.setdefault(subtype, {})
@@ -835,7 +744,7 @@ def _config_from_flattened_entries(
item_block[key] = value
continue
if category in {"provider", "tool"}:
if category == "tool":
category_block = config.setdefault(category, {})
subtype_block = category_block.setdefault(subtype, {})
subtype_block[key] = value
@@ -849,7 +758,7 @@ def _config_from_flattened_entries(
if isinstance(item_block, dict):
item_block[key] = value
_normalize_plugin_config_aliases(config)
_canonicalize_plugin_config(config)
_sync_alldebrid_api_key(config)
return config
@@ -880,9 +789,9 @@ def _merge_non_conflicting_config_changes(
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")
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"):
@@ -908,18 +817,10 @@ def load_config(*, emit_summary: bool = False) -> Dict[str, Any]:
_CONFIG_SUMMARY_PENDING = False
return _CONFIG_CACHE
# 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)
# Load strictly from database
db_config = get_config_all()
if db_config:
_normalize_plugin_config_aliases(db_config)
_canonicalize_plugin_config(db_config)
_sync_alldebrid_api_key(db_config)
_CONFIG_CACHE = db_config
_LAST_SAVED_CONFIG = deepcopy(db_config)
@@ -1007,7 +908,7 @@ def _release_save_lock(lock_dir: Path) -> None:
def save_config(config: Dict[str, Any]) -> int:
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
_normalize_plugin_config_aliases(config)
_canonicalize_plugin_config(config)
_sync_alldebrid_api_key(config)
# Acquire cross-process save lock to avoid concurrent saves from different
@@ -1065,31 +966,39 @@ def save_config(config: Dict[str, Any]) -> int:
# 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 ('store', 'provider', 'tool') and isinstance(value, dict):
if key in ('plugin', '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):
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 (?, ?, ?, ?, ?)",
(key, subtype, name, k, val_str),
("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:
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),
("tool", subtype, "default", k, val_str),
)
count += 1
else:
@@ -1197,30 +1106,15 @@ def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: floa
# Nothing special to verify; return success.
return saved
# Reload directly from disk and compare the canonical debrid/provider keys
# Reload directly from disk and compare the canonical plugin key.
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")
reloaded_key = _extract_expected_alldebrid_key(reloaded)
except Exception:
store_key = None
reloaded_key = None
if prov_key == expected_key or store_key == expected_key:
if reloaded_key == expected_key:
try:
# Log a short, masked fingerprint to aid debugging without exposing the key itself
import hashlib