diff --git a/API/HTTP.py b/API/HTTP.py index 79b3f48..db7b145 100644 --- a/API/HTTP.py +++ b/API/HTTP.py @@ -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"): diff --git a/Provider/openlibrary.py b/Provider/openlibrary.py index 5647032..7397881 100644 --- a/Provider/openlibrary.py +++ b/Provider/openlibrary.py @@ -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 diff --git a/SYS/config.py b/SYS/config.py index e4bbfb7..243a02d 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -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. diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 3d363a7..b621c09 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -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": diff --git a/logs/log_fallback.txt b/logs/log_fallback.txt index fe709e0..ce2dca9 100644 --- a/logs/log_fallback.txt +++ b/logs/log_fallback.txt @@ -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: +2026-01-31T03:13:22.207331Z [DEBUG] logger.debug: DEBUG: +2026-01-31T03:13:39.166965Z [DEBUG] alldebrid.search: [alldebrid] Failed to list account magnets: AllDebrid API error: The auth apikey is invalid