updated plugin refactor and added FTP and SCP plugins , also hydrusnetwork plugin migration

This commit is contained in:
2026-04-27 21:17:53 -07:00
parent bfd5c20dc3
commit 8685fbb723
24 changed files with 3650 additions and 405 deletions
+144 -39
View File
@@ -8,6 +8,7 @@ import time
import os
import re
import datetime
import shutil
import sys
import tempfile
from copy import deepcopy
@@ -532,7 +533,7 @@ def resolve_cookies_path(
if candidate.is_file():
return candidate
plugin_cookie = resolve_plugin_asset_path("ytdlp", "cookies.txt", script_dir=base_dir)
plugin_cookie = _resolve_ytdlp_plugin_cookie_path(base_dir)
if plugin_cookie is not None:
return plugin_cookie
@@ -542,6 +543,30 @@ def resolve_cookies_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:
@@ -721,6 +746,105 @@ def _count_changed_entries(old_config: Dict[str, Any], new_config: Dict[str, Any
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":
store_block = config.setdefault("store", {})
subtype_block = store_block.setdefault(subtype, {})
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:
store_block = config.get("store", {}) if isinstance(config, dict) else {}
debrid = store_block.get("debrid") if isinstance(store_block, dict) else None
if isinstance(debrid, dict):
srv = debrid.get("all-debrid")
if isinstance(srv, dict):
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
v = srv.get(k)
if isinstance(v, str) and v.strip():
expected_key = v.strip()
break
elif isinstance(srv, str) and srv.strip():
expected_key = srv.strip()
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 = True) -> Dict[str, Any]:
global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING
if _CONFIG_CACHE:
@@ -838,6 +962,7 @@ def save_config(config: Dict[str, Any]) -> int:
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:
@@ -861,14 +986,22 @@ def save_config(config: Dict[str, Any]) -> int:
_CONFIG_CACHE = current_disk
_LAST_SAVED_CONFIG = deepcopy(current_disk)
return 0
# 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."
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.items():
for key, value in config_to_write.items():
if key in ('store', 'provider', 'tool') and isinstance(value, dict):
for subtype, instances in value.items():
if not isinstance(instances, dict):
@@ -904,6 +1037,8 @@ def save_config(config: Dict[str, Any]) -> int:
("global", "none", "none", key, val_str),
)
count += 1
_CONFIG_CACHE = config_to_write
_LAST_SAVED_CONFIG = deepcopy(config_to_write)
return count
@@ -964,9 +1099,6 @@ def save_config(config: Dict[str, Any]) -> int:
logger.exception("Failed to release save lock after CRITICAL configuration save failure: %s", exc)
raise
clear_config_cache()
_CONFIG_CACHE = config
_LAST_SAVED_CONFIG = deepcopy(config)
return saved_entries
@@ -988,37 +1120,10 @@ def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: floa
AllDebrid) were written successfully. If verification fails after the
configured number of retries, a RuntimeError is raised.
"""
# Detect an API key that should be verified (provider or store-backed)
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:
# _extract_api_key is a small internal helper; reuse the implementation here
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:
store_block = config.get("store", {}) if isinstance(config, dict) else {}
debrid = store_block.get("debrid") if isinstance(store_block, dict) else None
if isinstance(debrid, dict):
srv = debrid.get("all-debrid")
if isinstance(srv, dict):
for k in ("api_key", "API_KEY", "apikey", "APIKEY"):
v = srv.get(k)
if isinstance(v, str) and v.strip():
expected_key = v.strip()
break
elif isinstance(srv, str) and srv.strip():
expected_key = srv.strip()
except Exception as exc:
logger.debug("Failed to determine expected key for save verification: %s", exc, exc_info=True)
# 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