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
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:

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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."""

View File

@@ -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.<key>
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: