f
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from SYS.config import load_config
|
||||
from SYS.logger import debug, log
|
||||
|
||||
_zt_server_proc: Optional[subprocess.Popen] = None
|
||||
_zt_server_last_config: Optional[str] = None
|
||||
|
||||
# We no longer use atexit here because explicit lifecycle management
|
||||
# is preferred in TUI/REPL, and background servers use a monitor thread
|
||||
# to shut down when the parent dies.
|
||||
# atexit.register(lambda: stop_zerotier_server())
|
||||
|
||||
def ensure_zerotier_server_running() -> None:
|
||||
"""Check config and ensure the ZeroTier storage server is running if needed."""
|
||||
global _zt_server_proc, _zt_server_last_config
|
||||
|
||||
try:
|
||||
# Load config from the project root (where config.conf typically lives)
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
cfg = load_config(repo_root)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
zt_conf = cfg.get("networking", {}).get("zerotier", {})
|
||||
serve_target = zt_conf.get("serve")
|
||||
port = zt_conf.get("port") or 999
|
||||
api_key = zt_conf.get("api_key")
|
||||
|
||||
# Config hash to detect changes
|
||||
config_id = f"{serve_target}|{port}|{api_key}"
|
||||
|
||||
# Check if proc is still alive
|
||||
if _zt_server_proc:
|
||||
if _zt_server_proc.poll() is not None:
|
||||
# Process died
|
||||
debug("ZeroTier background server died. Restarting...")
|
||||
_zt_server_proc = None
|
||||
elif config_id == _zt_server_last_config:
|
||||
# Already running with correct config
|
||||
return
|
||||
|
||||
# If config changed and we have a proc, stop it
|
||||
if _zt_server_proc and config_id != _zt_server_last_config:
|
||||
debug("ZeroTier server config changed. Stopping old process...")
|
||||
try:
|
||||
_zt_server_proc.terminate()
|
||||
_zt_server_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
_zt_server_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_zt_server_proc = None
|
||||
|
||||
_zt_server_last_config = config_id
|
||||
|
||||
if not serve_target:
|
||||
return
|
||||
|
||||
# Resolve path
|
||||
storage_path = None
|
||||
folders = cfg.get("store", {}).get("folder", {})
|
||||
for name, block in folders.items():
|
||||
if name.lower() == serve_target.lower():
|
||||
storage_path = block.get("path") or block.get("PATH")
|
||||
break
|
||||
|
||||
if not storage_path:
|
||||
# Fallback to direct path
|
||||
storage_path = serve_target
|
||||
|
||||
if not storage_path or not Path(storage_path).exists():
|
||||
debug(f"ZeroTier host target '{serve_target}' not found at {storage_path}. Cannot start server.")
|
||||
return
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
server_script = repo_root / "scripts" / "remote_storage_server.py"
|
||||
|
||||
if not server_script.exists():
|
||||
debug(f"ZeroTier server script not found at {server_script}")
|
||||
return
|
||||
|
||||
# Use the same python executable that is currently running
|
||||
# On Windows, explicitly prefer the .venv python if it exists
|
||||
python_exe = sys.executable
|
||||
if sys.platform == "win32":
|
||||
venv_py = repo_root / ".venv" / "Scripts" / "python.exe"
|
||||
if venv_py.exists():
|
||||
python_exe = str(venv_py)
|
||||
|
||||
cmd = [python_exe, str(server_script),
|
||||
"--storage-path", str(storage_path),
|
||||
"--port", str(port),
|
||||
"--monitor"]
|
||||
cmd += ["--parent-pid", str(os.getpid())]
|
||||
if api_key:
|
||||
cmd += ["--api-key", str(api_key)]
|
||||
|
||||
try:
|
||||
debug(f"Starting ZeroTier storage server: {cmd}")
|
||||
# Capture errors to a log file instead of DEVNULL
|
||||
log_file = repo_root / "zt_server_error.log"
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"\n--- Starting server at {__import__('datetime').datetime.now()} ---\n")
|
||||
f.write(f"Command: {' '.join(cmd)}\n")
|
||||
f.write(f"CWD: {repo_root}\n")
|
||||
f.write(f"Python: {python_exe}\n")
|
||||
|
||||
err_f = open(log_file, "a")
|
||||
# On Windows, CREATE_NO_WINDOW = 0x08000000 ensures no console pops up
|
||||
import subprocess
|
||||
_zt_server_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=err_f,
|
||||
cwd=str(repo_root),
|
||||
creationflags=0x08000000 if sys.platform == "win32" else 0
|
||||
)
|
||||
log(f"ZeroTier background server started on port {port} (sharing {serve_target})")
|
||||
except Exception as e:
|
||||
debug(f"Failed to start ZeroTier server: {e}")
|
||||
_zt_server_proc = None
|
||||
|
||||
def stop_zerotier_server() -> None:
|
||||
"""Stop the background server if it is running."""
|
||||
global _zt_server_proc
|
||||
if _zt_server_proc:
|
||||
try:
|
||||
_zt_server_proc.terminate()
|
||||
_zt_server_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
_zt_server_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_zt_server_proc = None
|
||||
133
SYS/config.py
133
SYS/config.py
@@ -188,19 +188,6 @@ def _apply_conf_block(
|
||||
tool[tool_name] = dict(block)
|
||||
return
|
||||
|
||||
if kind_l == "networking":
|
||||
net_name = str(subtype).strip().lower()
|
||||
if not net_name:
|
||||
return
|
||||
net = config.setdefault("networking", {})
|
||||
if not isinstance(net, dict):
|
||||
config["networking"] = {}
|
||||
net = config["networking"]
|
||||
existing = net.get(net_name)
|
||||
if isinstance(existing, dict):
|
||||
_merge_dict_inplace(existing, block)
|
||||
else:
|
||||
net[net_name] = dict(block)
|
||||
return
|
||||
|
||||
|
||||
@@ -366,24 +353,6 @@ def _serialize_conf(config: Dict[str, Any]) -> str:
|
||||
seen_keys.add(k_upper)
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
# Networking blocks
|
||||
networking = config.get("networking")
|
||||
if isinstance(networking, dict):
|
||||
for name in sorted(networking.keys()):
|
||||
block = networking.get(name)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"[networking={name}]")
|
||||
|
||||
seen_keys = set()
|
||||
for k in sorted(block.keys()):
|
||||
k_upper = k.upper()
|
||||
if k_upper in seen_keys:
|
||||
continue
|
||||
seen_keys.add(k_upper)
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
@@ -674,34 +643,6 @@ def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]:
|
||||
return path
|
||||
|
||||
|
||||
def migrate_conf_to_db(config: Dict[str, Any]) -> None:
|
||||
"""Migrate the configuration dictionary to the database."""
|
||||
log("Migrating configuration from .conf to database...")
|
||||
for key, value in config.items():
|
||||
if key in ("store", "provider", "tool", "networking"):
|
||||
cat = key
|
||||
sub_dict = value
|
||||
if isinstance(sub_dict, dict):
|
||||
for subtype, subtype_items in sub_dict.items():
|
||||
if isinstance(subtype_items, dict):
|
||||
# For provider/tool/networking, subtype is the name (e.g. alldebrid)
|
||||
# but for store, it's the type (e.g. hydrusnetwork)
|
||||
if cat == "store" and str(subtype).strip().lower() == "folder":
|
||||
continue
|
||||
if cat != "store":
|
||||
for k, v in subtype_items.items():
|
||||
save_config_value(cat, subtype, "", k, v)
|
||||
else:
|
||||
for name, items in subtype_items.items():
|
||||
if isinstance(items, dict):
|
||||
for k, v in items.items():
|
||||
save_config_value(cat, subtype, name, k, v)
|
||||
else:
|
||||
# Global setting
|
||||
save_config_value("global", "", "", key, value)
|
||||
log("Configuration migration complete!")
|
||||
|
||||
|
||||
def load_config(
|
||||
config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFIG_FILENAME
|
||||
) -> Dict[str, Any]:
|
||||
@@ -712,37 +653,12 @@ def load_config(
|
||||
if cache_key in _CONFIG_CACHE:
|
||||
return _CONFIG_CACHE[cache_key]
|
||||
|
||||
# 1. Try loading from database first
|
||||
# Load from database
|
||||
db_config = get_config_all()
|
||||
if db_config:
|
||||
_CONFIG_CACHE[cache_key] = db_config
|
||||
return db_config
|
||||
|
||||
# 2. If DB is empty, try loading from legacy config.conf
|
||||
if config_path.exists():
|
||||
if config_path.suffix.lower() != ".conf":
|
||||
log(f"Unsupported config format: {config_path.name} (only .conf is supported)")
|
||||
return {}
|
||||
|
||||
try:
|
||||
config = _load_conf_config(base_dir, config_path)
|
||||
# Migrate to database
|
||||
migrate_conf_to_db(config)
|
||||
|
||||
# Optional: Rename old config file to mark as migrated
|
||||
try:
|
||||
migrated_path = config_path.with_name(config_path.name + ".migrated")
|
||||
config_path.rename(migrated_path)
|
||||
log(f"Legacy config file renamed to {migrated_path.name}")
|
||||
except Exception as e:
|
||||
log(f"Could not rename legacy config file: {e}")
|
||||
|
||||
_CONFIG_CACHE[cache_key] = config
|
||||
return config
|
||||
except Exception as e:
|
||||
log(f"Failed to load legacy config at {config_path}: {e}")
|
||||
return {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@@ -771,18 +687,43 @@ def save_config(
|
||||
base_dir = config_dir or SCRIPT_DIR
|
||||
config_path = base_dir / filename
|
||||
|
||||
if config_path.suffix.lower() != ".conf":
|
||||
raise RuntimeError(
|
||||
f"Unsupported config format: {config_path.name} (only .conf is supported)"
|
||||
)
|
||||
|
||||
# Safety Check: placeholder (folder store validation removed)
|
||||
_validate_config_safety(config)
|
||||
|
||||
# 1. Save to Database
|
||||
try:
|
||||
config_path.write_text(_serialize_conf(config), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to write config to {config_path}: {exc}") from exc
|
||||
from SYS.database import db, save_config_value
|
||||
|
||||
# We want to clear and re-save or just update?
|
||||
# For simplicity, we'll iterate and update.
|
||||
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)
|
||||
else:
|
||||
for k, v in instances.items():
|
||||
save_config_value(key, subtype, "default", k, v)
|
||||
else:
|
||||
# global settings
|
||||
if not key.startswith("_"):
|
||||
save_config_value("global", "none", "none", key, value)
|
||||
except Exception as e:
|
||||
log(f"Failed to save config to database: {e}")
|
||||
|
||||
# 2. Legacy fallback: write to .conf for now (optional, but keep for backward compat for a bit)
|
||||
if config_path.suffix.lower() == ".conf":
|
||||
# Safety Check: placeholder (folder store validation removed)
|
||||
_validate_config_safety(config)
|
||||
|
||||
try:
|
||||
config_path.write_text(_serialize_conf(config), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log(f"Failed to write legacy config to {config_path}: {exc}")
|
||||
|
||||
cache_key = _make_cache_key(config_dir, filename, config_path)
|
||||
_CONFIG_CACHE[cache_key] = config
|
||||
|
||||
@@ -138,7 +138,8 @@ def save_config_value(category: str, subtype: str, item_name: str, key: str, val
|
||||
def get_config_all() -> Dict[str, Any]:
|
||||
"""Retrieve all configuration from the database in the legacy dict format."""
|
||||
try:
|
||||
db.execute("DELETE FROM config WHERE category='store' AND LOWER(subtype)='folder'")
|
||||
db.execute("DELETE FROM config WHERE category='store' AND LOWER(subtype) in ('folder', 'zerotier')")
|
||||
db.execute("DELETE FROM config WHERE category='networking'")
|
||||
except Exception:
|
||||
pass
|
||||
rows = db.fetchall("SELECT category, subtype, item_name, key, value FROM config")
|
||||
@@ -165,7 +166,7 @@ def get_config_all() -> Dict[str, Any]:
|
||||
config[key] = parsed_val
|
||||
else:
|
||||
# Modular structure: config[cat][sub][name][key]
|
||||
if cat in ('provider', 'tool', 'networking'):
|
||||
if cat in ('provider', 'tool'):
|
||||
cat_dict = config.setdefault(cat, {})
|
||||
sub_dict = cat_dict.setdefault(sub, {})
|
||||
sub_dict[key] = parsed_val
|
||||
|
||||
@@ -48,14 +48,6 @@ _PROVIDER_DEPENDENCIES: Dict[str, List[Tuple[str, str]]] = {
|
||||
"soulseek": [("aioslsk", "aioslsk>=1.6.0")],
|
||||
}
|
||||
|
||||
# Dependencies required when ZeroTier features are configured (auto-install when enabled)
|
||||
_ZEROTIER_DEPENDENCIES: List[Tuple[str, str]] = [
|
||||
("flask", "flask>=2.3.0"),
|
||||
("flask_cors", "flask-cors>=3.0.1"),
|
||||
("werkzeug", "werkzeug>=2.3.0"),
|
||||
]
|
||||
|
||||
|
||||
def florencevision_missing_modules() -> List[str]:
|
||||
return [
|
||||
requirement
|
||||
@@ -151,29 +143,5 @@ def maybe_auto_install_configured_tools(config: Dict[str, Any]) -> None:
|
||||
label = f"{provider_name.title()} provider"
|
||||
_install_requirements(label, requirements)
|
||||
|
||||
# ZeroTier: if a zerotier section is present OR a zerotier store is configured,
|
||||
# optionally auto-install Flask-based remote server dependencies so the
|
||||
# `remote_storage_server.py` and CLI helper will run out-of-the-box.
|
||||
try:
|
||||
zerotier_cfg = (config or {}).get("zerotier")
|
||||
store_cfg = (config or {}).get("store") if isinstance(config, dict) else {}
|
||||
store_has_zerotier = isinstance(store_cfg, dict) and bool(store_cfg.get("zerotier"))
|
||||
|
||||
if (isinstance(zerotier_cfg, dict) and zerotier_cfg) or store_has_zerotier:
|
||||
auto_install = True
|
||||
if isinstance(zerotier_cfg, dict) and "auto_install" in zerotier_cfg:
|
||||
auto_install = _as_bool(zerotier_cfg.get("auto_install"), True)
|
||||
if auto_install:
|
||||
missing = [
|
||||
requirement
|
||||
for import_name, requirement in _ZEROTIER_DEPENDENCIES
|
||||
if not _try_import(import_name)
|
||||
]
|
||||
if missing:
|
||||
_install_requirements("ZeroTier", missing)
|
||||
except Exception:
|
||||
# Don't let optional-dep logic raise at startup
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["maybe_auto_install_configured_tools", "florencevision_missing_modules"]
|
||||
|
||||
Reference in New Issue
Block a user