From b3a4ba14e536dcd61ec37d058b0dc96514a5b0bc Mon Sep 17 00:00:00 2001 From: Nose Date: Fri, 23 Jan 2026 16:46:48 -0800 Subject: [PATCH] f --- CLI.py | 48 +-------------- SYS/config.py | 101 +++++++++++++++++++++----------- SYS/database.py | 45 ++++++++++++-- TUI/modalscreen/config_modal.py | 70 ++++++++++++++++++---- scripts/bootstrap.py | 35 +++++++---- 5 files changed, 193 insertions(+), 106 deletions(-) diff --git a/CLI.py b/CLI.py index acf8e87..5164155 100644 --- a/CLI.py +++ b/CLI.py @@ -32,7 +32,6 @@ if not os.environ.get("MM_DEBUG"): pass import json -import re import shlex import sys import threading @@ -395,7 +394,7 @@ class CmdletCompleter(Completer): for idx, tok in enumerate(tokens): low = str(tok or "").strip().lower() if "=" in low: - head, val = low.split("=", 1) + head, _ = low.split("=", 1) if head in want: return tok.split("=", 1)[1] if low in want and idx + 1 < len(tokens): @@ -1482,51 +1481,6 @@ class CLI: def repl() -> None: self.run_repl() - @app.command("remote-server") - def remote_server( - storage_path: str = typer.Argument( - None, help="Path to the storage root" - ), - port: int = typer.Option(None, "--port", help="Port to run the server on"), - api_key: str | None = typer.Option(None, "--api-key", help="API key for authentication"), - host: str = "0.0.0.0", - debug_server: bool = False, - background: bool = False, - ) -> None: - """Start the remote storage server. - - NOTE: The legacy local storage server has been removed. Use HydrusNetwork - integrations instead. - """ - print( - "Error: remote-server is no longer available because legacy local storage has been removed.", - file=sys.stderr, - ) - return - - print( - f"Starting remote storage server at http://{host}:{port}, storage: {storage}" - ) - - if background: - try: - from werkzeug.serving import make_server - import threading - - server = make_server(host, port, app_obj) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - print(f"Server started in background (thread id={thread.ident})") - return - except Exception as exc: - print("Failed to start background server, falling back to foreground:", exc, file=sys.stderr) - - # Foreground run blocks the CLI until server exits - try: - app_obj.run(host=host, port=port, debug=debug_server, use_reloader=False, threaded=True) - except KeyboardInterrupt: - print("Remote server stopped by user") - @app.callback(invoke_without_command=True) def main_callback(ctx: typer.Context) -> None: if ctx.invoked_subcommand is None: diff --git a/SYS/config.py b/SYS/config.py index a9e7518..a56d944 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -5,6 +5,8 @@ from __future__ import annotations import re import tempfile import json +import sqlite3 +import time from pathlib import Path from typing import Any, Dict, Optional, List from SYS.logger import log @@ -15,6 +17,8 @@ DEFAULT_CONFIG_FILENAME = "config.conf" SCRIPT_DIR = Path(__file__).resolve().parent _CONFIG_CACHE: Dict[str, Dict[str, Any]] = {} +_CONFIG_SAVE_MAX_RETRIES = 5 +_CONFIG_SAVE_RETRY_DELAY = 0.15 def global_config() -> List[Dict[str, Any]]: @@ -52,26 +56,36 @@ def get_hydrus_instance( ) -> Optional[Dict[str, Any]]: """Get a specific Hydrus instance config by name. - Supports multiple formats: - - Current: config["store"]["hydrusnetwork"][instance_name] - - Legacy: config["storage"]["hydrus"][instance_name] - - Old: config["HydrusNetwork"][instance_name] - - Args: - config: Configuration dict - instance_name: Name of the Hydrus instance (default: "home") - - Returns: - Dict with access key and URL, or None if not found + Supports modern config plus a fallback when no exact match exists. """ - # Canonical: config["store"]["hydrusnetwork"]["home"] store = config.get("store", {}) - if isinstance(store, dict): - hydrusnetwork = store.get("hydrusnetwork", {}) - if isinstance(hydrusnetwork, dict): - instance = hydrusnetwork.get(instance_name) - if isinstance(instance, dict): - return instance + if not isinstance(store, dict): + return None + + hydrusnetwork = store.get("hydrusnetwork", {}) + if not isinstance(hydrusnetwork, dict) or not hydrusnetwork: + return None + + instance = hydrusnetwork.get(instance_name) + if isinstance(instance, dict): + return instance + + target = str(instance_name or "").lower() + for name, conf in hydrusnetwork.items(): + if isinstance(conf, dict) and str(name).lower() == target: + return conf + + keys = sorted(hydrusnetwork.keys()) + for key in keys: + if not str(key or "").startswith("new_"): + candidate = hydrusnetwork.get(key) + if isinstance(candidate, dict): + return candidate + first_key = keys[0] + candidate = hydrusnetwork.get(first_key) + if isinstance(candidate, dict): + return candidate + return None @@ -349,7 +363,9 @@ def load_config( def reload_config( config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFIG_FILENAME ) -> Dict[str, Any]: - cache_key = _make_cache_key(config_dir, filename, None) + base_dir = config_dir or SCRIPT_DIR + config_path = base_dir / filename + cache_key = _make_cache_key(config_dir, filename, config_path) _CONFIG_CACHE.pop(cache_key, None) return load_config(config_dir=config_dir, filename=filename) @@ -359,42 +375,61 @@ def save_config( config: Dict[str, Any], config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFIG_FILENAME, -) -> None: +) -> int: base_dir = config_dir or SCRIPT_DIR config_path = base_dir / filename - # 1. Save to Database - try: - from SYS.database import db, save_config_value - + def _write_entries() -> int: + count = 0 with db.transaction(): - # Replace the table contents so removed entries disappear from the DB. db.execute("DELETE FROM config") for key, value in config.items(): if key in ('store', 'provider', 'tool'): if isinstance(value, dict): for subtype, instances in value.items(): if isinstance(instances, dict): - # provider/tool are usually config[cat][subtype][key] - # but store is config['store'][subtype][name][key] if key == 'store': for name, settings in instances.items(): if isinstance(settings, dict): for k, v in settings.items(): save_config_value(key, subtype, name, k, v) + count += 1 else: for k, v in instances.items(): save_config_value(key, subtype, "default", k, v) + count += 1 else: - # global settings - if not key.startswith("_"): + if not key.startswith("_") and value is not None: save_config_value("global", "none", "none", key, value) - except Exception as e: - log(f"Failed to save config to database: {e}") + count += 1 + return count + + saved_entries = 0 + attempts = 0 + while True: + try: + saved_entries = _write_entries() + log(f"Saved {saved_entries} configuration entries to database.") + 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: + log(f"CRITICAL: Failed to save config to database: {exc}") + raise + delay = _CONFIG_SAVE_RETRY_DELAY * attempts + log( + f"Database busy locking medios.db (attempt {attempts}/{_CONFIG_SAVE_MAX_RETRIES}); retrying in {delay:.2f}s." + ) + time.sleep(delay) + except Exception as exc: + log(f"CRITICAL: Failed to save config to database: {exc}") + raise cache_key = _make_cache_key(config_dir, filename, config_path) clear_config_cache() _CONFIG_CACHE[cache_key] = config + return saved_entries def load() -> Dict[str, Any]: @@ -402,6 +437,6 @@ def load() -> Dict[str, Any]: return load_config() -def save(config: Dict[str, Any]) -> None: +def save(config: Dict[str, Any]) -> int: """Persist *config* back to disk.""" - save_config(config) + return save_config(config) diff --git a/SYS/database.py b/SYS/database.py index f358e9a..5676601 100644 --- a/SYS/database.py +++ b/SYS/database.py @@ -3,14 +3,44 @@ from __future__ import annotations import sqlite3 import json import threading +import os from queue import Queue from pathlib import Path from typing import Any, Dict, List, Optional from contextlib import contextmanager +from SYS.logger import log -# The database is located in the project root -ROOT_DIR = Path(__file__).resolve().parent.parent -DB_PATH = ROOT_DIR / "medios.db" +# The database is located in the project root (prefer explicit repo hints). +def _resolve_root_dir() -> Path: + env_root = ( + os.environ.get("MM_REPO") + or os.environ.get("MM_ROOT") + or os.environ.get("REPO") + ) + if env_root: + try: + candidate = Path(env_root).expanduser().resolve() + if candidate.exists(): + return candidate + except Exception: + pass + + cwd = Path.cwd().resolve() + for base in [cwd, *cwd.parents]: + if (base / "medios.db").exists(): + return base + if (base / "CLI.py").exists(): + return base + if (base / "config.conf").exists(): + return base + if (base / "scripts").exists() and (base / "SYS").exists(): + return base + + return Path(__file__).resolve().parent.parent + + +ROOT_DIR = _resolve_root_dir() +DB_PATH = (ROOT_DIR / "medios.db").resolve() class Database: _instance: Optional[Database] = None @@ -22,8 +52,15 @@ class Database: return cls._instance def _init_db(self): + self.db_path = DB_PATH + db_existed = self.db_path.exists() + if db_existed: + log(f"Opening existing medios.db at {self.db_path}") + else: + log(f"Creating medios.db at {self.db_path}") + self.conn = sqlite3.connect( - str(DB_PATH), + str(self.db_path), check_same_thread=False, timeout=30.0 # Increase timeout to 30s to avoid locking issues ) diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index cc63d86..539b71b 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -5,7 +5,8 @@ from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Ru from textual import on, work from typing import Any -from SYS.config import load_config, save_config, global_config +from SYS.config import load_config, save_config, reload_config, global_config +from SYS.logger import log from Store.registry import _discover_store_classes, _required_keys_for from ProviderCore.registry import list_providers from TUI.modalscreen.selection_modal import SelectionModal @@ -526,11 +527,15 @@ class ConfigModal(ModalScreen): self.editing_item_type = None self.refresh_view() elif bid == "save-btn": + self._synchronize_inputs_to_config() if not self.validate_current_editor(): return try: - self.save_all() - self.notify("Configuration saved!") + saved = self.save_all() + msg = f"Configuration saved ({saved} entries)" + if saved == 0: + msg = "Configuration saved (no rows changed)" + self.notify(msg) # Return to the main list view within the current category self.editing_item_name = None self.editing_item_type = None @@ -557,8 +562,11 @@ class ConfigModal(ModalScreen): removed = True if removed: try: - self.save_all() - self.notify("Configuration saved!") + saved = self.save_all() + msg = f"Configuration saved ({saved} entries)" + if saved == 0: + msg = "Configuration saved (no rows changed)" + self.notify(msg) except Exception as exc: self.notify(f"Save failed: {exc}", severity="error", timeout=10) self.refresh_view() @@ -707,8 +715,19 @@ class ConfigModal(ModalScreen): key = self._input_id_map[widget_id] + # Try to preserve boolean/integer types + processed_value = value + if isinstance(value, str): + low = value.lower() + if low == "true": + processed_value = True + elif low == "false": + processed_value = False + elif value.isdigit(): + processed_value = int(value) + if widget_id.startswith("global-"): - self.config_data[key] = value + self.config_data[key] = processed_value elif widget_id.startswith("item-") and self.editing_item_name: it = str(self.editing_item_type or "") inm = str(self.editing_item_name or "") @@ -722,14 +741,41 @@ class ConfigModal(ModalScreen): self.config_data["store"][stype] = {} if inm not in self.config_data["store"][stype]: self.config_data["store"][stype][inm] = {} - self.config_data["store"][stype][inm][key] = value + + # Special case: Renaming the store via the NAME field + if key.upper() == "NAME" and processed_value and str(processed_value) != inm: + new_inm = str(processed_value) + # Move the whole dictionary to the new key + self.config_data["store"][stype][new_inm] = self.config_data["store"][stype].pop(inm) + # Update editing_item_name so further changes to this screen hit the new key + self.editing_item_name = new_inm + inm = new_inm + + self.config_data["store"][stype][inm][key] = processed_value else: # Provider or other top-level sections if it not in self.config_data: self.config_data[it] = {} if inm not in self.config_data[it]: self.config_data[it][inm] = {} - self.config_data[it][inm][key] = value + self.config_data[it][inm][key] = processed_value + + def _synchronize_inputs_to_config(self) -> None: + """Capture current input/select values before saving.""" + widgets = list(self.query(Input)) + list(self.query(Select)) + for widget in widgets: + widget_id = widget.id + if not widget_id or widget_id not in self._input_id_map: + continue + + if isinstance(widget, Select): + if widget.value == Select.BLANK: + continue + value = widget.value + else: + value = widget.value + + self._update_config_value(widget_id, value) @on(Input.Changed) def on_input_changed(self, event: Input.Changed) -> None: @@ -743,8 +789,12 @@ class ConfigModal(ModalScreen): if event.value != Select.BLANK: self._update_config_value(event.select.id, event.value) - def save_all(self) -> None: - save_config(self.config_data) + def save_all(self) -> int: + self._synchronize_inputs_to_config() + entries = save_config(self.config_data) + self.config_data = reload_config() + log(f"ConfigModal saved {entries} configuration entries") + return entries def validate_current_editor(self) -> bool: """Ensure all required fields for the current item are filled.""" diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 95ad388..e9c652b 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -752,25 +752,36 @@ def main() -> int: try: import sqlite3 with sqlite3.connect(str(db_path), timeout=30.0) as conn: - # We want to set store.hydrusnetwork.hydrus. + conn.row_factory = sqlite3.Row cur = conn.cursor() - # Check if hydrusnetwork store exists - cur.execute("SELECT 1 FROM config WHERE category='store' AND subtype='hydrusnetwork'") - if cur.fetchone(): + + # Find all existing hydrusnetwork store names + cur.execute( + "SELECT DISTINCT item_name FROM config WHERE category='store' AND subtype='hydrusnetwork'" + ) + rows = cur.fetchall() + item_names = [r[0] for r in rows if r[0]] + + if not item_names: + # Only create if none exist. Use a sensible name from the path if possible. + # We don't have the hydrus_path here easily, but we can try to find it. + # For now, if we are in bootstrap, we might just be setting a global. + # But this function is specifically for store settings. + # Let's use 'home' instead of 'hydrus' as it's the standard default. + item_name = "home" cur.execute( "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", - ('store', 'hydrusnetwork', 'hydrus', key, value) + ('store', 'hydrusnetwork', item_name, 'NAME', item_name) ) - else: - # Create the section + item_names = [item_name] + + # Update all existing instances with this key/value + for name in item_names: cur.execute( "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", - ('store', 'hydrusnetwork', 'hydrus', 'name', 'hydrus') - ) - cur.execute( - "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", - ('store', 'hydrusnetwork', 'hydrus', key, value) + ('store', 'hydrusnetwork', name, key, value) ) + conn.commit() return True except Exception as e: