This commit is contained in:
2026-01-31 15:37:17 -08:00
parent ae7acd48ac
commit c854f8c6a8
5 changed files with 151 additions and 7 deletions

View File

@@ -52,12 +52,22 @@ def _resolve_verify_value(verify_ssl: bool) -> Union[bool, str]:
def _try_module_bundle(mod_name: str) -> Optional[str]:
# Prefer checking sys.modules first (helps test injection / monkeypatching)
try:
mod = sys.modules.get(mod_name)
if mod is None:
mod = __import__(mod_name)
except (ImportError, ModuleNotFoundError):
return None
mod = sys.modules.get(mod_name)
if mod is None:
# Avoid raising ModuleNotFoundError so debuggers and callers aren't interrupted.
# Check for module availability before attempting to import it.
try:
import importlib.util
spec = importlib.util.find_spec(mod_name)
if spec is None:
return None
import importlib
mod = importlib.import_module(mod_name)
except Exception:
# Treat any import/initialization failure as module not available.
return None
# Common APIs that return a bundle path
for attr in ("where", "get_ca_bundle", "bundle_path", "get_bundle_path", "get_bundle"):

View File

@@ -27,7 +27,8 @@ from Provider.metadata_provider import (
)
from SYS.utils import unique_path
_ARCHIVE_VERIFY_VALUE = get_requests_verify_value()
# Resolve lazily to avoid import-time module checks (prevents debugger first-chance noise)
_ARCHIVE_VERIFY_VALUE = None # will be resolved on first session creation
_DEFAULT_ARCHIVE_SCALE = 4
_QUALITY_TO_ARCHIVE_SCALE = {
"high": 2,
@@ -38,6 +39,9 @@ _QUALITY_TO_ARCHIVE_SCALE = {
def _create_archive_session() -> requests.Session:
session = requests.Session()
global _ARCHIVE_VERIFY_VALUE
if _ARCHIVE_VERIFY_VALUE is None:
_ARCHIVE_VERIFY_VALUE = get_requests_verify_value()
session.verify = _ARCHIVE_VERIFY_VALUE
return session

View File

@@ -725,6 +725,92 @@ def save(config: Dict[str, Any]) -> int:
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.
"""
# 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:
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:
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.

View File

@@ -173,6 +173,8 @@ class ConfigModal(ModalScreen):
yield ScrollableContainer(id="fields-container")
with Horizontal(id="config-actions"):
yield Button("Save", variant="success", id="save-btn")
# Durable synchronous save: waits and verifies DB persisted critical keys
yield Button("Save (durable)", variant="primary", id="save-durable-btn")
yield Button("Add Store", variant="primary", id="add-store-btn")
yield Button("Add Provider", variant="primary", id="add-provider-btn")
yield Button("Add Tool", variant="primary", id="add-tool-btn")
@@ -790,6 +792,43 @@ class ConfigModal(ModalScreen):
self.refresh_view()
except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
elif bid == "save-durable-btn":
# Perform a synchronous, verified save and notify status to the user.
self._synchronize_inputs_to_config()
if not self.validate_current_editor():
return
if self.editing_item_name and not self._editor_has_changes():
self.notify("No changes to save", severity="warning", timeout=3)
return
try:
from SYS.config import save_config_and_verify
saved = save_config_and_verify(self.config_data, retries=3, delay=0.1)
try:
self.config_data = reload_config()
except Exception:
pass
if saved == 0:
msg = f"Configuration saved (no rows changed) to {db.db_path.name}"
else:
msg = f"Configuration saved ({saved} change(s)) to {db.db_path.name} (verified)"
try:
self.notify(msg, timeout=6)
except Exception:
pass
# Return to the main list view within the current category
self.editing_item_name = None
self.editing_item_type = None
self.refresh_view()
self._editor_snapshot = None
except Exception as exc:
self.notify(f"Durable save failed: {exc}", severity="error", timeout=10)
try:
log(f"Durable save failed: {exc}")
except Exception:
pass
elif bid in self._button_id_map:
action, itype, name = self._button_id_map[bid]
if action == "edit":

View File

@@ -562,3 +562,8 @@ http://10.162.158.28:45899/get_files/file?hash=5c7296f1a5544522e3d118f60080e0389
2026-01-31T03:07:25.275447Z [DEBUG] logger.debug: DEBUG: No resolution path matched. result type=PipeObject
2026-01-31T03:07:42.183988Z [DEBUG] add_file._resolve_source: File path could not be resolved
2026-01-31T03:07:59.118354Z [DEBUG] logger.debug: DEBUG: [add-file] RESOLVED source: path=None, hash=N/A...
2026-01-31T03:12:02.961829Z [DEBUG] logger.debug: DEBUG: [search-file] Calling alldebrid.search(filters={})
2026-01-31T03:12:48.309103Z [DEBUG] logger.debug: DEBUG: [search-file] Calling alldebrid.search(filters={})
2026-01-31T03:13:05.258091Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000161B7383D10>
2026-01-31T03:13:22.207331Z [DEBUG] logger.debug: DEBUG: <rich.panel.Panel object at 0x00000161B7383D10>
2026-01-31T03:13:39.166965Z [DEBUG] alldebrid.search: [alldebrid] Failed to list account magnets: AllDebrid API error: The auth apikey is invalid