f
This commit is contained in:
48
CLI.py
48
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:
|
||||
|
||||
101
SYS/config.py
101
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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user