This commit is contained in:
2026-01-22 01:53:13 -08:00
parent b3e7f3e277
commit 33406a6ecf
17 changed files with 857 additions and 877 deletions

181
CLI.py
View File

@@ -15,17 +15,33 @@ import os
from pathlib import Path from pathlib import Path
if not os.environ.get("MM_DEBUG"): if not os.environ.get("MM_DEBUG"):
try: try:
conf_path = Path(__file__).resolve().parent / "config.conf" # Check database first
if conf_path.exists(): db_path = Path(__file__).resolve().parent / "medios.db"
for ln in conf_path.read_text(encoding="utf-8").splitlines(): if db_path.exists():
ln_strip = ln.strip() import sqlite3
if ln_strip.startswith("debug"): with sqlite3.connect(str(db_path)) as conn:
parts = ln_strip.split("=", 1) cur = conn.cursor()
if len(parts) >= 2: # Check for global debug key
val = parts[1].strip().strip('"').strip("'").strip().lower() cur.execute("SELECT value FROM config WHERE key = 'debug' AND category = 'global'")
if val in ("1", "true", "yes", "on"): row = cur.fetchone()
os.environ["MM_DEBUG"] = "1" if row:
break val = str(row[0]).strip().lower()
if val in ("1", "true", "yes", "on"):
os.environ["MM_DEBUG"] = "1"
# Fallback to legacy config.conf if not set by DB
if not os.environ.get("MM_DEBUG"):
conf_path = Path(__file__).resolve().parent / "config.conf"
if conf_path.exists():
for ln in conf_path.read_text(encoding="utf-8").splitlines():
ln_strip = ln.strip()
if ln_strip.startswith("debug"):
parts = ln_strip.split("=", 1)
if len(parts) >= 2:
val = parts[1].strip().strip('"').strip("'").strip().lower()
if val in ("1", "true", "yes", "on"):
os.environ["MM_DEBUG"] = "1"
break
except Exception: except Exception:
pass pass
@@ -297,7 +313,7 @@ class CmdletIntrospection:
if normalized_arg in ("storage", "store"): if normalized_arg in ("storage", "store"):
# Use cached/lightweight names for completions to avoid instantiating backends # Use cached/lightweight names for completions to avoid instantiating backends
# (instantiating backends may perform initialization such as opening folder DBs). # (instantiating backends may perform heavy initialization).
backends = cls.store_choices(config, force=False) backends = cls.store_choices(config, force=False)
if backends: if backends:
return backends return backends
@@ -1484,7 +1500,7 @@ class CLI:
@app.command("remote-server") @app.command("remote-server")
def remote_server( def remote_server(
storage_path: str = typer.Argument( storage_path: str = typer.Argument(
None, help="Path to the store folder or store name from config" None, help="Path to the storage root"
), ),
port: int = typer.Option(None, "--port", help="Port to run the server on"), 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"), api_key: str | None = typer.Option(None, "--api-key", help="API key for authentication"),
@@ -1492,81 +1508,16 @@ class CLI:
debug_server: bool = False, debug_server: bool = False,
background: bool = False, background: bool = False,
) -> None: ) -> None:
"""Start the remote storage Flask server. """Start the remote storage server.
If no path is provided, it looks for [networking=zerotier] 'serve' and 'port' in config. NOTE: The legacy local storage server has been removed. Use HydrusNetwork
'serve' can be a path or the name of a [store=folder] entry. integrations instead.
Examples:
mm remote-server C:\\path\\to\\store --port 999 --api-key mykey
mm remote-server my_folder_name
mm remote-server --background
""" """
try: print(
from scripts import remote_storage_server as rss "Error: remote-server is no longer available because legacy local storage has been removed.",
except Exception as exc: file=sys.stderr,
print( )
"Error: remote_storage_server script not available:", return
exc,
file=sys.stderr,
)
return
# Ensure Flask present
if not getattr(rss, "HAS_FLASK", False):
print(
"ERROR: Flask and flask-cors required; install with: pip install flask flask-cors",
file=sys.stderr,
)
return
from SYS.config import load_config
conf = load_config()
# Resolve from Networking config if omitted
zt_conf = conf.get("networking", {}).get("zerotier", {})
if not storage_path:
storage_path = zt_conf.get("serve")
if port is None:
port = int(zt_conf.get("port") or 999)
if api_key is None:
api_key = zt_conf.get("api_key")
if not storage_path:
print(
"Error: No storage path provided and no [networking=zerotier] 'serve' configured.",
file=sys.stderr,
)
return
from pathlib import Path
# Check if storage_path is a named folder store
folders = conf.get("store", {}).get("folder", {})
found_path = None
for name, block in folders.items():
if name.lower() == storage_path.lower():
found_path = block.get("path") or block.get("PATH")
break
if found_path:
storage = Path(found_path).resolve()
else:
storage = Path(storage_path).resolve()
if not storage.exists():
print(f"Error: Storage path does not exist: {storage}", file=sys.stderr)
return
rss.STORAGE_PATH = storage
rss.API_KEY = api_key
try:
app_obj = rss.create_app()
except Exception as exc:
print("Failed to create remote_storage_server app:", exc, file=sys.stderr)
return
print( print(
f"Starting remote storage server at http://{host}:{port}, storage: {storage}" f"Starting remote storage server at http://{host}:{port}, storage: {storage}"
@@ -2102,64 +2053,6 @@ Come to love it when others take what you share, as there is no greater joy
detail=str(exc) detail=str(exc)
) )
if _has_store_subtype(config, "folder"):
store_cfg = config.get("store")
folder_cfg = store_cfg.get("folder",
{}) if isinstance(store_cfg,
dict) else {}
if isinstance(folder_cfg, dict) and folder_cfg:
for instance_name, instance_cfg in folder_cfg.items():
if not isinstance(instance_cfg, dict):
continue
name_key = str(instance_cfg.get("NAME") or instance_name)
path_val = str(
instance_cfg.get("PATH") or instance_cfg.get("path")
or ""
).strip()
ok = bool(
store_registry
and store_registry.is_available(name_key)
)
if ok and store_registry:
backend = store_registry[name_key]
scan_ok = bool(getattr(backend, "scan_ok", True))
scan_detail = str(
getattr(backend,
"scan_detail",
"") or ""
)
stats = getattr(backend, "scan_stats", None)
files = None
if isinstance(stats, dict):
total_db = stats.get("files_total_db")
if isinstance(total_db, (int, float)):
files = int(total_db)
status = "SCANNED" if scan_ok else "ERROR"
detail = (path_val + (" - " if path_val else "")
) + (scan_detail or "Up to date")
_add_startup_check(
status,
name_key,
store="folder",
files=files,
detail=detail
)
else:
err = None
if store_registry:
err = store_registry.get_backend_error(
instance_name
) or store_registry.get_backend_error(name_key)
detail = (path_val + (" - " if path_val else "")
) + (err or "Unavailable")
_add_startup_check(
"ERROR",
name_key,
store="folder",
detail=detail
)
if _has_store_subtype(config, "debrid"): if _has_store_subtype(config, "debrid"):
try: try:
from SYS.config import get_debrid_api_key from SYS.config import get_debrid_api_key

View File

@@ -8,6 +8,7 @@ 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
from SYS.utils import expand_path from SYS.utils import expand_path
from SYS.database import db, get_config_all, save_config_value
DEFAULT_CONFIG_FILENAME = "config.conf" DEFAULT_CONFIG_FILENAME = "config.conf"
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
@@ -208,10 +209,9 @@ def parse_conf_text(text: str, *, base: Optional[Dict[str, Any]] = None) -> Dict
Supported patterns: Supported patterns:
- Top-level key/value: temp="./temp" - Top-level key/value: temp="./temp"
- Sections: [store=folder] + name/path lines
- Sections: [store=hydrusnetwork] + name/access key/url lines - Sections: [store=hydrusnetwork] + name/access key/url lines
- Sections: [provider=OpenLibrary] + email/password lines - Sections: [provider=OpenLibrary] + email/password lines
- Dotted keys: store.folder.default.path="C:\\Media" (optional) - Dotted keys: store.hydrusnetwork.<name>.url="http://..." (optional)
""" """
config: Dict[str, Any] = dict(base or {}) config: Dict[str, Any] = dict(base or {})
@@ -519,7 +519,6 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
"""Get local storage path from config. """Get local storage path from config.
Supports multiple formats: Supports multiple formats:
- New: config["store"]["folder"]["any_name"]["path"]
- Old: config["storage"]["local"]["path"] - Old: config["storage"]["local"]["path"]
- Old: config["Local"]["path"] - Old: config["Local"]["path"]
@@ -529,17 +528,6 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
Returns: Returns:
Path object if found, None otherwise Path object if found, None otherwise
""" """
# Try new format: iterate all folder stores and use the first valid path found.
store = config.get("store", {})
if isinstance(store, dict):
folder_config = store.get("folder", {})
if isinstance(folder_config, dict):
for name, inst_cfg in folder_config.items():
if isinstance(inst_cfg, dict):
p = inst_cfg.get("path") or inst_cfg.get("PATH")
if p:
return expand_path(p)
# Fall back to storage.local.path format # Fall back to storage.local.path format
storage = config.get("storage", {}) storage = config.get("storage", {})
if isinstance(storage, dict): if isinstance(storage, dict):
@@ -686,32 +674,76 @@ def resolve_debug_log(config: Dict[str, Any]) -> Optional[Path]:
return 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( def load_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]:
base_dir = config_dir or SCRIPT_DIR base_dir = config_dir or SCRIPT_DIR
config_path = base_dir / filename config_path = base_dir / filename
cache_key = _make_cache_key(config_dir, filename, config_path) cache_key = _make_cache_key(config_dir, filename, config_path)
if cache_key in _CONFIG_CACHE: if cache_key in _CONFIG_CACHE:
return _CONFIG_CACHE[cache_key] return _CONFIG_CACHE[cache_key]
if config_path.suffix.lower() != ".conf": # 1. Try loading from database first
log(f"Unsupported config format: {config_path.name} (only .conf is supported)") db_config = get_config_all()
_CONFIG_CACHE[cache_key] = {} if db_config:
return {} _CONFIG_CACHE[cache_key] = db_config
return db_config
try: # 2. If DB is empty, try loading from legacy config.conf
data = _load_conf_config(base_dir, config_path) if config_path.exists():
except FileNotFoundError: if config_path.suffix.lower() != ".conf":
_CONFIG_CACHE[cache_key] = {} log(f"Unsupported config format: {config_path.name} (only .conf is supported)")
return {} return {}
except OSError as exc:
log(f"Failed to read {config_path}: {exc}") try:
_CONFIG_CACHE[cache_key] = {} config = _load_conf_config(base_dir, config_path)
return {} # 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] = data _CONFIG_CACHE[cache_key] = config
return data return config
except Exception as e:
log(f"Failed to load legacy config at {config_path}: {e}")
return {}
return {}
def reload_config( def reload_config(
@@ -723,55 +755,12 @@ def reload_config(
def _validate_config_safety(config: Dict[str, Any]) -> None: def _validate_config_safety(config: Dict[str, Any]) -> None:
"""Check for dangerous configurations, like folder stores in non-empty dirs.""" """Validate configuration safety.
store = config.get("store")
if not isinstance(store, dict):
return
folder_stores = store.get("folder") Folder store validation has been removed because the folder store backend
if not isinstance(folder_stores, dict): is no longer supported.
return """
return
for name, cfg in folder_stores.items():
if not isinstance(cfg, dict):
continue
path_str = cfg.get("path") or cfg.get("PATH")
if not path_str:
continue
try:
p = expand_path(path_str).resolve()
# If the path doesn't exist yet, it's fine (will be created empty)
if not p.exists():
continue
if not p.is_dir():
continue
# DB name from API/folder.py
db_file = p / "medios-macina.db"
if db_file.exists():
# Existing portable library, allowed to re-attach
continue
# Check if directory has any files (other than the DB we just checked)
items = list(p.iterdir())
if items:
item_names = [i.name for i in items[:3]]
if len(items) > 3:
item_names.append("...")
raise RuntimeError(
f"Configuration Error: Local library '{name}' target directory is not empty.\n"
f"Path: {p}\n"
f"Found {len(items)} items: {', '.join(item_names)}\n"
f"To prevent accidental mass-hashing, new libraries must be set to unique, empty folders."
)
except RuntimeError:
raise
except Exception:
# We don't want to crash on invalid paths during validation if they aren't 'unsafe'
pass
def save_config( def save_config(
@@ -787,7 +776,7 @@ def save_config(
f"Unsupported config format: {config_path.name} (only .conf is supported)" f"Unsupported config format: {config_path.name} (only .conf is supported)"
) )
# Safety Check: Validate folder stores are in empty dirs or existing libraries # Safety Check: placeholder (folder store validation removed)
_validate_config_safety(config) _validate_config_safety(config)
try: try:

253
SYS/database.py Normal file
View File

@@ -0,0 +1,253 @@
from __future__ import annotations
import sqlite3
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
# The database is located in the project root
ROOT_DIR = Path(__file__).resolve().parent.parent
DB_PATH = ROOT_DIR / "medios.db"
class Database:
_instance: Optional[Database] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(Database, cls).__new__(cls)
cls._instance._init_db()
return cls._instance
def _init_db(self):
self.conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self._create_tables()
def _create_tables(self):
cursor = self.conn.cursor()
# Config table: stores all settings previously in config.conf
# category: global, store, provider, tool, networking
# subtype: e.g., hydrusnetwork, folder, alldebrid
# item_name: e.g., hn-local, default
# key: the setting key
# value: the setting value (serialized to string)
cursor.execute("""
CREATE TABLE IF NOT EXISTS config (
category TEXT,
subtype TEXT,
item_name TEXT,
key TEXT,
value TEXT,
PRIMARY KEY (category, subtype, item_name, key)
)
""")
# Logs table
cursor.execute("""
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
level TEXT,
module TEXT,
message TEXT
)
""")
# Workers table (for background tasks)
cursor.execute("""
CREATE TABLE IF NOT EXISTS workers (
id TEXT PRIMARY KEY,
type TEXT,
title TEXT,
description TEXT,
status TEXT DEFAULT 'running',
progress REAL DEFAULT 0.0,
details TEXT,
result TEXT DEFAULT 'pending',
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
# Worker stdout/logs
cursor.execute("""
CREATE TABLE IF NOT EXISTS worker_stdout (
worker_id TEXT,
channel TEXT DEFAULT 'stdout',
content TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(worker_id) REFERENCES workers(id)
)
""")
self.conn.commit()
def get_connection(self):
return self.conn
def execute(self, query: str, params: tuple = ()):
cursor = self.conn.cursor()
cursor.execute(query, params)
self.conn.commit()
return cursor
def fetchall(self, query: str, params: tuple = ()):
cursor = self.conn.cursor()
cursor.execute(query, params)
return cursor.fetchall()
def fetchone(self, query: str, params: tuple = ()):
cursor = self.conn.cursor()
cursor.execute(query, params)
return cursor.fetchone()
# Singleton instance
db = Database()
def get_db() -> Database:
return db
def log_to_db(level: str, module: str, message: str):
"""Log a message to the database."""
try:
db.execute(
"INSERT INTO logs (level, module, message) VALUES (?, ?, ?)",
(level, module, message)
)
except Exception:
# Avoid recursive logging errors if DB is locked
pass
# Initialize DB logger in the unified logger
try:
from SYS.logger import set_db_logger
set_db_logger(log_to_db)
except ImportError:
pass
def save_config_value(category: str, subtype: str, item_name: str, key: str, value: Any):
"""Save a configuration value to the database."""
val_str = json.dumps(value) if not isinstance(value, str) else value
db.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
(category, subtype, item_name, key, val_str)
)
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'")
except Exception:
pass
rows = db.fetchall("SELECT category, subtype, item_name, key, value FROM config")
config: Dict[str, Any] = {}
for row in rows:
cat = row['category']
sub = row['subtype']
name = row['item_name']
key = row['key']
val = row['value']
# Drop legacy folder store entries (folder store is removed).
if cat == 'store' and str(sub).strip().lower() == 'folder':
continue
# Try to parse JSON value, fallback to string
try:
parsed_val = json.loads(val)
except Exception:
parsed_val = val
if cat == 'global':
config[key] = parsed_val
else:
# Modular structure: config[cat][sub][name][key]
if cat in ('provider', 'tool', 'networking'):
cat_dict = config.setdefault(cat, {})
sub_dict = cat_dict.setdefault(sub, {})
sub_dict[key] = parsed_val
elif cat == 'store':
cat_dict = config.setdefault(cat, {})
sub_dict = cat_dict.setdefault(sub, {})
name_dict = sub_dict.setdefault(name, {})
name_dict[key] = parsed_val
else:
config.setdefault(cat, {})[key] = parsed_val
return config
# Worker Management Methods for medios.db
def insert_worker(worker_id: str, worker_type: str, title: str = "", description: str = "") -> bool:
try:
db.execute(
"INSERT INTO workers (id, type, title, description, status) VALUES (?, ?, ?, ?, 'running')",
(worker_id, worker_type, title, description)
)
return True
except Exception:
return False
def update_worker(worker_id: str, **kwargs) -> bool:
if not kwargs:
return True
# Filter valid columns
valid_cols = {'type', 'title', 'description', 'status', 'progress', 'details', 'result', 'error_message'}
cols = []
vals = []
for k, v in kwargs.items():
if k in valid_cols:
cols.append(f"{k} = ?")
vals.append(v)
if not cols:
return True
cols.append("updated_at = CURRENT_TIMESTAMP")
query = f"UPDATE workers SET {', '.join(cols)} WHERE id = ?"
vals.append(worker_id)
try:
db.execute(query, tuple(vals))
return True
except Exception:
return False
def append_worker_stdout(worker_id: str, content: str, channel: str = 'stdout'):
try:
db.execute(
"INSERT INTO worker_stdout (worker_id, channel, content) VALUES (?, ?, ?)",
(worker_id, channel, content)
)
except Exception:
pass
def get_worker_stdout(worker_id: str, channel: Optional[str] = None) -> str:
query = "SELECT content FROM worker_stdout WHERE worker_id = ?"
params = [worker_id]
if channel:
query += " AND channel = ?"
params.append(channel)
query += " ORDER BY timestamp ASC"
rows = db.fetchall(query, tuple(params))
return "\n".join(row['content'] for row in rows)
def get_active_workers() -> List[Dict[str, Any]]:
rows = db.fetchall("SELECT * FROM workers WHERE status = 'running' ORDER BY created_at DESC")
return [dict(row) for row in rows]
def get_worker(worker_id: str) -> Optional[Dict[str, Any]]:
row = db.fetchone("SELECT * FROM workers WHERE id = ?", (worker_id,))
return dict(row) if row else None
def expire_running_workers(older_than_seconds: int = 300, status: str = 'error', reason: str = 'timeout') -> int:
# SQLITE doesn't have a simple way to do DATETIME - INTERVAL, so we'll use strftime/unixepoch if available
# or just do regular update for all running ones for now as a simple fallback
query = f"UPDATE workers SET status = ?, error_message = ? WHERE status = 'running'"
db.execute(query, (status, reason))
return 0 # We don't easily get the rowcount from db.execute right now

View File

@@ -4,9 +4,17 @@ import sys
import inspect import inspect
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional
from SYS.rich_display import console_for from SYS.rich_display import console_for
# Global DB logger set later to avoid circular imports
_DB_LOGGER = None
def set_db_logger(func):
global _DB_LOGGER
_DB_LOGGER = func
_DEBUG_ENABLED = False _DEBUG_ENABLED = False
_thread_local = threading.local() _thread_local = threading.local()
@@ -207,6 +215,15 @@ def log(*args, **kwargs) -> None:
console_for(file).print(prefix, *args, sep=sep, end=end) console_for(file).print(prefix, *args, sep=sep, end=end)
else: else:
console_for(file).print(*args, sep=sep, end=end) console_for(file).print(*args, sep=sep, end=end)
# Log to database if available
if _DB_LOGGER:
try:
msg = sep.join(map(str, args))
level = "DEBUG" if add_prefix else "INFO"
_DB_LOGGER(level, f"{file_name}.{func_name}", msg)
except Exception:
pass
finally: finally:
del frame del frame
del caller_frame del caller_frame

View File

@@ -11,8 +11,17 @@ from datetime import datetime
from threading import Thread, Lock from threading import Thread, Lock
import time import time
from API.folder import API_folder_store
from SYS.logger import log from SYS.logger import log
from SYS.database import (
db,
insert_worker,
update_worker,
append_worker_stdout,
get_worker_stdout as db_get_worker_stdout,
get_active_workers as db_get_active_workers,
get_worker as db_get_worker,
expire_running_workers as db_expire_running_workers
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -157,7 +166,6 @@ class WorkerLoggingHandler(logging.StreamHandler):
def __init__( def __init__(
self, self,
worker_id: str, worker_id: str,
db: API_folder_store,
manager: Optional["WorkerManager"] = None, manager: Optional["WorkerManager"] = None,
buffer_size: int = 50, buffer_size: int = 50,
): ):
@@ -165,12 +173,10 @@ class WorkerLoggingHandler(logging.StreamHandler):
Args: Args:
worker_id: ID of the worker to capture logs for worker_id: ID of the worker to capture logs for
db: Reference to LocalLibraryDB for storing logs
buffer_size: Number of logs to buffer before flushing to DB buffer_size: Number of logs to buffer before flushing to DB
""" """
super().__init__() super().__init__()
self.worker_id = worker_id self.worker_id = worker_id
self.db = db
self.manager = manager self.manager = manager
self.buffer_size = buffer_size self.buffer_size = buffer_size
self.buffer: list[str] = [] self.buffer: list[str] = []
@@ -232,7 +238,7 @@ class WorkerLoggingHandler(logging.StreamHandler):
channel="log" channel="log"
) )
else: else:
self.db.append_worker_stdout( append_worker_stdout(
self.worker_id, self.worker_id,
log_text, log_text,
channel="log" channel="log"
@@ -255,29 +261,22 @@ class WorkerLoggingHandler(logging.StreamHandler):
class WorkerManager: class WorkerManager:
"""Manages persistent worker tasks with auto-refresh capability.""" """Manages persistent worker tasks using the central medios.db."""
def __init__(self, library_root: Path, auto_refresh_interval: float = 2.0): def __init__(self, auto_refresh_interval: float = 2.0):
"""Initialize the worker manager. """Initialize the worker manager.
Args: Args:
library_root: Root directory for the local library database
auto_refresh_interval: Seconds between auto-refresh checks (0 = disabled) auto_refresh_interval: Seconds between auto-refresh checks (0 = disabled)
""" """
self.library_root = Path(library_root)
self.db = API_folder_store(library_root)
self.auto_refresh_interval = auto_refresh_interval self.auto_refresh_interval = auto_refresh_interval
self.refresh_callbacks: List[Callable] = [] self.refresh_callbacks: List[Callable] = []
self.refresh_thread: Optional[Thread] = None self.refresh_thread: Optional[Thread] = None
self._stop_refresh = False self._stop_refresh = False
self._lock = Lock() self._lock = Lock()
# Reuse the DB's own lock so there is exactly one lock guarding the self.worker_handlers: Dict[str, WorkerLoggingHandler] = {}
# sqlite connection (and it is safe for re-entrant/nested DB usage). self._worker_last_step: Dict[str, str] = {}
self._db_lock = self.db._db_lock
self.worker_handlers: Dict[str,
WorkerLoggingHandler] = {} # Track active handlers
self._worker_last_step: Dict[str,
str] = {}
# Buffered stdout/log batching to reduce DB lock contention. # Buffered stdout/log batching to reduce DB lock contention.
self._stdout_buffers: Dict[Tuple[str, str], List[str]] = {} self._stdout_buffers: Dict[Tuple[str, str], List[str]] = {}
self._stdout_buffer_sizes: Dict[Tuple[str, str], int] = {} self._stdout_buffer_sizes: Dict[Tuple[str, str], int] = {}
@@ -328,13 +327,11 @@ class WorkerManager:
Count of workers updated. Count of workers updated.
""" """
try: try:
with self._db_lock: return db_expire_running_workers(
return self.db.expire_running_workers( older_than_seconds=older_than_seconds,
older_than_seconds=older_than_seconds, status=status,
status=status, reason=reason or "Stale worker expired"
reason=reason, )
worker_id_prefix=worker_id_prefix,
)
except Exception as exc: except Exception as exc:
logger.error(f"Failed to expire stale workers: {exc}", exc_info=True) logger.error(f"Failed to expire stale workers: {exc}", exc_info=True)
return 0 return 0
@@ -362,7 +359,7 @@ class WorkerManager:
The logging handler that was created, or None if there was an error The logging handler that was created, or None if there was an error
""" """
try: try:
handler = WorkerLoggingHandler(worker_id, self.db, manager=self) handler = WorkerLoggingHandler(worker_id, manager=self)
with self._lock: with self._lock:
self.worker_handlers[worker_id] = handler self.worker_handlers[worker_id] = handler
@@ -437,16 +434,13 @@ class WorkerManager:
True if worker was inserted successfully True if worker was inserted successfully
""" """
try: try:
with self._db_lock: success = insert_worker(
result = self.db.insert_worker( worker_id,
worker_id, worker_type,
worker_type, title,
title, description
description, )
total_steps, if success:
pipe=pipe
)
if result > 0:
logger.debug( logger.debug(
f"[WorkerManager] Tracking worker: {worker_id} ({worker_type})" f"[WorkerManager] Tracking worker: {worker_id} ({worker_type})"
) )
@@ -482,18 +476,16 @@ class WorkerManager:
if progress > 0: if progress > 0:
kwargs["progress"] = progress kwargs["progress"] = progress
if current_step: if current_step:
kwargs["current_step"] = current_step kwargs["details"] = current_step
if details: if details:
kwargs["description"] = details kwargs["description"] = details
if error: if error:
kwargs["error_message"] = error kwargs["error_message"] = error
if kwargs: if kwargs:
kwargs["last_updated"] = datetime.now().isoformat() if "details" in kwargs and kwargs["details"]:
if "current_step" in kwargs and kwargs["current_step"]: self._worker_last_step[worker_id] = str(kwargs["details"])
self._worker_last_step[worker_id] = str(kwargs["current_step"]) return update_worker(worker_id, **kwargs)
with self._db_lock:
return self.db.update_worker(worker_id, **kwargs)
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -515,7 +507,7 @@ class WorkerManager:
worker_id: Unique identifier for the worker worker_id: Unique identifier for the worker
result: Result status ('completed', 'error', 'cancelled') result: Result status ('completed', 'error', 'cancelled')
error_msg: Error message if any error_msg: Error message if any
result_data: Result data as JSON string result_data: Result data as JSON string (saved in details)
Returns: Returns:
True if update was successful True if update was successful
@@ -526,16 +518,15 @@ class WorkerManager:
except Exception: except Exception:
pass pass
kwargs = { kwargs = {
"status": result, "status": "finished",
"completed_at": datetime.now().isoformat() "result": result
} }
if error_msg: if error_msg:
kwargs["error_message"] = error_msg kwargs["error_message"] = error_msg
if result_data: if result_data:
kwargs["result_data"] = result_data kwargs["details"] = result_data
with self._db_lock: success = update_worker(worker_id, **kwargs)
success = self.db.update_worker(worker_id, **kwargs)
logger.info(f"[WorkerManager] Worker finished: {worker_id} ({result})") logger.info(f"[WorkerManager] Worker finished: {worker_id} ({result})")
self._worker_last_step.pop(worker_id, None) self._worker_last_step.pop(worker_id, None)
return success return success
@@ -553,8 +544,7 @@ class WorkerManager:
List of active worker dictionaries List of active worker dictionaries
""" """
try: try:
with self._db_lock: return db_get_active_workers()
return self.db.get_active_workers()
except Exception as e: except Exception as e:
logger.error( logger.error(
f"[WorkerManager] Error getting active workers: {e}", f"[WorkerManager] Error getting active workers: {e}",
@@ -572,14 +562,9 @@ class WorkerManager:
List of finished worker dictionaries List of finished worker dictionaries
""" """
try: try:
with self._db_lock: # We don't have a get_all_workers in database.py yet, but we'll use a local query
all_workers = self.db.get_all_workers(limit=limit) rows = db.fetchall(f"SELECT * FROM workers WHERE status = 'finished' ORDER BY updated_at DESC LIMIT {limit}")
# Filter to only finished workers return [dict(row) for row in rows]
finished = [
w for w in all_workers
if w.get("status") in ["completed", "error", "cancelled"]
]
return finished
except Exception as e: except Exception as e:
logger.error( logger.error(
f"[WorkerManager] Error getting finished workers: {e}", f"[WorkerManager] Error getting finished workers: {e}",
@@ -597,7 +582,13 @@ class WorkerManager:
Worker data or None if not found Worker data or None if not found
""" """
try: try:
with self._db_lock: return db_get_worker(worker_id)
except Exception as e:
logger.error(
f"[WorkerManager] Error getting worker {worker_id}: {e}",
exc_info=True
)
return None
return self.db.get_worker(worker_id) return self.db.get_worker(worker_id)
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -815,14 +806,11 @@ class WorkerManager:
ok = True ok = True
for wid, ch, step, payload in pending_flush: for wid, ch, step, payload in pending_flush:
try: try:
with self._db_lock: append_worker_stdout(
result = self.db.append_worker_stdout( wid,
wid, payload,
payload, channel=ch
step=step, )
channel=ch
)
ok = ok and result
except Exception as e: except Exception as e:
logger.error( logger.error(
f"[WorkerManager] Error flushing stdout for {wid}: {e}", f"[WorkerManager] Error flushing stdout for {wid}: {e}",
@@ -851,7 +839,6 @@ class WorkerManager:
if not chunks: if not chunks:
return True return True
text = "".join(chunks) text = "".join(chunks)
step = self._stdout_buffer_steps.get(key)
self._stdout_buffers[key] = [] self._stdout_buffers[key] = []
self._stdout_buffer_sizes[key] = 0 self._stdout_buffer_sizes[key] = 0
self._stdout_last_flush[key] = time.monotonic() self._stdout_last_flush[key] = time.monotonic()
@@ -860,13 +847,12 @@ class WorkerManager:
if not text: if not text:
return True return True
try: try:
with self._db_lock: append_worker_stdout(
return self.db.append_worker_stdout( worker_id,
worker_id, text,
text, channel=channel,
step=step, )
channel=channel, return True
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"[WorkerManager] Error flushing stdout for {worker_id}: {e}", f"[WorkerManager] Error flushing stdout for {worker_id}: {e}",
@@ -884,8 +870,7 @@ class WorkerManager:
Worker's stdout or empty string Worker's stdout or empty string
""" """
try: try:
with self._db_lock: return db_get_worker_stdout(worker_id)
return self.db.get_worker_stdout(worker_id)
except Exception as e: except Exception as e:
logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True) logger.error(f"[WorkerManager] Error getting stdout: {e}", exc_info=True)
return "" return ""
@@ -909,21 +894,20 @@ class WorkerManager:
True if clear was successful True if clear was successful
""" """
try: try:
with self._db_lock: # Not implemented in database.py yet, but we'll add it or skip it
return self.db.clear_worker_stdout(worker_id) db.execute("DELETE FROM worker_stdout WHERE worker_id = ?", (worker_id,))
return True
except Exception as e: except Exception as e:
logger.error(f"[WorkerManager] Error clearing stdout: {e}", exc_info=True) logger.error(f"[WorkerManager] Error clearing stdout: {e}", exc_info=True)
return False return False
def close(self) -> None: def close(self) -> None:
"""Close the worker manager and database connection.""" """Close the worker manager."""
self.stop_auto_refresh() self.stop_auto_refresh()
try: try:
self._flush_all_stdout_buffers() self._flush_all_stdout_buffers()
except Exception: except Exception:
pass pass
with self._db_lock:
self.db.close()
logger.info("[WorkerManager] Closed") logger.info("[WorkerManager] Closed")
def _flush_all_stdout_buffers(self) -> None: def _flush_all_stdout_buffers(self) -> None:

View File

@@ -90,6 +90,7 @@ class Folder(Store):
NAME: Optional[str] = None, NAME: Optional[str] = None,
PATH: Optional[str] = None, PATH: Optional[str] = None,
) -> None: ) -> None:
log("WARNING: Folder store is DEPRECATED and will be removed in a future version. Please migrate to HydrusNetwork.")
if name is None and NAME is not None: if name is None and NAME is not None:
name = str(NAME) name = str(NAME)
if location is None and PATH is not None: if location is None and PATH is not None:

View File

@@ -66,6 +66,8 @@ def _discover_store_classes() -> Dict[str, Type[BaseStore]]:
module_name = module_info.name module_name = module_info.name
if module_name in {"__init__", "_base", "registry"}: if module_name in {"__init__", "_base", "registry"}:
continue continue
if module_name.lower() == "folder":
continue
try: try:
module = importlib.import_module(f"Store.{module_name}") module = importlib.import_module(f"Store.{module_name}")
@@ -215,6 +217,8 @@ class Store:
continue continue
store_type = _normalize_store_type(str(raw_store_type)) store_type = _normalize_store_type(str(raw_store_type))
if store_type == "folder":
continue
store_cls = classes_by_type.get(store_type) store_cls = classes_by_type.get(store_type)
if store_cls is None: if store_cls is None:
if not self._suppress_debug: if not self._suppress_debug:

View File

@@ -2751,58 +2751,8 @@ def register_url_with_local_library(
Returns: Returns:
True if url were registered, False otherwise True if url were registered, False otherwise
""" """
# Folder store removed; local library URL registration is disabled.
try: return False
from SYS.config import get_local_storage_path
from API.folder import API_folder_store
file_path = get_field(pipe_obj, "path")
url_field = get_field(pipe_obj, "url", [])
urls: List[str] = []
if isinstance(url_field, str):
urls = [u.strip() for u in url_field.split(",") if u.strip()]
elif isinstance(url_field, (list, tuple)):
urls = [u for u in url_field if isinstance(u, str) and u.strip()]
if not file_path or not urls:
return False
path_obj = Path(file_path)
if not path_obj.exists():
return False
storage_path = get_local_storage_path(config)
if not storage_path:
return False
# Optimization: Don't open DB if file isn't in library root
try:
path_obj.resolve().relative_to(Path(storage_path).resolve())
except ValueError:
return False
with API_folder_store(storage_path) as db:
file_hash = db.get_file_hash(path_obj)
if not file_hash:
return False
metadata = db.get_metadata(file_hash) or {}
existing_url = metadata.get("url") or []
# Add any new url
changed = False
for u in urls:
if u not in existing_url:
existing_url.append(u)
changed = True
if changed:
metadata["url"] = existing_url
db.save_metadata(path_obj, metadata)
return True
return True # url already existed
except Exception:
return False
def resolve_tidal_manifest_path(item: Any) -> Optional[str]: def resolve_tidal_manifest_path(item: Any) -> Optional[str]:

View File

@@ -7,7 +7,6 @@ import sys
from pathlib import Path from pathlib import Path
from SYS.logger import debug, log from SYS.logger import debug, log
from Store.Folder import Folder
from Store import Store from Store import Store
from . import _shared as sh from . import _shared as sh
from API import HydrusNetwork as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
@@ -280,61 +279,23 @@ class Delete_File(sh.Cmdlet):
except Exception: except Exception:
size_bytes = None size_bytes = None
# If lib_root is provided and this is from a folder store, use the Folder class # Delete the local file directly
if lib_root: try:
try: if path.exists() and path.is_file():
folder = Folder(Path(lib_root), name=store or "local") path.unlink()
if folder.delete_file(str(path)): local_deleted = True
local_deleted = True deleted_rows.append(
deleted_rows.append( {
{ "title":
"title": str(title_val).strip() if title_val else path.name,
str(title_val).strip() if title_val else path.name, "store": store_label,
"store": store_label, "hash": hash_hex or sh.normalize_hash(path.stem) or "",
"hash": hash_hex or sh.normalize_hash(path.stem) or "", "size_bytes": size_bytes,
"size_bytes": size_bytes, "ext": _get_ext_from_item() or path.suffix.lstrip("."),
"ext": _get_ext_from_item() or path.suffix.lstrip("."), }
} )
) except Exception as exc:
except Exception as exc: log(f"Local delete failed: {exc}", file=sys.stderr)
debug(f"Folder.delete_file failed: {exc}", file=sys.stderr)
# Fallback to manual deletion
try:
if path.exists() and path.is_file():
path.unlink()
local_deleted = True
deleted_rows.append(
{
"title":
str(title_val).strip() if title_val else path.name,
"store": store_label,
"hash": hash_hex or sh.normalize_hash(path.stem)
or "",
"size_bytes": size_bytes,
"ext": _get_ext_from_item()
or path.suffix.lstrip("."),
}
)
except Exception as exc:
log(f"Local delete failed: {exc}", file=sys.stderr)
else:
# No lib_root, just delete the file
try:
if path.exists() and path.is_file():
path.unlink()
local_deleted = True
deleted_rows.append(
{
"title":
str(title_val).strip() if title_val else path.name,
"store": store_label,
"hash": hash_hex or sh.normalize_hash(path.stem) or "",
"size_bytes": size_bytes,
"ext": _get_ext_from_item() or path.suffix.lstrip("."),
}
)
except Exception as exc:
log(f"Local delete failed: {exc}", file=sys.stderr)
# Remove common sidecars regardless of file removal success # Remove common sidecars regardless of file removal success
for sidecar in ( for sidecar in (
@@ -533,24 +494,6 @@ class Delete_File(sh.Cmdlet):
log("Invalid -query value (expected hash:<sha256>)", file=sys.stderr) log("Invalid -query value (expected hash:<sha256>)", file=sys.stderr)
return 1 return 1
# If no lib_root provided, try to get the first folder store from config
if not lib_root:
try:
storage_config = config.get("storage",
{})
folder_config = storage_config.get("folder",
{})
if folder_config:
# Get first folder store path
for store_name, store_config in folder_config.items():
if isinstance(store_config, dict):
path = store_config.get("path")
if path:
lib_root = path
break
except Exception:
pass
reason = " ".join(token for token in reason_tokens reason = " ".join(token for token in reason_tokens
if str(token).strip()).strip() if str(token).strip()).strip()

View File

@@ -175,27 +175,6 @@ class Get_File(sh.Cmdlet):
log(f"Error: Backend could not retrieve file for hash {file_hash}") log(f"Error: Backend could not retrieve file for hash {file_hash}")
return 1 return 1
# Folder store UX: without -path, just open the file in the default app.
# Only export/copy when -path is explicitly provided.
backend_name = type(backend).__name__
is_folder_backend = backend_name.lower() == "folder"
if is_folder_backend and not output_path:
display_title = resolve_display_title() or source_path.stem or "Opened"
ext_for_emit = metadata.get("ext") or source_path.suffix.lstrip(".")
self._open_file_default(source_path)
log(f"Opened: {source_path}", file=sys.stderr)
ctx.emit(
{
"hash": file_hash,
"store": store_name,
"path": str(source_path),
"title": str(display_title),
"ext": str(ext_for_emit or ""),
}
)
debug("[get-file] Completed successfully")
return 0
# Otherwise: export/copy to output_dir. # Otherwise: export/copy to output_dir.
if output_path: if output_path:
output_dir = Path(output_path).expanduser() output_dir = Path(output_path).expanduser()

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any, Dict, Sequence, Optional from typing import Any, Dict, Sequence, Optional
import sys import sys
from pathlib import Path
from SYS.logger import log from SYS.logger import log
@@ -19,12 +18,11 @@ get_hash_for_operation = sh.get_hash_for_operation
fetch_hydrus_metadata = sh.fetch_hydrus_metadata fetch_hydrus_metadata = sh.fetch_hydrus_metadata
should_show_help = sh.should_show_help should_show_help = sh.should_show_help
get_field = sh.get_field get_field = sh.get_field
from API.folder import API_folder_store
from Store import Store from Store import Store
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="get-relationship", name="get-relationship",
summary="Print relationships for the selected file (Hydrus or Local).", summary="Print relationships for the selected file (Hydrus).",
usage='get-relationship [-query "hash:<sha256>"]', usage='get-relationship [-query "hash:<sha256>"]',
alias=[], alias=[],
arg=[ arg=[
@@ -32,155 +30,12 @@ CMDLET = Cmdlet(
SharedArgs.STORE, SharedArgs.STORE,
], ],
detail=[ detail=[
"- Lists relationship data as returned by Hydrus or Local DB.", "- Lists relationship data as returned by Hydrus.",
], ],
) )
def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
if should_show_help(_args):
log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}")
return 0
# Parse -query and -store override
override_query: str | None = None
override_store: str | None = None
args_list = list(_args)
i = 0
while i < len(args_list):
a = args_list[i]
low = str(a).lower()
if low in {"-query",
"--query",
"query"} and i + 1 < len(args_list):
override_query = str(args_list[i + 1]).strip()
i += 2
continue
if low in {"-store",
"--store",
"store"} and i + 1 < len(args_list):
override_store = str(args_list[i + 1]).strip()
i += 2
continue
i += 1
override_hash: str | None = (
sh.parse_single_hash_query(override_query) if override_query else None
)
if override_query and not override_hash:
log('get-relationship requires -query "hash:<sha256>"', file=sys.stderr)
return 1
# Handle @N selection which creates a list
# This cmdlet is single-subject; require disambiguation when multiple items are provided.
if isinstance(result, list):
if len(result) == 0:
result = None
elif len(result) > 1 and not override_hash:
log(
'get-relationship expects a single item; select one row (e.g. @1) or pass -query "hash:<sha256>"',
file=sys.stderr,
)
return 1
else:
result = result[0]
# Initialize results collection
found_relationships = [] # List of dicts: {hash, type, title, path, store}
source_title = "Unknown"
def _add_relationship(entry: Dict[str, Any]) -> None:
"""Add relationship if not already present by hash or path."""
for existing in found_relationships:
if (entry.get("hash")
and str(existing.get("hash",
"")).lower() == str(entry["hash"]).lower()):
return
if (entry.get("path")
and str(existing.get("path",
"")).lower() == str(entry["path"]).lower()):
return
found_relationships.append(entry)
# Store/hash-first subject resolution
store_name: Optional[str] = override_store
if not store_name:
store_name = get_field(result, "store")
hash_hex = (
normalize_hash(override_hash)
if override_hash else normalize_hash(get_hash_for_operation(None,
result))
)
if not source_title or source_title == "Unknown":
source_title = (
get_field(result,
"title") or get_field(result,
"name")
or (hash_hex[:16] + "..." if hash_hex else "Unknown")
)
local_db_checked = False
if store_name and hash_hex:
try:
store = Store(config)
backend = store[str(store_name)]
# Folder store relationships
# IMPORTANT: only treat the Folder backend as a local DB store.
# Other backends may expose a location() method but are not SQLite folder stores.
if (type(backend).__name__ == "Folder" and hasattr(backend,
"location")
and callable(getattr(backend,
"location"))):
storage_path = Path(str(backend.location()))
with API_folder_store(storage_path) as db:
local_db_checked = True
# Update source title from tags if possible
try:
tags = db.get_tags(hash_hex)
for t in tags:
if isinstance(t, str) and t.lower().startswith("title:"):
source_title = t[6:].strip()
break
except Exception:
pass
metadata = db.get_metadata(hash_hex)
rels = (metadata or {}).get("relationships")
king_hashes: list[str] = []
# Forward relationships
if isinstance(rels, dict):
for rel_type, hashes in rels.items():
if not isinstance(hashes, list):
continue
for related_hash in hashes:
related_hash = normalize_hash(str(related_hash))
if not related_hash or related_hash == hash_hex:
continue
entry_type = (
"king" if str(rel_type).lower() == "alt" else
str(rel_type)
)
if entry_type == "king":
king_hashes.append(related_hash)
related_title = related_hash[:16] + "..."
try:
rel_tags = db.get_tags(related_hash)
for t in rel_tags:
if isinstance(
t,
str) and t.lower().startswith("title:"):
related_title = t[6:].strip()
break
except Exception:
pass pass
_add_relationship( _add_relationship(
@@ -270,10 +125,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception as e: except Exception as e:
log(f"Error checking store relationships: {e}", file=sys.stderr) log(f"Error checking store relationships: {e}", file=sys.stderr)
# If we found local relationships, we can stop or merge with Hydrus? # Fetch Hydrus relationships if we have a hash.
# For now, if we found local ones, let's show them.
# But if the file is also in Hydrus, we might want those too.
# Let's try Hydrus if we have a hash.
hash_hex = ( hash_hex = (
normalize_hash(override_hash) normalize_hash(override_hash)
@@ -281,7 +133,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
result)) result))
) )
if hash_hex and not local_db_checked: if hash_hex:
try: try:
client = None client = None
store_label = "hydrus" store_label = "hydrus"

View File

@@ -1,4 +1,4 @@
"""search-file cmdlet: Search for files in storage backends (Folder, Hydrus).""" """search-file cmdlet: Search for files in storage backends (Hydrus)."""
from __future__ import annotations from __future__ import annotations
@@ -11,12 +11,12 @@ import sys
from SYS.logger import log, debug from SYS.logger import log, debug
from ProviderCore.registry import get_search_provider, list_search_providers from ProviderCore.registry import get_search_provider, list_search_providers
from SYS.config import get_local_storage_path
from SYS.rich_display import ( from SYS.rich_display import (
show_provider_config_panel, show_provider_config_panel,
show_store_config_panel, show_store_config_panel,
show_available_providers_panel, show_available_providers_panel,
) )
from SYS.database import insert_worker, update_worker, append_worker_stdout
from ._shared import ( from ._shared import (
Cmdlet, Cmdlet,
@@ -32,17 +32,52 @@ from SYS import pipeline as ctx
STORAGE_ORIGINS = {"local", STORAGE_ORIGINS = {"local",
"hydrus", "hydrus",
"folder",
"zerotier"} "zerotier"}
class _WorkerLogger:
def __init__(self, worker_id: str) -> None:
self.worker_id = worker_id
def __enter__(self) -> "_WorkerLogger":
return self
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
return None
def insert_worker(
self,
worker_id: str,
worker_type: str,
title: str = "",
description: str = "",
**kwargs: Any,
) -> None:
try:
insert_worker(worker_id, worker_type, title=title, description=description)
except Exception:
pass
def update_worker_status(self, worker_id: str, status: str) -> None:
try:
update_worker(worker_id, status=status)
except Exception:
pass
def append_worker_stdout(self, worker_id: str, content: str) -> None:
try:
append_worker_stdout(worker_id, content)
except Exception:
pass
class search_file(Cmdlet): class search_file(Cmdlet):
"""Class-based search-file cmdlet for searching storage backends.""" """Class-based search-file cmdlet for searching storage backends."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
name="search-file", name="search-file",
summary="Search storage backends (Folder, Hydrus) or external providers (via -provider).", summary="Search storage backends (Hydrus) or external providers (via -provider).",
usage="search-file [-query <query>] [-store BACKEND] [-limit N] [-provider NAME]", usage="search-file [-query <query>] [-store BACKEND] [-limit N] [-provider NAME]",
arg=[ arg=[
CmdletArg( CmdletArg(
@@ -65,7 +100,7 @@ class search_file(Cmdlet):
), ),
], ],
detail=[ detail=[
"Search across storage backends: Folder stores and Hydrus instances", "Search across storage backends: Hydrus instances",
"Use -store to search a specific backend by name", "Use -store to search a specific backend by name",
"URL search: url:* (any URL) or url:<value> (URL substring)", "URL search: url:* (any URL) or url:<value> (URL substring)",
"Extension search: ext:<value> (e.g., ext:png)", "Extension search: ext:<value> (e.g., ext:png)",
@@ -74,12 +109,12 @@ class search_file(Cmdlet):
"Examples:", "Examples:",
"search-file -query foo # Search all storage backends", "search-file -query foo # Search all storage backends",
"search-file -store home -query '*' # Search 'home' Hydrus instance", "search-file -store home -query '*' # Search 'home' Hydrus instance",
"search-file -store test -query 'video' # Search 'test' folder store", "search-file -store home -query 'video' # Search 'home' Hydrus instance",
"search-file -query 'hash:deadbeef...' # Search by SHA256 hash", "search-file -query 'hash:deadbeef...' # Search by SHA256 hash",
"search-file -query 'url:*' # Files that have any URL", "search-file -query 'url:*' # Files that have any URL",
"search-file -query 'url:youtube.com' # Files whose URL contains substring", "search-file -query 'url:youtube.com' # Files whose URL contains substring",
"search-file -query 'ext:png' # Files whose metadata ext is png", "search-file -query 'ext:png' # Files whose metadata ext is png",
"search-file -query 'system:filetype = png' # Hydrus: native; Folder: maps to metadata.ext", "search-file -query 'system:filetype = png' # Hydrus: native",
"", "",
"Provider search (-provider):", "Provider search (-provider):",
"search-file -provider youtube 'tutorial' # Search YouTube provider", "search-file -provider youtube 'tutorial' # Search YouTube provider",
@@ -210,49 +245,15 @@ class search_file(Cmdlet):
return 1 return 1
worker_id = str(uuid.uuid4()) worker_id = str(uuid.uuid4())
library_root = get_local_storage_path(config or {}) if get_local_storage_path else None try:
insert_worker(
if not library_root: worker_id,
try: "search-file",
from Store.registry import get_backend_instance title=f"Search: {query}",
# Try the first configured folder backend without instantiating all backends description=f"Provider: {provider_name}, Query: {query}",
store_cfg = (config or {}).get("store") or {} )
folder_cfg = None except Exception:
for raw_store_type, instances in store_cfg.items(): pass
if _normalize_store_type(str(raw_store_type)) == "folder":
folder_cfg = instances
break
if isinstance(folder_cfg, dict):
for instance_name, instance_config in folder_cfg.items():
try:
backend = get_backend_instance(config, instance_name, suppress_debug=True)
if backend and type(backend).__name__ == "Folder":
library_root = expand_path(getattr(backend, "_location", None))
if library_root:
break
except Exception:
pass
except Exception:
pass
db = None
# Disable Folder DB usage for "external" searches when not using a folder store
# db = None
if library_root and False: # Disabled to prevent 'database is locked' errors during external searches
try:
from API.folder import API_folder_store
db = API_folder_store(library_root)
db.__enter__()
db.insert_worker(
worker_id,
"search-file",
title=f"Search: {query}",
description=f"Provider: {provider_name}, Query: {query}",
pipe=ctx.get_current_command_text(),
)
except Exception:
db = None
try: try:
results_list: List[Dict[str, Any]] = [] results_list: List[Dict[str, Any]] = []
@@ -381,9 +382,11 @@ class search_file(Cmdlet):
if not results: if not results:
log(f"No results found for query: {query}", file=sys.stderr) log(f"No results found for query: {query}", file=sys.stderr)
if db is not None: try:
db.append_worker_stdout(worker_id, json.dumps([], indent=2)) append_worker_stdout(worker_id, json.dumps([], indent=2))
db.update_worker_status(worker_id, "completed") update_worker(worker_id, status="completed")
except Exception:
pass
return 0 return 0
for search_result in results: for search_result in results:
@@ -415,9 +418,11 @@ class search_file(Cmdlet):
ctx.set_current_stage_table(table) ctx.set_current_stage_table(table)
if db is not None: try:
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2)) append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
db.update_worker_status(worker_id, "completed") update_worker(worker_id, status="completed")
except Exception:
pass
return 0 return 0
@@ -426,18 +431,11 @@ class search_file(Cmdlet):
import traceback import traceback
debug(traceback.format_exc()) debug(traceback.format_exc())
if db is not None: try:
try: update_worker(worker_id, status="error")
db.update_worker_status(worker_id, "error") except Exception:
except Exception: pass
pass
return 1 return 1
finally:
if db is not None:
try:
db.__exit__(None, None, None)
except Exception:
pass
# --- Execution ------------------------------------------------------ # --- Execution ------------------------------------------------------
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
@@ -591,37 +589,12 @@ class search_file(Cmdlet):
log("Provide a search query", file=sys.stderr) log("Provide a search query", file=sys.stderr)
return 1 return 1
from API.folder import API_folder_store
worker_id = str(uuid.uuid4()) worker_id = str(uuid.uuid4())
from Store import Store from Store import Store
storage_registry = Store(config=config or {}) storage_registry = Store(config=config or {})
library_root = get_local_storage_path(config or {})
if not library_root:
# Fallback for search-file: if no global folder path is found,
# try to use the specific backend mentioned in -store or the first available folder backend.
if storage_backend:
try:
backend = storage_registry[storage_backend]
if backend and type(backend).__name__ == "Folder":
library_root = expand_path(getattr(backend, "_location", None))
except Exception:
pass
else:
# Try all backends until we find a Folder one
for name in storage_registry.list_backends():
try:
backend = storage_registry[name]
if type(backend).__name__ == "Folder":
library_root = expand_path(getattr(backend, "_location", None))
if library_root:
break
except Exception:
continue
if not library_root: if not storage_registry.list_backends():
# Internal refreshes should not trigger config panels or stop progress. # Internal refreshes should not trigger config panels or stop progress.
if "-internal-refresh" in args_list: if "-internal-refresh" in args_list:
return 1 return 1
@@ -635,11 +608,11 @@ class search_file(Cmdlet):
progress.stop() progress.stop()
except Exception: except Exception:
pass pass
show_store_config_panel(["Folder Store"]) show_store_config_panel(["Hydrus Network"])
return 1 return 1
# Use context manager to ensure database is always closed # Use a lightweight worker logger to track search results in the central DB
with API_folder_store(library_root) as db: with _WorkerLogger(worker_id) as db:
try: try:
if "-internal-refresh" not in args_list: if "-internal-refresh" not in args_list:
db.insert_worker( db.insert_worker(
@@ -713,18 +686,7 @@ class search_file(Cmdlet):
# Resolve a path/URL string if possible # Resolve a path/URL string if possible
path_str: Optional[str] = None path_str: Optional[str] = None
# IMPORTANT: avoid calling get_file() for remote backends. # Avoid calling get_file() for remote backends during search/refresh.
# For Hydrus, get_file() returns a browser URL (and may include access keys),
# which should not be pulled during search/refresh.
try:
if type(resolved_backend).__name__ == "Folder":
maybe_path = resolved_backend.get_file(h)
if isinstance(maybe_path, Path):
path_str = str(maybe_path)
elif isinstance(maybe_path, str) and maybe_path:
path_str = maybe_path
except Exception:
path_str = None
meta_obj: Dict[str, meta_obj: Dict[str,
Any] = {} Any] = {}

View File

@@ -4,6 +4,7 @@ import sys
import json import json
import socket import socket
import re import re
from datetime import datetime
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from pathlib import Path from pathlib import Path
from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args, resolve_tidal_manifest_path from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args, resolve_tidal_manifest_path
@@ -13,8 +14,7 @@ from MPV.mpv_ipc import MPV
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.models import PipeObject from SYS.models import PipeObject
from API.folder import LocalLibrarySearchOptimizer from SYS.config import get_hydrus_access_key, get_hydrus_url
from SYS.config import get_local_storage_path, get_hydrus_access_key, get_hydrus_url
_ALLDEBRID_UNLOCK_CACHE: Dict[str, _ALLDEBRID_UNLOCK_CACHE: Dict[str,
str] = {} str] = {}
@@ -27,6 +27,94 @@ def _repo_root() -> Path:
return Path(os.getcwd()) return Path(os.getcwd())
def _playlist_store_path() -> Path:
return _repo_root() / "mpv_playlists.json"
def _load_playlist_store(path: Path) -> Dict[str, Any]:
if not path.exists():
return {"next_id": 1, "playlists": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {"next_id": 1, "playlists": []}
data.setdefault("next_id", 1)
data.setdefault("playlists", [])
if not isinstance(data["playlists"], list):
data["playlists"] = []
return data
except Exception:
return {"next_id": 1, "playlists": []}
def _save_playlist_store(path: Path, data: Dict[str, Any]) -> bool:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
return True
except Exception:
return False
def _save_playlist(name: str, items: List[Any]) -> bool:
path = _playlist_store_path()
data = _load_playlist_store(path)
playlists = data.get("playlists", [])
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
for pl in playlists:
if str(pl.get("name")).strip().lower() == str(name).strip().lower():
pl["items"] = list(items)
pl["updated_at"] = now
return _save_playlist_store(path, data)
new_id = int(data.get("next_id") or 1)
data["next_id"] = new_id + 1
playlists.append({
"id": new_id,
"name": name,
"items": list(items),
"updated_at": now,
})
data["playlists"] = playlists
return _save_playlist_store(path, data)
def _get_playlist_by_id(playlist_id: int) -> Optional[tuple[str, List[Any]]]:
data = _load_playlist_store(_playlist_store_path())
for pl in data.get("playlists", []):
try:
if int(pl.get("id")) == int(playlist_id):
return str(pl.get("name") or ""), list(pl.get("items") or [])
except Exception:
continue
return None
def _delete_playlist(playlist_id: int) -> bool:
path = _playlist_store_path()
data = _load_playlist_store(path)
playlists = data.get("playlists", [])
kept = []
removed = False
for pl in playlists:
try:
if int(pl.get("id")) == int(playlist_id):
removed = True
continue
except Exception:
pass
kept.append(pl)
data["playlists"] = kept
return _save_playlist_store(path, data) if removed else False
def _get_playlists() -> List[Dict[str, Any]]:
data = _load_playlist_store(_playlist_store_path())
playlists = data.get("playlists", [])
return [dict(pl) for pl in playlists if isinstance(pl, dict)]
def _repo_log_dir() -> Path: def _repo_log_dir() -> Path:
d = _repo_root() / "Log" d = _repo_root() / "Log"
try: try:
@@ -828,23 +916,8 @@ def _get_playable_path(
backend_class = type(backend).__name__ backend_class = type(backend).__name__
backend_target_resolved = True backend_target_resolved = True
# Folder stores: resolve to an on-disk file path.
if (hasattr(backend, "get_file") and callable(getattr(backend, "get_file"))
and backend_class == "Folder"):
try:
resolved = backend.get_file(file_hash)
if isinstance(resolved, Path):
path = str(resolved)
elif resolved is not None:
path = str(resolved)
except Exception as e:
debug(
f"Error resolving file path from store '{store}': {e}",
file=sys.stderr
)
# HydrusNetwork: build a playable API file URL without browser side-effects. # HydrusNetwork: build a playable API file URL without browser side-effects.
elif backend_class == "HydrusNetwork": if backend_class == "HydrusNetwork":
try: try:
client = getattr(backend, "_client", None) client = getattr(backend, "_client", None)
base_url = getattr(client, "url", None) base_url = getattr(client, "url", None)
@@ -1367,58 +1440,38 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# If we save 'memory://...', it will work when loaded back. # If we save 'memory://...', it will work when loaded back.
clean_items.append(item) clean_items.append(item)
# Use config from context or load it if _save_playlist(playlist_name, clean_items):
config_data = config if config else {} debug(f"Playlist saved as '{playlist_name}'")
return 0
storage_path = get_local_storage_path(config_data) or _default_state_dir() debug(f"Failed to save playlist '{playlist_name}'")
try: return 1
Path(storage_path).mkdir(parents=True, exist_ok=True)
except Exception:
pass
with LocalLibrarySearchOptimizer(storage_path) as db:
if db.save_playlist(playlist_name, clean_items):
debug(f"Playlist saved as '{playlist_name}'")
return 0
else:
debug(f"Failed to save playlist '{playlist_name}'")
return 1
# Handle Load Playlist # Handle Load Playlist
current_playlist_name = None current_playlist_name = None
if load_mode: if load_mode:
# Use config from context or load it if index_arg:
config_data = config if config else {} try:
pl_id = int(index_arg)
storage_path = get_local_storage_path(config_data) # Handle Delete Playlist (if -clear is also passed)
if not storage_path: if clear_mode:
debug("Local storage path not configured.") if _delete_playlist(pl_id):
return 1 debug(f"Playlist ID {pl_id} deleted.")
# Clear index_arg so we fall through to list mode and show updated list
with LocalLibrarySearchOptimizer(storage_path) as db: index_arg = None
if index_arg: # Don't return, let it list the remaining playlists
try:
pl_id = int(index_arg)
# Handle Delete Playlist (if -clear is also passed)
if clear_mode:
if db.delete_playlist(pl_id):
debug(f"Playlist ID {pl_id} deleted.")
# Clear index_arg so we fall through to list mode and show updated list
index_arg = None
# Don't return, let it list the remaining playlists
else:
debug(f"Failed to delete playlist ID {pl_id}.")
return 1
else: else:
# Handle Load Playlist debug(f"Failed to delete playlist ID {pl_id}.")
result = db.get_playlist_by_id(pl_id) return 1
if result is None: else:
debug(f"Playlist ID {pl_id} not found.") # Handle Load Playlist
return 1 result = _get_playlist_by_id(pl_id)
if result is None:
debug(f"Playlist ID {pl_id} not found.")
return 1
name, items = result name, items = result
current_playlist_name = name current_playlist_name = name
# Queue items (replacing current playlist) # Queue items (replacing current playlist)
if items: if items:
@@ -1446,42 +1499,42 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
debug(f"Invalid playlist ID: {index_arg}") debug(f"Invalid playlist ID: {index_arg}")
return 1 return 1
# If we deleted or didn't have an index, list playlists # If we deleted or didn't have an index, list playlists
if not index_arg: if not index_arg:
playlists = db.get_playlists() playlists = _get_playlists()
if not playlists: if not playlists:
debug("No saved playlists found.") debug("No saved playlists found.")
return 0
table = Table("Saved Playlists")
for i, pl in enumerate(playlists):
item_count = len(pl.get("items", []))
row = table.add_row()
# row.add_column("ID", str(pl['id'])) # Hidden as per user request
row.add_column("Name", pl["name"])
row.add_column("Items", str(item_count))
row.add_column("Updated", pl["updated_at"])
# Set the playlist items as the result object for this row
# When user selects @N, they get the list of items
# We also set the source command to .pipe -load <ID> so it loads it
table.set_row_selection_args(i, ["-load", str(pl["id"])])
table.set_source_command(".mpv")
# Register results
ctx.set_last_result_table_overlay(
table,
[p["items"] for p in playlists]
)
ctx.set_current_stage_table(table)
# Do not print directly here.
# Both CmdletExecutor and PipelineExecutor render the current-stage/overlay table,
# so printing here would duplicate output.
return 0 return 0
table = Table("Saved Playlists")
for i, pl in enumerate(playlists):
item_count = len(pl.get("items", []))
row = table.add_row()
# row.add_column("ID", str(pl['id'])) # Hidden as per user request
row.add_column("Name", pl["name"])
row.add_column("Items", str(item_count))
row.add_column("Updated", pl.get("updated_at") or "")
# Set the playlist items as the result object for this row
# When user selects @N, they get the list of items
# We also set the source command to .pipe -load <ID> so it loads it
table.set_row_selection_args(i, ["-load", str(pl["id"])])
table.set_source_command(".mpv")
# Register results
ctx.set_last_result_table_overlay(
table,
[p["items"] for p in playlists]
)
ctx.set_current_stage_table(table)
# Do not print directly here.
# Both CmdletExecutor and PipelineExecutor render the current-stage/overlay table,
# so printing here would duplicate output.
return 0
# Everything below was originally outside a try block; keep it inside so `start_opts` is in scope. # Everything below was originally outside a try block; keep it inside so `start_opts` is in scope.
# Handle Play/Pause commands (but skip if we have index_arg to play a specific item) # Handle Play/Pause commands (but skip if we have index_arg to play a specific item)
@@ -1850,20 +1903,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if len(stem) == 64 and all(c in "0123456789abcdef" if len(stem) == 64 and all(c in "0123456789abcdef"
for c in stem.lower()): for c in stem.lower()):
file_hash = stem.lower() file_hash = stem.lower()
# Find which folder store has this file
if file_storage:
for backend_name in file_storage.list_backends():
backend = file_storage[backend_name]
if type(backend).__name__ == "Folder":
# Check if this backend has the file
try:
result_path = backend.get_file(file_hash)
if isinstance(result_path,
Path) and result_path.exists():
store_name = backend_name
break
except Exception:
pass
# Fallback to inferred store if we couldn't find it # Fallback to inferred store if we couldn't find it
if not store_name: if not store_name:

View File

@@ -242,28 +242,6 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
_add_startup_check(startup_table, "DISABLED", "Matrix", provider="matrix", detail=str(exc)) _add_startup_check(startup_table, "DISABLED", "Matrix", provider="matrix", detail=str(exc))
debug(f"Matrix instantiation failed: {exc}") debug(f"Matrix instantiation failed: {exc}")
# Folders
if _has_store_subtype(config, "folder"):
fcfg = config.get("store", {}).get("folder", {})
for iname, icfg in fcfg.items():
if not isinstance(icfg, dict): continue
nkey = str(icfg.get("NAME") or iname)
pval = str(icfg.get("PATH") or icfg.get("path") or "").strip()
debug(f"Folder store check: name={nkey}, path={pval}")
ok = bool(store_registry and store_registry.is_available(nkey))
if ok and store_registry:
backend = store_registry[nkey]
scan_ok = getattr(backend, "scan_ok", True)
sdet = getattr(backend, "scan_detail", "Up to date")
stats = getattr(backend, "scan_stats", {})
files = int(stats.get("files_total_db", 0)) if stats else None
debug(f"Folder backend '{nkey}': scan_ok={scan_ok}, scan_detail={sdet}, stats={stats}")
_add_startup_check(startup_table, "SCANNED" if scan_ok else "ERROR", nkey, store="folder", files=files, detail=f"{pval} - {sdet}")
else:
err = store_registry.get_backend_error(iname) if store_registry else None
debug(f"Folder backend '{nkey}' error: {err}")
_add_startup_check(startup_table, "ERROR", nkey, store="folder", detail=f"{pval} - {err or 'Unavailable'}")
# Cookies # Cookies
try: try:
from tool.ytdlp import YtDlpTool from tool.ytdlp import YtDlpTool

View File

@@ -12,7 +12,7 @@ from cmdlet import register
from cmdlet._shared import Cmdlet, CmdletArg from cmdlet._shared import Cmdlet, CmdletArg
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.logger import log from SYS.logger import log
from SYS.config import get_local_storage_path from SYS.database import db as _db, get_worker_stdout
DEFAULT_LIMIT = 100 DEFAULT_LIMIT = 100
WORKER_STATUS_FILTERS = {"running", WORKER_STATUS_FILTERS = {"running",
@@ -74,6 +74,69 @@ CMDLET = Cmdlet(
) )
def _normalize_worker_row(row: Dict[str, Any]) -> Dict[str, Any]:
worker_id = row.get("id")
created = row.get("created_at") or ""
updated = row.get("updated_at") or ""
payload = dict(row)
payload["worker_id"] = worker_id
payload["started_at"] = created
payload["last_updated"] = updated
payload["completed_at"] = updated
payload["pipe"] = row.get("details") or row.get("title") or ""
return payload
class _WorkerDB:
def clear_finished_workers(self) -> int:
try:
cur = _db.execute("DELETE FROM workers WHERE status != 'running'")
return int(getattr(cur, "rowcount", 0) or 0)
except Exception:
return 0
def get_worker(self, worker_id: str) -> Dict[str, Any] | None:
row = _db.fetchone("SELECT * FROM workers WHERE id = ?", (worker_id,))
if not row:
return None
worker = _normalize_worker_row(dict(row))
try:
worker["stdout"] = get_worker_stdout(worker_id)
except Exception:
worker["stdout"] = ""
return worker
def get_worker_events(self, worker_id: str) -> List[Dict[str, Any]]:
try:
rows = _db.fetchall(
"SELECT content, channel, timestamp FROM worker_stdout WHERE worker_id = ? ORDER BY timestamp ASC",
(worker_id,),
)
except Exception:
rows = []
events: List[Dict[str, Any]] = []
for row in rows:
try:
events.append({
"message": row.get("content"),
"channel": row.get("channel") or "stdout",
"created_at": row.get("timestamp"),
})
except Exception:
continue
return events
def get_all_workers(self, limit: int = 100) -> List[Dict[str, Any]]:
try:
rows = _db.fetchall(
"SELECT * FROM workers ORDER BY created_at DESC LIMIT ?",
(int(limit or 100),),
)
except Exception:
rows = []
return [_normalize_worker_row(dict(row)) for row in rows]
def _has_help_flag(args_list: Sequence[str]) -> bool: def _has_help_flag(args_list: Sequence[str]) -> bool:
return any(str(arg).lower() in HELP_FLAGS for arg in args_list) return any(str(arg).lower() in HELP_FLAGS for arg in args_list)
@@ -101,39 +164,33 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
options = _parse_worker_args(args_list) options = _parse_worker_args(args_list)
library_root = get_local_storage_path(config or {})
if not library_root:
log("No library root configured. Use the .config command to set up storage.", file=sys.stderr)
return 1
try: try:
from API.folder import API_folder_store db = _WorkerDB()
with API_folder_store(library_root) as db: if options.clear:
if options.clear: count = db.clear_finished_workers()
count = db.clear_finished_workers() log(f"Cleared {count} finished workers.")
log(f"Cleared {count} finished workers.") return 0
if options.worker_id:
worker = db.get_worker(options.worker_id)
if worker:
events: List[Dict[str, Any]] = []
try:
wid = worker.get("worker_id")
if wid:
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0 return 0
log(f"Worker not found: {options.worker_id}", file=sys.stderr)
return 1
if options.worker_id: if selection_requested:
worker = db.get_worker(options.worker_id) return _render_worker_selection(db, result)
if worker:
events: List[Dict[str, Any]] = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
log(f"Worker not found: {options.worker_id}", file=sys.stderr)
return 1
if selection_requested: return _render_worker_list(db, options.status, options.limit)
return _render_worker_selection(db, result)
return _render_worker_list(db, options.status, options.limit)
except Exception as exc: except Exception as exc:
log(f"Workers query failed: {exc}", file=sys.stderr) log(f"Workers query failed: {exc}", file=sys.stderr)
import traceback import traceback

View File

@@ -738,7 +738,40 @@ def main() -> int:
return 0 return 0
def _update_config_value(root: Path, key: str, value: str) -> bool: def _update_config_value(root: Path, key: str, value: str) -> bool:
db_path = root / "medios.db"
config_path = root / "config.conf" config_path = root / "config.conf"
# Try database first
if db_path.exists():
try:
import sqlite3
import json
with sqlite3.connect(str(db_path)) as conn:
# We want to set store.hydrusnetwork.hydrus.<key>
cur = conn.cursor()
# Check if hydrusnetwork store exists
cur.execute("SELECT 1 FROM config WHERE category='store' AND subtype='hydrusnetwork'")
if cur.fetchone():
cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', 'hydrus', key, value)
)
else:
# Create the section
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)
)
conn.commit()
return True
except Exception as e:
print(f"Error updating database config: {e}")
# Fallback to config.conf
if not config_path.exists(): if not config_path.exists():
fallback = root / "config.conf.remove" fallback = root / "config.conf.remove"
if fallback.exists(): if fallback.exists():
@@ -759,7 +792,7 @@ def main() -> int:
config_path.write_text(new_content, encoding="utf-8") config_path.write_text(new_content, encoding="utf-8")
return True return True
except Exception as e: except Exception as e:
print(f"Error updating config: {e}") print(f"Error updating legacy config: {e}")
return False return False
def _interactive_menu() -> str | int: def _interactive_menu() -> str | int:
@@ -1512,7 +1545,10 @@ if (Test-Path (Join-Path $repo 'CLI.py')) {
"if not defined MM_NO_UPDATE (\n" "if not defined MM_NO_UPDATE (\n"
" if exist \"!REPO!\\.git\" (\n" " if exist \"!REPO!\\.git\" (\n"
" set \"AUTO_UPDATE=true\"\n" " set \"AUTO_UPDATE=true\"\n"
" if exist \"!REPO!\\config.conf\" (\n" " if exist \"!REPO!\\medios.db\" (\n"
" \"sqlite3\" \"!REPO!\\medios.db\" \"SELECT value FROM config WHERE key='auto_update' AND category='global'\" | findstr /i /r \"false no off 0\" >nul 2>&1\n"
" if !errorlevel! == 0 set \"AUTO_UPDATE=false\"\n"
" ) else if exist \"!REPO!\\config.conf\" (\n"
" findstr /i /r \"auto_update.*=.*false auto_update.*=.*no auto_update.*=.*off auto_update.*=.*0\" \"!REPO!\\config.conf\" >nul 2>&1\n" " findstr /i /r \"auto_update.*=.*false auto_update.*=.*no auto_update.*=.*off auto_update.*=.*0\" \"!REPO!\\config.conf\" >nul 2>&1\n"
" if !errorlevel! == 0 set \"AUTO_UPDATE=false\"\n" " if !errorlevel! == 0 set \"AUTO_UPDATE=false\"\n"
" )\n" " )\n"

View File

@@ -144,23 +144,63 @@ def run_git_pull(git: str, dest: Path) -> None:
def update_medios_config(hydrus_path: Path) -> bool: def update_medios_config(hydrus_path: Path) -> bool:
"""Attempt to update config.conf in the Medios-Macina root with the hydrus path. """Attempt to update Medios-Macina root configuration with the hydrus path.
This helps link the newly installed Hydrus instance with the main project. This helps link the newly installed Hydrus instance with the main project.
We check for medios.db first, then fall back to config.conf.
""" """
# Scripts is in <root>/scripts, so parent is root.
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
root = script_dir.parent root = script_dir.parent
db_path = root / "medios.db"
config_path = root / "config.conf" config_path = root / "config.conf"
hydrus_abs_path = str(hydrus_path.resolve())
# Try database first
if db_path.exists():
try:
import sqlite3
import json
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# We want to set store.hydrusnetwork.hydrus.gitclone
# First check if hydrusnetwork store exists
cur.execute("SELECT 1 FROM config WHERE category='store' AND subtype='hydrusnetwork'")
if cur.fetchone():
# Update or insert gitclone for the hydrus subtype
# Note: we assume the name is 'hydrus' or 'hn-local' or something.
# Usually it's 'hydrus' if newly created.
cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', 'hydrus', 'gitclone', hydrus_abs_path)
)
else:
# Create the section
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', 'gitclone', hydrus_abs_path)
)
conn.commit()
logging.info("✅ Linked Hydrus installation in medios.db (gitclone=\"%s\")", hydrus_abs_path)
return True
except Exception as e:
logging.error("Failed to update medios.db: %s", e)
# Fallback to config.conf
if not config_path.exists(): if not config_path.exists():
logging.debug("MM config.conf not found at %s; skipping auto-link.", config_path) logging.debug("MM config.conf not found at %s; skipping legacy auto-link.", config_path)
return False return False
try: try:
content = config_path.read_text(encoding="utf-8") content = config_path.read_text(encoding="utf-8")
key = "gitclone" key = "gitclone"
value = str(hydrus_path.resolve()) value = hydrus_abs_path
# Pattern to replace existing gitclone in the hydrusnetwork section # Pattern to replace existing gitclone in the hydrusnetwork section
pattern = rf'^(\s*{re.escape(key)}\s*=\s*)(.*)$' pattern = rf'^(\s*{re.escape(key)}\s*=\s*)(.*)$'
@@ -178,6 +218,9 @@ def update_medios_config(hydrus_path: Path) -> bool:
logging.info("✅ Linked Hydrus installation in Medios-Macina config (gitclone=\"%s\")", value) logging.info("✅ Linked Hydrus installation in Medios-Macina config (gitclone=\"%s\")", value)
return True return True
except Exception as e: except Exception as e:
logging.error("Failed to update config.conf: %s", e)
return False
return False
logging.debug("Failed to update MM config: %s", e) logging.debug("Failed to update MM config: %s", e)
return False return False