This commit is contained in:
2026-01-23 16:46:48 -08:00
parent 797b5fee40
commit b3a4ba14e5
5 changed files with 193 additions and 106 deletions

48
CLI.py
View File

@@ -32,7 +32,6 @@ if not os.environ.get("MM_DEBUG"):
pass pass
import json import json
import re
import shlex import shlex
import sys import sys
import threading import threading
@@ -395,7 +394,7 @@ class CmdletCompleter(Completer):
for idx, tok in enumerate(tokens): for idx, tok in enumerate(tokens):
low = str(tok or "").strip().lower() low = str(tok or "").strip().lower()
if "=" in low: if "=" in low:
head, val = low.split("=", 1) head, _ = low.split("=", 1)
if head in want: if head in want:
return tok.split("=", 1)[1] return tok.split("=", 1)[1]
if low in want and idx + 1 < len(tokens): if low in want and idx + 1 < len(tokens):
@@ -1482,51 +1481,6 @@ class CLI:
def repl() -> None: def repl() -> None:
self.run_repl() 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) @app.callback(invoke_without_command=True)
def main_callback(ctx: typer.Context) -> None: def main_callback(ctx: typer.Context) -> None:
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import re import re
import tempfile import tempfile
import json import json
import sqlite3
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, List from typing import Any, Dict, Optional, List
from SYS.logger import log from SYS.logger import log
@@ -15,6 +17,8 @@ DEFAULT_CONFIG_FILENAME = "config.conf"
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
_CONFIG_CACHE: Dict[str, Dict[str, Any]] = {} _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]]: def global_config() -> List[Dict[str, Any]]:
@@ -52,26 +56,36 @@ def get_hydrus_instance(
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Get a specific Hydrus instance config by name. """Get a specific Hydrus instance config by name.
Supports multiple formats: Supports modern config plus a fallback when no exact match exists.
- 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
""" """
# Canonical: config["store"]["hydrusnetwork"]["home"]
store = config.get("store", {}) store = config.get("store", {})
if isinstance(store, dict): if not isinstance(store, dict):
hydrusnetwork = store.get("hydrusnetwork", {}) return None
if isinstance(hydrusnetwork, dict):
instance = hydrusnetwork.get(instance_name) hydrusnetwork = store.get("hydrusnetwork", {})
if isinstance(instance, dict): if not isinstance(hydrusnetwork, dict) or not hydrusnetwork:
return instance 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 return None
@@ -349,7 +363,9 @@ def load_config(
def reload_config( def reload_config(
config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFIG_FILENAME config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFIG_FILENAME
) -> Dict[str, Any]: ) -> 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) _CONFIG_CACHE.pop(cache_key, None)
return load_config(config_dir=config_dir, filename=filename) return load_config(config_dir=config_dir, filename=filename)
@@ -359,42 +375,61 @@ def save_config(
config: Dict[str, Any], config: Dict[str, Any],
config_dir: Optional[Path] = None, config_dir: Optional[Path] = None,
filename: str = DEFAULT_CONFIG_FILENAME, filename: str = DEFAULT_CONFIG_FILENAME,
) -> None: ) -> int:
base_dir = config_dir or SCRIPT_DIR base_dir = config_dir or SCRIPT_DIR
config_path = base_dir / filename config_path = base_dir / filename
# 1. Save to Database def _write_entries() -> int:
try: count = 0
from SYS.database import db, save_config_value
with db.transaction(): with db.transaction():
# Replace the table contents so removed entries disappear from the DB.
db.execute("DELETE FROM config") db.execute("DELETE FROM config")
for key, value in config.items(): for key, value in config.items():
if key in ('store', 'provider', 'tool'): if key in ('store', 'provider', 'tool'):
if isinstance(value, dict): if isinstance(value, dict):
for subtype, instances in value.items(): for subtype, instances in value.items():
if isinstance(instances, dict): if isinstance(instances, dict):
# provider/tool are usually config[cat][subtype][key]
# but store is config['store'][subtype][name][key]
if key == 'store': if key == 'store':
for name, settings in instances.items(): for name, settings in instances.items():
if isinstance(settings, dict): if isinstance(settings, dict):
for k, v in settings.items(): for k, v in settings.items():
save_config_value(key, subtype, name, k, v) save_config_value(key, subtype, name, k, v)
count += 1
else: else:
for k, v in instances.items(): for k, v in instances.items():
save_config_value(key, subtype, "default", k, v) save_config_value(key, subtype, "default", k, v)
count += 1
else: else:
# global settings if not key.startswith("_") and value is not None:
if not key.startswith("_"):
save_config_value("global", "none", "none", key, value) save_config_value("global", "none", "none", key, value)
except Exception as e: count += 1
log(f"Failed to save config to database: {e}") 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) cache_key = _make_cache_key(config_dir, filename, config_path)
clear_config_cache() clear_config_cache()
_CONFIG_CACHE[cache_key] = config _CONFIG_CACHE[cache_key] = config
return saved_entries
def load() -> Dict[str, Any]: def load() -> Dict[str, Any]:
@@ -402,6 +437,6 @@ def load() -> Dict[str, Any]:
return load_config() return load_config()
def save(config: Dict[str, Any]) -> None: def save(config: Dict[str, Any]) -> int:
"""Persist *config* back to disk.""" """Persist *config* back to disk."""
save_config(config) return save_config(config)

View File

@@ -3,14 +3,44 @@ from __future__ import annotations
import sqlite3 import sqlite3
import json import json
import threading import threading
import os
from queue import Queue from queue import Queue
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from contextlib import contextmanager from contextlib import contextmanager
from SYS.logger import log
# The database is located in the project root # The database is located in the project root (prefer explicit repo hints).
ROOT_DIR = Path(__file__).resolve().parent.parent def _resolve_root_dir() -> Path:
DB_PATH = ROOT_DIR / "medios.db" 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: class Database:
_instance: Optional[Database] = None _instance: Optional[Database] = None
@@ -22,8 +52,15 @@ class Database:
return cls._instance return cls._instance
def _init_db(self): 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( self.conn = sqlite3.connect(
str(DB_PATH), str(self.db_path),
check_same_thread=False, check_same_thread=False,
timeout=30.0 # Increase timeout to 30s to avoid locking issues timeout=30.0 # Increase timeout to 30s to avoid locking issues
) )

View File

@@ -5,7 +5,8 @@ from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Ru
from textual import on, work from textual import on, work
from typing import Any 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 Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers from ProviderCore.registry import list_providers
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
@@ -526,11 +527,15 @@ class ConfigModal(ModalScreen):
self.editing_item_type = None self.editing_item_type = None
self.refresh_view() self.refresh_view()
elif bid == "save-btn": elif bid == "save-btn":
self._synchronize_inputs_to_config()
if not self.validate_current_editor(): if not self.validate_current_editor():
return return
try: try:
self.save_all() saved = self.save_all()
self.notify("Configuration saved!") 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 # Return to the main list view within the current category
self.editing_item_name = None self.editing_item_name = None
self.editing_item_type = None self.editing_item_type = None
@@ -557,8 +562,11 @@ class ConfigModal(ModalScreen):
removed = True removed = True
if removed: if removed:
try: try:
self.save_all() saved = self.save_all()
self.notify("Configuration saved!") msg = f"Configuration saved ({saved} entries)"
if saved == 0:
msg = "Configuration saved (no rows changed)"
self.notify(msg)
except Exception as exc: except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10) self.notify(f"Save failed: {exc}", severity="error", timeout=10)
self.refresh_view() self.refresh_view()
@@ -707,8 +715,19 @@ class ConfigModal(ModalScreen):
key = self._input_id_map[widget_id] 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-"): 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: elif widget_id.startswith("item-") and self.editing_item_name:
it = str(self.editing_item_type or "") it = str(self.editing_item_type or "")
inm = str(self.editing_item_name or "") inm = str(self.editing_item_name or "")
@@ -722,14 +741,41 @@ class ConfigModal(ModalScreen):
self.config_data["store"][stype] = {} self.config_data["store"][stype] = {}
if inm not in 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] = {}
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: else:
# Provider or other top-level sections # Provider or other top-level sections
if it not in self.config_data: if it not in self.config_data:
self.config_data[it] = {} self.config_data[it] = {}
if inm not in self.config_data[it]: if inm not in self.config_data[it]:
self.config_data[it][inm] = {} 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) @on(Input.Changed)
def on_input_changed(self, event: Input.Changed) -> None: def on_input_changed(self, event: Input.Changed) -> None:
@@ -743,8 +789,12 @@ class ConfigModal(ModalScreen):
if event.value != Select.BLANK: if event.value != Select.BLANK:
self._update_config_value(event.select.id, event.value) self._update_config_value(event.select.id, event.value)
def save_all(self) -> None: def save_all(self) -> int:
save_config(self.config_data) 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: def validate_current_editor(self) -> bool:
"""Ensure all required fields for the current item are filled.""" """Ensure all required fields for the current item are filled."""

View File

@@ -752,25 +752,36 @@ def main() -> int:
try: try:
import sqlite3 import sqlite3
with sqlite3.connect(str(db_path), timeout=30.0) as conn: with sqlite3.connect(str(db_path), timeout=30.0) as conn:
# We want to set store.hydrusnetwork.hydrus.<key> conn.row_factory = sqlite3.Row
cur = conn.cursor() cur = conn.cursor()
# Check if hydrusnetwork store exists
cur.execute("SELECT 1 FROM config WHERE category='store' AND subtype='hydrusnetwork'") # Find all existing hydrusnetwork store names
if cur.fetchone(): 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( cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", "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: item_names = [item_name]
# Create the section
# Update all existing instances with this key/value
for name in item_names:
cur.execute( cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)", "INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', 'hydrus', 'name', 'hydrus') ('store', 'hydrusnetwork', name, key, value)
)
cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', 'hydrus', key, value)
) )
conn.commit() conn.commit()
return True return True
except Exception as e: except Exception as e: