This commit is contained in:
nose
2025-12-11 19:04:02 -08:00
parent 6863c6c7ea
commit 16d8a763cd
103 changed files with 4759 additions and 9156 deletions

View File

@@ -10,7 +10,7 @@ import subprocess
import sys import sys
import time import time
from helper.logger import log, debug from SYS.logger import log, debug
import tempfile import tempfile
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -26,7 +26,7 @@ try: # Optional metadata helper for audio files
except ImportError: # pragma: no cover - best effort except ImportError: # pragma: no cover - best effort
mutagen = None # type: ignore mutagen = None # type: ignore
from .utils import ( from SYS.utils import (
decode_cbor, decode_cbor,
jsonify, jsonify,
ensure_directory, ensure_directory,
@@ -34,7 +34,7 @@ from .utils import (
unique_path, unique_path,
unique_preserve_order, unique_preserve_order,
) )
from .http_client import HTTPClient from .HTTP import HTTPClient
class HydrusRequestError(RuntimeError): class HydrusRequestError(RuntimeError):
@@ -1526,7 +1526,7 @@ CHUNK_SIZE = 1024 * 1024 # 1 MiB
def download_hydrus_file(file_url: str, headers: dict[str, str], destination: Path, timeout: float) -> int: def download_hydrus_file(file_url: str, headers: dict[str, str], destination: Path, timeout: float) -> int:
"""Download *file_url* into *destination* returning the byte count with progress bar.""" """Download *file_url* into *destination* returning the byte count with progress bar."""
from .progress import print_progress, print_final_progress from SYS.progress import print_progress, print_final_progress
downloaded = 0 downloaded = 0
start_time = time.time() start_time = time.time()

View File

@@ -9,13 +9,13 @@ from __future__ import annotations
import json import json
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
import time import time
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Set, List, Sequence from typing import Any, Dict, Optional, Set, List, Sequence
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from .http_client import HTTPClient from .HTTP import HTTPClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -23,7 +23,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
import requests import requests
from helper.logger import log, debug from SYS.logger import log, debug
try: try:
from Crypto.Cipher import AES # type: ignore from Crypto.Cipher import AES # type: ignore

View File

@@ -19,7 +19,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set from typing import Optional, Dict, Any, List, Tuple, Set
from .utils import sha256_file from SYS.utils import sha256_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WORKER_LOG_MAX_ENTRIES = 99 WORKER_LOG_MAX_ENTRIES = 99
@@ -143,7 +143,7 @@ def has_sidecar(media_path: Path) -> bool:
"""Check if a media file has a sidecar.""" """Check if a media file has a sidecar."""
return find_sidecar(media_path) is not None return find_sidecar(media_path) is not None
class FolderDB: class API_folder_store:
"""SQLite database for caching local library metadata.""" """SQLite database for caching local library metadata."""
DB_NAME = "medios-macina.db" DB_NAME = "medios-macina.db"
@@ -434,7 +434,7 @@ class FolderDB:
# Populate type column from ext if not already populated # Populate type column from ext if not already populated
if 'type' in existing_columns and 'ext' in existing_columns: if 'type' in existing_columns and 'ext' in existing_columns:
try: try:
from helper.utils_constant import get_type_from_ext from SYS.utils_constant import get_type_from_ext
cursor.execute("SELECT hash, ext FROM metadata WHERE type IS NULL OR type = ''") cursor.execute("SELECT hash, ext FROM metadata WHERE type IS NULL OR type = ''")
rows = cursor.fetchall() rows = cursor.fetchall()
for file_hash, ext in rows: for file_hash, ext in rows:
@@ -581,7 +581,7 @@ class FolderDB:
file_type = metadata.get('type') file_type = metadata.get('type')
ext = metadata.get('ext') ext = metadata.get('ext')
if not file_type and ext: if not file_type and ext:
from helper.utils_constant import get_type_from_ext from SYS.utils_constant import get_type_from_ext
file_type = get_type_from_ext(str(ext)) file_type = get_type_from_ext(str(ext))
cursor.execute(""" cursor.execute("""
@@ -635,7 +635,7 @@ class FolderDB:
file_type = metadata.get('type') file_type = metadata.get('type')
ext = metadata.get('ext') ext = metadata.get('ext')
if not file_type and ext: if not file_type and ext:
from helper.utils_constant import get_type_from_ext from SYS.utils_constant import get_type_from_ext
file_type = get_type_from_ext(str(ext)) file_type = get_type_from_ext(str(ext))
cursor.execute(""" cursor.execute("""
@@ -1535,7 +1535,7 @@ class DatabaseAPI:
def __init__(self, search_dir: Path): def __init__(self, search_dir: Path):
self.search_dir = search_dir self.search_dir = search_dir
self.db = FolderDB(search_dir) self.db = API_folder_store(search_dir)
def __enter__(self): def __enter__(self):
self.db.__enter__() self.db.__enter__()
@@ -1737,7 +1737,7 @@ class LocalLibraryInitializer:
def __init__(self, library_root: Path): def __init__(self, library_root: Path):
"""Initialize the database scanner.""" """Initialize the database scanner."""
self.library_root = Path(library_root) self.library_root = Path(library_root)
self.db = FolderDB(library_root) self.db = API_folder_store(library_root)
self.stats = { self.stats = {
'files_scanned': 0, 'files_new': 0, 'files_existing': 0, 'files_scanned': 0, 'files_new': 0, 'files_existing': 0,
'sidecars_imported': 0, 'sidecars_deleted': 0, 'sidecars_imported': 0, 'sidecars_deleted': 0,
@@ -2021,7 +2021,7 @@ class LocalLibraryInitializer:
logger.error(f"Error cleaning up orphaned sidecars: {e}", exc_info=True) logger.error(f"Error cleaning up orphaned sidecars: {e}", exc_info=True)
def migrate_tags_to_db(library_root: Path, db: FolderDB) -> int: def migrate_tags_to_db(library_root: Path, db: API_folder_store) -> int:
"""Migrate .tags files to the database.""" """Migrate .tags files to the database."""
migrated_count = 0 migrated_count = 0
@@ -2050,7 +2050,7 @@ def migrate_tags_to_db(library_root: Path, db: FolderDB) -> int:
return migrated_count return migrated_count
def migrate_metadata_to_db(library_root: Path, db: FolderDB) -> int: def migrate_metadata_to_db(library_root: Path, db: API_folder_store) -> int:
"""Migrate .metadata files to the database.""" """Migrate .metadata files to the database."""
migrated_count = 0 migrated_count = 0
@@ -2088,13 +2088,13 @@ def _parse_metadata_file(content: str) -> Dict[str, Any]:
return {} return {}
def migrate_all(library_root: Path, db: Optional[FolderDB] = None) -> Dict[str, int]: def migrate_all(library_root: Path, db: Optional[API_folder_store] = None) -> Dict[str, int]:
"""Migrate all sidecar files to database.""" """Migrate all sidecar files to database."""
should_close = db is None should_close = db is None
try: try:
if db is None: if db is None:
db = FolderDB(library_root) db = API_folder_store(library_root)
return { return {
'tags': migrate_tags_to_db(library_root, db), 'tags': migrate_tags_to_db(library_root, db),
@@ -2115,11 +2115,11 @@ class LocalLibrarySearchOptimizer:
def __init__(self, library_root: Path): def __init__(self, library_root: Path):
"""Initialize the search optimizer.""" """Initialize the search optimizer."""
self.library_root = Path(library_root) self.library_root = Path(library_root)
self.db: Optional[FolderDB] = None self.db: Optional[API_folder_store] = None
def __enter__(self): def __enter__(self):
"""Context manager entry.""" """Context manager entry."""
self.db = FolderDB(self.library_root) self.db = API_folder_store(self.library_root)
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):

93
CLI.py
View File

@@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional, Sequence, Set, TextIO, TYPE_CHECKI
import time import time
import threading import threading
from helper.logger import debug from SYS.logger import debug
try: try:
import typer import typer
@@ -48,17 +48,17 @@ except ImportError: # pragma: no cover - optional dependency
try: try:
from helper.worker_manager import WorkerManager from SYS.worker_manager import WorkerManager
except ImportError: # pragma: no cover - optional dependency except ImportError: # pragma: no cover - optional dependency
WorkerManager = None # type: ignore WorkerManager = None # type: ignore
try: try:
from helper.background_notifier import ensure_background_notifier from SYS.background_notifier import ensure_background_notifier
except ImportError: # pragma: no cover - optional dependency except ImportError: # pragma: no cover - optional dependency
ensure_background_notifier = lambda *_, **__: None # type: ignore ensure_background_notifier = lambda *_, **__: None # type: ignore
if TYPE_CHECKING: # pragma: no cover - typing helper if TYPE_CHECKING: # pragma: no cover - typing helper
from helper.worker_manager import WorkerManager as WorkerManagerType from SYS.worker_manager import WorkerManager as WorkerManagerType
else: else:
WorkerManagerType = Any WorkerManagerType = Any
@@ -68,7 +68,7 @@ from typing import Callable
from config import get_local_storage_path, load_config from config import get_local_storage_path, load_config
from helper.cmdlet_catalog import ( from cmdlets.catalog import (
import_cmd_module as _catalog_import_cmd_module, import_cmd_module as _catalog_import_cmd_module,
list_cmdlet_metadata as _catalog_list_cmdlet_metadata, list_cmdlet_metadata as _catalog_list_cmdlet_metadata,
list_cmdlet_names as _catalog_list_cmdlet_names, list_cmdlet_names as _catalog_list_cmdlet_names,
@@ -507,8 +507,8 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
# Support both "storage" and "store" argument names # Support both "storage" and "store" argument names
if normalized_arg in ("storage", "store"): if normalized_arg in ("storage", "store"):
try: try:
from helper.store import FileStorage from Store import Store
storage = FileStorage(_load_cli_config(), suppress_debug=True) storage = Store(_load_cli_config(), suppress_debug=True)
backends = storage.list_backends() backends = storage.list_backends()
if backends: if backends:
return backends return backends
@@ -518,15 +518,15 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
# Dynamic search providers # Dynamic search providers
if normalized_arg == "provider": if normalized_arg == "provider":
try: try:
from helper.provider import list_providers from Provider.registry import list_search_providers
providers = list_providers(_load_cli_config()) providers = list_search_providers(_load_cli_config())
available = [name for name, is_ready in providers.items() if is_ready] available = [name for name, is_ready in providers.items() if is_ready]
provider_choices = sorted(available) if available else sorted(providers.keys()) provider_choices = sorted(available) if available else sorted(providers.keys())
except Exception: except Exception:
provider_choices = [] provider_choices = []
try: try:
from helper.metadata_search import list_metadata_providers from Provider.metadata_provider import list_metadata_providers
meta_providers = list_metadata_providers(_load_cli_config()) meta_providers = list_metadata_providers(_load_cli_config())
meta_available = [n for n, ready in meta_providers.items() if ready] meta_available = [n for n, ready in meta_providers.items() if ready]
meta_choices = sorted(meta_available) if meta_available else sorted(meta_providers.keys()) meta_choices = sorted(meta_available) if meta_available else sorted(meta_providers.keys())
@@ -539,7 +539,7 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
if normalized_arg == "scrape": if normalized_arg == "scrape":
try: try:
from helper.metadata_search import list_metadata_providers from Provider.metadata_provider import list_metadata_providers
meta_providers = list_metadata_providers(_load_cli_config()) meta_providers = list_metadata_providers(_load_cli_config())
if meta_providers: if meta_providers:
return sorted(meta_providers.keys()) return sorted(meta_providers.keys())
@@ -687,7 +687,7 @@ def _create_cmdlet_cli():
# Initialize debug logging if enabled # Initialize debug logging if enabled
if config: if config:
from helper.logger import set_debug from SYS.logger import set_debug
debug_enabled = config.get("debug", False) debug_enabled = config.get("debug", False)
set_debug(debug_enabled) set_debug(debug_enabled)
@@ -772,14 +772,14 @@ def _create_cmdlet_cli():
try: try:
if config: if config:
from helper.logger import set_debug, debug from SYS.logger import set_debug, debug
debug_enabled = config.get("debug", False) debug_enabled = config.get("debug", False)
set_debug(debug_enabled) set_debug(debug_enabled)
if debug_enabled: if debug_enabled:
debug("✓ Debug logging enabled") debug("✓ Debug logging enabled")
try: try:
from helper.hydrus import get_client from API.HydrusNetwork import get_client
# get_client(config) # Pre-acquire and cache session key # get_client(config) # Pre-acquire and cache session key
# debug("✓ Hydrus session key acquired") # debug("✓ Hydrus session key acquired")
except RuntimeError: except RuntimeError:
@@ -859,7 +859,7 @@ def _create_cmdlet_cli():
except Exception as e: except Exception as e:
if config: if config:
from helper.logger import debug # local import to avoid failing when debug disabled from SYS.logger import debug # local import to avoid failing when debug disabled
debug(f"⚠ Could not check service availability: {e}") debug(f"⚠ Could not check service availability: {e}")
except Exception: except Exception:
pass # Silently ignore if config loading fails pass # Silently ignore if config loading fails
@@ -1263,16 +1263,24 @@ def _execute_pipeline(tokens: list):
if table_for_stage: if table_for_stage:
ctx.set_current_stage_table(table_for_stage) ctx.set_current_stage_table(table_for_stage)
# Special check for YouTube search results BEFORE command expansion # Special check for table-specific behavior BEFORE command expansion
# If we are selecting from a YouTube search, we want to force auto-piping to .pipe # If we are selecting from a YouTube or Soulseek search, we want to force auto-piping to .pipe
# instead of trying to expand to a command (which search-file doesn't support well for re-execution) # instead of trying to expand to a command (which search-file doesn't support well for re-execution)
source_cmd = ctx.get_current_stage_table_source_command() source_cmd = ctx.get_current_stage_table_source_command()
source_args = ctx.get_current_stage_table_source_args() source_args = ctx.get_current_stage_table_source_args()
if source_cmd == 'search-file' and source_args and 'youtube' in source_args: # Check table property
current_table = ctx.get_current_stage_table()
table_type = current_table.table if current_table and hasattr(current_table, 'table') else None
# Logic based on table type
if table_type == 'youtube' or table_type == 'soulseek':
# Force fallback to item-based selection so we can auto-pipe # Force fallback to item-based selection so we can auto-pipe
command_expanded = False command_expanded = False
# Skip the command expansion block below # Skip the command expansion block below
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Legacy check for youtube
command_expanded = False
else: else:
# Try command-based expansion first if we have source command info # Try command-based expansion first if we have source command info
command_expanded = False command_expanded = False
@@ -1335,16 +1343,29 @@ def _execute_pipeline(tokens: list):
log_msg = f"Applied @N selection {' | '.join(selection_parts)}" log_msg = f"Applied @N selection {' | '.join(selection_parts)}"
worker_manager.log_step(pipeline_session.worker_id, log_msg) if pipeline_session and worker_manager else None worker_manager.log_step(pipeline_session.worker_id, log_msg) if pipeline_session and worker_manager else None
# Special case for youtube search results in fallback mode: auto-pipe to .pipe # Special case for table-specific auto-piping
# This handles the case where @N is the ONLY stage (e.g. user typed "@1") # This handles the case where @N is the ONLY stage (e.g. user typed "@1")
# In this case, stages is [['@1']], but we are in the fallback block because command_expanded is False # In this case, stages is [['@1']], but we are in the fallback block because command_expanded is False
# We need to check if the source was youtube search
# Check table type
current_table = ctx.get_current_stage_table()
if not current_table:
current_table = ctx.get_last_result_table()
table_type = current_table.table if current_table and hasattr(current_table, 'table') else None
source_cmd = ctx.get_last_result_table_source_command() source_cmd = ctx.get_last_result_table_source_command()
source_args = ctx.get_last_result_table_source_args() source_args = ctx.get_last_result_table_source_args()
if source_cmd == 'search-file' and source_args and 'youtube' in source_args: if not stages:
# Only auto-pipe if no other stages follow (stages is empty because we popped the selection) if table_type == 'youtube':
if not stages: print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe'])
elif table_type == 'soulseek':
print(f"Auto-piping Soulseek selection to download-provider")
stages.append(['download-provider'])
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Legacy check
print(f"Auto-piping YouTube selection to .pipe") print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe']) stages.append(['.pipe'])
@@ -1606,8 +1627,30 @@ def _execute_pipeline(tokens: list):
else: else:
if cmd_name in selectable_commands: if cmd_name in selectable_commands:
table = ResultTable(table_title) table = ResultTable(table_title)
# Detect table type from items
first_table = None
consistent = True
for emitted in pipeline_ctx.emits: for emitted in pipeline_ctx.emits:
table.add_result(emitted) table.add_result(emitted)
# Check for table property
item_table = None
if isinstance(emitted, dict):
item_table = emitted.get('table')
else:
item_table = getattr(emitted, 'table', None)
if item_table:
if first_table is None:
first_table = item_table
elif first_table != item_table:
consistent = False
if consistent and first_table:
table.set_table(first_table)
table.set_source_command(cmd_name, stage_args) table.set_source_command(cmd_name, stage_args)
ctx.set_last_result_table(table, pipeline_ctx.emits) ctx.set_last_result_table(table, pipeline_ctx.emits)
elif cmd_name in display_only_commands: elif cmd_name in display_only_commands:
@@ -1772,7 +1815,7 @@ def _execute_cmdlet(cmd_name: str, args: list):
# Ensure native commands (cmdnats) are loaded # Ensure native commands (cmdnats) are loaded
try: try:
from helper.cmdlet_catalog import ensure_registry_loaded as _ensure_registry_loaded from cmdlets.catalog import ensure_registry_loaded as _ensure_registry_loaded
_ensure_registry_loaded() _ensure_registry_loaded()
except Exception: except Exception:
pass pass
@@ -1781,7 +1824,7 @@ def _execute_cmdlet(cmd_name: str, args: list):
cmd_fn = REGISTRY.get(cmd_name) cmd_fn = REGISTRY.get(cmd_name)
if not cmd_fn: if not cmd_fn:
# Attempt lazy import of the module and retry # Attempt lazy import of the module and retry
from helper.cmdlet_catalog import import_cmd_module as _catalog_import from cmdlets.catalog import import_cmd_module as _catalog_import
try: try:
mod = _catalog_import(cmd_name) mod = _catalog_import(cmd_name)
data = getattr(mod, "CMDLET", None) if mod else None data = getattr(mod, "CMDLET", None) if mod else None

5
MPV/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from MPV.mpv_ipc import MPV
__all__ = [
"MPV",
]

View File

@@ -11,16 +11,17 @@ import json
import os import os
import platform import platform
import socket import socket
import subprocess
import time as _time import time as _time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, List from typing import Any, Dict, Optional, List, BinaryIO, cast
from helper.logger import debug from SYS.logger import debug
# Fixed pipe name for persistent MPV connection across all Python sessions # Fixed pipe name for persistent MPV connection across all Python sessions
FIXED_IPC_PIPE_NAME = "mpv-medeia-macina" FIXED_IPC_PIPE_NAME = "mpv-medeia-macina"
MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent.parent / "LUA" / "main.lua") MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua")
class MPVIPCError(Exception): class MPVIPCError(Exception):
@@ -28,6 +29,172 @@ class MPVIPCError(Exception):
pass pass
class MPV:
"""High-level MPV controller for this app.
Responsibilities:
- Own the IPC pipe/socket path
- Start MPV with the bundled Lua script
- Query playlist and currently playing item via IPC
This class intentionally stays "dumb": it does not implement app logic.
App behavior is driven by cmdlets (e.g. `.pipe`) and the bundled Lua script.
"""
def __init__(
self,
ipc_path: Optional[str] = None,
lua_script_path: Optional[str | Path] = None,
timeout: float = 5.0,
) -> None:
self.timeout = timeout
self.ipc_path = ipc_path or get_ipc_pipe_path()
if lua_script_path is None:
lua_script_path = MPV_LUA_SCRIPT_PATH
lua_path = Path(str(lua_script_path)).resolve()
self.lua_script_path = str(lua_path)
def client(self) -> "MPVIPCClient":
return MPVIPCClient(socket_path=self.ipc_path, timeout=self.timeout)
def is_running(self) -> bool:
client = self.client()
try:
ok = client.connect()
return bool(ok)
finally:
client.disconnect()
def send(self, command: Dict[str, Any] | List[Any], silent: bool = False) -> Optional[Dict[str, Any]]:
client = self.client()
try:
if not client.connect():
return None
return client.send_command(command)
except Exception as exc:
if not silent:
debug(f"MPV IPC error: {exc}")
return None
finally:
client.disconnect()
def get_property(self, name: str, default: Any = None) -> Any:
resp = self.send({"command": ["get_property", name]})
if resp and resp.get("error") == "success":
return resp.get("data", default)
return default
def set_property(self, name: str, value: Any) -> bool:
resp = self.send({"command": ["set_property", name, value]})
return bool(resp and resp.get("error") == "success")
def get_playlist(self, silent: bool = False) -> Optional[List[Dict[str, Any]]]:
resp = self.send({"command": ["get_property", "playlist"], "request_id": 100}, silent=silent)
if resp is None:
return None
if resp.get("error") == "success":
data = resp.get("data", [])
return data if isinstance(data, list) else []
return []
def get_now_playing(self) -> Optional[Dict[str, Any]]:
if not self.is_running():
return None
playlist = self.get_playlist(silent=True) or []
pos = self.get_property("playlist-pos", None)
path = self.get_property("path", None)
title = self.get_property("media-title", None)
effective_path = _unwrap_memory_target(path) if isinstance(path, str) else path
current_item: Optional[Dict[str, Any]] = None
if isinstance(pos, int) and 0 <= pos < len(playlist):
item = playlist[pos]
current_item = item if isinstance(item, dict) else None
else:
for item in playlist:
if isinstance(item, dict) and item.get("current") is True:
current_item = item
break
return {
"path": effective_path,
"title": title,
"playlist_pos": pos,
"playlist_item": current_item,
}
def ensure_lua_loaded(self) -> None:
try:
script_path = self.lua_script_path
if not script_path or not os.path.exists(script_path):
return
# Safe to call repeatedly; mpv will reload the script.
self.send({"command": ["load-script", script_path], "request_id": 12}, silent=True)
except Exception:
return
def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool:
for _ in range(max(1, retries)):
client = self.client()
try:
if client.connect():
return True
finally:
client.disconnect()
_time.sleep(delay_seconds)
return False
def kill_existing_windows(self) -> None:
if platform.system() != "Windows":
return
try:
subprocess.run(
["taskkill", "/IM", "mpv.exe", "/F"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2,
)
except Exception:
return
def start(
self,
*,
extra_args: Optional[List[str]] = None,
ytdl_raw_options: Optional[str] = None,
http_header_fields: Optional[str] = None,
detached: bool = True,
) -> None:
cmd: List[str] = [
"mpv",
f"--input-ipc-server={self.ipc_path}",
"--idle=yes",
"--force-window=yes",
]
# Always load the bundled Lua script at startup.
if self.lua_script_path and os.path.exists(self.lua_script_path):
cmd.append(f"--script={self.lua_script_path}")
if ytdl_raw_options:
cmd.append(f"--ytdl-raw-options={ytdl_raw_options}")
if http_header_fields:
cmd.append(f"--http-header-fields={http_header_fields}")
if extra_args:
cmd.extend([str(a) for a in extra_args if a])
kwargs: Dict[str, Any] = {}
if detached and platform.system() == "Windows":
kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS
debug("Starting MPV")
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
def get_ipc_pipe_path() -> str: def get_ipc_pipe_path() -> str:
"""Get the fixed IPC pipe/socket path for persistent MPV connection. """Get the fixed IPC pipe/socket path for persistent MPV connection.
@@ -59,36 +226,6 @@ def _unwrap_memory_target(text: Optional[str]) -> Optional[str]:
return text return text
def _normalize_target(text: Optional[str]) -> Optional[str]:
"""Normalize playlist targets for deduping across raw/memory:// wrappers."""
if not text:
return None
real = _unwrap_memory_target(text)
if not real:
return None
real = real.strip()
if not real:
return None
lower = real.lower()
# Hydrus bare hash
if len(lower) == 64 and all(ch in "0123456789abcdef" for ch in lower):
return lower
# Hydrus file URL with hash query
try:
parsed = __import__("urllib.parse").parse.urlparse(real)
qs = __import__("urllib.parse").parse.parse_qs(parsed.query)
hash_qs = qs.get("hash", [None])[0]
if hash_qs and len(hash_qs) == 64 and all(ch in "0123456789abcdef" for ch in hash_qs.lower()):
return hash_qs.lower()
except Exception:
pass
# Normalize paths/url for comparison
return lower.replace('\\', '\\')
class MPVIPCClient: class MPVIPCClient:
"""Client for communicating with mpv via IPC socket/pipe. """Client for communicating with mpv via IPC socket/pipe.
@@ -105,7 +242,7 @@ class MPVIPCClient:
""" """
self.timeout = timeout self.timeout = timeout
self.socket_path = socket_path or get_ipc_pipe_path() self.socket_path = socket_path or get_ipc_pipe_path()
self.sock = None self.sock: socket.socket | BinaryIO | None = None
self.is_windows = platform.system() == "Windows" self.is_windows = platform.system() == "Windows"
def connect(self) -> bool: def connect(self) -> bool:
@@ -129,8 +266,13 @@ class MPVIPCClient:
if not os.path.exists(self.socket_path): if not os.path.exists(self.socket_path):
debug(f"IPC socket not found: {self.socket_path}") debug(f"IPC socket not found: {self.socket_path}")
return False return False
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) af_unix = getattr(socket, "AF_UNIX", None)
if af_unix is None:
debug("IPC AF_UNIX is not available on this platform")
return False
self.sock = socket.socket(af_unix, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout) self.sock.settimeout(self.timeout)
self.sock.connect(self.socket_path) self.sock.connect(self.socket_path)
return True return True
@@ -154,6 +296,7 @@ class MPVIPCClient:
try: try:
# Format command as JSON (mpv IPC protocol) # Format command as JSON (mpv IPC protocol)
request: Dict[str, Any]
if isinstance(command_data, list): if isinstance(command_data, list):
request = {"command": command_data} request = {"command": command_data}
else: else:
@@ -166,15 +309,17 @@ class MPVIPCClient:
payload = json.dumps(request) + "\n" payload = json.dumps(request) + "\n"
# Debug: log the command being sent # Debug: log the command being sent
from helper.logger import debug as _debug from SYS.logger import debug as _debug
_debug(f"[IPC] Sending: {payload.strip()}") _debug(f"[IPC] Sending: {payload.strip()}")
# Send command # Send command
if self.is_windows: if self.is_windows:
self.sock.write(payload.encode('utf-8')) pipe = cast(BinaryIO, self.sock)
self.sock.flush() pipe.write(payload.encode("utf-8"))
pipe.flush()
else: else:
self.sock.sendall(payload.encode('utf-8')) sock_obj = cast(socket.socket, self.sock)
sock_obj.sendall(payload.encode("utf-8"))
# Receive response # Receive response
# We need to read lines until we find the one with matching request_id # We need to read lines until we find the one with matching request_id
@@ -184,14 +329,16 @@ class MPVIPCClient:
response_data = b"" response_data = b""
if self.is_windows: if self.is_windows:
try: try:
response_data = self.sock.readline() pipe = cast(BinaryIO, self.sock)
response_data = pipe.readline()
except (OSError, IOError): except (OSError, IOError):
return None return None
else: else:
try: try:
# This is simplistic for Unix socket (might not get full line) # This is simplistic for Unix socket (might not get full line)
# But for now assuming MPV sends line-buffered JSON # But for now assuming MPV sends line-buffered JSON
chunk = self.sock.recv(4096) sock_obj = cast(socket.socket, self.sock)
chunk = sock_obj.recv(4096)
if not chunk: if not chunk:
break break
response_data = chunk response_data = chunk
@@ -209,7 +356,7 @@ class MPVIPCClient:
resp = json.loads(line) resp = json.loads(line)
# Debug: log responses # Debug: log responses
from helper.logger import debug as _debug from SYS.logger import debug as _debug
_debug(f"[IPC] Received: {line}") _debug(f"[IPC] Received: {line}")
# Check if this is the response to our request # Check if this is the response to our request
@@ -258,147 +405,3 @@ class MPVIPCClient:
"""Context manager exit.""" """Context manager exit."""
self.disconnect() self.disconnect()
def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = None,
append: bool = True) -> bool:
"""Send a file to be played in the existing MPV instance via IPC.
This attempts to send to an existing MPV instance. If it fails, the calling
code should start a new MPV instance with the IPC pipe.
Args:
file_url: URL or path to file to play
title: Display title for the file
headers: Optional HTTP headers (dict)
append: If True, append to playlist; if False, replace
Returns:
True if successfully sent to existing MPV, False if pipe unavailable.
"""
# Try to connect using the robust client
client = get_mpv_client()
if not client:
return False
try:
# Command 0: Subscribe to log messages so MPV console errors surface in REPL
_subscribe_log_messages(client)
# Command 1: Ensure our Lua helper is loaded for in-window controls
_ensure_lua_script_loaded(client)
# Command 2: Set headers if provided
if headers:
header_str = ",".join([f"{k}: {v}" for k, v in headers.items()])
cmd_headers = {
"command": ["set_property", "http-header-fields", header_str],
"request_id": 0
}
client.send_command(cmd_headers)
# Deduplicate: if target already exists in playlist, just play it
normalized_new = _normalize_target(file_url)
existing_index = None
existing_title = None
if normalized_new:
playlist_resp = client.send_command({"command": ["get_property", "playlist"], "request_id": 98})
if playlist_resp and playlist_resp.get("error") == "success":
for idx, item in enumerate(playlist_resp.get("data", []) or []):
for key in ("playlist-path", "filename"):
norm_existing = _normalize_target(item.get(key)) if isinstance(item, dict) else None
if norm_existing and norm_existing == normalized_new:
existing_index = idx
existing_title = item.get("title") if isinstance(item, dict) else None
break
if existing_index is not None:
break
if existing_index is not None and append:
play_cmd = {"command": ["playlist-play-index", existing_index], "request_id": 99}
play_resp = client.send_command(play_cmd)
if play_resp and play_resp.get("error") == "success":
client.send_command({"command": ["set_property", "pause", False], "request_id": 100})
safe_title = (title or existing_title or "").replace("\n", " ").replace("\r", " ").strip()
if safe_title:
client.send_command({"command": ["set_property", "force-media-title", safe_title], "request_id": 101})
debug(f"Already in playlist, playing existing entry: {safe_title or file_url}")
return True
# Command 2: Load file and inject title via memory:// wrapper so playlist shows friendly names immediately
target = file_url
load_mode = "append-play" if append else "replace"
safe_title = (title or "").replace("\n", " ").replace("\r", " ").strip()
target_to_send = target
if safe_title and not str(target).startswith("memory://"):
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
target_to_send = f"memory://{m3u_content}"
cmd_load = {
"command": ["loadfile", target_to_send, load_mode],
"request_id": 1
}
resp = client.send_command(cmd_load)
if not resp or resp.get('error') != 'success':
debug(f"MPV loadfile failed: {resp}")
return False
# Command 3: Set title (metadata for display) - still useful for window title
if safe_title:
cmd_title = {
"command": ["set_property", "force-media-title", safe_title],
"request_id": 2
}
client.send_command(cmd_title)
debug(f"Sent to existing MPV: {safe_title or title}")
return True
except Exception as e:
debug(f"Error in send_to_mpv: {e}")
return False
finally:
client.disconnect()
def get_mpv_client(socket_path: Optional[str] = None) -> Optional[MPVIPCClient]:
"""Get an MPV IPC client, attempting to connect.
Args:
socket_path: Custom socket path (uses default if None)
Returns:
Connected MPVIPCClient or None if connection fails.
"""
client = MPVIPCClient(socket_path=socket_path)
if client.connect():
return client
return None
def _subscribe_log_messages(client: MPVIPCClient) -> None:
"""Ask MPV to emit log messages over IPC so we can surface console errors."""
try:
client.send_command({"command": ["request_log_messages", "warn"], "request_id": 11})
except Exception as exc:
debug(f"Failed to subscribe to MPV logs: {exc}")
def _ensure_lua_script_loaded(client: MPVIPCClient) -> None:
"""Load the bundled MPV Lua script to enable in-window controls.
Safe to call repeatedly; mpv will simply reload the script if already present.
"""
try:
script_path = MPV_LUA_SCRIPT_PATH
if not script_path or not os.path.exists(script_path):
return
resp = client.send_command({"command": ["load-script", script_path], "request_id": 12})
if resp and resp.get("error") == "success":
debug(f"Loaded MPV Lua script: {script_path}")
else:
debug(f"MPV Lua load response: {resp}")
except Exception as exc:
debug(f"Failed to load MPV Lua script: {exc}")

5
Provider/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Provider plugin modules.
Concrete provider implementations live in this package.
The public entrypoint/registry is Provider.registry.
"""

84
Provider/_base.py Normal file
View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@dataclass
class SearchResult:
"""Unified search result format across all search providers."""
table: str # Provider name: "libgen", "soulseek", "bandcamp", "youtube", etc.
title: str # Display title/filename
path: str # Download target (URL, path, magnet, identifier)
detail: str = "" # Additional description
annotations: List[str] = field(default_factory=list) # Tags: ["120MB", "flac", "ready"]
media_kind: str = "other" # Type: "book", "audio", "video", "game", "magnet"
size_bytes: Optional[int] = None
tags: set[str] = field(default_factory=set) # Searchable tags
columns: List[Tuple[str, str]] = field(default_factory=list) # Display columns
full_metadata: Dict[str, Any] = field(default_factory=dict) # Extra metadata
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for pipeline processing."""
return {
"table": self.table,
"title": self.title,
"path": self.path,
"detail": self.detail,
"annotations": self.annotations,
"media_kind": self.media_kind,
"size_bytes": self.size_bytes,
"tags": list(self.tags),
"columns": list(self.columns),
"full_metadata": self.full_metadata,
}
class SearchProvider(ABC):
"""Base class for search providers."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {}
self.name = self.__class__.__name__.lower()
@abstractmethod
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
"""Search for items matching the query."""
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
"""Download an item from a search result."""
return None
def validate(self) -> bool:
"""Check if provider is available and properly configured."""
return True
class FileProvider(ABC):
"""Base class for file upload providers."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {}
self.name = self.__class__.__name__.lower()
@abstractmethod
def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload a file and return the URL."""
def validate(self) -> bool:
"""Check if provider is available/configured."""
return True

109
Provider/bandcamp.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import sys
from typing import Any, Dict, List, Optional
from Provider._base import SearchProvider, SearchResult
from SYS.logger import log, debug
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
class Bandcamp(SearchProvider):
"""Search provider for Bandcamp."""
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
if sync_playwright is None:
log(
"[bandcamp] Playwright not available. Install with: pip install playwright",
file=sys.stderr,
)
return []
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
if query.strip().lower().startswith("artist:"):
artist_name = query[7:].strip().strip('"')
search_url = f"https://bandcamp.com/search?q={artist_name}&item_type=b"
else:
search_url = f"https://bandcamp.com/search?q={query}&item_type=a"
results = self._scrape_url(page, search_url, limit)
browser.close()
return results
except Exception as exc:
log(f"[bandcamp] Search error: {exc}", file=sys.stderr)
return []
def _scrape_url(self, page: Any, url: str, limit: int) -> List[SearchResult]:
debug(f"[bandcamp] Scraping: {url}")
page.goto(url)
page.wait_for_load_state("domcontentloaded")
results: List[SearchResult] = []
search_results = page.query_selector_all(".searchresult")
if not search_results:
return results
for item in search_results[:limit]:
try:
heading = item.query_selector(".heading")
if not heading:
continue
link = heading.query_selector("a")
if not link:
continue
title = link.inner_text().strip()
target_url = link.get_attribute("href")
subhead = item.query_selector(".subhead")
artist = subhead.inner_text().strip() if subhead else "Unknown"
itemtype = item.query_selector(".itemtype")
media_type = itemtype.inner_text().strip() if itemtype else "album"
results.append(
SearchResult(
table="bandcamp",
title=title,
path=target_url,
detail=f"By: {artist}",
annotations=[media_type],
media_kind="audio",
columns=[
("Name", title),
("Artist", artist),
("Type", media_type),
],
full_metadata={
"artist": artist,
"type": media_type,
},
)
)
except Exception as exc:
debug(f"[bandcamp] Error parsing result: {exc}")
return results
def validate(self) -> bool:
return sync_playwright is not None

98
Provider/libgen.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import sys
from typing import Any, Dict, List, Optional
from Provider._base import SearchProvider, SearchResult
from SYS.logger import log
class Libgen(SearchProvider):
"""Search provider for Library Genesis books."""
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
filters = filters or {}
try:
from Provider.unified_book_downloader import UnifiedBookDownloader
from Provider.query_parser import parse_query, get_field, get_free_text
parsed = parse_query(query)
isbn = get_field(parsed, "isbn")
author = get_field(parsed, "author")
title = get_field(parsed, "title")
free_text = get_free_text(parsed)
search_query = isbn or title or author or free_text or query
downloader = UnifiedBookDownloader(config=self.config)
books = downloader.search_libgen(search_query, limit=limit)
results: List[SearchResult] = []
for idx, book in enumerate(books, 1):
title = book.get("title", "Unknown")
author = book.get("author", "Unknown")
year = book.get("year", "Unknown")
pages = book.get("pages") or book.get("pages_str") or ""
extension = book.get("extension", "") or book.get("ext", "")
filesize = book.get("filesize_str", "Unknown")
isbn = book.get("isbn", "")
mirror_url = book.get("mirror_url", "")
columns = [
("Title", title),
("Author", author),
("Pages", str(pages)),
("Ext", str(extension)),
]
detail = f"By: {author}"
if year and year != "Unknown":
detail += f" ({year})"
annotations = [f"{filesize}"]
if isbn:
annotations.append(f"ISBN: {isbn}")
results.append(
SearchResult(
table="libgen",
title=title,
path=mirror_url or f"libgen:{book.get('id', '')}",
detail=detail,
annotations=annotations,
media_kind="book",
columns=columns,
full_metadata={
"number": idx,
"author": author,
"year": year,
"isbn": isbn,
"filesize": filesize,
"pages": pages,
"extension": extension,
"book_id": book.get("book_id", ""),
"md5": book.get("md5", ""),
},
)
)
return results
except Exception as exc:
log(f"[libgen] Search error: {exc}", file=sys.stderr)
return []
def validate(self) -> bool:
try:
from Provider.unified_book_downloader import UnifiedBookDownloader # noqa: F401
return True
except Exception:
return False

94
Provider/matrix.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any
import requests
from Provider._base import FileProvider
class Matrix(FileProvider):
"""File provider for Matrix (Element) chat rooms."""
def validate(self) -> bool:
if not self.config:
return False
matrix_conf = self.config.get("storage", {}).get("matrix", {})
return bool(
matrix_conf.get("homeserver")
and matrix_conf.get("room_id")
and (matrix_conf.get("access_token") or matrix_conf.get("password"))
)
def upload(self, file_path: str, **kwargs: Any) -> str:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
matrix_conf = self.config.get("storage", {}).get("matrix", {})
homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token")
room_id = matrix_conf.get("room_id")
if not homeserver:
raise Exception("Matrix homeserver missing")
if not access_token:
raise Exception("Matrix access_token missing")
if not room_id:
raise Exception("Matrix room_id missing")
if not homeserver.startswith("http"):
homeserver = f"https://{homeserver}"
# Upload media
upload_url = f"{homeserver}/_matrix/media/v3/upload"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream",
}
mime_type, _ = mimetypes.guess_type(path)
if mime_type:
headers["Content-Type"] = mime_type
filename = path.name
with open(path, "rb") as handle:
resp = requests.post(upload_url, headers=headers, data=handle, params={"filename": filename})
if resp.status_code != 200:
raise Exception(f"Matrix upload failed: {resp.text}")
content_uri = resp.json().get("content_uri")
if not content_uri:
raise Exception("No content_uri returned")
# Send message
send_url = f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message"
# Determine message type
msgtype = "m.file"
ext = path.suffix.lower()
audio_exts = {".mp3", ".flac", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".wma", ".mka", ".alac"}
video_exts = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".flv", ".mpg", ".mpeg", ".ts", ".m4v", ".wmv"}
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
if ext in audio_exts:
msgtype = "m.audio"
elif ext in video_exts:
msgtype = "m.video"
elif ext in image_exts:
msgtype = "m.image"
info = {"mimetype": mime_type, "size": path.stat().st_size}
payload = {"msgtype": msgtype, "body": filename, "url": content_uri, "info": info}
resp = requests.post(send_url, headers=headers, json=payload)
if resp.status_code != 200:
raise Exception(f"Matrix send message failed: {resp.text}")
event_id = resp.json().get("event_id")
return f"https://matrix.to/#/{room_id}/{event_id}"

View File

@@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional, Type
import requests import requests
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
try: # Optional dependency try: # Optional dependency
import musicbrainzngs # type: ignore import musicbrainzngs # type: ignore

110
Provider/registry.py Normal file
View File

@@ -0,0 +1,110 @@
"""Provider registry.
Concrete provider implementations live in the `Provider/` package.
This module is the single source of truth for provider discovery.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Type
import sys
from SYS.logger import log
from Provider._base import FileProvider, SearchProvider, SearchResult
from Provider.bandcamp import Bandcamp
from Provider.libgen import Libgen
from Provider.matrix import Matrix
from Provider.soulseek import Soulseek, download_soulseek_file
from Provider.youtube import YouTube
from Provider.zeroxzero import ZeroXZero
_SEARCH_PROVIDERS: Dict[str, Type[SearchProvider]] = {
"libgen": Libgen,
"soulseek": Soulseek,
"bandcamp": Bandcamp,
"youtube": YouTube,
}
def get_search_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
"""Get a search provider by name."""
provider_class = _SEARCH_PROVIDERS.get((name or "").lower())
if provider_class is None:
log(f"[provider] Unknown search provider: {name}", file=sys.stderr)
return None
try:
provider = provider_class(config)
if not provider.validate():
log(f"[provider] Provider '{name}' is not available", file=sys.stderr)
return None
return provider
except Exception as exc:
log(f"[provider] Error initializing '{name}': {exc}", file=sys.stderr)
return None
def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
"""List all search providers and their availability."""
availability: Dict[str, bool] = {}
for name, provider_class in _SEARCH_PROVIDERS.items():
try:
provider = provider_class(config)
availability[name] = provider.validate()
except Exception:
availability[name] = False
return availability
_FILE_PROVIDERS: Dict[str, Type[FileProvider]] = {
"0x0": ZeroXZero,
"matrix": Matrix,
}
def get_file_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
"""Get a file provider by name."""
provider_class = _FILE_PROVIDERS.get((name or "").lower())
if provider_class is None:
log(f"[provider] Unknown file provider: {name}", file=sys.stderr)
return None
try:
provider = provider_class(config)
if not provider.validate():
log(f"[provider] File provider '{name}' is not available", file=sys.stderr)
return None
return provider
except Exception as exc:
log(f"[provider] Error initializing file provider '{name}': {exc}", file=sys.stderr)
return None
def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
"""List all file providers and their availability."""
availability: Dict[str, bool] = {}
for name, provider_class in _FILE_PROVIDERS.items():
try:
provider = provider_class(config)
availability[name] = provider.validate()
except Exception:
availability[name] = False
return availability
__all__ = [
"SearchResult",
"SearchProvider",
"FileProvider",
"get_search_provider",
"list_search_providers",
"get_file_provider",
"list_file_providers",
"download_soulseek_file",
]

380
Provider/soulseek.py Normal file
View File

@@ -0,0 +1,380 @@
from __future__ import annotations
import asyncio
import re
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from Provider._base import SearchProvider, SearchResult
from SYS.logger import log, debug
class Soulseek(SearchProvider):
"""Search provider for Soulseek P2P network."""
MUSIC_EXTENSIONS = {
".flac",
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
".alac",
".wma",
".ape",
".aiff",
".dsf",
".dff",
".wv",
".tta",
".tak",
".ac3",
".dts",
}
# NOTE: These defaults preserve existing behavior.
USERNAME = "asjhkjljhkjfdsd334"
PASSWORD = "khhhg"
DOWNLOAD_DIR = "./downloads"
MAX_WAIT_TRANSFER = 1200
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
"""Download file from Soulseek."""
try:
full_metadata = result.full_metadata or {}
username = full_metadata.get("username")
filename = full_metadata.get("filename") or result.path
if not username or not filename:
log(f"[soulseek] Missing metadata for download: {result.title}", file=sys.stderr)
return None
# This cmdlet stack is synchronous; use asyncio.run for clarity.
return asyncio.run(
download_soulseek_file(
username=username,
filename=filename,
output_dir=output_dir,
timeout=self.MAX_WAIT_TRANSFER,
)
)
except RuntimeError:
# If we're already inside an event loop (e.g., TUI), fall back to a
# dedicated loop in this thread.
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(
download_soulseek_file(
username=username,
filename=filename,
output_dir=output_dir,
timeout=self.MAX_WAIT_TRANSFER,
)
)
finally:
try:
loop.close()
except Exception:
pass
except Exception as exc:
log(f"[soulseek] Download error: {exc}", file=sys.stderr)
return None
async def perform_search(self, query: str, timeout: float = 9.0, limit: int = 50) -> List[Dict[str, Any]]:
"""Perform async Soulseek search."""
import os
from aioslsk.client import SoulSeekClient
from aioslsk.settings import CredentialsSettings, Settings
os.makedirs(self.DOWNLOAD_DIR, exist_ok=True)
settings = Settings(credentials=CredentialsSettings(username=self.USERNAME, password=self.PASSWORD))
client = SoulSeekClient(settings)
try:
await client.start()
await client.login()
except Exception as exc:
log(f"[soulseek] Login failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return []
try:
search_request = await client.searches.search(query)
await self._collect_results(search_request, timeout=timeout)
return self._flatten_results(search_request)[:limit]
except Exception as exc:
log(f"[soulseek] Search error: {type(exc).__name__}: {exc}", file=sys.stderr)
return []
finally:
try:
await client.stop()
except Exception:
pass
def _flatten_results(self, search_request: Any) -> List[dict]:
flat: List[dict] = []
for result in getattr(search_request, "results", []):
username = getattr(result, "username", "?")
for file_data in getattr(result, "shared_items", []):
flat.append(
{
"file": file_data,
"username": username,
"filename": getattr(file_data, "filename", "?"),
"size": getattr(file_data, "filesize", 0),
}
)
for file_data in getattr(result, "locked_results", []):
flat.append(
{
"file": file_data,
"username": username,
"filename": getattr(file_data, "filename", "?"),
"size": getattr(file_data, "filesize", 0),
}
)
return flat
async def _collect_results(self, search_request: Any, timeout: float = 75.0) -> None:
end = time.time() + timeout
last_count = 0
while time.time() < end:
current_count = len(getattr(search_request, "results", []))
if current_count > last_count:
debug(f"[soulseek] Got {current_count} result(s)...")
last_count = current_count
await asyncio.sleep(0.5)
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
filters = filters or {}
try:
flat_results = asyncio.run(self.perform_search(query, timeout=9.0, limit=limit))
if not flat_results:
return []
music_results: List[dict] = []
for item in flat_results:
filename = item["filename"]
ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else ""
if ext in self.MUSIC_EXTENSIONS:
music_results.append(item)
if not music_results:
return []
enriched_results: List[dict] = []
for item in music_results:
filename = item["filename"]
ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else ""
display_name = filename.replace("\\", "/").split("/")[-1]
path_parts = filename.replace("\\", "/").split("/")
artist = path_parts[-3] if len(path_parts) >= 3 else ""
album = path_parts[-2] if len(path_parts) >= 3 else (path_parts[-2] if len(path_parts) == 2 else "")
base_name = display_name.rsplit(".", 1)[0] if "." in display_name else display_name
track_num = ""
title = base_name
filename_artist = ""
match = re.match(r"^(\d{1,3})\s*[\.\-]?\s+(.+)$", base_name)
if match:
track_num = match.group(1)
rest = match.group(2)
if " - " in rest:
filename_artist, title = rest.split(" - ", 1)
else:
title = rest
if filename_artist:
artist = filename_artist
enriched_results.append(
{
**item,
"artist": artist,
"album": album,
"title": title,
"track_num": track_num,
"ext": ext,
}
)
if filters:
artist_filter = (filters.get("artist", "") or "").lower()
album_filter = (filters.get("album", "") or "").lower()
track_filter = (filters.get("track", "") or "").lower()
if artist_filter or album_filter or track_filter:
filtered: List[dict] = []
for item in enriched_results:
if artist_filter and artist_filter not in item["artist"].lower():
continue
if album_filter and album_filter not in item["album"].lower():
continue
if track_filter and track_filter not in item["title"].lower():
continue
filtered.append(item)
enriched_results = filtered
enriched_results.sort(key=lambda item: (item["ext"].lower() != ".flac", -item["size"]))
results: List[SearchResult] = []
for item in enriched_results:
artist_display = item["artist"] if item["artist"] else "(no artist)"
album_display = item["album"] if item["album"] else "(no album)"
size_mb = int(item["size"] / 1024 / 1024)
columns = [
("Track", item["track_num"] or "?"),
("Title", item["title"][:40]),
("Artist", artist_display[:32]),
("Album", album_display[:32]),
("Size", f"{size_mb} MB"),
]
results.append(
SearchResult(
table="soulseek",
title=item["title"],
path=item["filename"],
detail=f"{artist_display} - {album_display}",
annotations=[f"{size_mb} MB", item["ext"].lstrip(".").upper()],
media_kind="audio",
size_bytes=item["size"],
columns=columns,
full_metadata={
"username": item["username"],
"filename": item["filename"],
"artist": item["artist"],
"album": item["album"],
"track_num": item["track_num"],
"ext": item["ext"],
},
)
)
return results
except Exception as exc:
log(f"[soulseek] Search error: {exc}", file=sys.stderr)
return []
def validate(self) -> bool:
try:
from aioslsk.client import SoulSeekClient # noqa: F401
return True
except ImportError:
return False
async def download_soulseek_file(
username: str,
filename: str,
output_dir: Path = Path("./downloads"),
timeout: int = 1200,
) -> Optional[Path]:
"""Download a file from a Soulseek peer."""
try:
from aioslsk.client import SoulSeekClient
from aioslsk.settings import CredentialsSettings, Settings
from aioslsk.transfer.model import Transfer, TransferDirection
from aioslsk.transfer.state import TransferState
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
local_filename = filename.replace("\\", "/").split("/")[-1]
output_user_dir = output_dir / username
output_user_dir.mkdir(parents=True, exist_ok=True)
output_path = (output_user_dir / local_filename)
if output_path.exists():
base = output_path.stem
ext = output_path.suffix
counter = 1
while output_path.exists():
output_path = output_user_dir / f"{base}_{counter}{ext}"
counter += 1
output_path = output_path.resolve()
settings = Settings(credentials=CredentialsSettings(username=Soulseek.USERNAME, password=Soulseek.PASSWORD))
client = SoulSeekClient(settings)
try:
await client.start()
await client.login()
debug(f"[soulseek] Logged in as {Soulseek.USERNAME}")
debug(f"[soulseek] Requesting download from {username}: {filename}")
transfer = await client.transfers.add(Transfer(username, filename, TransferDirection.DOWNLOAD))
transfer.local_path = str(output_path)
await client.transfers.queue(transfer)
start_time = time.time()
last_log_time = 0.0
while not transfer.is_finalized():
if time.time() - start_time > timeout:
log(f"[soulseek] Download timeout after {timeout}s", file=sys.stderr)
return None
if time.time() - last_log_time >= 5.0 and transfer.bytes_transfered > 0:
progress = (transfer.bytes_transfered / transfer.filesize * 100) if transfer.filesize else 0
debug(
f"[soulseek] Progress: {progress:.1f}% "
f"({transfer.bytes_transfered}/{transfer.filesize})"
)
last_log_time = time.time()
await asyncio.sleep(1)
if transfer.state.VALUE == TransferState.COMPLETE and transfer.local_path:
downloaded_path = Path(transfer.local_path)
if downloaded_path.exists():
debug(f"[soulseek] Download complete: {downloaded_path}")
return downloaded_path
log(f"[soulseek] Transfer completed but file missing: {downloaded_path}", file=sys.stderr)
return None
log(
f"[soulseek] Download failed: state={transfer.state.VALUE} "
f"bytes={transfer.bytes_transfered}/{transfer.filesize}",
file=sys.stderr,
)
return None
finally:
try:
await client.stop()
except Exception:
pass
except ImportError:
log("[soulseek] aioslsk not installed. Install with: pip install aioslsk", file=sys.stderr)
return None
except Exception as exc:
log(f"[soulseek] Download failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return None

View File

@@ -15,7 +15,7 @@ import requests
from typing import Optional, Dict, Any, Tuple, List, Callable, cast from typing import Optional, Dict, Any, Tuple, List, Callable, cast
from pathlib import Path from pathlib import Path
from helper.logger import debug from SYS.logger import debug
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,7 +40,7 @@ class UnifiedBookDownloader:
def _init_downloaders(self) -> None: def _init_downloaders(self) -> None:
"""Initialize downloader functions from their modules.""" """Initialize downloader functions from their modules."""
try: try:
from helper.archive_client import ( from API.archive_client import (
check_direct_download, check_direct_download,
get_openlibrary_by_isbn, get_openlibrary_by_isbn,
loan loan
@@ -56,7 +56,7 @@ class UnifiedBookDownloader:
self.loan_func = None self.loan_func = None
try: try:
from helper.libgen_service import ( from Provider.libgen_service import (
DEFAULT_LIMIT as _LIBGEN_DEFAULT_LIMIT, DEFAULT_LIMIT as _LIBGEN_DEFAULT_LIMIT,
download_from_mirror as _libgen_download, download_from_mirror as _libgen_download,
search_libgen as _libgen_search, search_libgen as _libgen_search,
@@ -179,7 +179,7 @@ class UnifiedBookDownloader:
def _has_archive_credentials(self) -> bool: def _has_archive_credentials(self) -> bool:
"""Check if Archive.org credentials are available.""" """Check if Archive.org credentials are available."""
try: try:
from helper.archive_client import credential_openlibrary from API.archive_client import credential_openlibrary
email, password = credential_openlibrary(self.config) email, password = credential_openlibrary(self.config)
return bool(email and password) return bool(email and password)
except Exception: except Exception:
@@ -352,7 +352,7 @@ class UnifiedBookDownloader:
img2pdf merges pages into searchable PDF img2pdf merges pages into searchable PDF
""" """
try: try:
from helper.archive_client import credential_openlibrary from API.archive_client import credential_openlibrary
book_id = method.get('book_id', '') book_id = method.get('book_id', '')
@@ -562,7 +562,7 @@ class UnifiedBookDownloader:
Returns tuple of (success: bool, filepath/message: str) Returns tuple of (success: bool, filepath/message: str)
""" """
try: try:
from helper.archive_client import login, loan, get_book_infos, download from API.archive_client import login, loan, get_book_infos, download
import tempfile import tempfile
import shutil import shutil

94
Provider/youtube.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional
from Provider._base import SearchProvider, SearchResult
from SYS.logger import log
class YouTube(SearchProvider):
"""Search provider for YouTube using yt-dlp."""
def search(
self,
query: str,
limit: int = 10,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
ytdlp_path = shutil.which("yt-dlp")
if not ytdlp_path:
log("[youtube] yt-dlp not found in PATH", file=sys.stderr)
return []
search_query = f"ytsearch{limit}:{query}"
cmd = [ytdlp_path, "--dump-json", "--flat-playlist", "--no-warnings", search_query]
try:
process = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if process.returncode != 0:
log(f"[youtube] yt-dlp failed: {process.stderr}", file=sys.stderr)
return []
results: List[SearchResult] = []
for line in process.stdout.splitlines():
if not line.strip():
continue
try:
video_data = json.loads(line)
except json.JSONDecodeError:
continue
title = video_data.get("title", "Unknown")
video_id = video_data.get("id", "")
url = video_data.get("url") or f"https://youtube.com/watch?v={video_id}"
uploader = video_data.get("uploader", "Unknown")
duration = video_data.get("duration", 0)
view_count = video_data.get("view_count", 0)
duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}" if duration else ""
views_str = f"{view_count:,}" if view_count else ""
results.append(
SearchResult(
table="youtube",
title=title,
path=url,
detail=f"By: {uploader}",
annotations=[duration_str, f"{views_str} views"],
media_kind="video",
columns=[
("Title", title),
("Uploader", uploader),
("Duration", duration_str),
("Views", views_str),
],
full_metadata={
"video_id": video_id,
"uploader": uploader,
"duration": duration,
"view_count": view_count,
},
)
)
return results
except Exception as exc:
log(f"[youtube] Error: {exc}", file=sys.stderr)
return []
def validate(self) -> bool:
return shutil.which("yt-dlp") is not None

36
Provider/zeroxzero.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import os
import sys
from typing import Any
from Provider._base import FileProvider
from SYS.logger import log
class ZeroXZero(FileProvider):
"""File provider for 0x0.st."""
def upload(self, file_path: str, **kwargs: Any) -> str:
from API.HTTP import HTTPClient
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
headers = {"User-Agent": "Medeia-Macina/1.0"}
with HTTPClient(headers=headers) as client:
with open(file_path, "rb") as handle:
response = client.post("https://0x0.st", files={"file": handle})
if response.status_code == 200:
return response.text.strip()
raise Exception(f"Upload failed: {response.status_code} - {response.text}")
except Exception as exc:
log(f"[0x0] Upload error: {exc}", file=sys.stderr)
raise
def validate(self) -> bool:
return True

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Any, Callable, Dict, Optional, Set from typing import Any, Callable, Dict, Optional, Set
from helper.logger import log, debug from SYS.logger import log, debug
class BackgroundNotifier: class BackgroundNotifier:

View File

@@ -24,9 +24,9 @@ from urllib.parse import urljoin, urlparse
import httpx import httpx
from helper.logger import log, debug from SYS.logger import log, debug
from .utils import ensure_directory, sha256_file from SYS.utils import ensure_directory, sha256_file
from .http_client import HTTPClient from API.HTTP import HTTPClient
from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar
try: try:

View File

@@ -3,25 +3,25 @@
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
def format_progress_bar(current: int, total: int, width: int = 40, label: str = "") -> str: def format_progress_bar(current: int, total: int, width: int = 40, label: str = "") -> str:
"""Create a text-based progress bar. """Create a text-based progress bar.
Args: Args:
current: Current progress (bytes/items) current: Current progress (bytes/items)
total: Total to complete (bytes/items) total: Total to complete (bytes/items)
width: Width of the bar in characters (default 40) width: Width of the bar in characters (default 40)
label: Optional label prefix label: Optional label prefix
Returns: Returns:
Formatted progress bar string Formatted progress bar string
Examples: Examples:
format_progress_bar(50, 100) format_progress_bar(50, 100)
# Returns: "[████████████████░░░░░░░░░░░░░░░░░░░░] 50.0%" # Returns: "[████████████████░░░░░░░░░░░░░░░░░░░░] 50.0%"
format_progress_bar(256*1024*1024, 1024*1024*1024, label="download.zip") format_progress_bar(256*1024*1024, 1024*1024*1024, label="download.zip")
# Returns: "download.zip: [████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 25.0%" # Returns: "download.zip: [████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 25.0%"
""" """
@@ -31,30 +31,20 @@ def format_progress_bar(current: int, total: int, width: int = 40, label: str =
else: else:
percentage = (current / total) * 100 percentage = (current / total) * 100
filled = int((current / total) * width) filled = int((current / total) * width)
# Create bar: filled blocks + empty blocks
bar = "" * filled + "" * (width - filled) bar = "" * filled + "" * (width - filled)
# Format percentage
pct_str = f"{percentage:.1f}%" pct_str = f"{percentage:.1f}%"
# Build result
if label: if label:
result = f"{label}: [{bar}] {pct_str}" result = f"{label}: [{bar}] {pct_str}"
else: else:
result = f"[{bar}] {pct_str}" result = f"[{bar}] {pct_str}"
return result return result
def format_size(bytes_val: float) -> str: def format_size(bytes_val: float) -> str:
"""Format bytes to human-readable size. """Format bytes to human-readable size."""
Examples:
format_size(1024) -> "1.00 KB"
format_size(1024*1024) -> "1.00 MB"
format_size(1024*1024*1024) -> "1.00 GB"
"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']: for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_val < 1024: if bytes_val < 1024:
return f"{bytes_val:.2f} {unit}" return f"{bytes_val:.2f} {unit}"
@@ -63,59 +53,30 @@ def format_size(bytes_val: float) -> str:
def format_download_status(filename: str, current: int, total: int, speed: float = 0) -> str: def format_download_status(filename: str, current: int, total: int, speed: float = 0) -> str:
"""Format download status with progress bar and details. """Format download status with progress bar and details."""
Args:
filename: Name of file being downloaded
current: Current bytes downloaded
total: Total file size
speed: Download speed in bytes/sec
Returns:
Formatted status line
Examples:
format_download_status("movie.mkv", 512*1024*1024, 2*1024*1024*1024, 10*1024*1024)
# Returns: "movie.mkv: [████████████░░░░░░░░░░░░░░░░░░░░░░░░░░] 25.0% (512.00 MB / 2.00 GB @ 10.00 MB/s)"
"""
bar = format_progress_bar(current, total, width=30) bar = format_progress_bar(current, total, width=30)
size_current = format_size(current) size_current = format_size(current)
size_total = format_size(total) size_total = format_size(total)
if speed > 0: if speed > 0:
speed_str = f" @ {format_size(speed)}/s" speed_str = f" @ {format_size(speed)}/s"
else: else:
speed_str = "" speed_str = ""
return f"{bar} ({size_current} / {size_total}{speed_str})" return f"{bar} ({size_current} / {size_total}{speed_str})"
def print_progress(filename: str, current: int, total: int, speed: float = 0, end: str = "\r") -> None: def print_progress(filename: str, current: int, total: int, speed: float = 0, end: str = "\r") -> None:
"""Print download progress to stderr (doesn't interfere with piped output). """Print download progress to stderr (doesn't interfere with piped output)."""
Args:
filename: File being downloaded
current: Current bytes
total: Total bytes
speed: Speed in bytes/sec
end: Line ending (default "\r" for overwriting, use "\n" for final)
"""
status = format_download_status(filename, current, total, speed) status = format_download_status(filename, current, total, speed)
debug(status, end=end, flush=True) debug(status, end=end, flush=True)
def print_final_progress(filename: str, total: int, elapsed: float) -> None: def print_final_progress(filename: str, total: int, elapsed: float) -> None:
"""Print final progress line (100%) with time elapsed. """Print final progress line (100%) with time elapsed."""
Args:
filename: File that was downloaded
total: Total size
elapsed: Time elapsed in seconds
"""
bar = format_progress_bar(total, total, width=30) bar = format_progress_bar(total, total, width=30)
size_str = format_size(total) size_str = format_size(total)
# Format elapsed time
if elapsed < 60: if elapsed < 60:
time_str = f"{elapsed:.1f}s" time_str = f"{elapsed:.1f}s"
elif elapsed < 3600: elif elapsed < 3600:
@@ -124,20 +85,18 @@ def print_final_progress(filename: str, total: int, elapsed: float) -> None:
else: else:
hours = elapsed / 3600 hours = elapsed / 3600
time_str = f"{hours:.2f}h" time_str = f"{hours:.2f}h"
debug(f"{bar} ({size_str}) - {time_str}") debug(f"{bar} ({size_str}) - {time_str}")
if __name__ == "__main__": if __name__ == "__main__":
# Demo
import time import time
log("Progress Bar Demo:", file=sys.stderr) log("Progress Bar Demo:", file=sys.stderr)
# Demo 1: Simple progress
for i in range(101): for i in range(101):
print_progress("demo.bin", i * 10 * 1024 * 1024, 1024 * 1024 * 1024) print_progress("demo.bin", i * 10 * 1024 * 1024, 1024 * 1024 * 1024)
time.sleep(0.02) time.sleep(0.02)
print_final_progress("demo.bin", 1024 * 1024 * 1024, 2.0) print_final_progress("demo.bin", 1024 * 1024 * 1024, 2.0)
log() log()

View File

@@ -7,7 +7,7 @@ import socket
import subprocess import subprocess
import sys import sys
from helper.logger import log from SYS.logger import log
import threading import threading
import time import time
from typing import IO, Iterable from typing import IO, Iterable

View File

@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
from fnmatch import fnmatch from fnmatch import fnmatch
from urllib.parse import urlparse from urllib.parse import urlparse
import helper.utils_constant import SYS.utils_constant
try: try:
import cbor2 import cbor2
@@ -90,7 +90,7 @@ def create_metadata_sidecar(file_path: Path, metadata: dict) -> None:
metadata['hash'] = sha256_file(file_path) metadata['hash'] = sha256_file(file_path)
metadata['size'] = Path(file_path).stat().st_size metadata['size'] = Path(file_path).stat().st_size
format_found = False format_found = False
for mime_type, ext_map in helper.utils_constant.mime_maps.items(): for mime_type, ext_map in SYS.utils_constant.mime_maps.items():
for key, info in ext_map.items(): for key, info in ext_map.items():
if info.get("ext") == file_ext: if info.get("ext") == file_ext:
metadata['type'] = mime_type metadata['type'] = mime_type

View File

@@ -81,22 +81,20 @@ mime_maps = {
def get_type_from_ext(ext: str) -> str: def get_type_from_ext(ext: str) -> str:
"""Determine the type (e.g., 'image', 'video', 'audio') from file extension. """Determine the type (e.g., 'image', 'video', 'audio') from file extension.
Args: Args:
ext: File extension (with or without leading dot, e.g., 'jpg' or '.jpg') ext: File extension (with or without leading dot, e.g., 'jpg' or '.jpg')
Returns: Returns:
Type string (e.g., 'image', 'video', 'audio') or 'other' if unknown Type string (e.g., 'image', 'video', 'audio') or 'other' if unknown
""" """
if not ext: if not ext:
return 'other' return 'other'
# Normalize: remove leading dot and convert to lowercase
ext_clean = ext.lstrip('.').lower() ext_clean = ext.lstrip('.').lower()
# Search through mime_maps to find matching type
for type_name, extensions_dict in mime_maps.items(): for type_name, extensions_dict in mime_maps.items():
if ext_clean in extensions_dict: if ext_clean in extensions_dict:
return type_name return type_name
return 'other' return 'other'

View File

@@ -11,8 +11,8 @@ from datetime import datetime
from threading import Thread, Lock from threading import Thread, Lock
import time import time
from .folder_store import FolderDB from ..API.folder import API_folder_store
from helper.logger import log from SYS.logger import log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,7 +140,7 @@ class Worker:
class WorkerLoggingHandler(logging.StreamHandler): class WorkerLoggingHandler(logging.StreamHandler):
"""Custom logging handler that captures logs for a worker.""" """Custom logging handler that captures logs for a worker."""
def __init__(self, worker_id: str, db: FolderDB, def __init__(self, worker_id: str, db: API_folder_store,
manager: Optional['WorkerManager'] = None, manager: Optional['WorkerManager'] = None,
buffer_size: int = 50): buffer_size: int = 50):
"""Initialize the handler. """Initialize the handler.
@@ -235,7 +235,7 @@ class WorkerManager:
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.library_root = Path(library_root)
self.db = FolderDB(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

977
Store/Folder.py Normal file
View File

@@ -0,0 +1,977 @@
from __future__ import annotations
import json
import re
import shutil
import sys
from fnmatch import translate
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from SYS.logger import debug, log
from SYS.utils import sha256_file
from Store._base import StoreBackend
def _normalize_hash(value: Any) -> Optional[str]:
candidate = str(value or '').strip().lower()
if len(candidate) != 64:
return None
if any(ch not in '0123456789abcdef' for ch in candidate):
return None
return candidate
def _resolve_file_hash(db_hash: Optional[str], file_path: Path) -> Optional[str]:
normalized = _normalize_hash(db_hash) if db_hash else None
if normalized:
return normalized
return _normalize_hash(file_path.stem)
class Folder(StoreBackend):
""""""
# Track which locations have already been migrated to avoid repeated migrations
_migrated_locations = set()
def __init__(self, location: Optional[str] = None, name: Optional[str] = None) -> None:
self._location = location
self._name = name
if self._location:
try:
from API.folder import API_folder_store
from pathlib import Path
location_path = Path(self._location).expanduser()
# Use context manager to ensure connection is properly closed
with API_folder_store(location_path) as db:
if db.connection:
db.connection.commit()
# Call migration and discovery at startup
Folder.migrate_location(self._location)
except Exception as exc:
debug(f"Failed to initialize database for '{name}': {exc}")
@classmethod
def migrate_location(cls, location: Optional[str]) -> None:
"""Migrate a location to hash-based storage (one-time operation, call explicitly at startup)."""
if not location:
return
from pathlib import Path
location_path = Path(location).expanduser()
location_str = str(location_path)
# Only migrate once per location
if location_str in cls._migrated_locations:
return
cls._migrated_locations.add(location_str)
# Create a temporary instance just to call the migration
temp_instance = cls(location=location)
temp_instance._migrate_to_hash_storage(location_path)
def _migrate_to_hash_storage(self, location_path: Path) -> None:
"""Migrate existing files from filename-based to hash-based storage.
Checks for sidecars (.metadata, .tag) and imports them before renaming.
Also ensures all files have a title: tag.
"""
from API.folder import API_folder_store, read_sidecar, write_sidecar, find_sidecar
try:
with API_folder_store(location_path) as db:
cursor = db.connection.cursor()
# First pass: migrate filename-based files and add title tags
# Scan all files in the storage directory
for file_path in sorted(location_path.iterdir()):
if not file_path.is_file():
continue
# Skip database files and sidecars
if file_path.suffix in ('.db', '.metadata', '.tag', '-shm', '-wal'):
continue
# Also skip if the file ends with -shm or -wal (SQLite journal files)
if file_path.name.endswith(('-shm', '-wal')):
continue
# Check if filename is already a hash (without extension)
if len(file_path.stem) == 64 and all(c in '0123456789abcdef' for c in file_path.stem.lower()):
continue # Already migrated, will process in second pass
try:
# Compute file hash
file_hash = sha256_file(file_path)
# Preserve extension in the hash-based filename
file_ext = file_path.suffix # e.g., '.mp4'
hash_filename = file_hash + file_ext if file_ext else file_hash
hash_path = location_path / hash_filename
# Check for sidecars and import them
sidecar_path = find_sidecar(file_path)
tags_to_add = []
url_to_add = []
has_title_tag = False
if sidecar_path and sidecar_path.exists():
try:
_, tags, url = read_sidecar(sidecar_path)
if tags:
tags_to_add = list(tags)
# Check if title tag exists
has_title_tag = any(t.lower().startswith('title:') for t in tags_to_add)
if url:
url_to_add = list(url)
debug(f"Found sidecar for {file_path.name}: {len(tags_to_add)} tags, {len(url_to_add)} url", file=sys.stderr)
# Delete the sidecar after importing
sidecar_path.unlink()
except Exception as exc:
debug(f"Failed to read sidecar for {file_path.name}: {exc}", file=sys.stderr)
# Ensure there's a title tag (use original filename if not present)
if not has_title_tag:
tags_to_add.append(f"title:{file_path.name}")
# Rename file to hash if needed
if hash_path != file_path and not hash_path.exists():
debug(f"Migrating: {file_path.name} -> {hash_filename}", file=sys.stderr)
file_path.rename(hash_path)
# Create or update database entry
db.get_or_create_file_entry(hash_path)
# Save extension metadata
ext_clean = file_ext.lstrip('.') if file_ext else ''
db.save_metadata(hash_path, {
'hash': file_hash,
'ext': ext_clean,
'size': hash_path.stat().st_size
})
# Add all tags (including title tag)
if tags_to_add:
db.save_tags(hash_path, tags_to_add)
debug(f"Added {len(tags_to_add)} tags to {file_hash}", file=sys.stderr)
# Note: url would need a separate table if you want to store them
# For now, we're just noting them in debug
if url_to_add:
debug(f"Imported {len(url_to_add)} url for {file_hash}: {url_to_add}", file=sys.stderr)
except Exception as exc:
debug(f"Failed to migrate file {file_path.name}: {exc}", file=sys.stderr)
# Second pass: ensure all files in database have a title: tag
db.connection.commit()
cursor.execute('''
SELECT f.hash, f.file_path
FROM files f
WHERE NOT EXISTS (
SELECT 1 FROM tags t WHERE t.hash = f.hash AND LOWER(t.tag) LIKE 'title:%'
)
''')
files_without_title = cursor.fetchall()
for file_hash, file_path_str in files_without_title:
try:
file_path = Path(file_path_str)
if file_path.exists():
# Use the filename as the title
title_tag = f"title:{file_path.name}"
db.save_tags(file_path, [title_tag])
debug(f"Added title tag to {file_path.name}", file=sys.stderr)
except Exception as exc:
debug(f"Failed to add title tag to file {file_path_str}: {exc}", file=sys.stderr)
db.connection.commit()
# Third pass: discover files on disk that aren't in the database yet
# These are hash-named files that were added after initial indexing
cursor.execute('SELECT LOWER(hash) FROM files')
db_hashes = {row[0] for row in cursor.fetchall()}
discovered = 0
for file_path in sorted(location_path.rglob("*")):
if file_path.is_file():
# Check if file name (without extension) is a 64-char hex hash
name_without_ext = file_path.stem
if len(name_without_ext) == 64 and all(c in '0123456789abcdef' for c in name_without_ext.lower()):
file_hash = name_without_ext.lower()
# Skip if already in DB
if file_hash in db_hashes:
continue
try:
# Add file to DB (creates entry and auto-adds title: tag)
db.get_or_create_file_entry(file_path)
# Save extension metadata
file_ext = file_path.suffix
ext_clean = file_ext.lstrip('.') if file_ext else ''
db.save_metadata(file_path, {
'hash': file_hash,
'ext': ext_clean,
'size': file_path.stat().st_size
})
discovered += 1
except Exception as e:
debug(f"Failed to discover file {file_path.name}: {e}", file=sys.stderr)
if discovered > 0:
debug(f"Discovered and indexed {discovered} undiscovered files in {location_path.name}", file=sys.stderr)
db.connection.commit()
except Exception as exc:
debug(f"Migration to hash storage failed: {exc}", file=sys.stderr)
def location(self) -> str:
return self._location
def name(self) -> str:
return self._name
def add_file(self, file_path: Path, **kwargs: Any) -> str:
"""Add file to local folder storage with full metadata support.
Args:
file_path: Path to the file to add
move: If True, move file instead of copy (default: False)
tags: Optional list of tags to add
url: Optional list of url to associate with the file
title: Optional title (will be added as 'title:value' tag)
Returns:
File hash (SHA256 hex string) as identifier
"""
move_file = bool(kwargs.get("move"))
tags = kwargs.get("tags", [])
url = kwargs.get("url", [])
title = kwargs.get("title")
# Extract title from tags if not explicitly provided
if not title:
for tag in tags:
if isinstance(tag, str) and tag.lower().startswith("title:"):
title = tag.split(":", 1)[1].strip()
break
# Fallback to filename if no title
if not title:
title = file_path.name
# Ensure title is in tags
title_tag = f"title:{title}"
if not any(str(tag).lower().startswith("title:") for tag in tags):
tags = [title_tag] + list(tags)
try:
file_hash = sha256_file(file_path)
debug(f"File hash: {file_hash}", file=sys.stderr)
# Preserve extension in the stored filename
file_ext = file_path.suffix # e.g., '.mp4'
save_filename = file_hash + file_ext if file_ext else file_hash
save_file = Path(self._location) / save_filename
# Check if file already exists
from API.folder import API_folder_store
with API_folder_store(Path(self._location)) as db:
existing_path = db.search_hash(file_hash)
if existing_path and existing_path.exists():
log(
f"✓ File already in local storage: {existing_path}",
file=sys.stderr,
)
# Still add tags and url if provided
if tags:
self.add_tag(file_hash, tags)
if url:
self.add_url(file_hash, url)
return file_hash
# Move or copy file
if move_file:
shutil.move(str(file_path), str(save_file))
debug(f"Local move: {save_file}", file=sys.stderr)
else:
shutil.copy2(str(file_path), str(save_file))
debug(f"Local copy: {save_file}", file=sys.stderr)
# Save to database
with API_folder_store(Path(self._location)) as db:
db.get_or_create_file_entry(save_file)
# Save metadata including extension
ext_clean = file_ext.lstrip('.') if file_ext else ''
db.save_metadata(save_file, {
'hash': file_hash,
'ext': ext_clean,
'size': file_path.stat().st_size
})
# Add tags if provided
if tags:
self.add_tag(file_hash, tags)
# Add url if provided
if url:
self.add_url(file_hash, url)
log(f"✓ Added to local storage: {save_file.name}", file=sys.stderr)
return file_hash
except Exception as exc:
log(f"❌ Local storage failed: {exc}", file=sys.stderr)
raise
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Search local database for files by title tag or filename."""
from fnmatch import fnmatch
from API.folder import DatabaseAPI
limit = kwargs.get("limit")
try:
limit = int(limit) if limit is not None else None
except (TypeError, ValueError):
limit = None
if isinstance(limit, int) and limit <= 0:
limit = None
query = query.lower()
query_lower = query # Ensure query_lower is defined for all code paths
match_all = query == "*"
results = []
search_dir = Path(self._location).expanduser()
tokens = [t.strip() for t in query.split(',') if t.strip()]
if not match_all and len(tokens) == 1 and _normalize_hash(query):
debug("Hash queries require 'hash:' prefix for local search")
return results
if not match_all and _normalize_hash(query):
debug("Hash queries require 'hash:' prefix for local search")
return results
def _create_entry(file_path: Path, tags: list[str], size_bytes: int | None, db_hash: Optional[str]) -> dict[str, Any]:
path_str = str(file_path)
# Get title from tags if available, otherwise use hash as fallback
title = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None)
if not title:
# Fallback to hash if no title tag exists
hash_value = _resolve_file_hash(db_hash, file_path)
title = hash_value if hash_value else file_path.stem
# Extract extension from file path
ext = file_path.suffix.lstrip('.')
if not ext:
# Fallback: try to extract from title (original filename might be in title)
title_path = Path(title)
ext = title_path.suffix.lstrip('.')
# Build clean entry with only necessary fields
hash_value = _resolve_file_hash(db_hash, file_path)
entry = {
"title": title,
"ext": ext,
"path": path_str,
"target": path_str,
"store": self._name,
"size": size_bytes,
"hash": hash_value,
"tag": tags,
}
return entry
try:
if not search_dir.exists():
debug(f"Search directory does not exist: {search_dir}")
return results
try:
with DatabaseAPI(search_dir) as api:
if tokens and len(tokens) > 1:
def _like_pattern(term: str) -> str:
return term.replace('*', '%').replace('?', '_')
def _ids_for_token(token: str) -> set[int]:
token = token.strip()
if not token:
return set()
if ':' in token and not token.startswith(':'):
namespace, pattern = token.split(':', 1)
namespace = namespace.strip().lower()
pattern = pattern.strip().lower()
if namespace == 'hash':
normalized_hash = _normalize_hash(pattern)
if not normalized_hash:
return set()
h = api.get_file_hash_by_hash(normalized_hash)
return {h} if h else set()
if namespace == 'store':
if pattern not in {'local', 'file', 'filesystem'}:
return set()
return api.get_all_file_hashes()
query_pattern = f"{namespace}:%"
tag_rows = api.get_file_hashes_by_tag_pattern(query_pattern)
matched: set[str] = set()
for file_hash, tag_val in tag_rows:
if not tag_val:
continue
tag_lower = str(tag_val).lower()
if not tag_lower.startswith(f"{namespace}:"):
continue
value = tag_lower[len(namespace)+1:]
if fnmatch(value, pattern):
matched.add(file_hash)
return matched
term = token.lower()
like_pattern = f"%{_like_pattern(term)}%"
hashes = api.get_file_hashes_by_path_pattern(like_pattern)
hashes.update(api.get_file_hashes_by_tag_substring(like_pattern))
return hashes
try:
matching_hashes: set[str] | None = None
for token in tokens:
hashes = _ids_for_token(token)
matching_hashes = hashes if matching_hashes is None else matching_hashes & hashes
if not matching_hashes:
return results
if not matching_hashes:
return results
rows = api.get_file_metadata(matching_hashes, limit)
for file_hash, file_path_str, size_bytes, ext in rows:
if not file_path_str:
continue
file_path = Path(file_path_str)
if not file_path.exists():
continue
if size_bytes is None:
try:
size_bytes = file_path.stat().st_size
except OSError:
size_bytes = None
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
return results
except Exception as exc:
log(f"⚠️ AND search failed: {exc}", file=sys.stderr)
debug(f"AND search exception details: {exc}")
return []
if ":" in query and not query.startswith(":"):
namespace, pattern = query.split(":", 1)
namespace = namespace.strip().lower()
pattern = pattern.strip().lower()
debug(f"Performing namespace search: {namespace}:{pattern}")
if namespace == "hash":
normalized_hash = _normalize_hash(pattern)
if not normalized_hash:
return results
h = api.get_file_hash_by_hash(normalized_hash)
hashes = {h} if h else set()
rows = api.get_file_metadata(hashes, limit)
for file_hash, file_path_str, size_bytes, ext in rows:
if not file_path_str:
continue
file_path = Path(file_path_str)
if not file_path.exists():
continue
if size_bytes is None:
try:
size_bytes = file_path.stat().st_size
except OSError:
size_bytes = None
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
return results
query_pattern = f"{namespace}:%"
rows = api.get_files_by_namespace_pattern(query_pattern, limit)
debug(f"Found {len(rows)} potential matches in DB")
for file_hash, file_path_str, size_bytes, ext in rows:
if not file_path_str:
continue
tags = api.get_tags_by_namespace_and_file(file_hash, query_pattern)
for tag in tags:
tag_lower = tag.lower()
if tag_lower.startswith(f"{namespace}:"):
value = tag_lower[len(namespace)+1:]
if fnmatch(value, pattern):
file_path = Path(file_path_str)
if file_path.exists():
if size_bytes is None:
size_bytes = file_path.stat().st_size
all_tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, all_tags, size_bytes, file_hash)
results.append(entry)
else:
debug(f"File missing on disk: {file_path}")
break
if limit is not None and len(results) >= limit:
return results
elif not match_all:
terms = [t.strip() for t in query_lower.replace(',', ' ').split() if t.strip()]
if not terms:
terms = [query_lower]
debug(f"Performing filename/tag search for terms: {terms}")
fetch_limit = (limit or 45) * 50
conditions = ["LOWER(f.file_path) LIKE ?" for _ in terms]
params = [f"%{t}%" for t in terms]
rows = api.get_files_by_multiple_path_conditions(conditions, params, fetch_limit)
debug(f"Found {len(rows)} filename matches in DB (before whole-word filter)")
word_regex = None
if len(terms) == 1:
term = terms[0]
has_wildcard = '*' in term or '?' in term
if has_wildcard:
try:
from fnmatch import translate
word_regex = re.compile(translate(term), re.IGNORECASE)
except Exception:
word_regex = None
else:
try:
pattern = r'(?<![a-zA-Z0-9])' + re.escape(term) + r'(?![a-zA-Z0-9])'
word_regex = re.compile(pattern, re.IGNORECASE)
except Exception:
word_regex = None
seen_files = set()
for file_id, file_path_str, size_bytes, file_hash in rows:
if not file_path_str or file_path_str in seen_files:
continue
if word_regex:
p = Path(file_path_str)
if not word_regex.search(p.name):
continue
seen_files.add(file_path_str)
file_path = Path(file_path_str)
if file_path.exists():
if size_bytes is None:
size_bytes = file_path.stat().st_size
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
if terms:
title_hits: dict[str, dict[str, Any]] = {}
for term in terms:
title_pattern = f"title:%{term}%"
title_rows = api.get_files_by_title_tag_pattern(title_pattern, fetch_limit)
for file_hash, file_path_str, size_bytes, ext in title_rows:
if not file_path_str:
continue
entry = title_hits.get(file_hash)
if entry:
entry["count"] += 1
if size_bytes is not None:
entry["size"] = size_bytes
else:
title_hits[file_hash] = {
"path": file_path_str,
"size": size_bytes,
"hash": file_hash,
"count": 1,
}
if title_hits:
required = len(terms)
for file_hash, info in title_hits.items():
if info.get("count") != required:
continue
file_path_str = info.get("path")
if not file_path_str or file_path_str in seen_files:
continue
file_path = Path(file_path_str)
if not file_path.exists():
continue
seen_files.add(file_path_str)
size_bytes = info.get("size")
if size_bytes is None:
try:
size_bytes = file_path.stat().st_size
except OSError:
size_bytes = None
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, info.get("hash"))
results.append(entry)
if limit is not None and len(results) >= limit:
return results
query_pattern = f"%{query_lower}%"
tag_rows = api.get_files_by_simple_tag_pattern(query_pattern, limit)
for file_hash, file_path_str, size_bytes, ext in tag_rows:
if not file_path_str or file_path_str in seen_files:
continue
seen_files.add(file_path_str)
file_path = Path(file_path_str)
if file_path.exists():
if size_bytes is None:
size_bytes = file_path.stat().st_size
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
else:
rows = api.get_all_files(limit)
for file_hash, file_path_str, size_bytes, ext in rows:
if file_path_str:
file_path = Path(file_path_str)
if file_path.exists():
if size_bytes is None:
size_bytes = file_path.stat().st_size
tags = api.get_tags_for_file(file_hash)
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if results:
debug(f"Returning {len(results)} results from DB")
else:
debug("No results found in DB")
return results
except Exception as e:
log(f"⚠️ Database search failed: {e}", file=sys.stderr)
debug(f"DB search exception details: {e}")
return []
except Exception as exc:
log(f"❌ Local search failed: {exc}", file=sys.stderr)
raise
def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Alias for search_file to match the interface expected by FileStorage."""
return self.search_store(query, **kwargs)
def _resolve_library_root(self, file_path: Path, config: Dict[str, Any]) -> Optional[Path]:
"""Return the library root containing medios-macina.db.
Prefer the store's configured location, then config override, then walk parents
of the file path to find a directory with medios-macina.db."""
candidates: list[Path] = []
if self._location:
candidates.append(Path(self._location).expanduser())
cfg_root = get_local_storage_path(config) if config else None
if cfg_root:
candidates.append(Path(cfg_root).expanduser())
for root in candidates:
db_path = root / "medios-macina.db"
if db_path.exists():
return root
try:
for parent in [file_path] + list(file_path.parents):
db_path = parent / "medios-macina.db"
if db_path.exists():
return parent
except Exception:
pass
return None
def get_file(self, file_hash: str, **kwargs: Any) -> Optional[Path]:
"""Retrieve file by hash, returning path to the file.
Args:
file_hash: SHA256 hash of the file (64-char hex string)
Returns:
Path to the file or None if not found
"""
try:
# Normalize the hash
normalized_hash = _normalize_hash(file_hash)
if not normalized_hash:
return None
search_dir = Path(self._location).expanduser()
from API.folder import API_folder_store
with API_folder_store(search_dir) as db:
# Search for file by hash
file_path = db.search_hash(normalized_hash)
if file_path and file_path.exists():
return file_path
return None
except Exception as exc:
debug(f"Failed to get file for hash {file_hash}: {exc}")
return None
def get_metadata(self, file_hash: str) -> Optional[Dict[str, Any]]:
"""Get metadata for a file from the database by hash.
Args:
file_hash: SHA256 hash of the file (64-char hex string)
Returns:
Dict with metadata fields (ext, size, hash, duration, etc.) or None if not found
"""
try:
# Normalize the hash
normalized_hash = _normalize_hash(file_hash)
if not normalized_hash:
return None
search_dir = Path(self._location).expanduser()
from API.folder import DatabaseAPI
with DatabaseAPI(search_dir) as api:
# Get file hash
file_hash_result = api.get_file_hash_by_hash(normalized_hash)
if not file_hash_result:
return None
# Query metadata directly from database
cursor = api.get_cursor()
cursor.execute("""
SELECT * FROM metadata WHERE hash = ?
""", (file_hash_result,))
row = cursor.fetchone()
if not row:
return None
metadata = dict(row)
# Canonicalize metadata keys (no legacy aliases)
if "file_path" in metadata and "path" not in metadata:
metadata["path"] = metadata.get("file_path")
metadata.pop("file_path", None)
# Parse JSON fields
for field in ['url', 'relationships']:
if metadata.get(field):
try:
metadata[field] = json.loads(metadata[field])
except (json.JSONDecodeError, TypeError):
metadata[field] = [] if field == 'url' else []
return metadata
except Exception as exc:
debug(f"Failed to get metadata for hash {file_hash}: {exc}")
return None
def get_tag(self, file_identifier: str, **kwargs: Any) -> Tuple[List[str], str]:
"""Get tags for a local file by hash.
Returns:
Tuple of (tags_list, store_name) where store_name is the actual store name
"""
from API.folder import API_folder_store
try:
file_hash = file_identifier
if self._location:
try:
with API_folder_store(Path(self._location)) as db:
db_tags = db.get_tags(file_hash)
if db_tags:
# Return actual store name instead of generic "local_db"
store_name = self._name if self._name else "local"
return list(db_tags), store_name
except Exception as exc:
debug(f"Local DB lookup failed: {exc}")
return [], "unknown"
except Exception as exc:
debug(f"get_tags failed for local file: {exc}")
return [], "unknown"
def add_tag(self, hash: str, tag: List[str], **kwargs: Any) -> bool:
"""Add tags to a local file by hash (via API_folder_store).
Handles namespace collapsing: when adding namespace:value, removes existing namespace:* tags.
Returns True if tags were successfully added.
"""
from API.folder import API_folder_store
try:
if not self._location:
return False
try:
with API_folder_store(Path(self._location)) as db:
# Get existing tags
existing_tags = list(db.get_tags(hash) or [])
original_tags_lower = {t.lower() for t in existing_tags}
# Merge new tags, handling namespace overwrites
for new_tag in tag:
if ':' in new_tag:
namespace = new_tag.split(':', 1)[0]
# Remove existing tags in same namespace
existing_tags = [t for t in existing_tags if not t.startswith(namespace + ':')]
# Add new tag if not already present (case-insensitive check)
if new_tag.lower() not in original_tags_lower:
existing_tags.append(new_tag)
# Save merged tags
db.add_tags_to_hash(hash, existing_tags)
return True
except Exception as exc:
debug(f"Local DB add_tags failed: {exc}")
return False
except Exception as exc:
debug(f"add_tag failed for local file: {exc}")
return False
def delete_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
"""Remove tags from a local file by hash."""
from API.folder import API_folder_store
try:
file_hash = file_identifier
if self._location:
try:
with API_folder_store(Path(self._location)) as db:
db.remove_tags_from_hash(file_hash, list(tags))
return True
except Exception as exc:
debug(f"Local DB remove_tags failed: {exc}")
return False
except Exception as exc:
debug(f"delete_tag failed for local file: {exc}")
return False
def get_url(self, file_identifier: str, **kwargs: Any) -> List[str]:
"""Get known url for a local file by hash."""
from API.folder import API_folder_store
try:
file_hash = file_identifier
if self._location:
try:
with API_folder_store(Path(self._location)) as db:
meta = db.get_metadata(file_hash) or {}
return list(meta.get("url") or [])
except Exception as exc:
debug(f"Local DB get_metadata failed: {exc}")
return []
except Exception as exc:
debug(f"get_url failed for local file: {exc}")
return []
def add_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
"""Add known url to a local file by hash."""
from API.folder import API_folder_store
try:
file_hash = file_identifier
if self._location:
try:
with API_folder_store(Path(self._location)) as db:
meta = db.get_metadata(file_hash) or {}
existing_urls = list(meta.get("url") or [])
changed = False
for u in list(url or []):
if not u:
continue
if u not in existing_urls:
existing_urls.append(u)
changed = True
if changed:
db.update_metadata_by_hash(file_hash, {"url": existing_urls})
return True
except Exception as exc:
debug(f"Local DB add_url failed: {exc}")
return False
except Exception as exc:
debug(f"add_url failed for local file: {exc}")
return False
def delete_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
"""Delete known url from a local file by hash."""
from API.folder import API_folder_store
try:
file_hash = file_identifier
if self._location:
try:
with API_folder_store(Path(self._location)) as db:
meta = db.get_metadata(file_hash) or {}
existing_urls = list(meta.get("url") or [])
remove_set = {u for u in (url or []) if u}
if not remove_set:
return False
new_urls = [u for u in existing_urls if u not in remove_set]
if new_urls != existing_urls:
db.update_metadata_by_hash(file_hash, {"url": new_urls})
return True
except Exception as exc:
debug(f"Local DB delete_url failed: {exc}")
return False
except Exception as exc:
debug(f"delete_url failed for local file: {exc}")
return False
def delete_file(self, file_identifier: str, **kwargs: Any) -> bool:
"""Delete a file from the folder store.
Args:
file_identifier: The file path (as string) or hash of the file to delete
**kwargs: Optional parameters
Returns:
True if deletion succeeded, False otherwise
"""
from API.folder import API_folder_store
try:
file_path = Path(file_identifier)
# Delete from database
with API_folder_store(Path(self._location)) as db:
db.delete_file(file_path)
# Delete the actual file from disk
if file_path.exists():
file_path.unlink()
debug(f"Deleted file: {file_path}")
return True
else:
debug(f"File not found on disk: {file_path}")
return True # Already gone
except Exception as exc:
debug(f"delete_file failed: {exc}")
return False

597
Store/HydrusNetwork.py Normal file
View File

@@ -0,0 +1,597 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from SYS.logger import debug, log
from SYS.utils_constant import mime_maps
from Store._base import StoreBackend
class HydrusNetwork(StoreBackend):
"""File storage backend for Hydrus client.
Each instance represents a specific Hydrus client connection.
Maintains its own HydrusClient with session key.
"""
def __init__(self, instance_name: str, api_key: str, url: str) -> None:
"""Initialize Hydrus storage backend.
Args:
instance_name: Name of this Hydrus instance (e.g., 'home', 'work')
api_key: Hydrus Client API access key
url: Hydrus client URL (e.g., 'http://192.168.1.230:45869')
"""
from API.HydrusNetwork import HydrusClient
self._instance_name = instance_name
self._api_key = api_key
self._url = url
# Create persistent client with session key for this instance
self._client = HydrusClient(url=url, access_key=api_key)
def name(self) -> str:
return self._instance_name
def get_name(self) -> str:
return self._instance_name
def add_file(self, file_path: Path, **kwargs: Any) -> str:
"""Upload file to Hydrus with full metadata support.
Args:
file_path: Path to the file to upload
tags: Optional list of tags to add
url: Optional list of url to associate with the file
title: Optional title (will be added as 'title:value' tag)
Returns:
File hash from Hydrus
Raises:
Exception: If upload fails
"""
from SYS.utils import sha256_file
tags = kwargs.get("tags", [])
url = kwargs.get("url", [])
title = kwargs.get("title")
# Add title to tags if provided and not already present
if title:
title_tag = f"title:{title}"
if not any(str(tag).lower().startswith("title:") for tag in tags):
tags = [title_tag] + list(tags)
try:
# Compute file hash
file_hash = sha256_file(file_path)
debug(f"File hash: {file_hash}")
# Use persistent client with session key
client = self._client
if client is None:
raise Exception("Hydrus client unavailable")
# Check if file already exists in Hydrus
file_exists = False
try:
metadata = client.fetch_file_metadata(hashes=[file_hash])
if metadata and isinstance(metadata, dict):
files = metadata.get("file_metadata", [])
if files:
file_exists = True
log(
f" Duplicate detected - file already in Hydrus with hash: {file_hash}",
file=sys.stderr,
)
except Exception:
pass
# Upload file if not already present
if not file_exists:
log(f"Uploading to Hydrus: {file_path.name}", file=sys.stderr)
response = client.add_file(file_path)
# Extract hash from response
hydrus_hash: Optional[str] = None
if isinstance(response, dict):
hydrus_hash = response.get("hash") or response.get("file_hash")
if not hydrus_hash:
hashes = response.get("hashes")
if isinstance(hashes, list) and hashes:
hydrus_hash = hashes[0]
if not hydrus_hash:
raise Exception(f"Hydrus response missing file hash: {response}")
file_hash = hydrus_hash
log(f"Hydrus: {file_hash}", file=sys.stderr)
# Add tags if provided (both for new and existing files)
if tags:
try:
# Use default tag service
service_name = "my tags"
except Exception:
service_name = "my tags"
try:
debug(f"Adding {len(tags)} tag(s) to Hydrus: {tags}")
client.add_tags(file_hash, tags, service_name)
log(f"Tags added via '{service_name}'", file=sys.stderr)
except Exception as exc:
log(f"⚠️ Failed to add tags: {exc}", file=sys.stderr)
# Associate url if provided (both for new and existing files)
if url:
log(f"Associating {len(url)} URL(s) with file", file=sys.stderr)
for url in url:
if url:
try:
client.associate_url(file_hash, str(url))
debug(f"Associated URL: {url}")
except Exception as exc:
log(f"⚠️ Failed to associate URL {url}: {exc}", file=sys.stderr)
return file_hash
except Exception as exc:
log(f"❌ Hydrus upload failed: {exc}", file=sys.stderr)
raise
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Search Hydrus database for files matching query.
Args:
query: Search query (tags, filenames, hashes, etc.)
limit: Maximum number of results to return (default: 100)
Returns:
List of dicts with 'name', 'hash', 'size', 'tags' fields
Example:
results = storage["hydrus"].search("artist:john_doe music")
results = storage["hydrus"].search("Simple Man")
"""
limit = kwargs.get("limit", 100)
try:
client = self._client
if client is None:
raise Exception("Hydrus client unavailable")
debug(f"Searching Hydrus for: {query}")
# Parse the query into tags
# Handle both simple tags and complex queries
# "*" means "match all" - use system:everything tag in Hydrus
if query.strip() == "*":
# Use system:everything to match all files in Hydrus
tags = ["system:everything"]
else:
query_lower = query.lower().strip()
# If query doesn't have a namespace (no ':'), search all files and filter by title/tags
# If query has explicit namespace, use it as a tag search
if ':' not in query_lower:
# No namespace provided: search all files, then filter by title/tags containing the query
tags = ["system:everything"]
else:
# User provided explicit namespace (e.g., "creator:john" or "system:has_audio")
# Use it as a tag search
tags = [query_lower]
if not tags:
debug(f"Found 0 result(s)")
return []
# Search files with the tags
search_result = client.search_files(
tags=tags,
return_hashes=True,
return_file_ids=True
)
# Extract file IDs from search result
file_ids = search_result.get("file_ids", [])
hashes = search_result.get("hashes", [])
if not file_ids and not hashes:
debug(f"Found 0 result(s)")
return []
# Fetch metadata for the found files
results = []
query_lower = query.lower().strip()
# Split by comma or space for AND logic
search_terms = set(query_lower.replace(',', ' ').split()) # For substring matching
if file_ids:
metadata = client.fetch_file_metadata(file_ids=file_ids)
metadata_list = metadata.get("metadata", [])
for meta in metadata_list:
if len(results) >= limit:
break
file_id = meta.get("file_id")
hash_hex = meta.get("hash")
size = meta.get("size", 0)
# Get tags for this file and extract title
tags_set = meta.get("tags", {})
all_tags = []
title = f"Hydrus File {file_id}" # Default fallback
all_tags_str = "" # For substring matching
# debug(f"[HydrusBackend.search] Processing file_id={file_id}, tags type={type(tags_set)}")
if isinstance(tags_set, dict):
# Collect both storage_tags and display_tags to capture siblings/parents and ensure title: is seen
def _collect(tag_list: Any) -> None:
nonlocal title, all_tags_str
if not isinstance(tag_list, list):
return
for tag in tag_list:
tag_text = str(tag) if tag else ""
if not tag_text:
continue
all_tags.append(tag_text)
all_tags_str += " " + tag_text.lower()
if tag_text.lower().startswith("title:") and title == f"Hydrus File {file_id}":
title = tag_text.split(":", 1)[1].strip()
for _service_name, service_tags in tags_set.items():
if not isinstance(service_tags, dict):
continue
storage_tags = service_tags.get("storage_tags", {})
if isinstance(storage_tags, dict):
for tag_list in storage_tags.values():
_collect(tag_list)
display_tags = service_tags.get("display_tags", [])
_collect(display_tags)
# Also consider top-level flattened tags payload if provided (Hydrus API sometimes includes it)
top_level_tags = meta.get("tags_flat", []) or meta.get("tags", [])
_collect(top_level_tags)
# Resolve extension from MIME type
mime_type = meta.get("mime")
ext = ""
if mime_type:
for category in mime_maps.values():
for _ext_key, info in category.items():
if mime_type in info.get("mimes", []):
ext = info.get("ext", "").lstrip('.')
break
if ext:
break
# Filter results based on query type
# If user provided explicit namespace (has ':'), don't do substring filtering
# Just include what the tag search returned
has_namespace = ':' in query_lower
if has_namespace:
# Explicit namespace search - already filtered by Hydrus tag search
# Include this result as-is
file_url = f"{self._url.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append({
"hash": hash_hex,
"url": file_url,
"name": title,
"title": title,
"size": size,
"size_bytes": size,
"store": self._instance_name,
"tags": all_tags,
"file_id": file_id,
"mime": mime_type,
"ext": ext,
})
else:
# Free-form search: check if search terms match the title or tags
# Match if ALL search terms are found in title or tags (AND logic)
# AND use whole word matching
# Combine title and tags for searching
searchable_text = (title + " " + all_tags_str).lower()
match = True
if query_lower != "*":
for term in search_terms:
# Regex for whole word: \bterm\b
# Escape term to handle special chars
pattern = r'\b' + re.escape(term) + r'\b'
if not re.search(pattern, searchable_text):
match = False
break
if match:
file_url = f"{self._url.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append({
"hash": hash_hex,
"url": file_url,
"name": title,
"title": title,
"size": size,
"size_bytes": size,
"store": self._instance_name,
"tags": all_tags,
"file_id": file_id,
"mime": mime_type,
"ext": ext,
})
debug(f"Found {len(results)} result(s)")
return results[:limit]
except Exception as exc:
log(f"❌ Hydrus search failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
raise
def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None:
"""Open file in browser via Hydrus client API URL."""
import webbrowser
debug(f"[HydrusNetwork.get_file] Starting for hash: {file_hash[:12]}...")
# Build browser URL with access key
base_url = self._client.url.rstrip('/')
access_key = self._client.access_key
browser_url = f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}"
debug(f"[HydrusNetwork.get_file] Opening URL: {browser_url}")
# Open in default browser
webbrowser.open(browser_url)
debug(f"[HydrusNetwork.get_file] Browser opened successfully")
# Return the URL string instead of downloading
debug(f"[HydrusNetwork.get_file] Returning URL: {browser_url}")
return browser_url
def get_metadata(self, file_hash: str, **kwargs: Any) -> Optional[Dict[str, Any]]:
"""Get metadata for a file from Hydrus by hash.
Args:
file_hash: SHA256 hash of the file (64-char hex string)
Returns:
Dict with metadata fields or None if not found
"""
try:
client = self._client
if not client:
debug("get_metadata: Hydrus client unavailable")
return None
# Fetch file metadata
payload = client.fetch_file_metadata(hashes=[file_hash], include_service_keys_to_tags=True)
if not payload or not payload.get("metadata"):
return None
meta = payload["metadata"][0]
# Extract title from tags
title = f"Hydrus_{file_hash[:12]}"
tags_payload = meta.get("tags", {})
if isinstance(tags_payload, dict):
for service_data in tags_payload.values():
if isinstance(service_data, dict):
display_tags = service_data.get("display_tags", {})
if isinstance(display_tags, dict):
current_tags = display_tags.get("0", [])
if isinstance(current_tags, list):
for tag in current_tags:
if str(tag).lower().startswith("title:"):
title = tag.split(":", 1)[1].strip()
break
if title != f"Hydrus_{file_hash[:12]}":
break
# Determine extension from mime type
mime_type = meta.get("mime", "")
ext = ""
if mime_type:
from SYS.utils_constant import mime_maps
for _category, extensions in mime_maps.items():
for extension, mime in extensions.items():
if mime == mime_type:
ext = extension.lstrip(".")
break
if ext:
break
return {
"hash": file_hash,
"title": title,
"ext": ext,
"size": meta.get("size", 0),
"mime": mime_type,
}
except Exception as exc:
debug(f"Failed to get metadata from Hydrus: {exc}")
return None
def get_tag(self, file_identifier: str, **kwargs: Any) -> Tuple[List[str], str]:
"""Get tags for a file from Hydrus by hash.
Args:
file_identifier: File hash (SHA256 hex string)
**kwargs: Optional service_name parameter
Returns:
Tuple of (tags_list, source_description)
where source is always "hydrus"
"""
try:
from API import HydrusNetwork as hydrus_wrapper
file_hash = str(file_identifier)
# Get Hydrus client and service info
client = self._client
if not client:
debug("get_tags: Hydrus client unavailable")
return [], "unknown"
# Fetch file metadata
payload = client.fetch_file_metadata(
hashes=[file_hash],
include_service_keys_to_tags=True,
include_file_url=False
)
items = payload.get("metadata") if isinstance(payload, dict) else None
if not isinstance(items, list) or not items:
debug(f"get_tags: No metadata returned for hash {file_hash}")
return [], "unknown"
meta = items[0] if isinstance(items[0], dict) else None
if not isinstance(meta, dict) or meta.get("file_id") is None:
debug(f"get_tags: Invalid metadata for hash {file_hash}")
return [], "unknown"
# Extract tags using service name
service_name = "my tags"
service_key = hydrus_wrapper.get_tag_service_key(client, service_name)
# Extract tags from metadata
tags = self._extract_tags_from_hydrus_meta(meta, service_key, service_name)
return tags, "hydrus"
except Exception as exc:
debug(f"get_tags failed for Hydrus file: {exc}")
return [], "unknown"
def add_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
"""Add tags to a Hydrus file.
"""
try:
client = self._client
if client is None:
debug("add_tag: Hydrus client unavailable")
return False
service_name = kwargs.get("service_name") or "my tags"
# Ensure tags is a list
tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)]
if not tag_list:
return False
client.add_tags(file_identifier, tag_list, service_name)
return True
except Exception as exc:
debug(f"Hydrus add_tag failed: {exc}")
return False
def delete_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
"""Delete tags from a Hydrus file.
"""
try:
client = self._client
if client is None:
debug("delete_tag: Hydrus client unavailable")
return False
service_name = kwargs.get("service_name") or "my tags"
tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)]
if not tag_list:
return False
client.delete_tags(file_identifier, tag_list, service_name)
return True
except Exception as exc:
debug(f"Hydrus delete_tag failed: {exc}")
return False
def get_url(self, file_identifier: str, **kwargs: Any) -> List[str]:
"""Get known url for a Hydrus file.
"""
try:
client = self._client
if client is None:
debug("get_url: Hydrus client unavailable")
return []
payload = client.fetch_file_metadata(hashes=[str(file_identifier)], include_file_url=True)
items = payload.get("metadata") if isinstance(payload, dict) else None
if not isinstance(items, list) or not items:
return []
meta = items[0]
url = meta.get("url") or []
return list(url)
except Exception as exc:
debug(f"Hydrus get_url failed: {exc}")
return []
def add_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
"""Associate one or more url with a Hydrus file.
"""
try:
client = self._client
if client is None:
debug("add_url: Hydrus client unavailable")
return False
for u in url:
client.associate_url(file_identifier, u)
return True
except Exception as exc:
debug(f"Hydrus add_url failed: {exc}")
return False
def delete_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
"""Delete one or more url from a Hydrus file.
"""
try:
client = self._client
if client is None:
debug("delete_url: Hydrus client unavailable")
return False
for u in url:
client.delete_url(file_identifier, u)
return True
except Exception as exc:
debug(f"Hydrus delete_url failed: {exc}")
return False
@staticmethod
def _extract_tags_from_hydrus_meta(
meta: Dict[str, Any],
service_key: Optional[str],
service_name: str
) -> List[str]:
"""Extract current tags from Hydrus metadata dict.
Prefers display_tags (includes siblings/parents, excludes deleted).
Falls back to storage_tags status '0' (current).
"""
tags_payload = meta.get("tags")
if not isinstance(tags_payload, dict):
return []
svc_data = None
if service_key:
svc_data = tags_payload.get(service_key)
if not isinstance(svc_data, dict):
return []
# Prefer display_tags (Hydrus computes siblings/parents)
display = svc_data.get("display_tags")
if isinstance(display, list) and display:
return [str(t) for t in display if isinstance(t, (str, bytes)) and str(t).strip()]
# Fallback to storage_tags status '0' (current)
storage = svc_data.get("storage_tags")
if isinstance(storage, dict):
current_list = storage.get("0") or storage.get(0)
if isinstance(current_list, list):
return [str(t) for t in current_list if isinstance(t, (str, bytes)) and str(t).strip()]
return []

7
Store/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from Store._base import StoreBackend
from Store.registry import Store
__all__ = [
"StoreBackend",
"Store",
]

55
Store/_base.py Normal file
View File

@@ -0,0 +1,55 @@
"""Store backend base types.
Concrete store implementations live in the `Store/` package.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
class StoreBackend(ABC):
@abstractmethod
def add_file(self, file_path: Path, **kwargs: Any) -> str:
raise NotImplementedError
@abstractmethod
def name(self) -> str:
raise NotImplementedError
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
raise NotImplementedError(f"{self.name()} backend does not support searching")
@abstractmethod
def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None:
raise NotImplementedError
@abstractmethod
def get_metadata(self, file_hash: str, **kwargs: Any) -> Optional[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def get_tag(self, file_identifier: str, **kwargs: Any) -> Tuple[List[str], str]:
raise NotImplementedError
@abstractmethod
def add_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
raise NotImplementedError
@abstractmethod
def delete_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool:
raise NotImplementedError
@abstractmethod
def get_url(self, file_identifier: str, **kwargs: Any) -> List[str]:
raise NotImplementedError
@abstractmethod
def add_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
raise NotImplementedError
@abstractmethod
def delete_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool:
raise NotImplementedError

99
Store/registry.py Normal file
View File

@@ -0,0 +1,99 @@
"""Store registry.
Concrete store implementations live in the `Store/` package.
This module is the single source of truth for store discovery.
Config schema (canonical):
{
"store": {
"folder": {
"default": {"path": "C:/Media"},
"test": {"path": "C:/Temp"}
},
"hydrusnetwork": {
"home": {"Hydrus-Client-API-Access-Key": "...", "url": "http://..."}
}
}
}
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
from SYS.logger import debug
from Store._base import StoreBackend
from Store.Folder import Folder
from Store.HydrusNetwork import HydrusNetwork
class Store:
def __init__(self, config: Optional[Dict[str, Any]] = None, suppress_debug: bool = False) -> None:
self._config = config or {}
self._suppress_debug = suppress_debug
self._backends: Dict[str, StoreBackend] = {}
self._load_backends()
def _load_backends(self) -> None:
store_cfg = self._config.get("store")
if not isinstance(store_cfg, dict):
store_cfg = {}
folder_cfg = store_cfg.get("folder")
if isinstance(folder_cfg, dict):
for name, value in folder_cfg.items():
path_val: Optional[str]
if isinstance(value, dict):
path_val = value.get("path")
elif isinstance(value, (str, bytes)):
path_val = str(value)
else:
path_val = None
if not path_val:
continue
location = str(Path(str(path_val)).expanduser())
self._backends[str(name)] = Folder(location=location, name=str(name))
hydrus_cfg = store_cfg.get("hydrusnetwork")
if isinstance(hydrus_cfg, dict):
for instance_name, instance_config in hydrus_cfg.items():
if not isinstance(instance_config, dict):
continue
api_key = instance_config.get("Hydrus-Client-API-Access-Key")
url = instance_config.get("url")
if not api_key or not url:
continue
try:
self._backends[str(instance_name)] = HydrusNetwork(
instance_name=str(instance_name),
api_key=str(api_key),
url=str(url),
)
except Exception as exc:
if not self._suppress_debug:
debug(f"[Store] Failed to register Hydrus instance '{instance_name}': {exc}")
def list_backends(self) -> list[str]:
return sorted(self._backends.keys())
def list_searchable_backends(self) -> list[str]:
searchable: list[str] = []
for name, backend in self._backends.items():
if type(backend).search_store is not StoreBackend.search_store:
searchable.append(name)
return sorted(searchable)
def __getitem__(self, backend_name: str) -> StoreBackend:
if backend_name not in self._backends:
raise KeyError(f"Unknown store backend: {backend_name}. Available: {list(self._backends.keys())}")
return self._backends[backend_name]
def is_available(self, backend_name: str) -> bool:
return backend_name in self._backends

View File

@@ -18,7 +18,7 @@ from typing import Optional, Callable, Any
from pathlib import Path from pathlib import Path
import sys import sys
from helper.logger import log from SYS.logger import log
import json import json
# Add parent directory to path for imports # Add parent directory to path for imports
@@ -327,7 +327,7 @@ class DownloadModal(ModalScreen):
else: else:
# Fallback if helper not available # Fallback if helper not available
import uuid import uuid
from helper.worker_manager import Worker from SYS.worker_manager import Worker
worker_id = f"dl_{uuid.uuid4().hex[:8]}" worker_id = f"dl_{uuid.uuid4().hex[:8]}"
worker = Worker(worker_id, "download", f"Download: {url[:50]}", worker = Worker(worker_id, "download", f"Download: {url[:50]}",
f"Tags: {', '.join(tags) if tags else 'None'}", None) f"Tags: {', '.join(tags) if tags else 'None'}", None)
@@ -688,7 +688,6 @@ class DownloadModal(ModalScreen):
'target': str(filepath), 'target': str(filepath),
'path': str(filepath), 'path': str(filepath),
'media_kind': 'audio', 'media_kind': 'audio',
'hash_hex': None,
'hash': None, 'hash': None,
'url': [], 'url': [],
'title': filepath_obj.stem 'title': filepath_obj.stem

View File

@@ -15,7 +15,7 @@ from datetime import datetime
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from helper.utils import format_metadata_value from SYS.utils import format_metadata_value
from config import load_config from config import load_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,7 +69,7 @@ class ExportModal(ModalScreen):
""" """
ext_lower = ext.lower() if ext else '' ext_lower = ext.lower() if ext else ''
from helper.utils_constant import mime_maps from SYS.utils_constant import mime_maps
found_type = "unknown" found_type = "unknown"
@@ -404,9 +404,9 @@ class ExportModal(ModalScreen):
metadata = self.result_data.get('metadata', {}) metadata = self.result_data.get('metadata', {})
# Extract file source info from result_data (passed by hub-ui) # Extract file source info from result_data (passed by hub-ui)
file_hash = self.result_data.get('hash') or self.result_data.get('file_hash') file_hash = self.result_data.get('hash')
file_url = self.result_data.get('url') or self.result_data.get('file_url') file_url = self.result_data.get('url')
file_path = self.result_data.get('path') or self.result_data.get('file_path') # For local files file_path = self.result_data.get('path')
source = self.result_data.get('source', 'unknown') source = self.result_data.get('source', 'unknown')
# Prepare export data # Prepare export data
@@ -420,11 +420,8 @@ class ExportModal(ModalScreen):
'metadata': metadata, 'metadata': metadata,
'original_data': self.result_data, 'original_data': self.result_data,
'hash': file_hash, 'hash': file_hash,
'file_hash': file_hash,
'url': file_url, 'url': file_url,
'file_url': file_url,
'path': file_path, 'path': file_path,
'file_path': file_path, # Pass file path for local files
'source': source, 'source': source,
} }

View File

@@ -16,7 +16,7 @@ import asyncio
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from config import load_config from config import load_config
from result_table import ResultTable from result_table import ResultTable
from helper.provider import get_provider from Provider.registry import get_search_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,7 +158,7 @@ class SearchModal(ModalScreen):
self.current_worker.log_step(f"Connecting to {source}...") self.current_worker.log_step(f"Connecting to {source}...")
try: try:
provider = get_provider(source) provider = get_search_provider(source)
if not provider: if not provider:
logger.error(f"[search-modal] Provider not available: {source}") logger.error(f"[search-modal] Provider not available: {source}")
if self.current_worker: if self.current_worker:
@@ -282,7 +282,7 @@ class SearchModal(ModalScreen):
# Handle both SearchResult objects and dicts # Handle both SearchResult objects and dicts
if hasattr(result, 'full_metadata'): if hasattr(result, 'full_metadata'):
metadata = result.full_metadata or {} metadata = result.full_metadata or {}
source = result.origin source = result.table
title = result.title title = result.title
else: else:
# Handle dict (legacy or from to_dict) # Handle dict (legacy or from to_dict)
@@ -293,7 +293,7 @@ class SearchModal(ModalScreen):
else: else:
metadata = result metadata = result
source = result.get('origin', result.get('source', '')) source = result.get('table', '')
title = result.get('title', '') title = result.get('title', '')
# Format tags based on result source # Format tags based on result source
@@ -332,7 +332,7 @@ class SearchModal(ModalScreen):
async def _download_book(self, result: Any) -> None: async def _download_book(self, result: Any) -> None:
"""Download a book from OpenLibrary using unified downloader.""" """Download a book from OpenLibrary using unified downloader."""
try: try:
from helper.unified_book_downloader import UnifiedBookDownloader from Provider.unified_book_downloader import UnifiedBookDownloader
from config import load_config from config import load_config
# Convert SearchResult to dict if needed # Convert SearchResult to dict if needed

View File

@@ -25,7 +25,7 @@ for path in (ROOT_DIR, BASE_DIR):
import pipeline as ctx import pipeline as ctx
from cmdlets import REGISTRY from cmdlets import REGISTRY
from config import get_local_storage_path, load_config from config import get_local_storage_path, load_config
from helper.worker_manager import WorkerManager from SYS.worker_manager import WorkerManager
try: # Reuse the CLI selection parser instead of reimplementing it. try: # Reuse the CLI selection parser instead of reimplementing it.
from CLI import _parse_selection_syntax from CLI import _parse_selection_syntax

View File

@@ -72,6 +72,6 @@ for _root_mod in ("select_cmdlet",):
# Also import helper modules that register cmdlets # Also import helper modules that register cmdlets
try: try:
import helper.alldebrid as _alldebrid import API.alldebrid as _alldebrid
except Exception: except Exception:
pass pass

View File

@@ -11,7 +11,7 @@ import sys
import inspect import inspect
from collections.abc import Iterable as IterableABC from collections.abc import Iterable as IterableABC
from helper.logger import log, debug from SYS.logger import log, debug
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -149,7 +149,7 @@ class SharedArgs:
@staticmethod @staticmethod
def get_store_choices(config: Optional[Dict[str, Any]] = None) -> List[str]: def get_store_choices(config: Optional[Dict[str, Any]] = None) -> List[str]:
"""Get list of available storage backend names from FileStorage. """Get list of available store backend names.
This method dynamically discovers all configured storage backends This method dynamically discovers all configured storage backends
instead of using a static list. Should be called when building instead of using a static list. Should be called when building
@@ -162,13 +162,10 @@ class SharedArgs:
List of backend names (e.g., ['default', 'test', 'home', 'work']) List of backend names (e.g., ['default', 'test', 'home', 'work'])
Example: Example:
# In a cmdlet that needs dynamic choices
from helper.store import FileStorage
storage = FileStorage(config)
SharedArgs.STORE.choices = SharedArgs.get_store_choices(config) SharedArgs.STORE.choices = SharedArgs.get_store_choices(config)
""" """
try: try:
from helper.store import FileStorage from Store import Store
# If no config provided, try to load it # If no config provided, try to load it
if config is None: if config is None:
@@ -178,8 +175,8 @@ class SharedArgs:
except Exception: except Exception:
return [] return []
file_storage = FileStorage(config) store = Store(config)
return file_storage.list_backends() return store.list_backends()
except Exception: except Exception:
# Fallback to empty list if FileStorage isn't available # Fallback to empty list if FileStorage isn't available
return [] return []
@@ -609,7 +606,7 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]:
return text.lower() if text else None return text.lower() if text else None
def get_hash_for_operation(override_hash: Optional[str], result: Any, field_name: str = "hash_hex") -> Optional[str]: def get_hash_for_operation(override_hash: Optional[str], result: Any, field_name: str = "hash") -> Optional[str]:
"""Get normalized hash from override or result object, consolidating common pattern. """Get normalized hash from override or result object, consolidating common pattern.
Eliminates repeated pattern: normalize_hash(override) if override else normalize_hash(get_field(result, ...)) Eliminates repeated pattern: normalize_hash(override) if override else normalize_hash(get_field(result, ...))
@@ -617,15 +614,14 @@ def get_hash_for_operation(override_hash: Optional[str], result: Any, field_name
Args: Args:
override_hash: Hash passed as command argument (takes precedence) override_hash: Hash passed as command argument (takes precedence)
result: Object containing hash field (fallback) result: Object containing hash field (fallback)
field_name: Name of hash field in result object (default: "hash_hex") field_name: Name of hash field in result object (default: "hash")
Returns: Returns:
Normalized hash string, or None if neither override nor result provides valid hash Normalized hash string, or None if neither override nor result provides valid hash
""" """
if override_hash: if override_hash:
return normalize_hash(override_hash) return normalize_hash(override_hash)
# Try multiple field names for robustness hash_value = get_field(result, field_name) or getattr(result, field_name, None) or getattr(result, "hash", None)
hash_value = get_field(result, field_name) or getattr(result, field_name, None) or getattr(result, "hash", None) or result.get("file_hash") if isinstance(result, dict) else None
return normalize_hash(hash_value) return normalize_hash(hash_value)
@@ -645,8 +641,8 @@ def fetch_hydrus_metadata(config: Any, hash_hex: str, **kwargs) -> tuple[Optiona
- metadata_dict: Dict from Hydrus (first item in metadata list) or None if unavailable - metadata_dict: Dict from Hydrus (first item in metadata list) or None if unavailable
- error_code: 0 on success, 1 on any error (suitable for returning from cmdlet execute()) - error_code: 0 on success, 1 on any error (suitable for returning from cmdlet execute())
""" """
from helper import hydrus from API import HydrusNetwork
hydrus_wrapper = hydrus hydrus_wrapper = HydrusNetwork
try: try:
client = hydrus_wrapper.get_client(config) client = hydrus_wrapper.get_client(config)
@@ -670,24 +666,6 @@ def fetch_hydrus_metadata(config: Any, hash_hex: str, **kwargs) -> tuple[Optiona
return meta, 0 return meta, 0
def get_origin(obj: Any, default: Optional[str] = None) -> Optional[str]:
"""Extract origin field with fallback to store/source field, consolidating common pattern.
Supports both dict and object access patterns.
Args:
obj: Object (dict or dataclass) with 'store', 'origin', or 'source' field
default: Default value if none of the fields are found
Returns:
Store/origin/source string, or default if none exist
"""
if isinstance(obj, dict):
return obj.get("store") or obj.get("origin") or obj.get("source") or default
else:
return getattr(obj, "store", None) or getattr(obj, "origin", None) or getattr(obj, "source", None) or default
def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any: def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any:
"""Extract a field from either a dict or object with fallback default. """Extract a field from either a dict or object with fallback default.
@@ -706,56 +684,19 @@ def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any:
Examples: Examples:
get_field(result, "hash") # From dict or object get_field(result, "hash") # From dict or object
get_field(result, "origin", "unknown") # With default get_field(result, "table", "unknown") # With default
""" """
# Handle lists by accessing the first element # Handle lists by accessing the first element
if isinstance(obj, list) and obj: if isinstance(obj, list) and obj:
obj = obj[0] obj = obj[0]
if isinstance(obj, dict): if isinstance(obj, dict):
# Direct lookup first return obj.get(field, default)
val = obj.get(field, default)
if val is not None:
return val
# Fallback aliases for common fields
if field == "path":
for alt in ("file_path", "target", "filepath", "file"):
v = obj.get(alt)
if v:
return v
if field == "hash":
for alt in ("file_hash", "hash_hex"):
v = obj.get(alt)
if v:
return v
if field == "store":
for alt in ("storage", "storage_source", "origin"):
v = obj.get(alt)
if v:
return v
return default
else: else:
# Try direct attribute access first # Try direct attribute access first
value = getattr(obj, field, None) value = getattr(obj, field, None)
if value is not None: if value is not None:
return value return value
# Attribute fallback aliases for common fields
if field == "path":
for alt in ("file_path", "target", "filepath", "file", "url"):
v = getattr(obj, alt, None)
if v:
return v
if field == "hash":
for alt in ("file_hash", "hash_hex"):
v = getattr(obj, alt, None)
if v:
return v
if field == "store":
for alt in ("storage", "storage_source", "origin"):
v = getattr(obj, alt, None)
if v:
return v
# For PipeObjects, also check the extra field # For PipeObjects, also check the extra field
if hasattr(obj, 'extra') and isinstance(obj.extra, dict): if hasattr(obj, 'extra') and isinstance(obj.extra, dict):
@@ -1148,7 +1089,7 @@ def create_pipe_object_result(
file_path: str, file_path: str,
cmdlet_name: str, cmdlet_name: str,
title: Optional[str] = None, title: Optional[str] = None,
file_hash: Optional[str] = None, hash_value: Optional[str] = None,
is_temp: bool = False, is_temp: bool = False,
parent_hash: Optional[str] = None, parent_hash: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
@@ -1165,7 +1106,7 @@ def create_pipe_object_result(
file_path: Path to the file file_path: Path to the file
cmdlet_name: Name of the cmdlet that created this (e.g., 'download-data', 'screen-shot') cmdlet_name: Name of the cmdlet that created this (e.g., 'download-data', 'screen-shot')
title: Human-readable title title: Human-readable title
file_hash: SHA-256 hash of file (for integrity) hash_value: SHA-256 hash of file (for integrity)
is_temp: If True, this is a temporary/intermediate artifact is_temp: If True, this is a temporary/intermediate artifact
parent_hash: Hash of the parent file in the chain (for provenance) parent_hash: Hash of the parent file in the chain (for provenance)
tags: List of tags to apply tags: List of tags to apply
@@ -1183,13 +1124,12 @@ def create_pipe_object_result(
if title: if title:
result['title'] = title result['title'] = title
if file_hash: if hash_value:
result['file_hash'] = file_hash result['hash'] = hash_value
result['hash'] = file_hash
if is_temp: if is_temp:
result['is_temp'] = True result['is_temp'] = True
if parent_hash: if parent_hash:
result['parent_id'] = parent_hash # parent_id is the parent's file_hash result['parent_hash'] = parent_hash
if tags: if tags:
result['tags'] = tags result['tags'] = tags
@@ -1219,17 +1159,17 @@ def mark_as_temp(pipe_object: Dict[str, Any]) -> Dict[str, Any]:
return pipe_object return pipe_object
def set_parent_id(pipe_object: Dict[str, Any], parent_hash: str) -> Dict[str, Any]: def set_parent_hash(pipe_object: Dict[str, Any], parent_hash: str) -> Dict[str, Any]:
"""Set the parent_id for provenance tracking. """Set the parent_hash for provenance tracking.
Args: Args:
pipe_object: Result dict pipe_object: Result dict
parent_hash: Parent file's hash parent_hash: Parent file's hash
Returns: Returns:
Modified dict with parent_id set to the hash Modified dict with parent_hash set to the hash
""" """
pipe_object['parent_id'] = parent_hash pipe_object['parent_hash'] = parent_hash
return pipe_object return pipe_object
@@ -1254,13 +1194,13 @@ def get_pipe_object_hash(pipe_object: Any) -> Optional[str]:
"""Extract file hash from PipeObject, dict, or pipeline-friendly object.""" """Extract file hash from PipeObject, dict, or pipeline-friendly object."""
if pipe_object is None: if pipe_object is None:
return None return None
for attr in ('file_hash', 'hash_hex', 'hash'): for attr in ('hash',):
if hasattr(pipe_object, attr): if hasattr(pipe_object, attr):
value = getattr(pipe_object, attr) value = getattr(pipe_object, attr)
if value: if value:
return value return value
if isinstance(pipe_object, dict): if isinstance(pipe_object, dict):
for key in ('file_hash', 'hash_hex', 'hash'): for key in ('hash',):
value = pipe_object.get(key) value = pipe_object.get(key)
if value: if value:
return value return value
@@ -1522,13 +1462,12 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
""" """
# Debug: Print ResultItem details if coming from search_file.py # Debug: Print ResultItem details if coming from search_file.py
try: try:
from helper.logger import is_debug_enabled, debug from SYS.logger import is_debug_enabled, debug
if is_debug_enabled() and hasattr(value, '__class__') and value.__class__.__name__ == 'ResultItem': if is_debug_enabled() and hasattr(value, '__class__') and value.__class__.__name__ == 'ResultItem':
debug("[ResultItem -> PipeObject conversion]") debug("[ResultItem -> PipeObject conversion]")
debug(f" origin={getattr(value, 'origin', None)}")
debug(f" title={getattr(value, 'title', None)}") debug(f" title={getattr(value, 'title', None)}")
debug(f" target={getattr(value, 'target', None)}") debug(f" target={getattr(value, 'target', None)}")
debug(f" hash_hex={getattr(value, 'hash_hex', None)}") debug(f" hash={getattr(value, 'hash', None)}")
debug(f" media_kind={getattr(value, 'media_kind', None)}") debug(f" media_kind={getattr(value, 'media_kind', None)}")
debug(f" tags={getattr(value, 'tags', None)}") debug(f" tags={getattr(value, 'tags', None)}")
debug(f" tag_summary={getattr(value, 'tag_summary', None)}") debug(f" tag_summary={getattr(value, 'tag_summary', None)}")
@@ -1554,14 +1493,11 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
if isinstance(value, dict): if isinstance(value, dict):
# Extract hash and store (canonical identifiers) # Extract hash and store (canonical identifiers)
hash_val = value.get("hash") or value.get("file_hash") hash_val = value.get("hash")
# Recognize multiple possible store naming conventions (store, origin, storage, storage_source) store_val = value.get("store") or "PATH"
store_val = value.get("store") or value.get("origin") or value.get("storage") or value.get("storage_source") or "PATH" if not store_val or store_val == "PATH":
# If the store value is embedded under extra, also detect it
if not store_val or store_val in ("local", "PATH"):
extra_store = None
try: try:
extra_store = value.get("extra", {}).get("store") or value.get("extra", {}).get("storage") or value.get("extra", {}).get("storage_source") extra_store = value.get("extra", {}).get("store")
except Exception: except Exception:
extra_store = None extra_store = None
if extra_store: if extra_store:
@@ -1572,7 +1508,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
path_val = value.get("path") path_val = value.get("path")
if path_val: if path_val:
try: try:
from helper.utils import sha256_file from SYS.utils import sha256_file
from pathlib import Path from pathlib import Path
hash_val = sha256_file(Path(path_val)) hash_val = sha256_file(Path(path_val))
except Exception: except Exception:
@@ -1655,7 +1591,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
relationships=rels, relationships=rels,
is_temp=bool(value.get("is_temp", False)), is_temp=bool(value.get("is_temp", False)),
action=value.get("action"), action=value.get("action"),
parent_hash=value.get("parent_hash") or value.get("parent_id"), parent_hash=value.get("parent_hash"),
extra=extra, extra=extra,
) )
@@ -1671,7 +1607,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
if path_val and path_val != "unknown": if path_val and path_val != "unknown":
try: try:
from helper.utils import sha256_file from SYS.utils import sha256_file
from pathlib import Path from pathlib import Path
path_obj = Path(path_val) path_obj = Path(path_val)
hash_val = sha256_file(path_obj) hash_val = sha256_file(path_obj)
@@ -1714,7 +1650,7 @@ def register_url_with_local_library(pipe_obj: models.PipeObject, config: Dict[st
try: try:
from config import get_local_storage_path from config import get_local_storage_path
from helper.folder_store import FolderDB from API.folder import API_folder_store
file_path = get_field(pipe_obj, "path") file_path = get_field(pipe_obj, "path")
url_field = get_field(pipe_obj, "url", []) url_field = get_field(pipe_obj, "url", [])
@@ -1735,7 +1671,7 @@ def register_url_with_local_library(pipe_obj: models.PipeObject, config: Dict[st
if not storage_path: if not storage_path:
return False return False
with FolderDB(storage_path) as db: with API_folder_store(storage_path) as db:
file_hash = db.get_file_hash(path_obj) file_hash = db.get_file_hash(path_obj)
if not file_hash: if not file_hash:
return False return False

View File

@@ -7,26 +7,22 @@ import shutil
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from helper.logger import log, debug from SYS.logger import log, debug
from helper.store import FileStorage from Store import Store
from ._shared import ( from ._shared import (
Cmdlet, CmdletArg, parse_cmdlet_args, SharedArgs, Cmdlet, CmdletArg, parse_cmdlet_args, SharedArgs,
extract_tags_from_result, extract_title_from_result, extract_url_from_result, extract_tags_from_result, extract_title_from_result, extract_url_from_result,
merge_sequences, extract_relationships, extract_duration, get_origin, coerce_to_pipe_object merge_sequences, extract_relationships, extract_duration, coerce_to_pipe_object
) )
from ._shared import collapse_namespace_tags from ._shared import collapse_namespace_tags
from helper.folder_store import read_sidecar, find_sidecar, write_sidecar, FolderDB from API.folder import read_sidecar, find_sidecar, write_sidecar, API_folder_store
from helper.utils import sha256_file, unique_path from SYS.utils import sha256_file, unique_path
from metadata import write_metadata from metadata import write_metadata
# Use official Hydrus supported filetypes from hydrus_wrapper # Use official Hydrus supported filetypes from hydrus_wrapper
SUPPORTED_MEDIA_EXTENSIONS = hydrus_wrapper.ALL_SUPPORTED_EXTENSIONS SUPPORTED_MEDIA_EXTENSIONS = hydrus_wrapper.ALL_SUPPORTED_EXTENSIONS
# Initialize file storage system
storage = FileStorage()
class Add_File(Cmdlet): class Add_File(Cmdlet):
"""Add file into the DB""" """Add file into the DB"""
@@ -96,8 +92,11 @@ class Add_File(Cmdlet):
media_path_or_url, file_hash = self._resolve_source(result, path_arg, pipe_obj, config) media_path_or_url, file_hash = self._resolve_source(result, path_arg, pipe_obj, config)
debug(f"[add-file] RESOLVED source: path={media_path_or_url}, hash={file_hash[:12] if file_hash else 'N/A'}...") debug(f"[add-file] RESOLVED source: path={media_path_or_url}, hash={file_hash[:12] if file_hash else 'N/A'}...")
if not media_path_or_url: if not media_path_or_url:
debug(f"[add-file] ERROR: Could not resolve source file/URL") debug(f"[add-file] ERROR: Could not resolve source file/URL")
return 1 return 1
# Update pipe_obj with resolved path
pipe_obj.path = str(media_path_or_url) if isinstance(media_path_or_url, (str, Path)) else str(media_path_or_url)
# Check if it's a URL before validating as file # Check if it's a URL before validating as file
if isinstance(media_path_or_url, str) and media_path_or_url.lower().startswith(("http://", "https://", "magnet:", "torrent:")): if isinstance(media_path_or_url, str) and media_path_or_url.lower().startswith(("http://", "https://", "magnet:", "torrent:")):
@@ -116,15 +115,15 @@ class Add_File(Cmdlet):
debug(f"[add-file] DECISION POINT: provider={provider_name}, location={location}") debug(f"[add-file] DECISION POINT: provider={provider_name}, location={location}")
debug(f" media_path={media_path}, exists={media_path.exists()}") debug(f" media_path={media_path}, exists={media_path.exists()}")
# Execute transfer based on destination (using class-based FileStorage system) # Execute transfer based on destination (using Store registry)
if provider_name: if provider_name:
debug(f"[add-file] ROUTE: file provider upload") debug(f"[add-file] ROUTE: file provider upload")
return self._handle_provider_upload(media_path, provider_name, pipe_obj, config, delete_after) return self._handle_provider_upload(media_path, provider_name, pipe_obj, config, delete_after)
elif location: elif location:
# Check if location is a registered backend name using FileStorage # Check if location is a registered backend name
try: try:
storage = FileStorage(config) store = Store(config)
backends = storage.list_backends() backends = store.list_backends()
if location in backends: if location in backends:
debug(f"[add-file] ROUTE: storage backend '{location}'") debug(f"[add-file] ROUTE: storage backend '{location}'")
@@ -165,15 +164,19 @@ class Add_File(Cmdlet):
debug(f"[add-file] Using hash+store from result: hash={result_hash[:12]}..., store={result_store}") debug(f"[add-file] Using hash+store from result: hash={result_hash[:12]}..., store={result_store}")
# Use get_file to retrieve from the specific store # Use get_file to retrieve from the specific store
try: try:
from helper.store import FileStorage store = Store(config)
storage = FileStorage(config) if result_store in store.list_backends():
if result_store in storage.list_backends(): backend = store[result_store]
backend = storage[result_store]
media_path = backend.get_file(result_hash) media_path = backend.get_file(result_hash)
if media_path and media_path.exists(): if isinstance(media_path, Path) and media_path.exists():
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
debug(f"[add-file] Retrieved file from {result_store}: {media_path}") debug(f"[add-file] Retrieved file from {result_store}: {media_path}")
return media_path, result_hash return media_path, result_hash
if isinstance(media_path, str) and media_path.lower().startswith(("http://", "https://")):
pipe_obj.path = media_path
debug(f"[add-file] Retrieved URL from {result_store}: {media_path}")
return media_path, result_hash
except Exception as exc: except Exception as exc:
debug(f"[add-file] Failed to retrieve via hash+store: {exc}") debug(f"[add-file] Failed to retrieve via hash+store: {exc}")
@@ -385,20 +388,6 @@ class Add_File(Cmdlet):
url = list(extract_url_from_result(result) or []) url = list(extract_url_from_result(result) or [])
return url return url
@staticmethod
def _get_origin(result: Any, pipe_obj: models.PipeObject) -> Optional[str]:
try:
if isinstance(pipe_obj.extra, dict):
origin = get_origin(pipe_obj.extra)
if origin:
return origin
except Exception:
pass
if isinstance(result, dict):
return get_origin(result)
return None
@staticmethod @staticmethod
def _get_relationships(result: Any, pipe_obj: models.PipeObject) -> Optional[Dict[str, Any]]: def _get_relationships(result: Any, pipe_obj: models.PipeObject) -> Optional[Dict[str, Any]]:
try: try:
@@ -427,16 +416,16 @@ class Add_File(Cmdlet):
def _update_pipe_object_destination( def _update_pipe_object_destination(
pipe_obj: models.PipeObject, pipe_obj: models.PipeObject,
*, *,
hash: str, hash_value: str,
store: str, store: str,
file_path: str, path: Optional[str],
tags: List[str], tags: List[str],
title: Optional[str], title: Optional[str],
extra_updates: Optional[Dict[str, Any]] = None, extra_updates: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
pipe_obj.hash = hash pipe_obj.hash = hash_value
pipe_obj.store = store pipe_obj.store = store
pipe_obj.path = file_path pipe_obj.path = path
pipe_obj.tags = tags pipe_obj.tags = tags
if title: if title:
pipe_obj.title = title pipe_obj.title = title
@@ -485,9 +474,9 @@ class Add_File(Cmdlet):
if preferred_title: if preferred_title:
preferred_title = preferred_title.replace("_", " ").strip() preferred_title = preferred_title.replace("_", " ").strip()
result_origin = Add_File._get_origin(result, pipe_obj) store = getattr(pipe_obj, "store", None)
_, sidecar_hash, sidecar_tags, sidecar_url = Add_File._load_sidecar_bundle( _, sidecar_hash, sidecar_tags, sidecar_url = Add_File._load_sidecar_bundle(
media_path, result_origin, config media_path, store, config
) )
def normalize_title_tag(tag: str) -> str: def normalize_title_tag(tag: str) -> str:
@@ -589,7 +578,6 @@ class Add_File(Cmdlet):
# Update PipeObject and emit # Update PipeObject and emit
extra_updates = { extra_updates = {
"storage_source": "local",
"url": url, "url": url,
"export_path": str(destination_root), "export_path": str(destination_root),
} }
@@ -600,9 +588,9 @@ class Add_File(Cmdlet):
Add_File._update_pipe_object_destination( Add_File._update_pipe_object_destination(
pipe_obj, pipe_obj,
hash=f_hash or "unknown", hash_value=f_hash or "unknown",
store="local", store="local",
file_path=str(target_path), path=str(target_path),
tags=tags, tags=tags,
title=chosen_title, title=chosen_title,
extra_updates=extra_updates, extra_updates=extra_updates,
@@ -615,6 +603,78 @@ class Add_File(Cmdlet):
return 0 return 0
@staticmethod
def _download_soulseek_file(
result: Any,
config: Dict[str, Any]
) -> Optional[Path]:
"""
Download a file from Soulseek peer.
Extracts username and filename from soulseek result metadata and initiates download.
"""
try:
import asyncio
from Provider.registry import download_soulseek_file
from pathlib import Path
# Extract metadata from result
full_metadata = {}
if isinstance(result, dict):
full_metadata = result.get("full_metadata", {})
elif hasattr(result, "extra") and isinstance(result.extra, dict) and "full_metadata" in result.extra:
full_metadata = result.extra.get("full_metadata", {})
elif hasattr(result, "full_metadata"):
# Direct attribute access (fallback)
val = getattr(result, "full_metadata", {})
if isinstance(val, dict):
full_metadata = val
username = full_metadata.get("username")
filename = full_metadata.get("filename")
if not username or not filename:
debug(f"[add-file] ERROR: Could not extract soulseek metadata from result (type={type(result).__name__})")
if hasattr(result, "extra"):
debug(f"[add-file] Result extra keys: {list(result.extra.keys())}")
return None
if not username or not filename:
debug(f"[add-file] ERROR: Missing soulseek metadata (username={username}, filename={filename})")
return None
debug(f"[add-file] Starting soulseek download: {username} -> {filename}")
# Determine output directory (prefer downloads folder in config)
output_dir = Path(config.get("output_dir", "./downloads")) if isinstance(config.get("output_dir"), str) else Path("./downloads")
output_dir.mkdir(parents=True, exist_ok=True)
# Run async download in event loop
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
downloaded_path = loop.run_until_complete(
download_soulseek_file(
username=username,
filename=filename,
output_dir=output_dir,
timeout=1200 # 20 minutes
)
)
return downloaded_path
except Exception as e:
log(f"[add-file] Soulseek download error: {type(e).__name__}: {e}", file=sys.stderr)
debug(f"[add-file] Soulseek download traceback: {e}")
return None
@staticmethod @staticmethod
def _handle_provider_upload( def _handle_provider_upload(
media_path: Path, media_path: Path,
@@ -624,7 +684,7 @@ class Add_File(Cmdlet):
delete_after: bool, delete_after: bool,
) -> int: ) -> int:
"""Handle uploading to a file provider (e.g. 0x0).""" """Handle uploading to a file provider (e.g. 0x0)."""
from helper.provider import get_file_provider from Provider.registry import get_file_provider
log(f"Uploading via {provider_name}: {media_path.name}", file=sys.stderr) log(f"Uploading via {provider_name}: {media_path.name}", file=sys.stderr)
@@ -666,9 +726,9 @@ class Add_File(Cmdlet):
file_path = pipe_obj.path or (str(media_path) if media_path else None) or "" file_path = pipe_obj.path or (str(media_path) if media_path else None) or ""
Add_File._update_pipe_object_destination( Add_File._update_pipe_object_destination(
pipe_obj, pipe_obj,
hash=f_hash or "unknown", hash_value=f_hash or "unknown",
store=provider_name or "provider", store=provider_name or "provider",
file_path=file_path, path=file_path,
tags=pipe_obj.tags, tags=pipe_obj.tags,
title=pipe_obj.title or (media_path.name if media_path else None), title=pipe_obj.title or (media_path.name if media_path else None),
extra_updates=extra_updates, extra_updates=extra_updates,
@@ -687,14 +747,11 @@ class Add_File(Cmdlet):
delete_after: bool, delete_after: bool,
) -> int: ) -> int:
"""Handle uploading to a registered storage backend (e.g., 'test' folder store, 'hydrus', etc.).""" """Handle uploading to a registered storage backend (e.g., 'test' folder store, 'hydrus', etc.)."""
from config import load_config
log(f"Adding file to storage backend '{backend_name}': {media_path.name}", file=sys.stderr) log(f"Adding file to storage backend '{backend_name}': {media_path.name}", file=sys.stderr)
try: try:
cfg = load_config() store = Store(config)
storage = FileStorage(cfg) backend = store[backend_name]
backend = storage[backend_name]
# Prepare metadata from pipe_obj and sidecars # Prepare metadata from pipe_obj and sidecars
tags, url, title, f_hash = Add_File._prepare_metadata(None, media_path, pipe_obj, config) tags, url, title, f_hash = Add_File._prepare_metadata(None, media_path, pipe_obj, config)
@@ -708,24 +765,26 @@ class Add_File(Cmdlet):
url=url url=url
) )
log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr) log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr)
# Update pipe object with result stored_path: Optional[str] = None
# For backends that return paths, file_path = identifier try:
# For backends that return hashes, file_path = "backend:hash" maybe_path = backend.get_file(file_identifier)
file_path_str = str(file_identifier) if isinstance(maybe_path, Path):
if len(file_identifier) == 64 and all(c in '0123456789abcdef' for c in file_identifier.lower()): stored_path = str(maybe_path)
# It's a hash - use backend:hash format elif isinstance(maybe_path, str) and maybe_path:
file_path_str = f"{backend_name}:{file_identifier}" # Some backends may return a browser URL
stored_path = maybe_path
except Exception:
stored_path = None
Add_File._update_pipe_object_destination( Add_File._update_pipe_object_destination(
pipe_obj, pipe_obj,
hash=file_identifier if len(file_identifier) == 64 else f_hash or "unknown", hash_value=file_identifier if len(file_identifier) == 64 else f_hash or "unknown",
store=backend_name, store=backend_name,
file_path=file_path_str, path=stored_path,
tags=tags, tags=tags,
title=title or pipe_obj.title or media_path.name, title=title or pipe_obj.title or media_path.name,
extra_updates={ extra_updates={
"storage_source": backend_name,
"url": url, "url": url,
}, },
) )
@@ -745,16 +804,16 @@ class Add_File(Cmdlet):
@staticmethod @staticmethod
def _load_sidecar_bundle( def _load_sidecar_bundle(
media_path: Path, media_path: Path,
origin: Optional[str], store: Optional[str],
config: Dict[str, Any], config: Dict[str, Any],
) -> Tuple[Optional[Path], Optional[str], List[str], List[str]]: ) -> Tuple[Optional[Path], Optional[str], List[str], List[str]]:
"""Load sidecar metadata.""" """Load sidecar metadata."""
if origin and origin.lower() == "local": if store and store.lower() == "local":
try: try:
from config import get_local_storage_path from config import get_local_storage_path
db_root = get_local_storage_path(config) db_root = get_local_storage_path(config)
if db_root: if db_root:
with FolderDB(Path(db_root)) as db: with API_folder_store(Path(db_root)) as db:
file_hash = db.get_file_hash(media_path) file_hash = db.get_file_hash(media_path)
if file_hash: if file_hash:
tags = db.get_tags(file_hash) or [] tags = db.get_tags(file_hash) or []
@@ -837,7 +896,7 @@ class Add_File(Cmdlet):
except OSError: except OSError:
payload['size'] = None payload['size'] = None
with FolderDB(library_root) as db: with API_folder_store(library_root) as db:
try: try:
db.save_file_info(dest_path, payload, tags) db.save_file_info(dest_path, payload, tags)
except Exception as exc: except Exception as exc:

View File

@@ -6,9 +6,9 @@ import json
from . import register from . import register
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, normalize_hash, should_show_help from ._shared import Cmdlet, CmdletArg, normalize_hash, should_show_help
from helper.logger import log from SYS.logger import log
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="add-note", name="add-note",

View File

@@ -8,14 +8,14 @@ import re
from pathlib import Path from pathlib import Path
import sys import sys
from helper.logger import log from SYS.logger import log
from . import register from . import register
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, should_show_help, get_field from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, should_show_help, get_field
from helper.folder_store import read_sidecar, find_sidecar from API.folder import read_sidecar, find_sidecar
CMDLET = Cmdlet( CMDLET = Cmdlet(
@@ -228,7 +228,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
items_to_process = [{"file_path": arg_path}] items_to_process = [{"file_path": arg_path}]
# Import local storage utilities # Import local storage utilities
from helper.folder_store import LocalLibrarySearchOptimizer from API.folder import LocalLibrarySearchOptimizer
from config import get_local_storage_path from config import get_local_storage_path
local_storage_path = get_local_storage_path(config) if config else None local_storage_path = get_local_storage_path(config) if config else None

View File

@@ -4,13 +4,13 @@ from typing import Any, Dict, List, Sequence, Optional
from pathlib import Path from pathlib import Path
import sys import sys
from helper.logger import log from SYS.logger import log
import models import models
import pipeline as ctx import pipeline as ctx
from ._shared import normalize_result_input, filter_results_by_temp from ._shared import normalize_result_input, filter_results_by_temp
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from helper.folder_store import write_sidecar, FolderDB from API.folder import write_sidecar, API_folder_store
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field
from config import get_local_storage_path from config import get_local_storage_path
@@ -91,17 +91,16 @@ class Add_Tag(Cmdlet):
res["columns"] = updated res["columns"] = updated
@staticmethod @staticmethod
def _matches_target(item: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> bool: def _matches_target(item: Any, file_hash: Optional[str], path: Optional[str]) -> bool:
"""Determine whether a result item refers to the given hash/path target.""" """Determine whether a result item refers to the given hash/path target."""
hydrus_hash_l = hydrus_hash.lower() if hydrus_hash else None
file_hash_l = file_hash.lower() if file_hash else None file_hash_l = file_hash.lower() if file_hash else None
file_path_l = file_path.lower() if file_path else None path_l = path.lower() if path else None
def norm(val: Any) -> Optional[str]: def norm(val: Any) -> Optional[str]:
return str(val).lower() if val is not None else None return str(val).lower() if val is not None else None
hash_fields = ["hydrus_hash", "hash", "hash_hex", "file_hash"] hash_fields = ["hash"]
path_fields = ["path", "file_path", "target"] path_fields = ["path", "target"]
if isinstance(item, dict): if isinstance(item, dict):
hashes = [norm(item.get(field)) for field in hash_fields] hashes = [norm(item.get(field)) for field in hash_fields]
@@ -110,11 +109,9 @@ class Add_Tag(Cmdlet):
hashes = [norm(get_field(item, field)) for field in hash_fields] hashes = [norm(get_field(item, field)) for field in hash_fields]
paths = [norm(get_field(item, field)) for field in path_fields] paths = [norm(get_field(item, field)) for field in path_fields]
if hydrus_hash_l and hydrus_hash_l in hashes:
return True
if file_hash_l and file_hash_l in hashes: if file_hash_l and file_hash_l in hashes:
return True return True
if file_path_l and file_path_l in paths: if path_l and path_l in paths:
return True return True
return False return False
@@ -146,7 +143,7 @@ class Add_Tag(Cmdlet):
if changed: if changed:
item["columns"] = updated_cols item["columns"] = updated_cols
def _refresh_result_table_title(self, new_title: str, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> None: def _refresh_result_table_title(self, new_title: str, file_hash: Optional[str], path: Optional[str]) -> None:
"""Refresh the cached result table with an updated title and redisplay it.""" """Refresh the cached result table with an updated title and redisplay it."""
try: try:
last_table = ctx.get_last_result_table() last_table = ctx.get_last_result_table()
@@ -158,7 +155,7 @@ class Add_Tag(Cmdlet):
match_found = False match_found = False
for item in items: for item in items:
try: try:
if self._matches_target(item, hydrus_hash, file_hash, file_path): if self._matches_target(item, file_hash, path):
self._update_item_title_fields(item, new_title) self._update_item_title_fields(item, new_title)
match_found = True match_found = True
except Exception: except Exception:
@@ -178,21 +175,21 @@ class Add_Tag(Cmdlet):
except Exception: except Exception:
pass pass
def _refresh_tags_view(self, res: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str], config: Dict[str, Any]) -> None: def _refresh_tags_view(self, res: Any, file_hash: Optional[str], path: Optional[str], config: Dict[str, Any]) -> None:
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh.""" """Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
try: try:
from cmdlets import get_tag as get_tag_cmd # type: ignore from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception: except Exception:
return return
target_hash = hydrus_hash or file_hash target_hash = file_hash
refresh_args: List[str] = [] refresh_args: List[str] = []
if target_hash: if target_hash:
refresh_args = ["-hash", target_hash, "-store", target_hash] refresh_args = ["-hash", target_hash]
try: try:
subject = ctx.get_last_result_subject() subject = ctx.get_last_result_subject()
if subject and self._matches_target(subject, hydrus_hash, file_hash, file_path): if subject and self._matches_target(subject, file_hash, path):
get_tag_cmd._run(subject, refresh_args, config) get_tag_cmd._run(subject, refresh_args, config)
return return
except Exception: except Exception:
@@ -217,7 +214,7 @@ class Add_Tag(Cmdlet):
# Get explicit -hash and -store overrides from CLI # Get explicit -hash and -store overrides from CLI
hash_override = normalize_hash(parsed.get("hash")) hash_override = normalize_hash(parsed.get("hash"))
store_override = parsed.get("store") or parsed.get("storage") store_override = parsed.get("store")
# Normalize input to list # Normalize input to list
results = normalize_result_input(result) results = normalize_result_input(result)
@@ -327,7 +324,7 @@ class Add_Tag(Cmdlet):
file_path = get_field(res, "path") file_path = get_field(res, "path")
# Try tags from top-level 'tags' or from 'extra.tags' # Try tags from top-level 'tags' or from 'extra.tags'
tags = get_field(res, "tags") or (get_field(res, "extra") or {}).get("tags", []) tags = get_field(res, "tags") or (get_field(res, "extra") or {}).get("tags", [])
file_hash = get_field(res, "hash") or get_field(res, "file_hash") or get_field(res, "hash_hex") or "" file_hash = get_field(res, "hash") or ""
if not file_path: if not file_path:
log(f"[add_tag] Warning: Result has no path, skipping", file=sys.stderr) log(f"[add_tag] Warning: Result has no path, skipping", file=sys.stderr)
ctx.emit(res) ctx.emit(res)
@@ -362,16 +359,8 @@ class Add_Tag(Cmdlet):
existing_tags = get_field(res, "tags") or [] existing_tags = get_field(res, "tags") or []
if not existing_tags: if not existing_tags:
existing_tags = (get_field(res, "extra", {}) or {}).get("tags") or [] existing_tags = (get_field(res, "extra", {}) or {}).get("tags") or []
file_hash = get_field(res, "hash") or get_field(res, "file_hash") or get_field(res, "hash_hex") or "" file_hash = get_field(res, "hash") or ""
storage_source = get_field(res, "store") or get_field(res, "storage") or get_field(res, "storage_source") or get_field(res, "origin") store_name = store_override or get_field(res, "store")
hydrus_hash = get_field(res, "hydrus_hash") or file_hash
# Infer storage source from result if not found
if not storage_source:
if file_path:
storage_source = 'local'
elif file_hash and file_hash != "unknown":
storage_source = 'hydrus'
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)} original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
original_title = self._extract_title_tag(list(existing_tags)) original_title = self._extract_title_tag(list(existing_tags))
@@ -379,8 +368,10 @@ class Add_Tag(Cmdlet):
# Apply CLI overrides if provided # Apply CLI overrides if provided
if hash_override and not file_hash: if hash_override and not file_hash:
file_hash = hash_override file_hash = hash_override
if store_override and not storage_source: if not store_name:
storage_source = store_override log("[add_tag] Missing store (use -store or pipe a result with store)", file=sys.stderr)
ctx.emit(res)
continue
# Check if we have sufficient identifier (file_path OR file_hash) # Check if we have sufficient identifier (file_path OR file_hash)
if not file_path and not file_hash: if not file_path and not file_hash:
@@ -418,146 +409,78 @@ class Add_Tag(Cmdlet):
new_tags_added = [] new_tags_added = []
final_tags = list(existing_tags) if existing_tags else [] final_tags = list(existing_tags) if existing_tags else []
# Determine where to add tags: Hydrus or Folder storage # Resolve hash from path if needed
if storage_source and storage_source.lower() == 'hydrus': if not file_hash and file_path:
# Add tags to Hydrus using the API
target_hash = file_hash
if target_hash:
try:
hydrus_client = hydrus_wrapper.get_client(config)
service_name = hydrus_wrapper.get_tag_service_name(config)
# For namespaced tags, remove old tags in same namespace
removed_tags = []
for new_tag in tags_to_add:
if ':' in new_tag:
namespace = new_tag.split(':', 1)[0]
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
removed_tags.extend(to_remove)
# Add new tags
if tags_to_add:
log(f"[add_tag] Adding {len(tags_to_add)} tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
hydrus_client.add_tags(target_hash, tags_to_add, service_name)
# Delete replaced namespace tags
if removed_tags:
unique_removed = sorted(set(removed_tags))
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
if tags_to_add or removed_tags:
total_new_tags += len(tags_to_add)
total_modified += 1
log(f"[add_tag] ✓ Added {len(tags_to_add)} tag(s) to Hydrus", file=sys.stderr)
# Refresh final tag list from the backend for accurate display
try:
from helper.store import FileStorage
storage = FileStorage(config)
if storage and storage_source in storage.list_backends():
backend = storage[storage_source]
refreshed_tags, _ = backend.get_tag(target_hash)
if refreshed_tags is not None:
final_tags = refreshed_tags
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
# Update result tags for downstream cmdlets/UI
if isinstance(res, models.PipeObject):
res.tags = refreshed_tags
if isinstance(res.extra, dict):
res.extra['tags'] = refreshed_tags
elif isinstance(res, dict):
res['tags'] = refreshed_tags
except Exception:
# Ignore failures - this is best-effort for refreshing tag state
pass
except Exception as e:
log(f"[add_tag] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
else:
log(f"[add_tag] Warning: No hash available for Hydrus file, skipping", file=sys.stderr)
elif storage_source:
# For any Folder-based storage (local, test, default, etc.), delegate to backend
# If storage_source is not a registered backend, fallback to writing a sidecar
from helper.store import FileStorage
storage = FileStorage(config)
try: try:
if storage and storage_source in storage.list_backends(): from SYS.utils import sha256_file
backend = storage[storage_source] file_hash = sha256_file(Path(file_path))
if file_hash and backend.add_tag(file_hash, tags_to_add): except Exception:
# Refresh tags from backend to get merged result file_hash = ""
refreshed_tags, _ = backend.get_tag(file_hash)
if refreshed_tags:
# Update result tags
if isinstance(res, models.PipeObject):
res.tags = refreshed_tags
# Also keep as extra for compatibility
if isinstance(res.extra, dict):
res.extra['tags'] = refreshed_tags
elif isinstance(res, dict):
res['tags'] = refreshed_tags
# Update title if changed if not file_hash:
title_value = self._extract_title_tag(refreshed_tags) log("[add_tag] Warning: No hash available, skipping", file=sys.stderr)
self._apply_title_to_result(res, title_value)
# Compute stats
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
total_new_tags += len(new_tags_added)
if new_tags_added:
total_modified += 1
log(f"[add_tag] Added {len(new_tags_added)} new tag(s); {len(refreshed_tags)} total tag(s) stored in {storage_source}", file=sys.stderr)
final_tags = refreshed_tags
else:
log(f"[add_tag] Warning: Failed to add tags to {storage_source}", file=sys.stderr)
else:
# Not a registered backend - fallback to sidecar if we have a path
if file_path:
try:
sidecar_path = write_sidecar(Path(file_path), tags_to_add, [], file_hash)
log(f"[add_tag] Wrote {len(tags_to_add)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
total_new_tags += len(tags_to_add)
total_modified += 1
# Update res tags
if isinstance(res, models.PipeObject):
res.tags = (res.tags or []) + tags_to_add
if isinstance(res.extra, dict):
res.extra['tags'] = res.tags
elif isinstance(res, dict):
res['tags'] = list(set((res.get('tags') or []) + tags_to_add))
except Exception as exc:
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {exc}", file=sys.stderr)
else:
log(f"[add_tag] Warning: Storage backend '{storage_source}' not found in config", file=sys.stderr)
except KeyError:
# storage[storage_source] raised KeyError - treat as absent backend
if file_path:
try:
sidecar_path = write_sidecar(Path(file_path), tags_to_add, [], file_hash)
log(f"[add_tag] Wrote {len(tags_to_add)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
total_new_tags += len(tags_to_add)
total_modified += 1
# Update res tags for downstream
if isinstance(res, models.PipeObject):
res.tags = (res.tags or []) + tags_to_add
if isinstance(res.extra, dict):
res.extra['tags'] = res.tags
elif isinstance(res, dict):
res['tags'] = list(set((res.get('tags') or []) + tags_to_add))
except Exception as exc:
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {exc}", file=sys.stderr)
else:
log(f"[add_tag] Warning: Storage backend '{storage_source}' not found in config", file=sys.stderr)
else:
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
# (local/hydrus are handled above).
ctx.emit(res) ctx.emit(res)
continue continue
# Route tag updates through the configured store backend
try:
storage = Store(config)
backend = storage[store_name]
# For namespaced tags, compute old tags in same namespace to remove
removed_tags = []
for new_tag in tags_to_add:
if ':' in new_tag:
namespace = new_tag.split(':', 1)[0]
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
removed_tags.extend(to_remove)
ok = backend.add_tag(file_hash, tags_to_add, config=config)
if removed_tags:
unique_removed = sorted(set(removed_tags))
backend.delete_tag(file_hash, unique_removed, config=config)
if not ok:
log(f"[add_tag] Warning: Failed to add tags via store '{store_name}'", file=sys.stderr)
ctx.emit(res)
continue
refreshed_tags, _ = backend.get_tag(file_hash, config=config)
refreshed_tags = list(refreshed_tags or [])
final_tags = refreshed_tags
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
# Update result tags for downstream cmdlets/UI
if isinstance(res, models.PipeObject):
res.tags = refreshed_tags
if isinstance(res.extra, dict):
res.extra['tags'] = refreshed_tags
elif isinstance(res, dict):
res['tags'] = refreshed_tags
# Update title if changed
title_value = self._extract_title_tag(refreshed_tags)
self._apply_title_to_result(res, title_value)
total_new_tags += len(new_tags_added)
if new_tags_added:
total_modified += 1
except KeyError:
log(f"[add_tag] Store '{store_name}' not configured", file=sys.stderr)
ctx.emit(res)
continue
except Exception as e:
log(f"[add_tag] Warning: Backend error for store '{store_name}': {e}", file=sys.stderr)
ctx.emit(res)
continue
# If title changed, refresh the cached result table so the display reflects the new name # If title changed, refresh the cached result table so the display reflects the new name
final_title = self._extract_title_tag(final_tags) final_title = self._extract_title_tag(final_tags)
if final_title and (not original_title or final_title.lower() != original_title.lower()): if final_title and (not original_title or final_title.lower() != original_title.lower()):
self._refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path) self._refresh_result_table_title(final_title, file_hash, file_path)
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh) # If tags changed, refresh tag view via get-tag
if new_tags_added or removed_tags: if new_tags_added or removed_tags:
self._refresh_tags_view(res, hydrus_hash, file_hash, file_path, config) self._refresh_tags_view(res, file_hash, file_path, config)
# Emit the modified result # Emit the modified result
ctx.emit(res) ctx.emit(res)
log(f"[add_tag] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr) log(f"[add_tag] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)

View File

@@ -4,16 +4,25 @@ from typing import Any, Dict, List, Sequence, Optional
from pathlib import Path from pathlib import Path
import sys import sys
from helper.logger import log from SYS.logger import log
import models import models
import pipeline as ctx import pipeline as ctx
from ._shared import normalize_result_input, filter_results_by_temp from ._shared import normalize_result_input, filter_results_by_temp
from helper import hydrus as hydrus_wrapper from ._shared import (
from helper.folder_store import read_sidecar, write_sidecar, find_sidecar, has_sidecar, FolderDB Cmdlet,
from metadata import rename CmdletArg,
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field SharedArgs,
from config import get_local_storage_path normalize_hash,
parse_tag_arguments,
expand_tag_groups,
parse_cmdlet_args,
collapse_namespace_tags,
should_show_help,
get_field,
)
from Store import Store
from SYS.utils import sha256_file
def _extract_title_tag(tags: List[str]) -> Optional[str]: def _extract_title_tag(tags: List[str]) -> Optional[str]:
@@ -57,31 +66,25 @@ def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
res["columns"] = updated res["columns"] = updated
def _matches_target(item: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> bool: def _matches_target(item: Any, target_hash: Optional[str], target_path: Optional[str]) -> bool:
"""Determine whether a result item refers to the given hash/path target.""" """Determine whether a result item refers to the given hash/path target (canonical fields only)."""
hydrus_hash_l = hydrus_hash.lower() if hydrus_hash else None
file_hash_l = file_hash.lower() if file_hash else None
file_path_l = file_path.lower() if file_path else None
def norm(val: Any) -> Optional[str]: def norm(val: Any) -> Optional[str]:
return str(val).lower() if val is not None else None return str(val).lower() if val is not None else None
# Define field names to check for hashes and paths target_hash_l = target_hash.lower() if target_hash else None
hash_fields = ["hydrus_hash", "hash", "hash_hex", "file_hash"] target_path_l = target_path.lower() if target_path else None
path_fields = ["path", "file_path", "target"]
if isinstance(item, dict): if isinstance(item, dict):
hashes = [norm(item.get(field)) for field in hash_fields] hashes = [norm(item.get("hash"))]
paths = [norm(item.get(field)) for field in path_fields] paths = [norm(item.get("path"))]
else: else:
hashes = [norm(get_field(item, field)) for field in hash_fields] hashes = [norm(get_field(item, "hash"))]
paths = [norm(get_field(item, field)) for field in path_fields] paths = [norm(get_field(item, "path"))]
if hydrus_hash_l and hydrus_hash_l in hashes: if target_hash_l and target_hash_l in hashes:
return True return True
if file_hash_l and file_hash_l in hashes: if target_path_l and target_path_l in paths:
return True
if file_path_l and file_path_l in paths:
return True return True
return False return False
@@ -114,7 +117,7 @@ def _update_item_title_fields(item: Any, new_title: str) -> None:
item["columns"] = updated_cols item["columns"] = updated_cols
def _refresh_result_table_title(new_title: str, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> None: def _refresh_result_table_title(new_title: str, target_hash: Optional[str], target_path: Optional[str]) -> None:
"""Refresh the cached result table with an updated title and redisplay it.""" """Refresh the cached result table with an updated title and redisplay it."""
try: try:
last_table = ctx.get_last_result_table() last_table = ctx.get_last_result_table()
@@ -126,7 +129,7 @@ def _refresh_result_table_title(new_title: str, hydrus_hash: Optional[str], file
match_found = False match_found = False
for item in items: for item in items:
try: try:
if _matches_target(item, hydrus_hash, file_hash, file_path): if _matches_target(item, target_hash, target_path):
_update_item_title_fields(item, new_title) _update_item_title_fields(item, new_title)
match_found = True match_found = True
except Exception: except Exception:
@@ -149,31 +152,30 @@ def _refresh_result_table_title(new_title: str, hydrus_hash: Optional[str], file
pass pass
def _refresh_tags_view(res: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str], config: Dict[str, Any]) -> None: def _refresh_tags_view(res: Any, target_hash: Optional[str], store_name: Optional[str], target_path: Optional[str], config: Dict[str, Any]) -> None:
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh.""" """Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
try: try:
from cmdlets import get_tag as get_tag_cmd # type: ignore from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception: except Exception:
return return
target_hash = hydrus_hash or file_hash if not target_hash or not store_name:
refresh_args: List[str] = [] return
if target_hash:
refresh_args = ["-hash", target_hash, "-store", target_hash] refresh_args: List[str] = ["-hash", target_hash, "-store", store_name]
try: try:
subject = ctx.get_last_result_subject() subject = ctx.get_last_result_subject()
if subject and _matches_target(subject, hydrus_hash, file_hash, file_path): if subject and _matches_target(subject, target_hash, target_path):
get_tag_cmd._run(subject, refresh_args, config) get_tag_cmd._run(subject, refresh_args, config)
return return
except Exception: except Exception:
pass pass
if target_hash: try:
try: get_tag_cmd._run(res, refresh_args, config)
get_tag_cmd._run(res, refresh_args, config) except Exception:
except Exception: pass
pass
@@ -183,10 +185,11 @@ class Add_Tag(Cmdlet):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
name="add-tags", name="add-tags",
summary="Add tags to a Hydrus file or write them to a local .tags sidecar.", summary="Add tags to a file in a store.",
usage="add-tags [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]", usage="add-tags -store <store> [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
arg=[ arg=[
SharedArgs.HASH, SharedArgs.HASH,
SharedArgs.STORE,
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"), CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."), CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."), CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
@@ -194,11 +197,11 @@ class Add_Tag(Cmdlet):
], ],
detail=[ detail=[
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.", "- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.", "- Requires a store backend: use -store or pipe items that include store.",
"- With a Hydrus hash, tags are sent to the 'my tags' service.", "- If -hash is not provided, uses the piped item's hash (or derives from its path when possible).",
"- Multiple tags can be comma-separated or space-separated.", "- Multiple tags can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult", "- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- Tags can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"", "- Tags can also reference lists with curly braces: add-tags {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:", "- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)", " Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)", " Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
@@ -245,10 +248,8 @@ class Add_Tag(Cmdlet):
# Try multiple tag lookup strategies in order # Try multiple tag lookup strategies in order
tag_lookups = [ tag_lookups = [
lambda x: x.extra.get("tags") if isinstance(x, models.PipeObject) and isinstance(x.extra, dict) else None,
lambda x: x.get("tags") if isinstance(x, dict) else None,
lambda x: x.get("extra", {}).get("tags") if isinstance(x, dict) and isinstance(x.get("extra"), dict) else None,
lambda x: getattr(x, "tags", None), lambda x: getattr(x, "tags", None),
lambda x: x.get("tags") if isinstance(x, dict) else None,
] ]
for lookup in tag_lookups: for lookup in tag_lookups:
@@ -297,288 +298,158 @@ class Add_Tag(Cmdlet):
hash_override = normalize_hash(parsed.get("hash")) or extracted_hash hash_override = normalize_hash(parsed.get("hash")) or extracted_hash
duplicate_arg = parsed.get("duplicate") duplicate_arg = parsed.get("duplicate")
# If no tags provided (and no list), write sidecar files with embedded tags # Tags ARE provided - apply them to each store-backed result
# Note: Since 'tags' is required=False in the cmdlet arg, this block can be reached via CLI total_added = 0
# when no tag arguments are provided.
if not tags_to_add and not duplicate_arg:
# Write sidecar files with the tags that are already in the result dicts
sidecar_count = 0
for res in results:
# Handle both dict and PipeObject formats
file_path = None
tags = []
file_hash = ""
if isinstance(res, models.PipeObject):
file_path = res.file_path
tags = res.extra.get('tags', [])
file_hash = res.hash or ""
elif isinstance(res, dict):
file_path = res.get('file_path')
# Try multiple tag locations in order
tag_sources = [lambda: res.get('tags', []), lambda: res.get('extra', {}).get('tags', [])]
for source in tag_sources:
tags = source()
if tags:
break
file_hash = res.get('hash', "")
if not file_path:
log(f"[add_tags] Warning: Result has no file_path, skipping", file=sys.stderr)
ctx.emit(res)
continue
if tags:
# Write sidecar file for this file with its tags
try:
sidecar_path = write_sidecar(Path(file_path), tags, [], file_hash)
log(f"[add_tags] Wrote {len(tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
sidecar_count += 1
except Exception as e:
log(f"[add_tags] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
ctx.emit(res)
if sidecar_count > 0:
log(f"[add_tags] Wrote {sidecar_count} sidecar file(s) with embedded tags", file=sys.stderr)
else:
log(f"[add_tags] No tags to write - passed {len(results)} result(s) through unchanged", file=sys.stderr)
return 0
# Tags ARE provided - append them to each result and write sidecar files or add to Hydrus
sidecar_count = 0
total_new_tags = 0
total_modified = 0 total_modified = 0
for res in results:
# Handle both dict and PipeObject formats
file_path = None
existing_tags = []
file_hash = ""
storage_source = None
hydrus_hash = None
# Define field name aliases to check store_override = parsed.get("store")
path_field_names = ['file_path', 'path']
source_field_names = ['storage_source', 'source', 'origin'] for res in results:
hash_field_names = ['hydrus_hash', 'hash', 'hash_hex'] store_name: Optional[str]
raw_hash: Optional[str]
raw_path: Optional[str]
if isinstance(res, models.PipeObject): if isinstance(res, models.PipeObject):
file_path = res.file_path store_name = store_override or res.store
existing_tags = res.extra.get('tags', []) raw_hash = res.hash
file_hash = res.file_hash or "" raw_path = res.path
for field in source_field_names:
storage_source = res.extra.get(field)
if storage_source:
break
hydrus_hash = res.extra.get('hydrus_hash')
elif isinstance(res, dict): elif isinstance(res, dict):
# Try path field names in order store_name = store_override or res.get("store")
for field in path_field_names: raw_hash = res.get("hash")
file_path = res.get(field) raw_path = res.get("path")
if file_path:
break
# Try tag locations in order
tag_sources = [lambda: res.get('tags', []), lambda: res.get('extra', {}).get('tags', [])]
for source in tag_sources:
existing_tags = source()
if existing_tags:
break
file_hash = res.get('file_hash', "")
# Try source field names in order (top-level then extra)
for field in source_field_names:
storage_source = res.get(field)
if storage_source:
break
if not storage_source and 'extra' in res:
for field in source_field_names:
storage_source = res.get('extra', {}).get(field)
if storage_source:
break
# Try hash field names in order (top-level then extra)
for field in hash_field_names:
hydrus_hash = res.get(field)
if hydrus_hash:
break
if not hydrus_hash and 'extra' in res:
for field in hash_field_names:
hydrus_hash = res.get('extra', {}).get(field)
if hydrus_hash:
break
if not hydrus_hash and file_hash:
hydrus_hash = file_hash
if not storage_source and hydrus_hash and not file_path:
storage_source = 'hydrus'
# If we have a file path but no storage source, assume local to avoid sidecar spam
if not storage_source and file_path:
storage_source = 'local'
else: else:
ctx.emit(res) ctx.emit(res)
continue continue
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)} if not store_name:
original_tags_snapshot = list(existing_tags) log("[add_tags] Error: Missing -store and item has no store field", file=sys.stderr)
original_title = _extract_title_tag(original_tags_snapshot) return 1
removed_tags: List[str] = []
# Apply hash override if provided resolved_hash = normalize_hash(hash_override) if hash_override else normalize_hash(raw_hash)
if hash_override: if not resolved_hash and raw_path:
hydrus_hash = hash_override try:
# If we have a hash override, we treat it as a Hydrus target p = Path(str(raw_path))
storage_source = "hydrus" stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
resolved_hash = stem.lower()
elif p.exists() and p.is_file():
resolved_hash = sha256_file(p)
except Exception:
resolved_hash = None
if not file_path and not hydrus_hash: if not resolved_hash:
log(f"[add_tags] Warning: Result has neither file_path nor hash available, skipping", file=sys.stderr) log("[add_tags] Warning: Item missing usable hash (and could not derive from path); skipping", file=sys.stderr)
ctx.emit(res) ctx.emit(res)
continue continue
try:
backend = Store(config)[str(store_name)]
except Exception as exc:
log(f"[add_tags] Error: Unknown store '{store_name}': {exc}", file=sys.stderr)
return 1
try:
existing_tags, _src = backend.get_tag(resolved_hash, config=config)
except Exception:
existing_tags = []
existing_tags_list = [t for t in (existing_tags or []) if isinstance(t, str)]
existing_lower = {t.lower() for t in existing_tags_list}
original_title = _extract_title_tag(existing_tags_list)
# Per-item tag list (do not mutate shared list)
item_tags_to_add = list(tags_to_add)
item_tags_to_add = collapse_namespace_tags(item_tags_to_add, "title", prefer="last")
# Handle -duplicate logic (copy existing tags to new namespaces) # Handle -duplicate logic (copy existing tags to new namespaces)
if duplicate_arg: if duplicate_arg:
# Parse duplicate format: source:target1,target2 or source,target1,target2 parts = str(duplicate_arg).split(':')
parts = duplicate_arg.split(':')
source_ns = "" source_ns = ""
targets = [] targets: list[str] = []
if len(parts) > 1: if len(parts) > 1:
# Explicit format: source:target1,target2
source_ns = parts[0] source_ns = parts[0]
targets = parts[1].split(',') targets = [t.strip() for t in parts[1].split(',') if t.strip()]
else: else:
# Inferred format: source,target1,target2 parts2 = str(duplicate_arg).split(',')
parts = duplicate_arg.split(',') if len(parts2) > 1:
if len(parts) > 1: source_ns = parts2[0]
source_ns = parts[0] targets = [t.strip() for t in parts2[1:] if t.strip()]
targets = parts[1:]
if source_ns and targets: if source_ns and targets:
# Find tags in source namespace source_prefix = source_ns.lower() + ":"
source_tags = [t for t in existing_tags if t.startswith(source_ns + ':')] for t in existing_tags_list:
for t in source_tags: if not t.lower().startswith(source_prefix):
value = t.split(':', 1)[1] continue
value = t.split(":", 1)[1]
for target_ns in targets: for target_ns in targets:
new_tag = f"{target_ns}:{value}" new_tag = f"{target_ns}:{value}"
if new_tag not in existing_tags and new_tag not in tags_to_add: if new_tag.lower() not in existing_lower:
tags_to_add.append(new_tag) item_tags_to_add.append(new_tag)
# Merge new tags with existing tags, handling namespace overwrites # Namespace replacement: delete old namespace:* when adding namespace:value
# When adding a tag like "namespace:value", remove any existing "namespace:*" tags removed_namespace_tags: list[str] = []
for new_tag in tags_to_add: for new_tag in item_tags_to_add:
# Check if this is a namespaced tag (format: "namespace:value") if not isinstance(new_tag, str) or ":" not in new_tag:
if ':' in new_tag: continue
namespace = new_tag.split(':', 1)[0] ns = new_tag.split(":", 1)[0].strip()
# Track removals for Hydrus: delete old tags in same namespace (except identical) if not ns:
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()] continue
removed_tags.extend(to_remove) ns_prefix = ns.lower() + ":"
# Remove any existing tags with the same namespace for t in existing_tags_list:
existing_tags = [t for t in existing_tags if not (t.startswith(namespace + ':'))] if t.lower().startswith(ns_prefix) and t.lower() != new_tag.lower():
removed_namespace_tags.append(t)
# Add the new tag if not already present removed_namespace_tags = sorted({t for t in removed_namespace_tags})
if new_tag not in existing_tags:
existing_tags.append(new_tag)
# Ensure only one tag per namespace (e.g., single title:) with latest preferred actual_tags_to_add = [t for t in item_tags_to_add if isinstance(t, str) and t.lower() not in existing_lower]
existing_tags = collapse_namespace_tags(existing_tags, "title", prefer="last")
# Compute new tags relative to original changed = False
new_tags_added = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower] if removed_namespace_tags:
total_new_tags += len(new_tags_added) try:
backend.delete_tag(resolved_hash, removed_namespace_tags, config=config)
changed = True
except Exception as exc:
log(f"[add_tags] Warning: Failed deleting namespace tags: {exc}", file=sys.stderr)
# Update the result's tags if actual_tags_to_add:
try:
backend.add_tag(resolved_hash, actual_tags_to_add, config=config)
changed = True
except Exception as exc:
log(f"[add_tags] Warning: Failed adding tags: {exc}", file=sys.stderr)
if changed:
total_added += len(actual_tags_to_add)
total_modified += 1
try:
refreshed_tags, _src2 = backend.get_tag(resolved_hash, config=config)
refreshed_list = [t for t in (refreshed_tags or []) if isinstance(t, str)]
except Exception:
refreshed_list = existing_tags_list
# Update the result's tags using canonical field
if isinstance(res, models.PipeObject): if isinstance(res, models.PipeObject):
res.extra['tags'] = existing_tags res.tags = refreshed_list
elif isinstance(res, dict): elif isinstance(res, dict):
res['tags'] = existing_tags res["tags"] = refreshed_list
# If a title: tag was added, update the in-memory title and columns so downstream display reflects it immediately final_title = _extract_title_tag(refreshed_list)
title_value = _extract_title_tag(existing_tags) _apply_title_to_result(res, final_title)
_apply_title_to_result(res, title_value)
final_tags = existing_tags
# Determine where to add tags: Hydrus, local DB, or sidecar
if storage_source and storage_source.lower() == 'hydrus':
# Add tags to Hydrus using the API
target_hash = hydrus_hash or file_hash
if target_hash:
try:
tags_to_send = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
hydrus_client = hydrus_wrapper.get_client(config)
service_name = hydrus_wrapper.get_tag_service_name(config)
if tags_to_send:
log(f"[add_tags] Adding {len(tags_to_send)} new tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
hydrus_client.add_tags(target_hash, tags_to_send, service_name)
else:
log(f"[add_tags] No new tags to add for Hydrus file: {target_hash}", file=sys.stderr)
# Delete old namespace tags we replaced (e.g., previous title:)
if removed_tags:
unique_removed = sorted(set(removed_tags))
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
if tags_to_send:
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
elif removed_tags:
log(f"[add_tags] ✓ Removed {len(unique_removed)} tag(s) from Hydrus", file=sys.stderr)
sidecar_count += 1
if tags_to_send or removed_tags:
total_modified += 1
except Exception as e:
log(f"[add_tags] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
else:
log(f"[add_tags] Warning: No hash available for Hydrus file, skipping", file=sys.stderr)
elif storage_source and storage_source.lower() == 'local':
# For local storage, save directly to DB (no sidecar needed)
if file_path:
library_root = get_local_storage_path(config)
if library_root:
try:
path_obj = Path(file_path)
with FolderDB(library_root) as db:
db.save_tags(path_obj, existing_tags)
# Reload tags to reflect DB state (preserves auto-title logic)
file_hash = db.get_file_hash(path_obj)
refreshed_tags = db.get_tags(file_hash) if file_hash else existing_tags
# Recompute title from refreshed tags for accurate display
refreshed_title = _extract_title_tag(refreshed_tags)
if refreshed_title:
_apply_title_to_result(res, refreshed_title)
res_tags = refreshed_tags or existing_tags
if isinstance(res, models.PipeObject):
res.extra['tags'] = res_tags
elif isinstance(res, dict):
res['tags'] = res_tags
log(f"[add_tags] Added {len(new_tags_added)} new tag(s); {len(res_tags)} total tag(s) stored locally", file=sys.stderr)
sidecar_count += 1
if new_tags_added or removed_tags:
total_modified += 1
final_tags = res_tags
except Exception as e:
log(f"[add_tags] Warning: Failed to save tags to local DB: {e}", file=sys.stderr)
else:
log(f"[add_tags] Warning: No library root configured for local storage, skipping", file=sys.stderr)
else:
log(f"[add_tags] Warning: No file path for local storage, skipping", file=sys.stderr)
else:
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
# (local/hydrus are handled above).
ctx.emit(res)
continue
# If title changed, refresh the cached result table so the display reflects the new name
final_title = _extract_title_tag(final_tags)
if final_title and (not original_title or final_title.lower() != original_title.lower()): if final_title and (not original_title or final_title.lower() != original_title.lower()):
_refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path) _refresh_result_table_title(final_title, resolved_hash, raw_path)
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh) if changed:
if new_tags_added or removed_tags: _refresh_tags_view(res, resolved_hash, str(store_name), raw_path, config)
_refresh_tags_view(res, hydrus_hash, file_hash, file_path, config)
# Emit the modified result
ctx.emit(res) ctx.emit(res)
log(f"[add_tags] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr) log(
f"[add_tags] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
file=sys.stderr,
)
return 0 return 0

View File

@@ -6,8 +6,8 @@ import sys
from . import register from . import register
import pipeline as ctx import pipeline as ctx
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
from helper.logger import log from SYS.logger import log
from helper.store import FileStorage from Store import Store
class Add_Url(Cmdlet): class Add_Url(Cmdlet):
@@ -54,19 +54,19 @@ class Add_Url(Cmdlet):
return 1 return 1
# Parse url (comma-separated) # Parse url (comma-separated)
url = [u.strip() for u in str(url_arg).split(',') if u.strip()] urls = [u.strip() for u in str(url_arg).split(',') if u.strip()]
if not url: if not urls:
log("Error: No valid url provided") log("Error: No valid url provided")
return 1 return 1
# Get backend and add url # Get backend and add url
try: try:
storage = FileStorage(config) storage = Store(config)
backend = storage[store_name] backend = storage[store_name]
for url in url: backend.add_url(file_hash, urls)
backend.add_url(file_hash, url) for u in urls:
ctx.emit(f"Added URL: {url}") ctx.emit(f"Added URL: {u}")
return 0 return 0

View File

@@ -81,7 +81,6 @@ def get_cmdlet_metadata(cmd_name: str) -> Optional[Dict[str, Any]]:
mod = import_cmd_module(normalized) mod = import_cmd_module(normalized)
data = getattr(mod, "CMDLET", None) if mod else None data = getattr(mod, "CMDLET", None) if mod else None
# Fallback: resolve via registered function's module (covers aliases)
if data is None: if data is None:
try: try:
reg_fn = (REGISTRY or {}).get(cmd_name.replace('_', '-').lower()) reg_fn = (REGISTRY or {}).get(cmd_name.replace('_', '-').lower())

View File

@@ -4,10 +4,10 @@ from typing import Any, Dict, Sequence
import json import json
import sys import sys
from helper.logger import log from SYS.logger import log
from . import register from . import register
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, should_show_help from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, should_show_help

View File

@@ -11,7 +11,7 @@ from pathlib import Path
import sys import sys
import json import json
from helper.logger import log from SYS.logger import log
from . import register from . import register
from ._shared import Cmdlet, CmdletArg, get_pipe_object_path, normalize_result_input, filter_results_by_temp, should_show_help from ._shared import Cmdlet, CmdletArg, get_pipe_object_path, normalize_result_input, filter_results_by_temp, should_show_help

View File

@@ -5,10 +5,10 @@ from typing import Any, Dict, Sequence
import sys import sys
from pathlib import Path from pathlib import Path
from helper.logger import debug, log from SYS.logger import debug, log
from helper.store import Folder from Store.Folder import Folder
from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, get_origin, get_field, should_show_help from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, get_field, should_show_help
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
import pipeline as ctx import pipeline as ctx
@@ -48,17 +48,17 @@ class Delete_File(Cmdlet):
hash_hex_raw = get_field(item, "hash_hex") or get_field(item, "hash") hash_hex_raw = get_field(item, "hash_hex") or get_field(item, "hash")
target = get_field(item, "target") or get_field(item, "file_path") or get_field(item, "path") target = get_field(item, "target") or get_field(item, "file_path") or get_field(item, "path")
origin = get_origin(item)
# Also check the store field explicitly from PipeObject
store = None store = None
if isinstance(item, dict): if isinstance(item, dict):
store = item.get("store") store = item.get("store")
else: else:
store = get_field(item, "store") store = get_field(item, "store")
store_lower = str(store).lower() if store else ""
is_hydrus_store = bool(store_lower) and ("hydrus" in store_lower or store_lower in {"home", "work"})
# For Hydrus files, the target IS the hash # For Hydrus files, the target IS the hash
if origin and origin.lower() == "hydrus" and not hash_hex_raw: if is_hydrus_store and not hash_hex_raw:
hash_hex_raw = target hash_hex_raw = target
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw) hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw)
@@ -72,7 +72,7 @@ class Delete_File(Cmdlet):
# If lib_root is provided and this is from a folder store, use the Folder class # If lib_root is provided and this is from a folder store, use the Folder class
if lib_root: if lib_root:
try: try:
folder = Folder(Path(lib_root), name=origin or "local") folder = Folder(Path(lib_root), name=store or "local")
if folder.delete_file(str(path)): if folder.delete_file(str(path)):
local_deleted = True local_deleted = True
ctx.emit(f"Removed file: {path.name}") ctx.emit(f"Removed file: {path.name}")
@@ -109,17 +109,7 @@ class Delete_File(Cmdlet):
pass pass
hydrus_deleted = False hydrus_deleted = False
# Only attempt Hydrus deletion if store is explicitly Hydrus-related should_try_hydrus = is_hydrus_store
# Check both origin and store fields to determine if this is a Hydrus file
should_try_hydrus = False
# Check if store indicates this is a Hydrus backend
if store and ("hydrus" in store.lower() or store.lower() == "home" or store.lower() == "work"):
should_try_hydrus = True
# Fallback to origin check if store not available
elif origin and origin.lower() == "hydrus":
should_try_hydrus = True
# If conserve is set to hydrus, definitely don't delete # If conserve is set to hydrus, definitely don't delete
if conserve == "hydrus": if conserve == "hydrus":

View File

@@ -4,9 +4,9 @@ from typing import Any, Dict, Sequence
import json import json
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, should_show_help, get_field from ._shared import Cmdlet, CmdletArg, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, should_show_help, get_field
from helper.logger import log from SYS.logger import log
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="delete-note", name="delete-note",

View File

@@ -7,11 +7,11 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
from helper.logger import log from SYS.logger import log
import pipeline as ctx import pipeline as ctx
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, get_field from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, get_field
from helper.folder_store import LocalLibrarySearchOptimizer from API.folder import LocalLibrarySearchOptimizer
from config import get_local_storage_path from config import get_local_storage_path

View File

@@ -8,12 +8,12 @@ import sys
from . import register from . import register
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, should_show_help, get_field
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, fetch_hydrus_metadata, should_show_help, get_field from SYS.logger import debug, log
from helper.logger import debug, log from Store import Store
def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, config: Dict[str, Any]) -> None: def _refresh_tag_view_if_current(file_hash: str | None, store_name: str | None, path: str | None, config: Dict[str, Any]) -> None:
"""If the current subject matches the target, refresh tags via get-tag.""" """If the current subject matches the target, refresh tags via get-tag."""
try: try:
from cmdlets import get_tag as get_tag_cmd # type: ignore from cmdlets import get_tag as get_tag_cmd # type: ignore
@@ -28,17 +28,17 @@ def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, co
def norm(val: Any) -> str: def norm(val: Any) -> str:
return str(val).lower() return str(val).lower()
target_hash = norm(hash_hex) if hash_hex else None target_hash = norm(file_hash) if file_hash else None
target_path = norm(file_path) if file_path else None target_path = norm(path) if path else None
subj_hashes: list[str] = [] subj_hashes: list[str] = []
subj_paths: list[str] = [] subj_paths: list[str] = []
if isinstance(subject, dict): if isinstance(subject, dict):
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v] subj_hashes = [norm(v) for v in [subject.get("hash")] if v]
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v] subj_paths = [norm(v) for v in [subject.get("path"), subject.get("target")] if v]
else: else:
subj_hashes = [norm(get_field(subject, f)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if get_field(subject, f)] subj_hashes = [norm(get_field(subject, f)) for f in ("hash",) if get_field(subject, f)]
subj_paths = [norm(get_field(subject, f)) for f in ("file_path", "path", "target") if get_field(subject, f)] subj_paths = [norm(get_field(subject, f)) for f in ("path", "target") if get_field(subject, f)]
is_match = False is_match = False
if target_hash and target_hash in subj_hashes: if target_hash and target_hash in subj_hashes:
@@ -49,20 +49,20 @@ def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, co
return return
refresh_args: list[str] = [] refresh_args: list[str] = []
if hash_hex: if file_hash:
refresh_args.extend(["-hash", hash_hex]) refresh_args.extend(["-hash", file_hash])
get_tag_cmd._run(subject, refresh_args, config) get_tag_cmd._run(subject, refresh_args, config)
except Exception: except Exception:
pass pass
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="delete-tags", name="delete-tag",
summary="Remove tags from a Hydrus file.", summary="Remove tags from a file in a store.",
usage="del-tags [-hash <sha256>] <tag>[,<tag>...]", usage="delete-tag -store <store> [-hash <sha256>] <tag>[,<tag>...]",
alias=["del-tag", "del-tags", "delete-tag"],
arg=[ arg=[
SharedArgs.HASH, SharedArgs.HASH,
SharedArgs.STORE,
CmdletArg("<tag>[,<tag>...]", required=True, description="One or more tags to remove. Comma- or space-separated."), CmdletArg("<tag>[,<tag>...]", required=True, description="One or more tags to remove. Comma- or space-separated."),
], ],
detail=[ detail=[
@@ -71,7 +71,7 @@ CMDLET = Cmdlet(
], ],
) )
@register(["del-tag", "del-tags", "delete-tag", "delete-tags"]) # Still needed for backward compatibility @register(["delete-tag"])
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 # Help
if should_show_help(args): if should_show_help(args):
@@ -94,6 +94,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Parse -hash override and collect tags from remaining args # Parse -hash override and collect tags from remaining args
override_hash: str | None = None override_hash: str | None = None
override_store: str | None = None
rest: list[str] = [] rest: list[str] = []
i = 0 i = 0
while i < len(args): while i < len(args):
@@ -103,6 +104,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
override_hash = str(args[i + 1]).strip() override_hash = str(args[i + 1]).strip()
i += 2 i += 2
continue continue
if low in {"-store", "--store", "store"} and i + 1 < len(args):
override_store = str(args[i + 1]).strip()
i += 2
continue
rest.append(a) rest.append(a)
i += 1 i += 1
@@ -110,7 +115,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# @5 or @{2,5,8} to delete tags from ResultTable by index # @5 or @{2,5,8} to delete tags from ResultTable by index
tags_from_at_syntax = [] tags_from_at_syntax = []
hash_from_at_syntax = None hash_from_at_syntax = None
file_path_from_at_syntax = None path_from_at_syntax = None
store_from_at_syntax = None
if rest and str(rest[0]).startswith("@"): if rest and str(rest[0]).startswith("@"):
selector_arg = str(rest[0]) selector_arg = str(rest[0])
@@ -142,9 +148,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
tags_from_at_syntax.append(tag_name) tags_from_at_syntax.append(tag_name)
# Also get hash from first item for consistency # Also get hash from first item for consistency
if not hash_from_at_syntax: if not hash_from_at_syntax:
hash_from_at_syntax = get_field(item, 'hash_hex') hash_from_at_syntax = get_field(item, 'hash')
if not file_path_from_at_syntax: if not path_from_at_syntax:
file_path_from_at_syntax = get_field(item, 'file_path') path_from_at_syntax = get_field(item, 'path')
if not store_from_at_syntax:
store_from_at_syntax = get_field(item, 'store')
if not tags_from_at_syntax: if not tags_from_at_syntax:
log(f"No tags found at indices: {indices}") log(f"No tags found at indices: {indices}")
@@ -201,10 +209,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# This preserves the existing logic for @ selection. # This preserves the existing logic for @ selection.
tags = tags_from_at_syntax tags = tags_from_at_syntax
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax) file_hash = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax)
file_path = file_path_from_at_syntax path = path_from_at_syntax
store_name = override_store or store_from_at_syntax
if _process_deletion(tags, hash_hex, file_path, config): if _process_deletion(tags, file_hash, path, store_name, config):
success_count += 1 success_count += 1
else: else:
@@ -216,13 +225,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
for item in items_to_process: for item in items_to_process:
tags_to_delete = [] tags_to_delete = []
item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash_hex")) item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash"))
item_path = ( item_path = (
get_field(item, "path") get_field(item, "path")
or get_field(item, "file_path")
or get_field(item, "target") or get_field(item, "target")
) )
item_source = get_field(item, "source") item_store = override_store or get_field(item, "store")
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem': if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
# It's a TagItem # It's a TagItem
@@ -248,66 +256,43 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# but inside the loop we might have mixed items? Unlikely. # but inside the loop we might have mixed items? Unlikely.
continue continue
if tags_to_delete and (item_hash or item_path): if tags_to_delete:
if _process_deletion(tags_to_delete, item_hash, item_path, config, source=item_source): if _process_deletion(tags_to_delete, item_hash, item_path, item_store, config):
success_count += 1 success_count += 1
if success_count > 0: if success_count > 0:
return 0 return 0
return 1 return 1
def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | None, config: Dict[str, Any], source: str | None = None) -> bool: def _process_deletion(tags: list[str], file_hash: str | None, path: str | None, store_name: str | None, config: Dict[str, Any]) -> bool:
"""Helper to execute the deletion logic for a single target.""" """Helper to execute the deletion logic for a single target."""
if not tags: if not tags:
return False return False
if not store_name:
log("Store is required (use -store or pipe a result with store)", file=sys.stderr)
return False
resolved_hash = normalize_hash(file_hash) if file_hash else None
if not resolved_hash and path:
try:
from SYS.utils import sha256_file
resolved_hash = sha256_file(Path(path))
except Exception:
resolved_hash = None
if not resolved_hash:
log("Item does not include a usable hash (and hash could not be derived from path)", file=sys.stderr)
return False
def _fetch_existing_tags() -> list[str]: def _fetch_existing_tags() -> list[str]:
existing: list[str] = [] try:
# Prefer local DB when we have a path and not explicitly hydrus backend = Store(config)[store_name]
if file_path and (source == "local" or (source != "hydrus" and not hash_hex)): existing, _src = backend.get_tag(resolved_hash, config=config)
try: return list(existing or [])
from helper.folder_store import FolderDB except Exception:
from config import get_local_storage_path return []
path_obj = Path(file_path)
local_root = get_local_storage_path(config) or path_obj.parent
with FolderDB(local_root) as db:
file_hash = db.get_file_hash(path_obj)
existing = db.get_tags(file_hash) if file_hash else []
except Exception:
existing = []
elif hash_hex:
meta, _ = fetch_hydrus_metadata(
config, hash_hex,
include_service_keys_to_tags=True,
include_file_url=False,
)
if isinstance(meta, dict):
tags_payload = meta.get("tags")
if isinstance(tags_payload, dict):
seen: set[str] = set()
for svc_data in tags_payload.values():
if not isinstance(svc_data, dict):
continue
display = svc_data.get("display_tags")
if isinstance(display, list):
for t in display:
if isinstance(t, (str, bytes)):
val = str(t).strip()
if val and val not in seen:
seen.add(val)
existing.append(val)
storage = svc_data.get("storage_tags")
if isinstance(storage, dict):
current_list = storage.get("0") or storage.get(0)
if isinstance(current_list, list):
for t in current_list:
if isinstance(t, (str, bytes)):
val = str(t).strip()
if val and val not in seen:
seen.add(val)
existing.append(val)
return existing
# Safety: only block if this deletion would remove the final title tag # Safety: only block if this deletion would remove the final title tag
title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")] title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")]
@@ -320,61 +305,17 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
log("Cannot delete the last title: tag. Add a replacement title first (add-tag \"title:new title\").", file=sys.stderr) log("Cannot delete the last title: tag. Add a replacement title first (add-tag \"title:new title\").", file=sys.stderr)
return False return False
if not hash_hex and not file_path:
log("Item does not include a hash or file path")
return False
# Handle local file tag deletion
if file_path and (source == "local" or (not hash_hex and source != "hydrus")):
try:
from helper.folder_store import FolderDB
from pathlib import Path
path_obj = Path(file_path)
if not path_obj.exists():
log(f"File not found: {file_path}")
return False
# Try to get local storage path from config
from config import get_local_storage_path
local_root = get_local_storage_path(config)
if not local_root:
# Fallback: assume file is in a library root or use its parent
local_root = path_obj.parent
with FolderDB(local_root) as db:
db.remove_tags(path_obj, tags)
debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)")
_refresh_tag_view_if_current(hash_hex, file_path, config)
return True
except Exception as exc:
log(f"Failed to remove local tags: {exc}")
return False
# Hydrus deletion logic
if not hash_hex:
return False
try: try:
service_name = hydrus_wrapper.get_tag_service_name(config) backend = Store(config)[store_name]
client = hydrus_wrapper.get_client(config) ok = backend.delete_tag(resolved_hash, list(tags), config=config)
if ok:
if client is None: preview = resolved_hash[:12] + ('' if len(resolved_hash) > 12 else '')
log("Hydrus client unavailable") debug(f"Removed {len(tags)} tag(s) from {preview} via store '{store_name}'.")
return False _refresh_tag_view_if_current(resolved_hash, store_name, path, config)
return True
debug(f"Sending deletion request: hash={hash_hex}, tags={tags}, service={service_name}") return False
client.delete_tags(hash_hex, tags, service_name)
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
debug(f"Removed {len(tags)} tag(s) from {preview} via '{service_name}'.")
_refresh_tag_view_if_current(hash_hex, None, config)
return True
except Exception as exc: except Exception as exc:
log(f"Hydrus del-tag failed: {exc}") log(f"del-tag failed: {exc}")
return False return False

View File

@@ -6,8 +6,8 @@ import sys
from . import register from . import register
import pipeline as ctx import pipeline as ctx
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
from helper.logger import log from SYS.logger import log
from helper.store import FileStorage from Store import Store
class Delete_Url(Cmdlet): class Delete_Url(Cmdlet):
@@ -54,19 +54,19 @@ class Delete_Url(Cmdlet):
return 1 return 1
# Parse url (comma-separated) # Parse url (comma-separated)
url = [u.strip() for u in str(url_arg).split(',') if u.strip()] urls = [u.strip() for u in str(url_arg).split(',') if u.strip()]
if not url: if not urls:
log("Error: No valid url provided") log("Error: No valid url provided")
return 1 return 1
# Get backend and delete url # Get backend and delete url
try: try:
storage = FileStorage(config) storage = Store(config)
backend = storage[store_name] backend = storage[store_name]
for url in url: backend.delete_url(file_hash, urls)
backend.delete_url(file_hash, url) for u in urls:
ctx.emit(f"Deleted URL: {url}") ctx.emit(f"Deleted URL: {u}")
return 0 return 0

View File

@@ -15,8 +15,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
from helper.download import DownloadError, _download_direct_file from SYS.download import DownloadError, _download_direct_file
from helper.logger import log, debug from SYS.logger import log, debug
from models import DownloadOptions from models import DownloadOptions
import pipeline as pipeline_context import pipeline as pipeline_context
@@ -168,19 +168,16 @@ class Download_File(Cmdlet):
# Build tags with title for searchability # Build tags with title for searchability
tags = [f"title:{title}"] tags = [f"title:{title}"]
# Prefer canonical fields while keeping legacy keys for compatibility # Canonical pipeline payload (no legacy aliases)
return { return {
"path": str(media_path), "path": str(media_path),
"hash": hash_value, "hash": hash_value,
"file_hash": hash_value,
"title": title, "title": title,
"file_title": title, "file_title": title,
"action": "cmdlet:download-file", "action": "cmdlet:download-file",
"download_mode": "file", "download_mode": "file",
"url": url or (download_result.get('url') if isinstance(download_result, dict) else None), "url": url or (download_result.get('url') if isinstance(download_result, dict) else None),
"url": [url] if url else [],
"store": "local", "store": "local",
"storage_source": "downloads",
"media_kind": "file", "media_kind": "file",
"tags": tags, "tags": tags,
} }

View File

@@ -29,9 +29,9 @@ from typing import Any, Dict, Iterator, List, Optional
import httpx import httpx
from helper.logger import log, debug from SYS.logger import log, debug
from helper.utils import ensure_directory, sha256_file from SYS.utils import ensure_directory, sha256_file
from helper.http_client import HTTPClient from API.HTTP import HTTPClient
from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar
import pipeline as pipeline_context import pipeline as pipeline_context
from result_table import ResultTable from result_table import ResultTable
@@ -1199,7 +1199,7 @@ class Download_Media(Cmdlet):
# Build format dict for emission and table # Build format dict for emission and table
format_dict = { format_dict = {
"origin": "download-media", "table": "download-media",
"title": f"Format {format_id}", "title": f"Format {format_id}",
"url": url, "url": url,
"target": url, "target": url,

View File

@@ -0,0 +1,157 @@
"""download-provider cmdlet: Download items from external providers."""
from __future__ import annotations
from typing import Any, Dict, Sequence, List, Optional
from pathlib import Path
import sys
import json
from SYS.logger import log, debug
from Provider.registry import get_search_provider, SearchResult
from SYS.utils import unique_path
from ._shared import Cmdlet, CmdletArg, should_show_help, get_field, coerce_to_pipe_object
import pipeline as ctx
# Optional dependencies
try:
from config import get_local_storage_path, resolve_output_dir
except Exception: # pragma: no cover
get_local_storage_path = None # type: ignore
resolve_output_dir = None # type: ignore
class Download_Provider(Cmdlet):
"""Download items from external providers."""
def __init__(self):
super().__init__(
name="download-provider",
summary="Download items from external providers (soulseek, libgen, etc).",
usage="download-provider [item] [-output DIR]",
arg=[
CmdletArg("output", type="string", alias="o", description="Output directory"),
],
detail=[
"Download items from external providers.",
"Usually called automatically by @N selection on provider results.",
"Can be used manually by piping a provider result item.",
],
exec=self.run
)
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Execute download-provider cmdlet."""
if should_show_help(args):
ctx.emit(self.__dict__)
return 0
# Parse arguments
output_dir_arg = None
i = 0
while i < len(args):
arg = args[i]
if arg in ("-output", "--output", "-o") and i + 1 < len(args):
output_dir_arg = args[i+1]
i += 2
else:
i += 1
# Determine output directory
if output_dir_arg:
output_dir = Path(output_dir_arg)
elif resolve_output_dir:
output_dir = resolve_output_dir(config)
else:
output_dir = Path("./downloads")
output_dir.mkdir(parents=True, exist_ok=True)
# Process input result
items = []
if isinstance(result, list):
items = result
elif result:
items = [result]
if not items:
log("No items to download", file=sys.stderr)
return 1
success_count = 0
for item in items:
try:
# Extract provider info
table = get_field(item, "table")
if not table:
log(f"Skipping item without provider info: {item}", file=sys.stderr)
continue
provider = get_search_provider(table, config)
if not provider:
log(f"Provider '{table}' not available for download", file=sys.stderr)
continue
# Reconstruct SearchResult if needed
# The provider.download method expects a SearchResult object or compatible dict
if isinstance(item, dict):
# Ensure full_metadata is present
if "full_metadata" not in item and "extra" in item:
item["full_metadata"] = item["extra"].get("full_metadata", {})
search_result = SearchResult(
table=table,
title=item.get("title", "Unknown"),
path=item.get("path", ""),
full_metadata=item.get("full_metadata", {})
)
else:
# Assume it's an object with attributes (like PipeObject)
full_metadata = getattr(item, "full_metadata", {})
# Check extra dict if full_metadata is missing/empty
if not full_metadata and hasattr(item, "extra") and isinstance(item.extra, dict):
full_metadata = item.extra.get("full_metadata", {})
# Fallback: if full_metadata key isn't there, maybe the extra dict IS the metadata
if not full_metadata and "username" in item.extra:
full_metadata = item.extra
search_result = SearchResult(
table=table,
title=getattr(item, "title", "Unknown"),
path=getattr(item, "path", ""),
full_metadata=full_metadata
)
debug(f"[download-provider] Downloading '{search_result.title}' via {table}...")
downloaded_path = provider.download(search_result, output_dir)
if downloaded_path:
debug(f"[download-provider] Download successful: {downloaded_path}")
# Create PipeObject for the downloaded file
pipe_obj = coerce_to_pipe_object({
"path": str(downloaded_path),
"title": search_result.title,
"table": "local", # Now it's a local file
"media_kind": getattr(item, "media_kind", "other"),
"tags": getattr(item, "tags", []),
"full_metadata": search_result.full_metadata
})
ctx.emit(pipe_obj)
success_count += 1
else:
log(f"Download failed for '{search_result.title}'", file=sys.stderr)
except Exception as e:
log(f"Error downloading item: {e}", file=sys.stderr)
import traceback
debug(traceback.format_exc())
if success_count > 0:
return 0
return 1
# Register cmdlet instance
Download_Provider_Instance = Download_Provider()

View File

@@ -14,7 +14,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Sequence from typing import Any, Dict, Optional, Sequence
from helper.logger import log from SYS.logger import log
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
class Download_Torrent(Cmdlet): class Download_Torrent(Cmdlet):
@@ -66,7 +66,7 @@ class Download_Torrent(Cmdlet):
worker_manager: Optional[Any] = None, worker_manager: Optional[Any] = None,
) -> None: ) -> None:
try: try:
from helper.alldebrid import AllDebridClient from API.alldebrid import AllDebridClient
client = AllDebridClient(api_key) client = AllDebridClient(api_key)
log(f"[Worker {worker_id}] Submitting magnet to AllDebrid...") log(f"[Worker {worker_id}] Submitting magnet to AllDebrid...")
magnet_info = client.magnet_add(magnet_url) magnet_info = client.magnet_add(magnet_url)

View File

@@ -8,8 +8,8 @@ import shutil
from . import register from . import register
import pipeline as ctx import pipeline as ctx
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
from helper.logger import log, debug from SYS.logger import log, debug
from helper.store import FileStorage from Store import Store
from config import resolve_output_dir from config import resolve_output_dir
@@ -68,8 +68,8 @@ class Get_File(Cmdlet):
debug(f"[get-file] Getting storage backend: {store_name}") debug(f"[get-file] Getting storage backend: {store_name}")
# Get storage backend # Get storage backend
storage = FileStorage(config) store = Store(config)
backend = storage[store_name] backend = store[store_name]
debug(f"[get-file] Backend retrieved: {type(backend).__name__}") debug(f"[get-file] Backend retrieved: {type(backend).__name__}")
# Get file metadata to determine name and extension # Get file metadata to determine name and extension

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ from typing import Any, Dict, Sequence, Optional
import json import json
import sys import sys
from helper.logger import log from SYS.logger import log
from pathlib import Path from pathlib import Path
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field
@@ -69,7 +69,7 @@ class Get_Metadata(Cmdlet):
return "" return ""
@staticmethod @staticmethod
def _build_table_row(title: str, origin: str, path: str, mime: str, size_bytes: Optional[int], def _build_table_row(title: str, store: str, path: str, mime: str, size_bytes: Optional[int],
dur_seconds: Optional[int], imported_ts: Optional[int], url: list[str], dur_seconds: Optional[int], imported_ts: Optional[int], url: list[str],
hash_value: Optional[str], pages: Optional[int] = None) -> Dict[str, Any]: hash_value: Optional[str], pages: Optional[int] = None) -> Dict[str, Any]:
"""Build a table row dict with metadata fields.""" """Build a table row dict with metadata fields."""
@@ -97,13 +97,13 @@ class Get_Metadata(Cmdlet):
("Size(MB)", str(size_mb) if size_mb is not None else ""), ("Size(MB)", str(size_mb) if size_mb is not None else ""),
(duration_label, duration_value), (duration_label, duration_value),
("Imported", imported_label), ("Imported", imported_label),
("Store", origin or ""), ("Store", store or ""),
] ]
return { return {
"title": title or path, "title": title or path,
"path": path, "path": path,
"origin": origin, "store": store,
"mime": mime, "mime": mime,
"size_bytes": size_bytes, "size_bytes": size_bytes,
"duration_seconds": dur_int, "duration_seconds": dur_int,
@@ -143,8 +143,8 @@ class Get_Metadata(Cmdlet):
parsed = parse_cmdlet_args(args, self) parsed = parse_cmdlet_args(args, self)
# Get hash and store from parsed args or result # Get hash and store from parsed args or result
file_hash = parsed.get("hash") or get_field(result, "hash") or get_field(result, "file_hash") or get_field(result, "hash_hex") file_hash = parsed.get("hash") or get_field(result, "hash")
storage_source = parsed.get("store") or get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin") storage_source = parsed.get("store") or get_field(result, "store")
if not file_hash: if not file_hash:
log("No hash available - use -hash to specify", file=sys.stderr) log("No hash available - use -hash to specify", file=sys.stderr)
@@ -156,8 +156,8 @@ class Get_Metadata(Cmdlet):
# Use storage backend to get metadata # Use storage backend to get metadata
try: try:
from helper.store import FileStorage from Store import Store
storage = FileStorage(config) storage = Store(config)
backend = storage[storage_source] backend = storage[storage_source]
# Get metadata from backend # Get metadata from backend
@@ -200,8 +200,8 @@ class Get_Metadata(Cmdlet):
# Build display row # Build display row
row = self._build_table_row( row = self._build_table_row(
title=title, title=title,
origin=storage_source, store=storage_source,
path=metadata.get("file_path", ""), path=metadata.get("path", ""),
mime=mime_type, mime=mime_type,
size_bytes=file_size, size_bytes=file_size,
dur_seconds=duration_seconds, dur_seconds=duration_seconds,

View File

@@ -6,9 +6,9 @@ import json
from . import register from . import register
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, get_field, should_show_help from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, get_field, should_show_help
from helper.logger import log from SYS.logger import log
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="get-note", name="get-note",

View File

@@ -5,13 +5,13 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
from helper.logger import log from SYS.logger import log
import models import models
import pipeline as ctx import pipeline as ctx
from helper import hydrus as hydrus_wrapper from API import HydrusNetwork as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, fmt_bytes, get_hash_for_operation, fetch_hydrus_metadata, should_show_help from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, fmt_bytes, get_hash_for_operation, fetch_hydrus_metadata, should_show_help
from helper.folder_store import FolderDB from API.folder import API_folder_store
from config import get_local_storage_path from config import get_local_storage_path
from result_table import ResultTable from result_table import ResultTable
@@ -53,7 +53,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
result = result[0] result = result[0]
# Initialize results collection # Initialize results collection
found_relationships = [] # List of dicts: {hash, type, title, path, origin} found_relationships = [] # List of dicts: {hash, type, title, path, store}
source_title = "Unknown" source_title = "Unknown"
def _add_relationship(entry: Dict[str, Any]) -> None: def _add_relationship(entry: Dict[str, Any]) -> None:
@@ -89,7 +89,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
storage_path = get_local_storage_path(config) storage_path = get_local_storage_path(config)
print(f"[DEBUG] Storage path: {storage_path}", file=sys.stderr) print(f"[DEBUG] Storage path: {storage_path}", file=sys.stderr)
if storage_path: if storage_path:
with FolderDB(storage_path) as db: with API_folder_store(storage_path) as db:
file_hash = db.get_file_hash(path_obj) file_hash = db.get_file_hash(path_obj)
metadata = db.get_metadata(file_hash) if file_hash else None metadata = db.get_metadata(file_hash) if file_hash else None
print(f"[DEBUG] Metadata found: {metadata is not None}", file=sys.stderr) print(f"[DEBUG] Metadata found: {metadata is not None}", file=sys.stderr)
@@ -130,7 +130,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"type": entry_type, "type": entry_type,
"title": title, "title": title,
"path": path, "path": path,
"origin": "local" "store": "local"
}) })
# RECURSIVE LOOKUP: If this is an "alt" relationship (meaning we're an alt pointing to a king), # RECURSIVE LOOKUP: If this is an "alt" relationship (meaning we're an alt pointing to a king),
@@ -169,7 +169,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"type": "king" if rel_type.lower() == "alt" else rel_type, "type": "king" if rel_type.lower() == "alt" else rel_type,
"title": parent_title, "title": parent_title,
"path": str(path), "path": str(path),
"origin": "local" "store": "local"
}) })
else: else:
# If already in results, ensure it's marked as king if appropriate # If already in results, ensure it's marked as king if appropriate
@@ -223,7 +223,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})", "type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
"title": child_title, "title": child_title,
"path": str(child_path_obj), "path": str(child_path_obj),
"origin": "local" "store": "local"
}) })
else: else:
print(f"[DEBUG] ⚠️ Parent has no relationships metadata", file=sys.stderr) print(f"[DEBUG] ⚠️ Parent has no relationships metadata", file=sys.stderr)
@@ -261,7 +261,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})", "type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
"title": child_title, "title": child_title,
"path": child_path, "path": child_path,
"origin": "local" "store": "local"
}) })
except Exception as e: except Exception as e:
@@ -299,7 +299,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
# "type": f"reverse-{rev_type}", # e.g. reverse-alt # "type": f"reverse-{rev_type}", # e.g. reverse-alt
# "title": rev_title, # "title": rev_title,
# "path": rev_path, # "path": rev_path,
# "origin": "local" # "store": "local"
# }) # })
except Exception as e: except Exception as e:
@@ -352,7 +352,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"type": rel_name, "type": rel_name,
"title": rel_hash, # Can't resolve title easily without another API call "title": rel_hash, # Can't resolve title easily without another API call
"path": None, "path": None,
"origin": "hydrus" "store": "hydrus"
}) })
except Exception as exc: except Exception as exc:
# Only log error if we didn't find local relationships either # Only log error if we didn't find local relationships either
@@ -390,7 +390,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
row.add_column("Type", item['type'].title()) row.add_column("Type", item['type'].title())
row.add_column("Title", item['title']) row.add_column("Title", item['title'])
# row.add_column("Hash", item['hash'][:16] + "...") # User requested removal # row.add_column("Hash", item['hash'][:16] + "...") # User requested removal
row.add_column("Origin", item['origin']) row.add_column("Store", item['store'])
# Create result object for pipeline # Create result object for pipeline
res_obj = { res_obj = {
@@ -398,7 +398,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
"hash": item['hash'], "hash": item['hash'],
"file_hash": item['hash'], "file_hash": item['hash'],
"relationship_type": item['type'], "relationship_type": item['type'],
"origin": item['origin'] "store": item['store']
} }
if item['path']: if item['path']:
res_obj["path"] = item['path'] res_obj["path"] = item['path']

View File

@@ -1,7 +1,7 @@
"""Get tags from Hydrus or local sidecar metadata. """Get tags from Hydrus or local sidecar metadata.
This cmdlet retrieves tags for a selected result, supporting both: This cmdlet retrieves tags for a selected result, supporting both:
- Hydrus Network (for files with hash_hex) - Hydrus Network (for files with hash)
- Local sidecar files (.tags) - Local sidecar files (.tags)
In interactive mode: navigate with numbers, add/delete tags In interactive mode: navigate with numbers, add/delete tags
@@ -12,15 +12,15 @@ from __future__ import annotations
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
from helper.metadata_search import get_metadata_provider, list_metadata_providers from Provider.metadata_provider import get_metadata_provider, list_metadata_providers
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
import pipeline as ctx import pipeline as ctx
from helper import hydrus from API import HydrusNetwork
from helper.folder_store import read_sidecar, write_sidecar, find_sidecar, FolderDB from API.folder import read_sidecar, write_sidecar, find_sidecar, API_folder_store
from ._shared import normalize_hash, looks_like_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field from ._shared import normalize_hash, looks_like_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field
from config import get_local_storage_path from config import get_local_storage_path
@@ -47,15 +47,14 @@ class TagItem:
""" """
tag_name: str tag_name: str
tag_index: int # 1-based index for user reference tag_index: int # 1-based index for user reference
hash_hex: Optional[str] = None hash: Optional[str] = None
source: str = "hydrus" store: str = "hydrus"
service_name: Optional[str] = None service_name: Optional[str] = None
file_path: Optional[str] = None path: Optional[str] = None
def __post_init__(self): def __post_init__(self):
# Make ResultTable happy by adding standard fields # Make ResultTable happy by adding standard fields
# NOTE: Don't set 'title' - we want only the tag column in ResultTable # NOTE: Don't set 'title' - we want only the tag column in ResultTable
self.origin = self.source
self.detail = f"Tag #{self.tag_index}" self.detail = f"Tag #{self.tag_index}"
self.target = self.tag_name self.target = self.tag_name
self.media_kind = "tag" self.media_kind = "tag"
@@ -65,20 +64,21 @@ class TagItem:
return { return {
"tag_name": self.tag_name, "tag_name": self.tag_name,
"tag_index": self.tag_index, "tag_index": self.tag_index,
"hash_hex": self.hash_hex, "hash": self.hash,
"source": self.source, "store": self.store,
"path": self.path,
"service_name": self.service_name, "service_name": self.service_name,
} }
def _emit_tags_as_table( def _emit_tags_as_table(
tags_list: List[str], tags_list: List[str],
hash_hex: Optional[str], file_hash: Optional[str],
source: str = "hydrus", store: str = "hydrus",
service_name: Optional[str] = None, service_name: Optional[str] = None,
config: Dict[str, Any] = None, config: Dict[str, Any] = None,
item_title: Optional[str] = None, item_title: Optional[str] = None,
file_path: Optional[str] = None, path: Optional[str] = None,
subject: Optional[Any] = None, subject: Optional[Any] = None,
) -> None: ) -> None:
"""Emit tags as TagItem objects and display via ResultTable. """Emit tags as TagItem objects and display via ResultTable.
@@ -92,8 +92,8 @@ def _emit_tags_as_table(
table_title = "Tags" table_title = "Tags"
if item_title: if item_title:
table_title = f"Tags: {item_title}" table_title = f"Tags: {item_title}"
if hash_hex: if file_hash:
table_title += f" [{hash_hex[:8]}]" table_title += f" [{file_hash[:8]}]"
table = ResultTable(table_title, max_columns=1) table = ResultTable(table_title, max_columns=1)
table.set_source_command("get-tag", []) table.set_source_command("get-tag", [])
@@ -104,10 +104,10 @@ def _emit_tags_as_table(
tag_item = TagItem( tag_item = TagItem(
tag_name=tag_name, tag_name=tag_name,
tag_index=idx, tag_index=idx,
hash_hex=hash_hex, hash=file_hash,
source=source, store=store,
service_name=service_name, service_name=service_name,
file_path=file_path, path=path,
) )
tag_items.append(tag_item) tag_items.append(tag_item)
table.add_result(tag_item) table.add_result(tag_item)
@@ -401,8 +401,8 @@ def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional
tag_item = TagItem( tag_item = TagItem(
tag_name=tag_name, tag_name=tag_name,
tag_index=idx, tag_index=idx,
hash_hex=hash_value, hash=hash_value,
source=source, store=source,
service_name=None service_name=None
) )
ctx.emit(tag_item) ctx.emit(tag_item)
@@ -698,7 +698,7 @@ def _scrape_isbn_metadata(isbn: str) -> List[str]:
"""Scrape metadata for an ISBN using Open Library API.""" """Scrape metadata for an ISBN using Open Library API."""
new_tags = [] new_tags = []
try: try:
from ..helper.http_client import HTTPClient from ..API.HTTP import HTTPClient
import json as json_module import json as json_module
isbn_clean = isbn.replace('-', '').strip() isbn_clean = isbn.replace('-', '').strip()
@@ -822,7 +822,7 @@ def _scrape_openlibrary_metadata(olid: str) -> List[str]:
""" """
new_tags = [] new_tags = []
try: try:
from ..helper.http_client import HTTPClient from ..API.HTTP import HTTPClient
import json as json_module import json as json_module
# Format: OL9674499M or just 9674499M # Format: OL9674499M or just 9674499M
@@ -995,7 +995,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
get-tag -scrape <url|provider> get-tag -scrape <url|provider>
Options: Options:
-hash <sha256>: Override hash to use instead of result's hash_hex -hash <sha256>: Override hash to use instead of result's hash
--store <key>: Store result to this key for pipeline --store <key>: Store result to this key for pipeline
--emit: Emit result without interactive prompt (quiet mode) --emit: Emit result without interactive prompt (quiet mode)
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks) -scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks)
@@ -1150,7 +1150,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
table = ResultTable(f"Metadata: {provider.name}") table = ResultTable(f"Metadata: {provider.name}")
table.set_source_command("get-tag", []) table.set_source_command("get-tag", [])
selection_payload = [] selection_payload = []
hash_for_payload = normalize_hash(hash_override) or normalize_hash(get_field(result, "hash_hex", None)) hash_for_payload = normalize_hash(hash_override) or normalize_hash(get_field(result, "hash", None))
store_for_payload = get_field(result, "store", None)
for idx, item in enumerate(items): for idx, item in enumerate(items):
tags = provider.to_tags(item) tags = provider.to_tags(item)
row = table.add_row() row = table.add_row()
@@ -1165,13 +1166,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"artist": item.get("artist"), "artist": item.get("artist"),
"album": item.get("album"), "album": item.get("album"),
"year": item.get("year"), "year": item.get("year"),
"hash": hash_for_payload,
"store": store_for_payload,
"extra": { "extra": {
"tags": tags, "tags": tags,
"provider": provider.name, "provider": provider.name,
"hydrus_hash": hash_for_payload,
"storage_source": get_field(result, "source", None) or get_field(result, "origin", None),
}, },
"file_hash": hash_for_payload,
} }
selection_payload.append(payload) selection_payload.append(payload)
table.set_row_selection_args(idx, [str(idx + 1)]) table.set_row_selection_args(idx, [str(idx + 1)])
@@ -1192,30 +1192,29 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if isinstance(result, list) and len(result) > 0: if isinstance(result, list) and len(result) > 0:
result = result[0] result = result[0]
hash_from_result = normalize_hash(get_field(result, "hash_hex", None)) hash_from_result = normalize_hash(get_field(result, "hash", None))
hash_hex = hash_override or hash_from_result file_hash = hash_override or hash_from_result
# Only use emit mode if explicitly requested with --emit flag, not just because we're in a pipeline # Only use emit mode if explicitly requested with --emit flag, not just because we're in a pipeline
# This allows interactive REPL to work even in pipelines # This allows interactive REPL to work even in pipelines
emit_mode = emit_requested or bool(store_key) emit_mode = emit_requested or bool(store_key)
store_label = (store_key.strip() if store_key and store_key.strip() else None) store_label = (store_key.strip() if store_key and store_key.strip() else None)
# Get hash and store from result # Get hash and store from result
file_hash = hash_hex store_name = get_field(result, "store")
storage_source = get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin")
if not file_hash: if not file_hash:
log("No hash available in result", file=sys.stderr) log("No hash available in result", file=sys.stderr)
return 1 return 1
if not storage_source: if not store_name:
log("No storage backend specified in result", file=sys.stderr) log("No store specified in result", file=sys.stderr)
return 1 return 1
# Get tags using storage backend # Get tags using storage backend
try: try:
from helper.store import FileStorage from Store import Store
storage = FileStorage(config) storage = Store(config)
backend = storage[storage_source] backend = storage[store_name]
current, source = backend.get_tag(file_hash, config=config) current, source = backend.get_tag(file_hash, config=config)
if not current: if not current:
@@ -1224,7 +1223,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
service_name = "" service_name = ""
except KeyError: except KeyError:
log(f"Storage backend '{storage_source}' not found", file=sys.stderr) log(f"Store '{store_name}' not found", file=sys.stderr)
return 1 return 1
except Exception as exc: except Exception as exc:
log(f"Failed to get tags: {exc}", file=sys.stderr) log(f"Failed to get tags: {exc}", file=sys.stderr)
@@ -1235,48 +1234,42 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
item_title = get_field(result, "title", None) or get_field(result, "name", None) or get_field(result, "filename", None) item_title = get_field(result, "title", None) or get_field(result, "name", None) or get_field(result, "filename", None)
# Build a subject payload representing the file whose tags are being shown # Build a subject payload representing the file whose tags are being shown
subject_origin = get_field(result, "origin", None) or get_field(result, "source", None) or source subject_store = get_field(result, "store", None) or store_name
subject_payload: Dict[str, Any] = { subject_payload: Dict[str, Any] = {
"tags": list(current), "tags": list(current),
"title": item_title, "title": item_title,
"name": item_title, "name": item_title,
"origin": subject_origin, "store": subject_store,
"source": subject_origin,
"storage_source": subject_origin,
"service_name": service_name, "service_name": service_name,
"extra": { "extra": {
"tags": list(current), "tags": list(current),
"storage_source": subject_origin,
"hydrus_hash": hash_hex,
}, },
} }
if hash_hex: if file_hash:
subject_payload.update({ subject_payload["hash"] = file_hash
"hash": hash_hex,
"hash_hex": hash_hex,
"file_hash": hash_hex,
"hydrus_hash": hash_hex,
})
if local_path: if local_path:
try: try:
path_text = str(local_path) path_text = str(local_path)
subject_payload.update({ subject_payload.update({
"file_path": path_text,
"path": path_text, "path": path_text,
"target": path_text,
}) })
subject_payload["extra"]["file_path"] = path_text
except Exception: except Exception:
pass pass
if source == "hydrus": _emit_tags_as_table(
_emit_tags_as_table(current, hash_hex=hash_hex, source="hydrus", service_name=service_name, config=config, item_title=item_title, subject=subject_payload) current,
else: file_hash=file_hash,
_emit_tags_as_table(current, hash_hex=hash_hex, source="local", service_name=None, config=config, item_title=item_title, file_path=str(local_path) if local_path else None, subject=subject_payload) store=subject_store,
service_name=service_name if source == "hydrus" else None,
config=config,
item_title=item_title,
path=str(local_path) if local_path else None,
subject=subject_payload,
)
# If emit requested or store key provided, emit payload # If emit requested or store key provided, emit payload
if emit_mode: if emit_mode:
_emit_tag_payload(source, current, hash_value=hash_hex, store_label=store_label) _emit_tag_payload(source, current, hash_value=file_hash, store_label=store_label)
return 0 return 0
@@ -1341,22 +1334,22 @@ class Get_Tag(Cmdlet):
# Get hash and store from parsed args or result # Get hash and store from parsed args or result
hash_override = parsed.get("hash") hash_override = parsed.get("hash")
file_hash = hash_override or get_field(result, "hash") or get_field(result, "file_hash") or get_field(result, "hash_hex") file_hash = normalize_hash(hash_override) or normalize_hash(get_field(result, "hash"))
storage_source = parsed.get("store") or get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin") store_name = parsed.get("store") or get_field(result, "store")
if not file_hash: if not file_hash:
log("No hash available in result", file=sys.stderr) log("No hash available in result", file=sys.stderr)
return 1 return 1
if not storage_source: if not store_name:
log("No storage backend specified in result", file=sys.stderr) log("No store specified in result", file=sys.stderr)
return 1 return 1
# Get tags using storage backend # Get tags using storage backend
try: try:
from helper.store import FileStorage from Store import Store
storage_obj = FileStorage(config) storage_obj = Store(config)
backend = storage_obj[storage_source] backend = storage_obj[store_name]
current, source = backend.get_tag(file_hash, config=config) current, source = backend.get_tag(file_hash, config=config)
if not current: if not current:
@@ -1367,18 +1360,18 @@ class Get_Tag(Cmdlet):
item_title = get_field(result, "title") or file_hash[:16] item_title = get_field(result, "title") or file_hash[:16]
_emit_tags_as_table( _emit_tags_as_table(
tags_list=current, tags_list=current,
hash_hex=file_hash, file_hash=file_hash,
source=source, store=store_name,
service_name="", service_name="",
config=config, config=config,
item_title=item_title, item_title=item_title,
file_path=None, path=None,
subject=result, subject=result,
) )
return 0 return 0
except KeyError: except KeyError:
log(f"Storage backend '{storage_source}' not found", file=sys.stderr) log(f"Store '{store_name}' not found", file=sys.stderr)
return 1 return 1
except Exception as exc: except Exception as exc:
log(f"Failed to get tags: {exc}", file=sys.stderr) log(f"Failed to get tags: {exc}", file=sys.stderr)

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@ import sys
from . import register from . import register
import pipeline as ctx import pipeline as ctx
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
from helper.logger import log from SYS.logger import log
from helper.store import FileStorage from Store import Store
class Get_Url(Cmdlet): class Get_Url(Cmdlet):
@@ -48,16 +48,16 @@ class Get_Url(Cmdlet):
# Get backend and retrieve url # Get backend and retrieve url
try: try:
storage = FileStorage(config) storage = Store(config)
backend = storage[store_name] backend = storage[store_name]
url = backend.get_url(file_hash) urls = backend.get_url(file_hash)
if url: if urls:
for url in url: for u in urls:
# Emit rich object for pipeline compatibility # Emit rich object for pipeline compatibility
ctx.emit({ ctx.emit({
"url": url, "url": u,
"hash": file_hash, "hash": file_hash,
"store": store_name, "store": store_name,
}) })

View File

@@ -6,8 +6,8 @@ from pathlib import Path
import json import json
import sys import sys
from helper.logger import log from SYS.logger import log
from helper.download import download_media from cmdlets.download_media import download_media
from models import DownloadOptions from models import DownloadOptions
from config import resolve_output_dir from config import resolve_output_dir
import subprocess as _subprocess import subprocess as _subprocess
@@ -326,7 +326,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
self.media_kind = media_kind self.media_kind = media_kind
self.tags = tags or [] self.tags = tags or []
self.url = url or [] self.url = url or []
self.origin = "local" # Ensure origin is set for add-file self.store = "local"
PipelineItem = SimpleItem PipelineItem = SimpleItem
merged_item = PipelineItem( merged_item = PipelineItem(
@@ -589,7 +589,7 @@ def _merge_audio(files: List[Path], output: Path, output_format: str) -> bool:
# Run ffmpeg with progress monitoring # Run ffmpeg with progress monitoring
try: try:
from helper.progress import print_progress, print_final_progress from SYS.progress import print_progress, print_final_progress
import re import re
process = _subprocess.Popen( process = _subprocess.Popen(

View File

@@ -18,9 +18,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
from urllib.parse import urlsplit, quote, urljoin from urllib.parse import urlsplit, quote, urljoin
from helper.logger import log, debug from SYS.logger import log, debug
from helper.http_client import HTTPClient from API.HTTP import HTTPClient
from helper.utils import ensure_directory, unique_path, unique_preserve_order from SYS.utils import ensure_directory, unique_path, unique_preserve_order
from . import register from . import register
from ._shared import Cmdlet, CmdletArg, SharedArgs, create_pipe_object_result, normalize_result_input, should_show_help, get_field from ._shared import Cmdlet, CmdletArg, SharedArgs, create_pipe_object_result, normalize_result_input, should_show_help, get_field
@@ -661,7 +661,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
file_path=str(screenshot_result.path), file_path=str(screenshot_result.path),
cmdlet_name='screen-shot', cmdlet_name='screen-shot',
title=f"Screenshot: {Path(screenshot_result.path).name}", title=f"Screenshot: {Path(screenshot_result.path).name}",
file_hash=screenshot_hash, hash_value=screenshot_hash,
is_temp=True, is_temp=True,
parent_hash=hashlib.sha256(url.encode()).hexdigest(), parent_hash=hashlib.sha256(url.encode()).hexdigest(),
extra={ extra={
@@ -695,30 +695,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return exit_code return exit_code
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="screen-shot", name="screen-shot",
summary="Capture a screenshot of a URL or file and mark as temporary artifact", summary="Capture a website screenshot",
usage="screen-shot <url> [options] or download-data <url> | screen-shot [options]", usage="screen-shot <url> [options] or download-data <url> | screen-shot [options]",
alias=["screenshot", "ss"], alias=["screenshot", "ss"],
arg=[ arg=[
CmdletArg(name="url", type="string", required=False, description="URL to screenshot (or from pipeline)"), SharedArgs.URL,
CmdletArg(name="format", type="string", description="Output format: png, jpeg, or pdf"), CmdletArg(name="format", type="string", description="Output format: png, jpeg, or pdf"),
CmdletArg(name="selector", type="string", description="CSS selector for element capture"), CmdletArg(name="selector", type="string", description="CSS selector for element capture"),
SharedArgs.ARCHIVE, # Use shared archive argument
SharedArgs.STORE, # Use shared storage argument
], ],
detail=[ detail=
"Take screenshots of url with optional archiving and element targeting.", ["""
"Screenshots are marked as temporary artifacts for cleanup by the cleanup cmdlet.",
"",
"Arguments:",
" url URL to capture (optional if piped from pipeline)", """]
" --format FORMAT Output format: png (default), jpeg, or pdf",
" --selector SEL CSS selector for capturing specific element",
" --archive, -arch Archive URL to Wayback/Archive.today/Archive.ph",
" --storage LOCATION Storage destination: hydrus, local, 0x0, debrid, or ftp",
"",
"Examples:",
" download-data https://example.com | screen-shot --storage local",
" download-data https://twitter.com/user/status/123 | screen-shot --selector 'article[role=article]' --storage hydrus --archive",
" screen-shot https://example.com --format jpeg --storage 0x0 --archive",
]
) )

View File

@@ -1,15 +1,23 @@
"""search-provider cmdlet: Search external providers (bandcamp, libgen, soulseek, youtube).""" """search-provider cmdlet: Search external providers (bandcamp, libgen, soulseek, youtube)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Sequence from typing import Any, Dict, List, Sequence, Optional
import sys import sys
import json
import uuid
import importlib
from helper.logger import log, debug from SYS.logger import log, debug
from helper.provider import get_search_provider, list_search_providers from Provider.registry import get_search_provider, list_search_providers
from ._shared import Cmdlet, CmdletArg, should_show_help from ._shared import Cmdlet, CmdletArg, should_show_help
import pipeline as ctx import pipeline as ctx
# Optional dependencies
try:
from config import get_local_storage_path
except Exception: # pragma: no cover
get_local_storage_path = None # type: ignore
class Search_Provider(Cmdlet): class Search_Provider(Cmdlet):
"""Search external content providers.""" """Search external content providers."""
@@ -88,30 +96,74 @@ class Search_Provider(Cmdlet):
if available: if available:
log(f" - {name}", file=sys.stderr) log(f" - {name}", file=sys.stderr)
return 1 return 1
# Execute search from API.folder import API_folder_store
try: worker_id = str(uuid.uuid4())
debug(f"[search-provider] Calling {provider_name}.search()") library_root = get_local_storage_path(config or {})
results = provider.search(query, limit=limit) if not library_root:
debug(f"[search-provider] Got {len(results)} results") log("No library root configured", file=sys.stderr)
if not results:
log(f"No results found for query: {query}", file=sys.stderr)
return 0
# Emit results for pipeline
for search_result in results:
ctx.emit(search_result.to_dict())
log(f"Found {len(results)} result(s) from {provider_name}", file=sys.stderr)
return 0
except Exception as e:
log(f"Error searching {provider_name}: {e}", file=sys.stderr)
import traceback
debug(traceback.format_exc())
return 1 return 1
# Use context manager to ensure database is always closed
with API_folder_store(library_root) as db:
try:
db.insert_worker(
worker_id,
"search-provider",
title=f"Search: {query}",
description=f"Provider: {provider_name}, Query: {query}",
pipe=ctx.get_current_command_text()
)
results_list = []
import result_table
importlib.reload(result_table)
from result_table import ResultTable
table_title = f"Search: {query} [{provider_name}]"
preserve_order = provider_name.lower() in ('youtube', 'openlibrary')
table = ResultTable(table_title).set_preserve_order(preserve_order)
table.set_table(provider_name)
debug(f"[search-provider] Calling {provider_name}.search()")
results = provider.search(query, limit=limit)
debug(f"[search-provider] Got {len(results)} results")
if not results:
log(f"No results found for query: {query}", file=sys.stderr)
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
db.update_worker_status(worker_id, 'completed')
return 0
# Emit results for pipeline
for search_result in results:
item_dict = search_result.to_dict() if hasattr(search_result, 'to_dict') else dict(search_result)
# Ensure table field is set (should be by provider, but just in case)
if 'table' not in item_dict:
item_dict['table'] = provider_name
table.add_result(search_result) # ResultTable handles SearchResult objects
results_list.append(item_dict)
ctx.emit(item_dict)
ctx.set_last_result_table(table, results_list)
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
db.update_worker_status(worker_id, 'completed')
log(f"Found {len(results)} result(s) from {provider_name}", file=sys.stderr)
return 0
except Exception as e:
log(f"Error searching {provider_name}: {e}", file=sys.stderr)
import traceback
debug(traceback.format_exc())
try:
db.update_worker_status(worker_id, 'error')
except Exception:
pass
return 1
# Register cmdlet instance # Register cmdlet instance
Search_Provider_Instance = Search_Provider() Search_Provider_Instance = Search_Provider()

View File

@@ -9,9 +9,9 @@ import re
import json import json
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
from ._shared import Cmdlet, CmdletArg, get_origin, get_field, should_show_help from ._shared import Cmdlet, CmdletArg, get_field, should_show_help
import pipeline as ctx import pipeline as ctx
# Optional dependencies # Optional dependencies
@@ -27,18 +27,18 @@ except Exception: # pragma: no cover
resolve_output_dir = None # type: ignore resolve_output_dir = None # type: ignore
try: try:
from helper.hydrus import HydrusClient, HydrusRequestError from API.HydrusNetwork import HydrusClient, HydrusRequestError
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
HydrusClient = None # type: ignore HydrusClient = None # type: ignore
HydrusRequestError = RuntimeError # type: ignore HydrusRequestError = RuntimeError # type: ignore
try: try:
from helper.utils import sha256_file from SYS.utils import sha256_file
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
sha256_file = None # type: ignore sha256_file = None # type: ignore
try: try:
from helper.utils_constant import mime_maps from SYS.utils_constant import mime_maps
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
mime_maps = {} # type: ignore mime_maps = {} # type: ignore
@@ -48,7 +48,7 @@ class SearchRecord:
size_bytes: int | None = None size_bytes: int | None = None
duration_seconds: str | None = None duration_seconds: str | None = None
tags: str | None = None tags: str | None = None
hash_hex: str | None = None hash: str | None = None
def as_dict(self) -> dict[str, str]: def as_dict(self) -> dict[str, str]:
payload: dict[str, str] = {"path": self.path} payload: dict[str, str] = {"path": self.path}
@@ -58,8 +58,8 @@ class SearchRecord:
payload["duration"] = self.duration_seconds payload["duration"] = self.duration_seconds
if self.tags: if self.tags:
payload["tags"] = self.tags payload["tags"] = self.tags
if self.hash_hex: if self.hash:
payload["hash"] = self.hash_hex payload["hash"] = self.hash
return payload return payload
@@ -115,7 +115,7 @@ class Search_Store(Cmdlet):
def _ensure_storage_columns(self, payload: Dict[str, Any]) -> Dict[str, Any]: def _ensure_storage_columns(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure storage results have the necessary fields for result_table display.""" """Ensure storage results have the necessary fields for result_table display."""
store_value = str(get_origin(payload, "") or "").lower() store_value = str(payload.get("store") or "").lower()
if store_value not in STORAGE_ORIGINS: if store_value not in STORAGE_ORIGINS:
return payload return payload
@@ -162,7 +162,7 @@ class Search_Store(Cmdlet):
while i < len(args_list): while i < len(args_list):
arg = args_list[i] arg = args_list[i]
low = arg.lower() low = arg.lower()
if low in {"-store", "--store", "-storage", "--storage"} and i + 1 < len(args_list): if low in {"-store", "--store"} and i + 1 < len(args_list):
storage_backend = args_list[i + 1] storage_backend = args_list[i + 1]
i += 2 i += 2
elif low in {"-tag", "--tag"} and i + 1 < len(args_list): elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
@@ -199,7 +199,7 @@ class Search_Store(Cmdlet):
log("Provide a search query", file=sys.stderr) log("Provide a search query", file=sys.stderr)
return 1 return 1
from helper.folder_store import FolderDB from API.folder import API_folder_store
from config import get_local_storage_path from config import get_local_storage_path
import uuid import uuid
worker_id = str(uuid.uuid4()) worker_id = str(uuid.uuid4())
@@ -209,7 +209,7 @@ class Search_Store(Cmdlet):
return 1 return 1
# Use context manager to ensure database is always closed # Use context manager to ensure database is always closed
with FolderDB(library_root) as db: with API_folder_store(library_root) as db:
try: try:
db.insert_worker( db.insert_worker(
worker_id, worker_id,
@@ -231,8 +231,8 @@ class Search_Store(Cmdlet):
table = ResultTable(table_title) table = ResultTable(table_title)
from helper.store import FileStorage from Store import Store
storage = FileStorage(config=config or {}) storage = Store(config=config or {})
backend_to_search = storage_backend or None backend_to_search = storage_backend or None
if backend_to_search: if backend_to_search:
@@ -242,18 +242,21 @@ class Search_Store(Cmdlet):
log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr) log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr)
db.update_worker_status(worker_id, 'error') db.update_worker_status(worker_id, 'error')
return 1 return 1
results = target_backend.search_file(query, limit=limit) results = target_backend.search_store(query, limit=limit)
else: else:
from helper.hydrus import is_hydrus_available from API.HydrusNetwork import is_hydrus_available
hydrus_available = is_hydrus_available(config or {}) hydrus_available = is_hydrus_available(config or {})
from Store.HydrusNetwork import HydrusNetwork
all_results = [] all_results = []
for backend_name in storage.list_searchable_backends(): for backend_name in storage.list_searchable_backends():
if backend_name.startswith("hydrus") and not hydrus_available:
continue
searched_backends.append(backend_name)
try: try:
backend_results = storage[backend_name].search_file(query, limit=limit - len(all_results)) backend = storage[backend_name]
if isinstance(backend, HydrusNetwork) and not hydrus_available:
continue
searched_backends.append(backend_name)
backend_results = backend.search_store(query, limit=limit - len(all_results))
if backend_results: if backend_results:
all_results.extend(backend_results) all_results.extend(backend_results)
if len(all_results) >= limit: if len(all_results) >= limit:
@@ -270,10 +273,10 @@ class Search_Store(Cmdlet):
storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends) storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
for item in results or []: for item in results or []:
origin = get_origin(item) store = get_field(item, "store")
if not origin: if not store:
continue continue
key = str(origin).lower() key = str(store).lower()
if key not in storage_counts: if key not in storage_counts:
storage_counts[key] = 0 storage_counts[key] = 0
storage_counts[key] += 1 storage_counts[key] += 1
@@ -295,14 +298,14 @@ class Search_Store(Cmdlet):
item_dict = _as_dict(item) item_dict = _as_dict(item)
if store_filter: if store_filter:
origin_val = str(get_origin(item_dict) or "").lower() store_val = str(item_dict.get("store") or "").lower()
if store_filter != origin_val: if store_filter != store_val:
continue continue
normalized = self._ensure_storage_columns(item_dict) normalized = self._ensure_storage_columns(item_dict)
# Make hash/store available for downstream cmdlets without rerunning search # Make hash/store available for downstream cmdlets without rerunning search
hash_val = normalized.get("hash") hash_val = normalized.get("hash")
store_val = normalized.get("store") or get_origin(item_dict) store_val = normalized.get("store") or item_dict.get("store")
if hash_val and not normalized.get("hash"): if hash_val and not normalized.get("hash"):
normalized["hash"] = hash_val normalized["hash"] = hash_val
if store_val and not normalized.get("store"): if store_val and not normalized.get("store"):

View File

@@ -9,8 +9,8 @@ import subprocess
import shutil import shutil
import re import re
from helper.logger import log, debug from SYS.logger import log, debug
from helper.utils import sha256_file from SYS.utils import sha256_file
from . import register from . import register
from ._shared import ( from ._shared import (
Cmdlet, Cmdlet,
@@ -219,11 +219,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Update original file in local DB if possible # Update original file in local DB if possible
try: try:
from config import get_local_storage_path from config import get_local_storage_path
from helper.folder_store import FolderDB from API.folder import API_folder_store
storage_path = get_local_storage_path(config) storage_path = get_local_storage_path(config)
if storage_path: if storage_path:
with FolderDB(storage_path) as db: with API_folder_store(storage_path) as db:
# Get original file metadata # Get original file metadata
# We need to find the original file by hash or path # We need to find the original file by hash or path
# Try path first # Try path first

View File

@@ -3,11 +3,11 @@ import os
import sys import sys
from typing import List, Dict, Any, Optional, Sequence from typing import List, Dict, Any, Optional, Sequence
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log from SYS.logger import log
from result_table import ResultTable from result_table import ResultTable
import pipeline as ctx import pipeline as ctx
ADJECTIVE_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "helper", "adjective.json") ADJECTIVE_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "cmdnats", "adjective.json")
def _load_adjectives() -> Dict[str, List[str]]: def _load_adjectives() -> Dict[str, List[str]]:
try: try:

View File

@@ -5,7 +5,7 @@ import shlex
import sys import sys
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log from SYS.logger import log
from result_table import ResultTable from result_table import ResultTable
import pipeline as ctx import pipeline as ctx
@@ -135,7 +135,7 @@ def _render_detail(meta: Dict[str, Any], args: Sequence[str]) -> None:
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
try: try:
from helper import cmdlet_catalog as _catalog from cmdlets import catalog as _catalog
CMDLET.arg[0].choices = _normalize_choice_list(_catalog.list_cmdlet_names()) CMDLET.arg[0].choices = _normalize_choice_list(_catalog.list_cmdlet_names())
metadata = _catalog.list_cmdlet_metadata() metadata = _catalog.list_cmdlet_metadata()

View File

@@ -1,10 +1,9 @@
from typing import Any, Dict, Sequence, List from typing import Any, Dict, Sequence, List
import sys import sys
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug from SYS.logger import log, debug
from result_table import ResultTable from result_table import ResultTable
# REFACTOR: Commenting out Matrix import until provider refactor is complete # REFACTOR: Commenting out Matrix import until provider refactor is complete
# from helper.store import MatrixStorageBackend
from config import save_config, load_config from config import save_config, load_config
import pipeline as ctx import pipeline as ctx

View File

@@ -1,54 +1,34 @@
from typing import Any, Dict, Sequence, List, Optional from typing import Any, Dict, Sequence, List, Optional
import sys import sys
import json import json
import platform
import socket import socket
import re import re
import subprocess import subprocess
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from pathlib import Path from pathlib import Path
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug from SYS.logger import debug
from result_table import ResultTable from result_table import ResultTable
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient from MPV.mpv_ipc import MPV
import pipeline as ctx import pipeline as ctx
from helper.download import is_url_supported_by_ytdlp from SYS.download import is_url_supported_by_ytdlp
from models import PipeObject
from helper.folder_store import LocalLibrarySearchOptimizer from API.folder import LocalLibrarySearchOptimizer
from config import get_local_storage_path, get_hydrus_access_key, get_hydrus_url from config import get_local_storage_path, get_hydrus_access_key, get_hydrus_url
from hydrus_health_check import get_cookies_file_path from hydrus_health_check import get_cookies_file_path
def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]: def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]:
"""Send a command to the MPV IPC pipe and return the response.""" """Send a command to the MPV IPC pipe and return the response."""
try: try:
ipc_pipe = get_ipc_pipe_path() mpv = MPV()
client = MPVIPCClient(socket_path=ipc_pipe) return mpv.send(command, silent=silent)
if not client.connect():
return None # MPV not running
response = client.send_command(command)
client.disconnect()
return response
except Exception as e: except Exception as e:
if not silent: if not silent:
debug(f"IPC Error: {e}", file=sys.stderr) debug(f"IPC Error: {e}", file=sys.stderr)
return None return None
def _is_mpv_running() -> bool:
"""Check if MPV is currently running and accessible via IPC."""
try:
ipc_pipe = get_ipc_pipe_path()
client = MPVIPCClient(socket_path=ipc_pipe)
if client.connect():
client.disconnect()
return True
return False
except Exception:
return False
def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]: def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
"""Get the current playlist from MPV. Returns None if MPV is not running.""" """Get the current playlist from MPV. Returns None if MPV is not running."""
cmd = {"command": ["get_property", "playlist"], "request_id": 100} cmd = {"command": ["get_property", "playlist"], "request_id": 100}
@@ -194,8 +174,7 @@ def _normalize_playlist_path(text: Optional[str]) -> Optional[str]:
pass pass
# Normalize slashes for Windows paths and lowercase for comparison # Normalize slashes for Windows paths and lowercase for comparison
real = real.replace('\\', '\\') real = real.replace('\\', '/')
real = real.replace('\\', '\\')
return real.lower() return real.lower()
@@ -300,37 +279,6 @@ def _infer_store_from_playlist_item(item: Dict[str, Any], file_storage: Optional
return host_stripped return host_stripped
def _format_playlist_location(name: str, max_len: int = 48) -> str:
"""Format playlist filename/URL for display while keeping backend untouched."""
target = name or ""
memory_target = _extract_target_from_memory_uri(target)
if memory_target:
target = memory_target
lower = target.lower()
# Local paths: show basename only
if re.match(r"^[a-z]:[\\/]", target, flags=re.IGNORECASE) or target.startswith("\\\\"):
target = Path(target).name
elif lower.startswith("file://"):
parsed = urlparse(target)
target = Path(parsed.path or "").name or target
else:
parsed = urlparse(target)
host = parsed.netloc or ""
if host:
host_no_port = host.split(":", 1)[0]
host_no_port = host_no_port[4:] if host_no_port.startswith("www.") else host_no_port
tail = parsed.path.split('/')[-1] if parsed.path else ""
if tail:
target = f"{host_no_port}/{tail}"
else:
target = host_no_port
if len(target) > max_len:
return target[: max_len - 3] + "..."
return target
def _build_hydrus_header(config: Dict[str, Any]) -> Optional[str]: def _build_hydrus_header(config: Dict[str, Any]) -> Optional[str]:
"""Return header string for Hydrus auth if configured.""" """Return header string for Hydrus auth if configured."""
try: try:
@@ -399,7 +347,8 @@ def _ensure_ytdl_cookies() -> None:
def _monitor_mpv_logs(duration: float = 3.0) -> None: def _monitor_mpv_logs(duration: float = 3.0) -> None:
"""Monitor MPV logs for a short duration to capture errors.""" """Monitor MPV logs for a short duration to capture errors."""
try: try:
client = MPVIPCClient() mpv = MPV()
client = mpv.client()
if not client.connect(): if not client.connect():
debug("Failed to connect to MPV for log monitoring", file=sys.stderr) debug("Failed to connect to MPV for log monitoring", file=sys.stderr)
return return
@@ -416,9 +365,14 @@ def _monitor_mpv_logs(duration: float = 3.0) -> None:
start_time = time.time() start_time = time.time()
# Unix sockets already have timeouts set; read until duration expires # Unix sockets already have timeouts set; read until duration expires
sock_obj = client.sock
if not isinstance(sock_obj, socket.socket):
client.disconnect()
return
while time.time() - start_time < duration: while time.time() - start_time < duration:
try: try:
chunk = client.sock.recv(4096) chunk = sock_obj.recv(4096)
except socket.timeout: except socket.timeout:
continue continue
except Exception: except Exception:
@@ -451,15 +405,14 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
Returns: Returns:
Tuple of (path, title) or None if no valid path found Tuple of (path, title) or None if no valid path found
""" """
path = None path: Optional[str] = None
title = None title: Optional[str] = None
store = None store: Optional[str] = None
file_hash = None file_hash: Optional[str] = None
# Extract fields from item - prefer a disk path ('path'), but accept 'url' as fallback for providers # Extract fields from item - prefer a disk path ('path'), but accept 'url' as fallback for providers
if isinstance(item, dict): if isinstance(item, dict):
# Support both canonical 'path' and legacy 'file_path' keys, and provider 'url' keys path = item.get("path")
path = item.get("path") or item.get("file_path")
# Fallbacks for provider-style entries where URL is stored in 'url' or 'source_url' or 'target' # Fallbacks for provider-style entries where URL is stored in 'url' or 'source_url' or 'target'
if not path: if not path:
path = item.get("url") or item.get("source_url") or item.get("target") path = item.get("url") or item.get("source_url") or item.get("target")
@@ -468,11 +421,11 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
if known and isinstance(known, list): if known and isinstance(known, list):
path = known[0] path = known[0]
title = item.get("title") or item.get("file_title") title = item.get("title") or item.get("file_title")
store = item.get("store") or item.get("storage") or item.get("storage_source") or item.get("origin") store = item.get("store")
file_hash = item.get("hash") or item.get("file_hash") or item.get("hash_hex") file_hash = item.get("hash")
elif hasattr(item, "path") or hasattr(item, "url") or hasattr(item, "source_url") or hasattr(item, "store") or hasattr(item, "hash"): elif hasattr(item, "path") or hasattr(item, "url") or hasattr(item, "source_url") or hasattr(item, "store") or hasattr(item, "hash"):
# Handle PipeObject / dataclass objects - prefer path, but fall back to url/source_url attributes # Handle PipeObject / dataclass objects - prefer path, but fall back to url/source_url attributes
path = getattr(item, "path", None) or getattr(item, "file_path", None) path = getattr(item, "path", None)
if not path: if not path:
path = getattr(item, "url", None) or getattr(item, "source_url", None) or getattr(item, "target", None) path = getattr(item, "url", None) or getattr(item, "source_url", None) or getattr(item, "target", None)
if not path: if not path:
@@ -480,7 +433,7 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
if known and isinstance(known, list): if known and isinstance(known, list):
path = known[0] path = known[0]
title = getattr(item, "title", None) or getattr(item, "file_title", None) title = getattr(item, "title", None) or getattr(item, "file_title", None)
store = getattr(item, "store", None) or getattr(item, "origin", None) store = getattr(item, "store", None)
file_hash = getattr(item, "hash", None) file_hash = getattr(item, "hash", None)
elif isinstance(item, str): elif isinstance(item, str):
path = item path = item
@@ -493,56 +446,51 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
if not path: if not path:
return None return None
if not isinstance(path, str):
path = str(path)
if title is not None and not isinstance(title, str):
title = str(title)
# If we have a store and hash, use store's .pipe() method if available # Resolve hash+store into a playable target (file path or URL).
# Skip this for URL-based providers (YouTube, SoundCloud, etc.) which have hash="unknown" # This is unrelated to MPV's IPC pipe and keeps "pipe" terminology reserved for:
# Also skip if path is already a URL (http/https) # - MPV IPC pipe (transport)
# - PipeObject (pipeline data)
if store and file_hash and file_hash != "unknown" and file_storage: if store and file_hash and file_hash != "unknown" and file_storage:
# Check if this is actually a URL - if so, just return it # If it's already a URL, MPV can usually play it directly.
if path.startswith(("http://", "https://")): if path.startswith(("http://", "https://")):
return (path, title) return (path, title)
try: try:
backend = file_storage[store] backend = file_storage[store]
# Check if backend has a .pipe() method except Exception:
if hasattr(backend, 'pipe') and callable(backend.pipe): backend = None
pipe_path = backend.pipe(file_hash, config)
if pipe_path: if backend is not None:
path = pipe_path backend_class = type(backend).__name__
debug(f"Got pipe path from {store} backend: {path}")
except KeyError: # Folder stores: resolve to an on-disk file path.
# Store not found in file_storage - it could be a search provider (youtube, bandcamp, etc.) if hasattr(backend, "get_file") and callable(getattr(backend, "get_file")) and backend_class == "Folder":
from helper.provider import get_search_provider try:
try: resolved = backend.get_file(file_hash)
provider = get_search_provider(store, config or {}) if isinstance(resolved, Path):
if provider and hasattr(provider, 'pipe') and callable(provider.pipe): path = str(resolved)
try: elif resolved is not None:
debug(f"Calling provider.pipe for '{store}' with path: {path}") path = str(resolved)
provider_path = provider.pipe(path, config or {}) except Exception as e:
debug(f"provider.pipe returned: {provider_path}") debug(f"Error resolving file path from store '{store}': {e}", file=sys.stderr)
if provider_path:
path = provider_path # HydrusNetwork: build a playable API file URL without browser side-effects.
debug(f"Got pipe path from provider '{store}': {path}") elif backend_class == "HydrusNetwork":
except Exception as e: try:
debug(f"Error in provider.pipe for '{store}': {e}", file=sys.stderr) client = getattr(backend, "_client", None)
except Exception as e: base_url = getattr(client, "url", None)
debug(f"Error calling provider.pipe for '{store}': {e}", file=sys.stderr) access_key = getattr(client, "access_key", None)
except Exception as e: if base_url and access_key:
debug(f"Error calling .pipe() on store '{store}': {e}", file=sys.stderr) base_url = str(base_url).rstrip("/")
path = f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}"
# As a fallback, if a provider exists for this store (e.g., youtube) and except Exception as e:
# this store is not part of FileStorage backends, call provider.pipe() debug(f"Error building Hydrus URL from store '{store}': {e}", file=sys.stderr)
if store and (not file_storage or store not in (file_storage.list_backends() if file_storage else [])):
try:
from helper.provider import get_search_provider
provider = get_search_provider(store, config or {})
if provider and hasattr(provider, 'pipe') and callable(provider.pipe):
provider_path = provider.pipe(path, config or {})
if provider_path:
path = provider_path
debug(f"Got pipe path from provider '{store}' (fallback): {path}")
except Exception as e:
debug(f"Error calling provider.pipe (fallback) for '{store}': {e}", file=sys.stderr)
return (path, title) return (path, title)
@@ -574,13 +522,13 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
except Exception: except Exception:
hydrus_url = None hydrus_url = None
# Initialize FileStorage for path resolution # Initialize Store registry for path resolution
file_storage = None file_storage = None
try: try:
from helper.store import FileStorage from Store import Store
file_storage = FileStorage(config or {}) file_storage = Store(config or {})
except Exception as e: except Exception as e:
debug(f"Warning: Could not initialize FileStorage: {e}", file=sys.stderr) debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
# Dedupe existing playlist before adding more (unless we're replacing it) # Dedupe existing playlist before adding more (unless we're replacing it)
existing_targets: set[str] = set() existing_targets: set[str] = set()
@@ -695,13 +643,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
parsed = parse_cmdlet_args(args, CMDLET) parsed = parse_cmdlet_args(args, CMDLET)
# Initialize FileStorage for detecting Hydrus instance names # Initialize Store registry for detecting Hydrus instance names
file_storage = None file_storage = None
try: try:
from helper.store import FileStorage from Store import Store
file_storage = FileStorage(config) file_storage = Store(config)
except Exception as e: except Exception as e:
debug(f"Warning: Could not initialize FileStorage: {e}", file=sys.stderr) debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
# Initialize mpv_started flag # Initialize mpv_started flag
mpv_started = False mpv_started = False
@@ -1119,7 +1067,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Check if this backend has the file # Check if this backend has the file
try: try:
result_path = backend.get_file(file_hash) result_path = backend.get_file(file_hash)
if result_path and result_path.exists(): if isinstance(result_path, Path) and result_path.exists():
store_name = backend_name store_name = backend_name
break break
except Exception: except Exception:
@@ -1130,7 +1078,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
store_name = _infer_store_from_playlist_item(item, file_storage=file_storage) store_name = _infer_store_from_playlist_item(item, file_storage=file_storage)
# Build PipeObject with proper metadata # Build PipeObject with proper metadata
from models import PipeObject
pipe_obj = PipeObject( pipe_obj = PipeObject(
hash=file_hash or "unknown", hash=file_hash or "unknown",
store=store_name or "unknown", store=store_name or "unknown",
@@ -1163,23 +1110,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None) -> None: def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None) -> None:
"""Start MPV with a list of items.""" """Start MPV with a list of items."""
import subprocess
import time as _time_module import time as _time_module
# Kill any existing MPV processes to ensure clean start mpv = MPV()
try: mpv.kill_existing_windows()
subprocess.run(['taskkill', '/IM', 'mpv.exe', '/F'], _time_module.sleep(0.5) # Wait for process to die
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=2)
_time_module.sleep(0.5) # Wait for process to die
except Exception:
pass
ipc_pipe = get_ipc_pipe_path()
# Start MPV in idle mode with IPC server
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
hydrus_header = _build_hydrus_header(config or {}) hydrus_header = _build_hydrus_header(config or {})
ytdl_opts = _build_ytdl_options(config, hydrus_header) ytdl_opts = _build_ytdl_options(config, hydrus_header)
@@ -1190,35 +1125,26 @@ def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None) -> Non
else: else:
debug("Starting MPV with browser cookies: chrome") debug("Starting MPV with browser cookies: chrome")
if ytdl_opts:
cmd.append(f'--ytdl-raw-options={ytdl_opts}')
try: try:
kwargs = {} # Always start MPV with the bundled Lua script via MPV class.
if platform.system() == 'Windows': mpv.start(
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS extra_args=[
'--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]',
# Log the complete MPV command being executed ],
debug(f"DEBUG: Full MPV command: {' '.join(cmd)}") ytdl_raw_options=ytdl_opts,
http_header_fields=hydrus_header,
if hydrus_header: detached=True,
cmd.append(f'--http-header-fields={hydrus_header}') )
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) debug("Started MPV process")
debug(f"Started MPV process")
# Wait for IPC pipe to be ready # Wait for IPC pipe to be ready
import time if not mpv.wait_for_ipc(retries=20, delay_seconds=0.2):
max_retries = 20
for i in range(max_retries):
time.sleep(0.2)
client = MPVIPCClient(socket_path=ipc_pipe)
if client.connect():
client.disconnect()
break
else:
debug("Timed out waiting for MPV IPC connection", file=sys.stderr) debug("Timed out waiting for MPV IPC connection", file=sys.stderr)
return return
# Ensure Lua script is loaded (redundant when started with --script, but safe)
mpv.ensure_lua_loaded()
# Queue items via IPC # Queue items via IPC
if items: if items:
_queue_items(items, config=config) _queue_items(items, config=config)

View File

@@ -10,7 +10,7 @@ from typing import Any, Dict, Sequence, List
from cmdlets import register from cmdlets import register
from cmdlets._shared import Cmdlet, CmdletArg from cmdlets._shared import Cmdlet, CmdletArg
import pipeline as ctx import pipeline as ctx
from helper.logger import log from SYS.logger import log
from config import get_local_storage_path from config import get_local_storage_path
DEFAULT_LIMIT = 100 DEFAULT_LIMIT = 100
@@ -74,9 +74,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
try: try:
from helper.folder_store import FolderDB from API.folder import API_folder_store
with FolderDB(library_root) as db: 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.")

View File

@@ -6,7 +6,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from pathlib import Path from pathlib import Path
from helper.logger import log from SYS.logger import log
DEFAULT_CONFIG_FILENAME = "config.json" DEFAULT_CONFIG_FILENAME = "config.json"
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent

View File

@@ -1,91 +0,0 @@
"""Helper modules for the downlow mpv integration."""
from . import hydrus as _hydrus
from . import download as _download
from . import tasks as _tasks
from . import utils as _utils
try: # Optional dependency on Playwright
from . import webshot as _webshot
except Exception as exc: # pragma: no cover - surfaced when Playwright is missing
_webshot = None # type: ignore
ScreenshotError = None # type: ignore[assignment]
ScreenshotOptions = None # type: ignore[assignment]
ScreenshotResult = None # type: ignore[assignment]
capture_screenshot = None # type: ignore[assignment]
ScreenshotImportError = exc # type: ignore[assignment]
else:
ScreenshotError = _webshot.ScreenshotError
ScreenshotOptions = _webshot.ScreenshotOptions
ScreenshotResult = _webshot.ScreenshotResult
capture_screenshot = _webshot.capture_screenshot
ScreenshotImportError = None
# CBOR utilities
decode_cbor = _utils.decode_cbor
jsonify = _utils.jsonify
# General utilities
CHUNK_SIZE = _utils.CHUNK_SIZE
ensure_directory = _utils.ensure_directory
unique_path = _utils.unique_path
download_hydrus_file = _hydrus.download_hydrus_file
sanitize_metadata_value = _utils.sanitize_metadata_value
unique_preserve_order = _utils.unique_preserve_order
sha256_file = _utils.sha256_file
create_metadata_sidecar = _utils.create_metadata_sidecar
create_tags_sidecar = _utils.create_tags_sidecar
# Format utilities
format_bytes = _utils.format_bytes
format_duration = _utils.format_duration
format_timestamp = _utils.format_timestamp
format_metadata_value = _utils.format_metadata_value
# Link utilities
extract_link = _utils.extract_link
extract_link_from_args = _utils.extract_link_from_args
extract_link_from_result = _utils.extract_link_from_result
get_api_key = _utils.get_api_key
add_direct_link_to_result = _utils.add_direct_link_to_result
# URL policy utilities
resolve_url_policy = _utils.resolve_url_policy
UrlPolicy = _utils.UrlPolicy
# Download utilities
DownloadOptions = _download.DownloadOptions
DownloadError = _download.DownloadError
DownloadMediaResult = _download.DownloadMediaResult
is_url_supported_by_ytdlp = _download.is_url_supported_by_ytdlp
probe_url = _download.probe_url
# Hydrus utilities
hydrus_request = _hydrus.hydrus_request
hydrus_export = _hydrus.hydrus_export
HydrusClient = _hydrus.HydrusClient
HydrusRequestError = _hydrus.HydrusRequestError
connect_ipc = _tasks.connect_ipc
ipc_sender = _tasks.ipc_sender
__all__ = [
'decode_cbor',
'jsonify',
'CHUNK_SIZE',
'ensure_directory',
'unique_path',
'download_hydrus_file',
'sanitize_metadata_value',
'unique_preserve_order',
'sha256_file',
'resolve_url_policy',
'UrlPolicy',
'ScreenshotError',
'ScreenshotOptions',
'ScreenshotResult',
'capture_screenshot',
'ScreenshotImportError',
'DownloadOptions',
'DownloadError',
'DownloadMediaResult',
'download_media',
'is_url_supported_by_ytdlp',
'probe_url',
'HydrusClient',
'HydrusRequestError',
'hydrus_request',
'hydrus_export',
'connect_ipc',
'ipc_sender',
]

View File

@@ -1,951 +0,0 @@
"""MPV file metadata aggregation helpers."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence
from urllib.parse import parse_qs, urlparse, unquote
from config import get_hydrus_url
from helper.utils import sha256_file, unique_preserve_order
from helper.hydrus import HydrusClient, HydrusRequestError
import metadata
class MPVFileError(RuntimeError):
"""Raised when we cannot construct an MPV file snapshot."""
@dataclass(slots=True)
class DebridMagnet:
"""Represents a magnet result from AllDebrid search.
This class matches the structure expected by the TUI (like Hydrus results)
with title, target, media_kind attributes for compatibility.
"""
magnet_id: str
title: str
size: int
status_code: int
status_text: str
progress: float
downloaded: int
seeders: int
dl_speed: int
tag_summary: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None # Complete magnet file metadata from AllDebrid API
@property
def target(self) -> str:
"""Return the target URI for this magnet (used by TUI for access operations)."""
return f"alldebrid://{self.magnet_id}"
@property
def media_kind(self) -> str:
"""Return media kind for display."""
return "magnet"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for metadata display."""
return {
"magnet_id": self.magnet_id,
"title": self.title,
"size": self.size,
"status_code": self.status_code,
"status_text": self.status_text,
"progress": f"{self.progress:.1f}%",
"downloaded": self.downloaded,
"seeders": self.seeders,
"dl_speed": self.dl_speed,
}
@dataclass(slots=True)
class HydrusSettings:
base_url: Optional[str]
access_key: Optional[str]
timeout: float
prefer_service_name: Optional[str]
include_relationships: bool
def as_metadata_options(self) -> Dict[str, Any]:
options: Dict[str, Any] = {
"timeout": self.timeout,
"include_relationships": self.include_relationships,
}
if self.prefer_service_name:
options["prefer_service_name"] = self.prefer_service_name
return options
@dataclass(slots=True)
class MPVfile:
path: Optional[str] = None
filename: Optional[str] = None
type: str = "unknown"
hash: Optional[str] = None
local_path: Optional[str] = None
mpv_metadata: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
remote_metadata: Optional[Dict[str, Any]] = None
relationships: Optional[Dict[str, Any]] = None
relationship_metadata: Dict[str, Any] = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
original_tags: Dict[str, str] = field(default_factory=dict)
url: List[str] = field(default_factory=list)
title: Optional[str] = None
source_url: Optional[str] = None
clip_time: Optional[str] = None
duration: Optional[float] = None
filesize_mb: Optional[float] = None
is_video: bool = False
is_audio: bool = False
is_deleted: Optional[bool] = None
is_local: Optional[bool] = None
has_current_file_service: Optional[bool] = None
tag_service_key: Optional[str] = None
swap_recommended: bool = False
warnings: List[str] = field(default_factory=list)
# New relationship fields for menu
king: Optional[str] = None
alts: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"path": self.path,
"filename": self.filename,
"type": self.type,
"hash": self.hash,
"local_path": self.local_path,
"mpv_metadata": self.mpv_metadata,
"metadata": self.metadata,
"remote_metadata": self.remote_metadata,
"relationships": self.relationships,
"relationship_metadata": self.relationship_metadata,
"tags": self.tags,
"original_tags": self.original_tags,
"url": self.url,
"title": self.title,
"source_url": self.source_url,
"clip_time": self.clip_time,
"duration": self.duration,
"filesize_mb": self.filesize_mb,
"is_video": self.is_video,
"is_audio": self.is_audio,
"is_deleted": self.is_deleted,
"is_local": self.is_local,
"has_current_file_service": self.has_current_file_service,
"tag_service_key": self.tag_service_key,
"swap_recommended": self.swap_recommended,
"warnings": self.warnings,
# relationship summary fields for easier Lua consumption
"king": self.king,
"alts": self.alts,
}
# Remove empty optional values for terser payloads.
for key in list(payload.keys()):
value = payload[key]
if value in (None, [], {}, ""):
del payload[key]
return payload
def _normalise_string_list(values: Optional[Iterable[Any]]) -> List[str]:
if not values:
return []
seen: set[str] = set()
result: List[str] = []
for value in values:
if value is None:
continue
text = str(value).strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
return result
def _looks_like_hash(value: Optional[str]) -> bool:
if not value:
return False
candidate = value.strip().lower()
return len(candidate) == 64 and all(ch in "0123456789abcdef" for ch in candidate)
class MPVFileBuilder:
def __init__(self, payload: Dict[str, Any], config: Dict[str, Any]):
self.payload = payload or {}
self.config = config or {}
self.state = MPVfile()
self.hydrus_settings = self._resolve_hydrus_settings()
self.remote_options = self._resolve_remote_options()
self.include_relationships = bool(self.payload.get("include_relationships", True))
self.last_url = self._normalise_url(self.payload.get("last_url"))
self._initialise_identity()
# ------------------------------------------------------------------
# public API
# ------------------------------------------------------------------
def build(self) -> Dict[str, Any]:
if self.state.type == "hydrus":
self._populate_hydrus_by_hash()
elif self.state.type == "local":
self._populate_local()
elif self.state.type == "remote":
self._populate_remote()
else:
# Attempt best effort resolution even for unknown types.
self._populate_local(best_effort=True)
self._finalise()
result = self.state.to_dict()
# Append King and Alts info to mpv_metadata for info menu
king = self.state.king
alts = self.state.alts
if king:
result.setdefault("mpv_metadata", {})["King"] = king
if alts:
result.setdefault("mpv_metadata", {})["Alts"] = ", ".join(alts)
return result
# ------------------------------------------------------------------
# configuration helpers
# ------------------------------------------------------------------
def _resolve_hydrus_settings(self) -> HydrusSettings:
overrides = self.payload.get("hydrus")
overrides = overrides if isinstance(overrides, dict) else {}
base_url = overrides.get("url") or overrides.get("base_url")
access_key = overrides.get("access_key")
timeout_raw = overrides.get("timeout") or overrides.get("hydrus_timeout")
prefer_service = overrides.get("prefer_service_name")
include_relationships = overrides.get("include_relationships")
if base_url is None:
base_url = get_hydrus_url(self.config)
if access_key is None:
raw_key = self.config.get("HydrusNetwork_Access_Key")
access_key = str(raw_key) if raw_key is not None else None
if timeout_raw is None:
timeout_raw = self.config.get("HydrusNetwork_Request_Timeout")
try:
timeout = float(timeout_raw) if timeout_raw is not None else 60.0
except (TypeError, ValueError):
timeout = 60.0
if prefer_service is None:
prefer_service = self.config.get("Hydrus_Tag_Service")
if isinstance(prefer_service, str):
prefer_service = prefer_service.strip() or None
if include_relationships is None:
include_relationships = self.payload.get("include_relationships")
include_relationships = bool(True if include_relationships is None else include_relationships)
base_url = base_url.strip() if isinstance(base_url, str) else None
access_key = access_key.strip() if isinstance(access_key, str) else None
return HydrusSettings(
base_url=base_url or None,
access_key=access_key or None,
timeout=timeout,
prefer_service_name=prefer_service,
include_relationships=include_relationships,
)
def _resolve_remote_options(self) -> Dict[str, Any]:
remote_payload = self.payload.get("remote")
remote_payload = remote_payload if isinstance(remote_payload, dict) else {}
options = remote_payload.get("options")
options = options if isinstance(options, dict) else {}
ytdlp_args = options.get("ytdlp_args")
if not ytdlp_args:
options["ytdlp_args"] = ["--no-playlist", "--skip-download", "--no-warnings"]
existing_timeout = options.get("timeout")
if existing_timeout is None:
options["timeout"] = min(90.0, max(10.0, float(self.payload.get("remote_timeout") or 45.0)))
return options
# ------------------------------------------------------------------
# initialisation
# ------------------------------------------------------------------
def _initialise_identity(self) -> None:
s = self.state
p = self.payload
def _str_or_none(v):
return str(v) if v is not None and v != "" else None
def _copy_dict_if_dict(v):
return dict(v) if isinstance(v, dict) else {}
# path and filename
s.path = _str_or_none(p.get("path"))
s.filename = _str_or_none(p.get("filename"))
# mpv metadata
s.mpv_metadata = _copy_dict_if_dict(p.get("mpv_metadata"))
# tags (support both "tags" and legacy "existing_tags")
existing_tags = p.get("tags") or p.get("existing_tags")
s.tags = _normalise_string_list(existing_tags)
if s.tags:
s.original_tags = {tag: tag for tag in s.tags}
# known url + last_url
s.url = _normalise_string_list(p.get("url"))
if self.last_url and self.last_url not in s.url:
s.url.append(self.last_url)
# source URL (explicit or fallback to last_url)
explicit_source = p.get("source_url")
s.source_url = self._normalise_url(explicit_source) or self.last_url
# hash (validate looks-like-hash)
hash_candidate = p.get("hash")
if isinstance(hash_candidate, str):
candidate = hash_candidate.strip().lower()
if _looks_like_hash(candidate):
s.hash = candidate
# local_path (non-empty string)
local_path_override = p.get("local_path")
if isinstance(local_path_override, str):
lp = local_path_override.strip()
if lp:
s.local_path = lp
# derive remaining fields from path/filename/type
self._derive_filename_from_path()
self._determine_type()
def _derive_filename_from_path(self) -> None:
if self.state.filename or not self.state.path:
return
parsed = urlparse(self.state.path)
if parsed.scheme in ("http", "https", "ytdl") and parsed.path:
candidate = Path(parsed.path).name
if candidate:
self.state.filename = candidate
elif parsed.scheme == "file":
decoded = self._decode_file_url(self.state.path)
if decoded:
self.state.filename = Path(decoded).name
else:
try:
self.state.filename = Path(self.state.path).name
except Exception:
pass
def _determine_type(self) -> None:
s = self.state
p = self.payload
def _set_local_from_path(pth: str | None):
if not pth:
return
# Prefer resolved local path when available
resolved = self._resolve_local_path(pth)
s.local_path = resolved if resolved else pth
s.type = "local"
# 1) Respect explicit type when valid
explicit = p.get("type")
if isinstance(explicit, str):
lowered = explicit.strip().lower()
if lowered in {"local", "hydrus", "remote"}:
s.type = lowered
if lowered == "local":
s.local_path = self._resolve_local_path(s.path)
return
# 2) Work from path
path = s.path or ""
if not path:
s.type = "unknown"
return
# 3) Hydrus-specific quick checks
if self._looks_like_hydrus_url(path):
s.type = "hydrus"
return
parsed = urlparse(path)
scheme = (parsed.scheme or "").lower()
# 4) scheme-based handling
if scheme == "hydrus":
s.type = "hydrus"
return
if scheme in {"http", "https", "rtmp", "rtsp", "magnet", "ytdl"}:
s.type = "hydrus" if self._looks_like_hydrus_url(path) else "remote"
return
if scheme == "file":
decoded = self._decode_file_url(path)
if decoded:
s.local_path = decoded
s.type = "local"
return
# 5) Windows/UNC absolute paths
if re.match(r"^[A-Za-z]:[\\/]", path) or path.startswith(("\\\\", "//")):
s.type = "local"
s.local_path = path
return
# 6) Fallback: if it looks like a URL with a scheme separator treat as remote/hydrus
if "://" in path:
s.type = "hydrus" if self._looks_like_hydrus_url(path) else "remote"
return
# 7) Otherwise treat as a local path
_set_local_from_path(path)
# ------------------------------------------------------------------
# population helpers
# ------------------------------------------------------------------
def _populate_local(self, best_effort: bool = False) -> None:
local_path = self.state.local_path or self._resolve_local_path(self.state.path)
if local_path:
self.state.local_path = local_path
self._load_sidecar_tags(local_path)
if not self.state.hash:
self._compute_local_hash(local_path)
# If Hydrus is configured and we have a hash, enrich from Hydrus; otherwise keep local tags only
if self.state.hash and self.hydrus_settings.base_url and self.hydrus_settings.access_key:
self._populate_hydrus_by_hash()
elif best_effort and self.hydrus_settings.base_url and self.state.source_url and self.hydrus_settings.access_key:
self._populate_hydrus_by_url(self.state.source_url)
# (helpers for resolving local path and loading sidecars already exist below)
def _populate_remote(self) -> None:
source_url = self.state.source_url or self.last_url or self.state.path
source_url = self._normalise_url(source_url)
if source_url:
self.state.source_url = source_url
remote_payload = {
"source_url": self.state.source_url,
"existing_tags": self.state.tags,
"metadata": self.payload.get("remote_metadata"),
"mpv_metadata": self.state.mpv_metadata,
"options": self.remote_options,
}
try:
remote_result = metadata.resolve_remote_metadata(remote_payload)
except Exception as exc: # pragma: no cover - surfaced to the caller
self.state.warnings.append(str(exc))
remote_result = None
if remote_result:
tags = remote_result.get("tags") or []
self._merge_tags(tags)
self.state.remote_metadata = remote_result.get("metadata")
self.state.title = remote_result.get("title") or self.state.title
self.state.duration = remote_result.get("duration") or self.state.duration
self.state.source_url = remote_result.get("source_url") or self.state.source_url
warnings = remote_result.get("warnings") or []
if warnings:
self.state.warnings.extend(warnings)
if self.hydrus_settings.base_url and self.state.source_url:
self._populate_hydrus_by_url(self.state.source_url)
def _populate_hydrus_by_hash(self) -> None:
hash_hex = self.state.hash or self._extract_hash_from_path(self.state.path)
if hash_hex and not _looks_like_hash(hash_hex):
hash_hex = None
if not hash_hex:
return
self.state.hash = hash_hex
if not self.hydrus_settings.base_url:
return
payload: Dict[str, Any] = {
"api_url": self.hydrus_settings.base_url,
"access_key": self.hydrus_settings.access_key or "",
"options": self.hydrus_settings.as_metadata_options(),
"hash": hash_hex,
}
try:
result = metadata.fetch_hydrus_metadata(payload)
except Exception as exc: # pragma: no cover - surfaced to caller
self.state.warnings.append(str(exc))
return
self._apply_hydrus_result(result)
# Enrich relationships using the dedicated Hydrus endpoint (robust GET)
if self.include_relationships and self.state.hash and self.hydrus_settings.base_url:
self._enrich_relationships_from_api(self.state.hash)
def _populate_hydrus_by_url(self, url: str) -> None:
if not self.hydrus_settings.base_url:
return
payload: Dict[str, Any] = {
"api_url": self.hydrus_settings.base_url,
"access_key": self.hydrus_settings.access_key or "",
"options": self.hydrus_settings.as_metadata_options(),
"url": url,
}
try:
result = metadata.fetch_hydrus_metadata_by_url(payload)
except Exception as exc: # pragma: no cover - surfaced to caller
self.state.warnings.append(str(exc))
return
if result.get("error") == "not_found":
self.state.warnings.extend(result.get("warnings") or [])
return
self._apply_hydrus_result(result)
self.state.type = "hydrus"
matched_url = result.get("matched_url") or result.get("url")
if matched_url and matched_url not in self.state.url:
self.state.url.append(matched_url)
# Enrich relationships once we know the hash
if self.include_relationships and self.state.hash and self.hydrus_settings.base_url:
self._enrich_relationships_from_api(self.state.hash)
# ------------------------------------------------------------------
# state modification helpers
# ------------------------------------------------------------------
def _apply_hydrus_result(self, result: Dict[str, Any]) -> None:
metadata_payload = result.get("metadata")
if isinstance(metadata_payload, dict):
# Process mime into type for Lua
mime = metadata_payload.get("mime")
if isinstance(mime, str):
if mime.startswith("video/"):
metadata_payload["type"] = "video"
elif mime.startswith("audio/"):
metadata_payload["type"] = "audio"
elif mime.startswith("image/"):
metadata_payload["type"] = "image"
else:
metadata_payload["type"] = "other"
self.state.metadata = metadata_payload
# Do NOT overwrite MPVfile.type with metadata.type
self._merge_url(metadata_payload.get("url") or metadata_payload.get("url_set"))
source_url = metadata_payload.get("original_url") or metadata_payload.get("source_url")
if source_url and not self.state.source_url:
self.state.source_url = self._normalise_url(source_url)
# If file_relationships are embedded in metadata, capture as relationships when missing
if self.state.relationships is None:
embedded = metadata_payload.get("file_relationships")
if isinstance(embedded, dict) and embedded:
self.state.relationships = embedded
tags = result.get("tags") or []
self._merge_tags(tags)
hash_value = result.get("hash") or result.get("matched_hash")
if isinstance(hash_value, str) and _looks_like_hash(hash_value):
self.state.hash = hash_value.lower()
self.state.tag_service_key = result.get("tag_service_key") or self.state.tag_service_key
self.state.duration = result.get("duration") or self.state.duration
self.state.filesize_mb = result.get("filesize_mb") or self.state.filesize_mb
self.state.is_video = bool(result.get("is_video") or self.state.is_video)
self.state.is_audio = bool(result.get("is_audio") or self.state.is_audio)
if result.get("is_deleted") is not None:
self.state.is_deleted = bool(result.get("is_deleted"))
if result.get("is_local") is not None:
self.state.is_local = bool(result.get("is_local"))
if result.get("has_current_file_service") is not None:
self.state.has_current_file_service = bool(result.get("has_current_file_service"))
# Consolidate relationships from explicit result or embedded metadata
relationships_obj: Optional[Dict[str, Any]] = None
if isinstance(result.get("relationships"), dict):
relationships_obj = result["relationships"]
self.state.relationships = relationships_obj
elif isinstance(self.state.relationships, dict):
relationships_obj = self.state.relationships
# Helper to flatten any hashes from the relationships object
def _collect_hashes(obj: Any, acc: set[str]) -> None:
if obj is None:
return
if isinstance(obj, dict):
for v in obj.values():
_collect_hashes(v, acc)
elif isinstance(obj, (list, tuple, set)):
for v in obj:
_collect_hashes(v, acc)
elif isinstance(obj, str) and _looks_like_hash(obj):
acc.add(obj.lower())
# Derive king and alts robustly from available data
king: Optional[str] = None
alts: list[str] = []
# 1) Try direct king fields on relationships object
rels = relationships_obj or {}
if isinstance(rels, dict):
# Common variants
for key in ("king", "king_hash", "duplicate_king", "best", "best_hash"):
val = rels.get(key)
if isinstance(val, str) and _looks_like_hash(val):
king = val.lower()
break
if isinstance(val, list):
for h in val:
if isinstance(h, str) and _looks_like_hash(h):
king = h.lower()
break
if king:
break
# 2) Extract alternates from known fields: numeric "3" (clips), or textual synonyms
for alt_key in ("3", "alternates", "alts", "clips"):
val = rels.get(alt_key)
if isinstance(val, list):
for h in val:
if isinstance(h, str) and _looks_like_hash(h):
h_low = h.lower()
if not king or h_low != king:
alts.append(h_low)
# some APIs might nest
elif isinstance(val, dict):
tmp: set[str] = set()
_collect_hashes(val, tmp)
for h in sorted(tmp):
if not king or h != king:
alts.append(h)
# 3) Use relationship_metadata keys as additional alternates and king hint
rel_meta = result.get("relationship_metadata")
if isinstance(rel_meta, dict):
# prefer king candidate with no clip_time if not set
if not king:
for h, meta in rel_meta.items():
if isinstance(h, str) and _looks_like_hash(h) and isinstance(meta, dict):
if not meta.get("clip_time"):
king = h.lower()
break
for h in rel_meta.keys():
if isinstance(h, str) and _looks_like_hash(h):
h_low = h.lower()
if not king or h_low != king:
alts.append(h_low)
# 4) As a last resort, flatten all relationship hashes
if not alts and relationships_obj:
tmp: set[str] = set()
_collect_hashes(relationships_obj, tmp)
for h in sorted(tmp):
if not king or h != king:
alts.append(h)
# 5) Include current file when appropriate
if self.state.hash and (not king or self.state.hash != king) and self.state.hash not in alts:
alts.append(self.state.hash)
# 6) Sort alternates by clip start time when available
rel_meta_all = result.get("relationship_metadata") if isinstance(result.get("relationship_metadata"), dict) else {}
def _clip_start_for(h: str) -> float:
meta = rel_meta_all.get(h) if isinstance(rel_meta_all, dict) else None
clip = meta.get("clip_time") if isinstance(meta, dict) else None
if isinstance(clip, str):
m = re.match(r"^(\d+)-(\d+)$", clip)
if m:
try:
return float(m.group(1))
except Exception:
return float("inf")
return float("inf")
if alts:
# de-duplicate while preserving earliest clip time ordering
seen: set[str] = set()
alts = [h for h in sorted(alts, key=_clip_start_for) if (h not in seen and not seen.add(h))]
self.state.king = king
self.state.alts = alts
if isinstance(result.get("relationship_metadata"), dict):
self.state.relationship_metadata = result["relationship_metadata"]
self.state.title = result.get("title") or self.state.title
self.state.clip_time = result.get("clip_time") or self.state.clip_time
if result.get("swap_recommended"):
self.state.swap_recommended = True
warnings = result.get("warnings") or []
if warnings:
self.state.warnings.extend(warnings)
# ------------------------------------------------------------------
# relationships enrichment (Hydrus endpoint + alt metadata)
# ------------------------------------------------------------------
def _enrich_relationships_from_api(self, file_hash: str) -> None:
"""Fetch relationships for the given hash and enrich state's king/alts and alt metadata.
- Uses GET /manage_file_relationships/get_file_relationships?hash=...
- If alts exist, batch-fetch their metadata via GET /get_files/file_metadata?hashes=[...]
- Extracts title, duration, size, tags (cleaned: title: kept with namespace, others stripped)
"""
base_url = self.hydrus_settings.base_url or ""
access_key = self.hydrus_settings.access_key or ""
if not base_url:
return
try:
client = HydrusClient(base_url, access_key, timeout=self.hydrus_settings.timeout)
except Exception as exc: # pragma: no cover - construction should rarely fail
self.state.warnings.append(f"Hydrus client init failed: {exc}")
return
try:
rel_resp = client.get_file_relationships(file_hash)
except HydrusRequestError as hre: # pragma: no cover - surfaced but non-fatal
self.state.warnings.append(f"relationships api: {hre}")
return
except Exception as exc: # pragma: no cover
self.state.warnings.append(f"relationships api: {exc}")
return
rel_map = rel_resp.get("file_relationships") or {}
rel_obj = None
if isinstance(rel_map, dict):
rel_obj = rel_map.get(file_hash) or next((v for v in rel_map.values() if isinstance(v, dict)), None)
if isinstance(rel_obj, dict):
# Preserve the full relationships object
self.state.relationships = rel_obj
# Update king and alts from canonical fields
king = rel_obj.get("king")
alts = rel_obj.get("3") or []
if isinstance(king, str) and _looks_like_hash(king):
self.state.king = king.lower()
if isinstance(alts, list):
self.state.alts = [h.lower() for h in alts if isinstance(h, str) and _looks_like_hash(h)]
# Fetch alt metadata if we have alts
if not self.state.alts:
return
try:
meta_resp = client.fetch_file_metadata(
hashes=self.state.alts,
include_service_keys_to_tags=True,
include_duration=True,
include_size=True,
include_file_url=False,
include_mime=False,
)
except HydrusRequestError as hre: # pragma: no cover
self.state.warnings.append(f"metadata api: {hre}")
return
except Exception as exc: # pragma: no cover
self.state.warnings.append(f"metadata api: {exc}")
return
if not isinstance(meta_resp, dict):
return
entries = meta_resp.get("metadata") or []
if not isinstance(entries, list):
return
def _extract_tags(meta: Dict[str, Any]) -> list[str]:
tags: list[str] = []
tag_root = meta.get("tags") or meta.get("service_keys_to_statuses_to_tags") or {}
if isinstance(tag_root, dict):
for service_dict in tag_root.values():
if not isinstance(service_dict, dict):
continue
# Prefer storage_tags but fall back to any list values under known keys
storage = service_dict.get("storage_tags")
if isinstance(storage, dict):
for vals in storage.values():
if isinstance(vals, list):
tags.extend([str(t) for t in vals if isinstance(t, str)])
else:
# fall back: inspect lists directly under service_dict
for vals in service_dict.values():
if isinstance(vals, list):
tags.extend([str(t) for t in vals if isinstance(t, str)])
return tags
def _clean_tags_and_title(all_tags: list[str]) -> tuple[Optional[str], list[str]]:
title_val: Optional[str] = None
cleaned: list[str] = []
for tag in all_tags:
if not isinstance(tag, str):
continue
if tag.startswith("title:"):
if title_val is None:
title_val = tag.split(":", 1)[1]
cleaned.append(tag) # keep namespaced title
else:
if ":" in tag:
cleaned.append(tag.split(":", 1)[1])
else:
cleaned.append(tag)
return title_val, cleaned
for meta in entries:
if not isinstance(meta, dict):
continue
h = meta.get("hash")
if not (isinstance(h, str) and _looks_like_hash(h)):
continue
tags_all = _extract_tags(meta)
title_val, tags_clean = _clean_tags_and_title(tags_all)
alt_info = {
"title": title_val,
"duration": meta.get("duration"),
"size": meta.get("size"),
"tags": tags_clean,
}
self.state.relationship_metadata[h.lower()] = alt_info
def _merge_tags(self, tags: Sequence[Any]) -> None:
incoming = _normalise_string_list(tags)
if not incoming:
return
combined = list(self.state.tags or []) + incoming
self.state.tags = unique_preserve_order(combined)
for tag in incoming:
if tag not in self.state.original_tags:
self.state.original_tags[tag] = tag
def _merge_url(self, url: Optional[Iterable[Any]]) -> None:
if not url:
return
combined = list(self.state.url or []) + _normalise_string_list(url)
self.state.url = unique_preserve_order(combined)
def _load_sidecar_tags(self, local_path: str) -> None:
try:
media_path = Path(local_path)
except Exception:
return
if not media_path.exists():
return
candidates = [media_path.with_suffix(".tags"), media_path.with_suffix(".tags.txt")]
for candidate in candidates:
if candidate.exists():
hash_value, tags, known = self._read_sidecar(candidate)
if hash_value and not self.state.hash and _looks_like_hash(hash_value):
self.state.hash = hash_value.lower()
self._merge_tags(tags)
self._merge_url(known)
break
def _read_sidecar(self, sidecar_path: Path) -> tuple[Optional[str], List[str], List[str]]:
try:
raw = sidecar_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return None, [], []
hash_value: Optional[str] = None
tags: List[str] = []
url: List[str] = []
for line in raw.splitlines():
trimmed = line.strip()
if not trimmed:
continue
lowered = trimmed.lower()
if lowered.startswith("hash:"):
candidate = trimmed.split(":", 1)[1].strip() if ":" in trimmed else ""
if candidate:
hash_value = candidate
elif lowered.startswith("url:") or lowered.startswith("url:"):
candidate = trimmed.split(":", 1)[1].strip() if ":" in trimmed else ""
if candidate:
url.append(candidate)
else:
tags.append(trimmed)
return hash_value, tags, url
def _compute_local_hash(self, local_path: str) -> None:
try:
digest = sha256_file(Path(local_path))
except OSError as exc:
self.state.warnings.append(f"sha256 failed: {exc}")
return
self.state.hash = digest.lower()
# ------------------------------------------------------------------
# finalisation helpers
# ------------------------------------------------------------------
def _finalise(self) -> None:
if self.state.tags:
self.state.tags = unique_preserve_order(self.state.tags)
if self.state.url:
self.state.url = unique_preserve_order(self.state.url)
# Ensure metadata.type is always present for Lua, but do NOT overwrite MPVfile.type
if not self.state.title:
if self.state.metadata.get("title"):
self.state.title = str(self.state.metadata["title"]).strip()
elif self.state.filename:
self.state.title = self.state.filename
if self.state.hash and not _looks_like_hash(self.state.hash):
self.state.hash = None
if self.state.relationship_metadata is None:
self.state.relationship_metadata = {}
if self.state.relationships is not None and not isinstance(self.state.relationships, dict):
self.state.relationships = None
if self.state.original_tags is None:
self.state.original_tags = {}
# ------------------------------------------------------------------
# util helpers
# ------------------------------------------------------------------
@staticmethod
def _normalise_url(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text
@staticmethod
def _resolve_local_path(path: Optional[str]) -> Optional[str]:
if not path:
return None
parsed = urlparse(path)
if parsed.scheme == "file":
decoded = MPVFileBuilder._decode_file_url(path)
return decoded
return path
@staticmethod
def _decode_file_url(value: str) -> Optional[str]:
parsed = urlparse(value)
if parsed.scheme != "file":
return None
netloc = parsed.netloc or ""
path = unquote(parsed.path or "")
if netloc:
path = f"//{netloc}{path}"
if os.name == "nt" and path.startswith("/") and re.match(r"/[A-Za-z]:", path):
path = path[1:]
path = path.replace("/", os.sep)
return path
def _looks_like_hydrus_url(self, url: str) -> bool:
if not url:
return False
if url.startswith("hydrus://"):
return True
if "Hydrus-Client-API-Access-Key=" in url:
return True
base = self.hydrus_settings.base_url
if base and url.startswith(base) and "/get_files/" in url:
return True
return False
@staticmethod
def _extract_hash_from_path(path: Optional[str]) -> Optional[str]:
if not path:
return None
parsed = urlparse(path)
query = parse_qs(parsed.query)
if "hash" in query and query["hash"]:
candidate = query["hash"][0].strip()
if candidate:
return candidate.lower()
match = re.search(r"hash=([0-9a-fA-F]{64})", path)
if match:
return match.group(1).lower()
return None
def build_mpv_file_state(payload: Dict[str, Any], config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
builder = MPVFileBuilder(payload or {}, config or {})
return builder.build()

View File

@@ -1,818 +0,0 @@
"""Provider interfaces for search and file upload functionality.
This module defines two distinct provider types:
1. SearchProvider: For searching content (books, music, videos, games)
2. FileProvider: For uploading files to hosting services
No legacy code or backwards compatibility - clean, single source of truth.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from pathlib import Path
import sys
import os
import json
import re
import time
import asyncio
import subprocess
import shutil
import mimetypes
import traceback
import requests
from helper.logger import log, debug
# Optional dependencies
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
# ============================================================================
# SEARCH PROVIDERS
# ============================================================================
@dataclass
class SearchResult:
"""Unified search result format across all search providers."""
origin: str # Provider name: "libgen", "soulseek", "debrid", "bandcamp", etc.
title: str # Display title/filename
path: str # Download target (URL, path, magnet, identifier)
detail: str = "" # Additional description
annotations: List[str] = field(default_factory=list) # Tags: ["120MB", "flac", "ready"]
media_kind: str = "other" # Type: "book", "audio", "video", "game", "magnet"
size_bytes: Optional[int] = None
tags: set[str] = field(default_factory=set) # Searchable tags
columns: List[Tuple[str, str]] = field(default_factory=list) # Display columns
full_metadata: Dict[str, Any] = field(default_factory=dict) # Extra metadata
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for pipeline processing."""
return {
"origin": self.origin,
"title": self.title,
"path": self.path,
"detail": self.detail,
"annotations": self.annotations,
"media_kind": self.media_kind,
"size_bytes": self.size_bytes,
"tags": list(self.tags),
"columns": list(self.columns),
"full_metadata": self.full_metadata,
}
class SearchProvider(ABC):
"""Base class for search providers."""
def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
self.name = self.__class__.__name__.lower()
@abstractmethod
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[SearchResult]:
"""Search for items matching the query.
Args:
query: Search query string
limit: Maximum results to return
filters: Optional filtering criteria
**kwargs: Provider-specific arguments
Returns:
List of SearchResult objects
"""
pass
def validate(self) -> bool:
"""Check if provider is available and properly configured."""
return True
class Libgen(SearchProvider):
"""Search provider for Library Genesis books."""
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[SearchResult]:
filters = filters or {}
try:
from helper.unified_book_downloader import UnifiedBookDownloader
from helper.query_parser import parse_query, get_field, get_free_text
parsed = parse_query(query)
isbn = get_field(parsed, 'isbn')
author = get_field(parsed, 'author')
title = get_field(parsed, 'title')
free_text = get_free_text(parsed)
search_query = isbn or title or author or free_text or query
downloader = UnifiedBookDownloader(config=self.config)
books = downloader.search_libgen(search_query, limit=limit)
results = []
for idx, book in enumerate(books, 1):
title = book.get("title", "Unknown")
author = book.get("author", "Unknown")
year = book.get("year", "Unknown")
pages = book.get("pages") or book.get("pages_str") or ""
extension = book.get("extension", "") or book.get("ext", "")
filesize = book.get("filesize_str", "Unknown")
isbn = book.get("isbn", "")
mirror_url = book.get("mirror_url", "")
columns = [
("Title", title),
("Author", author),
("Pages", str(pages)),
("Ext", str(extension)),
]
detail = f"By: {author}"
if year and year != "Unknown":
detail += f" ({year})"
annotations = [f"{filesize}"]
if isbn:
annotations.append(f"ISBN: {isbn}")
results.append(SearchResult(
origin="libgen",
title=title,
path=mirror_url or f"libgen:{book.get('id', '')}",
detail=detail,
annotations=annotations,
media_kind="book",
columns=columns,
full_metadata={
"number": idx,
"author": author,
"year": year,
"isbn": isbn,
"filesize": filesize,
"pages": pages,
"extension": extension,
"book_id": book.get("book_id", ""),
"md5": book.get("md5", ""),
},
))
return results
except Exception as e:
log(f"[libgen] Search error: {e}", file=sys.stderr)
return []
def validate(self) -> bool:
try:
from helper.unified_book_downloader import UnifiedBookDownloader
return True
except Exception:
return False
class Soulseek(SearchProvider):
"""Search provider for Soulseek P2P network."""
MUSIC_EXTENSIONS = {
'.flac', '.mp3', '.m4a', '.aac', '.ogg', '.opus',
'.wav', '.alac', '.wma', '.ape', '.aiff', '.dsf',
'.dff', '.wv', '.tta', '.tak', '.ac3', '.dts'
}
USERNAME = "asjhkjljhkjfdsd334"
PASSWORD = "khhhg"
DOWNLOAD_DIR = "./downloads"
MAX_WAIT_TRANSFER = 1200
async def perform_search(
self,
query: str,
timeout: float = 9.0,
limit: int = 50
) -> List[Dict[str, Any]]:
"""Perform async Soulseek search."""
import os
from aioslsk.client import SoulSeekClient
from aioslsk.settings import Settings, CredentialsSettings
os.makedirs(self.DOWNLOAD_DIR, exist_ok=True)
settings = Settings(credentials=CredentialsSettings(username=self.USERNAME, password=self.PASSWORD))
client = SoulSeekClient(settings)
try:
await client.start()
await client.login()
except Exception as e:
log(f"[soulseek] Login failed: {type(e).__name__}: {e}", file=sys.stderr)
return []
try:
search_request = await client.searches.search(query)
await self._collect_results(client, search_request, timeout=timeout)
return self._flatten_results(search_request)[:limit]
except Exception as e:
log(f"[soulseek] Search error: {type(e).__name__}: {e}", file=sys.stderr)
return []
finally:
try:
await client.stop()
except Exception:
pass
def _flatten_results(self, search_request) -> List[dict]:
flat = []
for result in search_request.results:
username = getattr(result, "username", "?")
for file_data in getattr(result, "shared_items", []):
flat.append({
"file": file_data,
"username": username,
"filename": getattr(file_data, "filename", "?"),
"size": getattr(file_data, "filesize", 0),
})
for file_data in getattr(result, "locked_results", []):
flat.append({
"file": file_data,
"username": username,
"filename": getattr(file_data, "filename", "?"),
"size": getattr(file_data, "filesize", 0),
})
return flat
async def _collect_results(self, client, search_request, timeout: float = 75.0) -> None:
end = time.time() + timeout
last_count = 0
while time.time() < end:
current_count = len(search_request.results)
if current_count > last_count:
debug(f"[soulseek] Got {current_count} result(s)...")
last_count = current_count
await asyncio.sleep(0.5)
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[SearchResult]:
filters = filters or {}
try:
flat_results = asyncio.run(self.perform_search(query, timeout=9.0, limit=limit))
if not flat_results:
return []
# Filter to music files only
music_results = []
for item in flat_results:
filename = item['filename']
ext = '.' + filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext in self.MUSIC_EXTENSIONS:
music_results.append(item)
if not music_results:
return []
# Extract metadata
enriched_results = []
for item in music_results:
filename = item['filename']
ext = '.' + filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
# Get display filename
display_name = filename.split('\\')[-1] if '\\' in filename else filename.split('/')[-1] if '/' in filename else filename
# Extract path hierarchy
path_parts = filename.replace('\\', '/').split('/')
artist = path_parts[-3] if len(path_parts) >= 3 else ''
album = path_parts[-2] if len(path_parts) >= 3 else path_parts[-2] if len(path_parts) == 2 else ''
# Extract track number and title
base_name = display_name.rsplit('.', 1)[0] if '.' in display_name else display_name
track_num = ''
title = base_name
filename_artist = ''
match = re.match(r'^(\d{1,3})\s*[\.\-]?\s+(.+)$', base_name)
if match:
track_num = match.group(1)
rest = match.group(2)
if ' - ' in rest:
filename_artist, title = rest.split(' - ', 1)
else:
title = rest
if filename_artist:
artist = filename_artist
enriched_results.append({
**item,
'artist': artist,
'album': album,
'title': title,
'track_num': track_num,
'ext': ext
})
# Apply filters
if filters:
artist_filter = filters.get('artist', '').lower() if filters.get('artist') else ''
album_filter = filters.get('album', '').lower() if filters.get('album') else ''
track_filter = filters.get('track', '').lower() if filters.get('track') else ''
if artist_filter or album_filter or track_filter:
filtered = []
for item in enriched_results:
if artist_filter and artist_filter not in item['artist'].lower():
continue
if album_filter and album_filter not in item['album'].lower():
continue
if track_filter and track_filter not in item['title'].lower():
continue
filtered.append(item)
enriched_results = filtered
# Sort: .flac first, then by size
enriched_results.sort(key=lambda item: (item['ext'].lower() != '.flac', -item['size']))
# Convert to SearchResult
results = []
for idx, item in enumerate(enriched_results, 1):
artist_display = item['artist'] if item['artist'] else "(no artist)"
album_display = item['album'] if item['album'] else "(no album)"
size_mb = int(item['size'] / 1024 / 1024)
columns = [
("Track", item['track_num'] or "?"),
("Title", item['title'][:40]),
("Artist", artist_display[:32]),
("Album", album_display[:32]),
("Size", f"{size_mb} MB"),
]
results.append(SearchResult(
origin="soulseek",
title=item['title'],
path=item['filename'],
detail=f"{artist_display} - {album_display}",
annotations=[f"{size_mb} MB", item['ext'].lstrip('.').upper()],
media_kind="audio",
size_bytes=item['size'],
columns=columns,
full_metadata={
"username": item['username'],
"filename": item['filename'],
"artist": item['artist'],
"album": item['album'],
"track_num": item['track_num'],
"ext": item['ext'],
},
))
return results
except Exception as e:
log(f"[soulseek] Search error: {e}", file=sys.stderr)
return []
def validate(self) -> bool:
try:
from aioslsk.client import SoulSeekClient
return True
except ImportError:
return False
class Bandcamp(SearchProvider):
"""Search provider for Bandcamp."""
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[SearchResult]:
if not PLAYWRIGHT_AVAILABLE:
log("[bandcamp] Playwright not available. Install with: pip install playwright", file=sys.stderr)
return []
results = []
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Parse query for artist: prefix
if query.strip().lower().startswith("artist:"):
artist_name = query[7:].strip().strip('"')
search_url = f"https://bandcamp.com/search?q={artist_name}&item_type=b"
else:
search_url = f"https://bandcamp.com/search?q={query}&item_type=a"
results = self._scrape_url(page, search_url, limit)
browser.close()
except Exception as e:
log(f"[bandcamp] Search error: {e}", file=sys.stderr)
return []
return results
def _scrape_url(self, page, url: str, limit: int) -> List[SearchResult]:
debug(f"[bandcamp] Scraping: {url}")
page.goto(url)
page.wait_for_load_state("domcontentloaded")
results = []
# Check for search results
search_results = page.query_selector_all(".searchresult")
if search_results:
for item in search_results[:limit]:
try:
heading = item.query_selector(".heading")
if not heading:
continue
link = heading.query_selector("a")
if not link:
continue
title = link.inner_text().strip()
target_url = link.get_attribute("href")
subhead = item.query_selector(".subhead")
artist = subhead.inner_text().strip() if subhead else "Unknown"
itemtype = item.query_selector(".itemtype")
media_type = itemtype.inner_text().strip() if itemtype else "album"
results.append(SearchResult(
origin="bandcamp",
title=title,
path=target_url,
detail=f"By: {artist}",
annotations=[media_type],
media_kind="audio",
columns=[
("Name", title),
("Artist", artist),
("Type", media_type),
],
full_metadata={
"artist": artist,
"type": media_type,
},
))
except Exception as e:
debug(f"[bandcamp] Error parsing result: {e}")
continue
return results
def validate(self) -> bool:
return PLAYWRIGHT_AVAILABLE
class YouTube(SearchProvider):
"""Search provider for YouTube using yt-dlp."""
def search(
self,
query: str,
limit: int = 10,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[SearchResult]:
ytdlp_path = shutil.which("yt-dlp")
if not ytdlp_path:
log("[youtube] yt-dlp not found in PATH", file=sys.stderr)
return []
search_query = f"ytsearch{limit}:{query}"
cmd = [
ytdlp_path,
"--dump-json",
"--flat-playlist",
"--no-warnings",
search_query
]
try:
process = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace"
)
if process.returncode != 0:
log(f"[youtube] yt-dlp failed: {process.stderr}", file=sys.stderr)
return []
results = []
for line in process.stdout.splitlines():
if not line.strip():
continue
try:
video_data = json.loads(line)
title = video_data.get("title", "Unknown")
video_id = video_data.get("id", "")
url = video_data.get("url") or f"https://youtube.com/watch?v={video_id}"
uploader = video_data.get("uploader", "Unknown")
duration = video_data.get("duration", 0)
view_count = video_data.get("view_count", 0)
duration_str = f"{int(duration//60)}:{int(duration%60):02d}" if duration else ""
views_str = f"{view_count:,}" if view_count else ""
results.append(SearchResult(
origin="youtube",
title=title,
path=url,
detail=f"By: {uploader}",
annotations=[duration_str, f"{views_str} views"],
media_kind="video",
columns=[
("Title", title),
("Uploader", uploader),
("Duration", duration_str),
("Views", views_str),
],
full_metadata={
"video_id": video_id,
"uploader": uploader,
"duration": duration,
"view_count": view_count,
},
))
except json.JSONDecodeError:
continue
return results
except Exception as e:
log(f"[youtube] Error: {e}", file=sys.stderr)
return []
def validate(self) -> bool:
return shutil.which("yt-dlp") is not None
def pipe(self, path: str, config: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Return the playable URL for MPV (just the path for YouTube)."""
return path
# Search provider registry
_SEARCH_PROVIDERS = {
"libgen": Libgen,
"soulseek": Soulseek,
"bandcamp": Bandcamp,
"youtube": YouTube,
}
def get_search_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
"""Get a search provider by name."""
provider_class = _SEARCH_PROVIDERS.get(name.lower())
if provider_class is None:
log(f"[provider] Unknown search provider: {name}", file=sys.stderr)
return None
try:
provider = provider_class(config)
if not provider.validate():
log(f"[provider] Provider '{name}' is not available", file=sys.stderr)
return None
return provider
except Exception as e:
log(f"[provider] Error initializing '{name}': {e}", file=sys.stderr)
return None
def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
"""List all search providers and their availability."""
availability = {}
for name, provider_class in _SEARCH_PROVIDERS.items():
try:
provider = provider_class(config)
availability[name] = provider.validate()
except Exception:
availability[name] = False
return availability
# ============================================================================
# FILE PROVIDERS
# ============================================================================
class FileProvider(ABC):
"""Base class for file upload providers."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {}
self.name = self.__class__.__name__.lower()
@abstractmethod
def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload a file and return the URL."""
pass
def validate(self) -> bool:
"""Check if provider is available/configured."""
return True
class ZeroXZero(FileProvider):
"""File provider for 0x0.st."""
def upload(self, file_path: str, **kwargs: Any) -> str:
from helper.http_client import HTTPClient
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
headers = {"User-Agent": "Medeia-Macina/1.0"}
with HTTPClient(headers=headers) as client:
with open(file_path, 'rb') as f:
response = client.post(
"https://0x0.st",
files={"file": f}
)
if response.status_code == 200:
return response.text.strip()
else:
raise Exception(f"Upload failed: {response.status_code} - {response.text}")
except Exception as e:
log(f"[0x0] Upload error: {e}", file=sys.stderr)
raise
def validate(self) -> bool:
return True
class Matrix(FileProvider):
"""File provider for Matrix (Element) chat rooms."""
def validate(self) -> bool:
if not self.config:
return False
matrix_conf = self.config.get('storage', {}).get('matrix', {})
return bool(
matrix_conf.get('homeserver') and
matrix_conf.get('room_id') and
(matrix_conf.get('access_token') or matrix_conf.get('password'))
)
def upload(self, file_path: str, **kwargs: Any) -> str:
from pathlib import Path
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
matrix_conf = self.config.get('storage', {}).get('matrix', {})
homeserver = matrix_conf.get('homeserver')
access_token = matrix_conf.get('access_token')
room_id = matrix_conf.get('room_id')
if not homeserver.startswith('http'):
homeserver = f"https://{homeserver}"
# Upload media
upload_url = f"{homeserver}/_matrix/media/v3/upload"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream"
}
mime_type, _ = mimetypes.guess_type(path)
if mime_type:
headers["Content-Type"] = mime_type
filename = path.name
with open(path, 'rb') as f:
resp = requests.post(upload_url, headers=headers, data=f, params={"filename": filename})
if resp.status_code != 200:
raise Exception(f"Matrix upload failed: {resp.text}")
content_uri = resp.json().get('content_uri')
if not content_uri:
raise Exception("No content_uri returned")
# Send message
send_url = f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message"
# Determine message type
msgtype = "m.file"
ext = path.suffix.lower()
AUDIO_EXTS = {'.mp3', '.flac', '.wav', '.m4a', '.aac', '.ogg', '.opus', '.wma', '.mka', '.alac'}
VIDEO_EXTS = {'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.mpg', '.mpeg', '.ts', '.m4v', '.wmv'}
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'}
if ext in AUDIO_EXTS:
msgtype = "m.audio"
elif ext in VIDEO_EXTS:
msgtype = "m.video"
elif ext in IMAGE_EXTS:
msgtype = "m.image"
info = {
"mimetype": mime_type,
"size": path.stat().st_size
}
payload = {
"msgtype": msgtype,
"body": filename,
"url": content_uri,
"info": info
}
resp = requests.post(send_url, headers=headers, json=payload)
if resp.status_code != 200:
raise Exception(f"Matrix send message failed: {resp.text}")
event_id = resp.json().get('event_id')
return f"https://matrix.to/#/{room_id}/{event_id}"
# File provider registry
_FILE_PROVIDERS = {
"0x0": ZeroXZero,
"matrix": Matrix,
}
def get_file_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
"""Get a file provider by name."""
provider_class = _FILE_PROVIDERS.get(name.lower())
if provider_class is None:
log(f"[provider] Unknown file provider: {name}", file=sys.stderr)
return None
try:
provider = provider_class(config)
if not provider.validate():
log(f"[provider] File provider '{name}' is not available", file=sys.stderr)
return None
return provider
except Exception as e:
log(f"[provider] Error initializing file provider '{name}': {e}", file=sys.stderr)
return None
def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
"""List all file providers and their availability."""
availability = {}
for name, provider_class in _FILE_PROVIDERS.items():
try:
provider = provider_class(config)
availability[name] = provider.validate()
except Exception:
availability[name] = False
return availability

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ disables Hydrus features if the API is unavailable.
import logging import logging
import sys import sys
from helper.logger import log, debug from SYS.logger import log, debug
from typing import Tuple, Optional, Dict, Any from typing import Tuple, Optional, Dict, Any
from pathlib import Path from pathlib import Path
@@ -37,7 +37,7 @@ def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[st
- (False, reason) if Hydrus is unavailable with reason - (False, reason) if Hydrus is unavailable with reason
""" """
try: try:
from helper.hydrus import is_available as _is_hydrus_available from API.HydrusNetwork import is_available as _is_hydrus_available
logger.info("[Hydrus Health Check] Pinging Hydrus API...") logger.info("[Hydrus Health Check] Pinging Hydrus API...")
is_available, reason = _is_hydrus_available(config, use_cache=False) is_available, reason = _is_hydrus_available(config, use_cache=False)
@@ -97,7 +97,7 @@ def initialize_hydrus_health_check(config: Dict[str, Any], emit_debug: bool = Tr
def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Debrid API is available.""" """Check if Debrid API is available."""
try: try:
from helper.http_client import HTTPClient from API.HTTP import HTTPClient
logger.info("[Debrid Health Check] Pinging Debrid API...") logger.info("[Debrid Health Check] Pinging Debrid API...")
with HTTPClient(timeout=10.0, verify_ssl=True) as client: with HTTPClient(timeout=10.0, verify_ssl=True) as client:
response = client.get('https://api.alldebrid.com/v4/ping') response = client.get('https://api.alldebrid.com/v4/ping')
@@ -387,8 +387,8 @@ def initialize_local_library_scan(config: Dict[str, Any], emit_debug: bool = Tru
This ensures that any new files in configured folder stores are indexed This ensures that any new files in configured folder stores are indexed
and their sidecar files are imported and cleaned up. and their sidecar files are imported and cleaned up.
""" """
from helper.folder_store import LocalLibraryInitializer from API.folder import LocalLibraryInitializer
from helper.store import Folder from Store.Folder import Folder
logger.info("[Startup] Starting folder store scans...") logger.info("[Startup] Starting folder store scans...")

View File

@@ -5,7 +5,7 @@ import sys
import shutil import shutil
import sqlite3 import sqlite3
import requests import requests
from helper.logger import log, debug from SYS.logger import log, debug
from urllib.parse import urlsplit, urlunsplit, unquote from urllib.parse import urlsplit, urlunsplit, unquote
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
@@ -29,7 +29,7 @@ except ImportError: # pragma: no cover
resolve_output_dir = None # type: ignore[assignment] resolve_output_dir = None # type: ignore[assignment]
try: try:
from helper.utils import sha256_file from SYS.utils import sha256_file
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
sha256_file = None # type: ignore[assignment] sha256_file = None # type: ignore[assignment]
@@ -3215,7 +3215,7 @@ def enrich_playlist_entries(entries: list, extractor: str) -> list:
List of enriched entry dicts List of enriched entry dicts
""" """
# Import here to avoid circular dependency # Import here to avoid circular dependency
from helper.download import is_url_supported_by_ytdlp from SYS.download import is_url_supported_by_ytdlp
if not entries: if not entries:
return entries return entries
@@ -3609,7 +3609,7 @@ def scrape_isbn_metadata(isbn: str) -> List[str]:
"""Scrape metadata for an ISBN using Open Library API.""" """Scrape metadata for an ISBN using Open Library API."""
new_tags = [] new_tags = []
try: try:
from helper.http_client import HTTPClient from API.HTTP import HTTPClient
import json as json_module import json as json_module
isbn_clean = isbn.replace('-', '').strip() isbn_clean = isbn.replace('-', '').strip()
@@ -3733,7 +3733,7 @@ def scrape_openlibrary_metadata(olid: str) -> List[str]:
""" """
new_tags = [] new_tags = []
try: try:
from helper.http_client import HTTPClient from API.HTTP import HTTPClient
import json as json_module import json as json_module
# Format: OL9674499M or just 9674499M # Format: OL9674499M or just 9674499M

View File

@@ -79,7 +79,7 @@ class PipeObject:
object state throughout the pipeline. object state throughout the pipeline.
""" """
try: try:
from helper.logger import is_debug_enabled, debug from SYS.logger import is_debug_enabled, debug
if not is_debug_enabled(): if not is_debug_enabled():
return return

View File

@@ -22,7 +22,7 @@ import sys
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
from models import PipelineStageContext from models import PipelineStageContext
from helper.logger import log from SYS.logger import log
def _is_selectable_table(table: Any) -> bool: def _is_selectable_table(table: Any) -> bool:

73
readme.md Normal file
View File

@@ -0,0 +1,73 @@
# Medios-Macina
- Audio
- Video
- Image
- Text
### File Store
- HydrusNetwork https://github.com/hydrusnetwork/hydrus
- Local drive (C://mylibrary/path)
### File Providers
- Youtube
- Openlibrary/Archive.org (free account needed)
- Soulseek
- Gog-Games (limited without paid API)
- Libgen
- All-Debrid https://alldebrid.com/
### Features
- Full MPV integration https://github.com/mpv-player/mpv
- Database file management
- API/CLI exclusive
- Plug and play stores and add-ons
- YT-DLP integration
- CMDLET easy syntax
- CLI auto-complete
Install what you need and want, after you have the requirements.txt installed as well you will need to open terminal at the repository download location and run the cli file like .
#### Quick
```shell
cd "C:\location\to\repository\medios-machina\"
python cli.py
```
Adding your first file
```python
.pipe -list # List MPV current playing/list
.pipe -save # Save current MPV playlist to local library
.pipe -load # List saved playlists; use @N to load one
.pipe "https://www.youtube.com/watch?v=_23dFb50Z2Y" # Add URL to current playlist
```
Example pipelines:
1. **Simple download with metadata (tags and URL registration)**:
```
download-media "https://www.youtube.com/watch?v=dQw4w9WgXcQ" | add-file -storage local | add-url
```
2. **Download playlist item with tags**:
```
download-media "https://www.youtube.com/playlist?list=PLxxxxx" -item 2 | add-file -storage local | add-url
```
3. **Download with merge (e.g., Bandcamp albums)**:
```
download-data "https://altrusiangrace.bandcamp.com/album/ancient-egyptian-legends-full-audiobook" | merge-file | add-file -storage local | add-url
```
4. **Download direct file (PDF, document)**:
```
download-file "https://example.com/file.pdf" | add-file -storage local | add-url
```
Search examples:
1. search-file -provider youtube "something in the way"
2. @1
3. download-media [URL] | add-file -storage local | add-url

View File

@@ -194,6 +194,13 @@ class ResultTable:
"""If True, skip automatic sorting so display order matches input order.""" """If True, skip automatic sorting so display order matches input order."""
self.no_choice: bool = False self.no_choice: bool = False
"""When True, suppress row numbers/selection to make the table non-interactive.""" """When True, suppress row numbers/selection to make the table non-interactive."""
self.table: Optional[str] = None
"""Table type (e.g., 'youtube', 'soulseek') for context-aware selection logic."""
def set_table(self, table: str) -> "ResultTable":
"""Set the table type for context-aware selection logic."""
self.table = table
return self
def set_no_choice(self, no_choice: bool = True) -> "ResultTable": def set_no_choice(self, no_choice: bool = True) -> "ResultTable":
"""Mark the table as non-interactive (no row numbers, no selection parsing).""" """Mark the table as non-interactive (no row numbers, no selection parsing)."""
@@ -393,11 +400,11 @@ class ResultTable:
# Core fields (legacy fallback) # Core fields (legacy fallback)
title = getattr(result, 'title', '') title = getattr(result, 'title', '')
origin = getattr(result, 'origin', '').lower() table = str(getattr(result, 'table', '') or '').lower()
# Handle extension separation for local files # Handle extension separation for local files
extension = "" extension = ""
if title and origin == 'local': if title and table == 'local':
path_obj = Path(title) path_obj = Path(title)
if path_obj.suffix: if path_obj.suffix:
extension = path_obj.suffix.lstrip('.') extension = path_obj.suffix.lstrip('.')
@@ -409,8 +416,8 @@ class ResultTable:
# Extension column # Extension column
row.add_column("Ext", extension) row.add_column("Ext", extension)
if hasattr(result, 'origin') and result.origin: if hasattr(result, 'table') and getattr(result, 'table', None):
row.add_column("Source", result.origin) row.add_column("Source", str(getattr(result, 'table')))
if hasattr(result, 'detail') and result.detail: if hasattr(result, 'detail') and result.detail:
row.add_column("Detail", result.detail) row.add_column("Detail", result.detail)
@@ -448,19 +455,19 @@ class ResultTable:
Shows only essential columns: Shows only essential columns:
- Title (required) - Title (required)
- Ext (extension) - Ext (extension)
- Origin (source backend) - Storage (source backend)
- Size (formatted MB, integer only) - Size (formatted MB, integer only)
All other fields are stored in item but not displayed to keep table compact. All other fields are stored in item but not displayed to keep table compact.
Use @row# syntax to pipe full item data to next command. Use @row# syntax to pipe full item data to next command.
""" """
# Title (required - use origin as fallback) # Title (required)
title = getattr(item, 'title', None) or getattr(item, 'origin', 'Unknown') title = getattr(item, 'title', None) or 'Unknown'
origin = getattr(item, 'origin', '').lower() table = str(getattr(item, 'table', '') or getattr(item, 'store', '') or '').lower()
# Handle extension separation for local files # Handle extension separation for local files
extension = "" extension = ""
if title and origin == 'local': if title and table == 'local':
# Try to split extension # Try to split extension
path_obj = Path(title) path_obj = Path(title)
if path_obj.suffix: if path_obj.suffix:
@@ -474,8 +481,10 @@ class ResultTable:
row.add_column("Ext", extension) row.add_column("Ext", extension)
# Storage (source backend - hydrus, local, debrid, etc) # Storage (source backend - hydrus, local, debrid, etc)
if hasattr(item, 'origin') and item.origin: if getattr(item, 'table', None):
row.add_column("Storage", item.origin) row.add_column("Storage", str(getattr(item, 'table')))
elif getattr(item, 'store', None):
row.add_column("Storage", str(getattr(item, 'store')))
# Size (for files) - integer MB only # Size (for files) - integer MB only
if hasattr(item, 'size_bytes') and item.size_bytes: if hasattr(item, 'size_bytes') and item.size_bytes:
@@ -499,8 +508,6 @@ class ResultTable:
# Source/Store (where the tags come from) # Source/Store (where the tags come from)
if hasattr(item, 'source') and item.source: if hasattr(item, 'source') and item.source:
row.add_column("Store", item.source) row.add_column("Store", item.source)
elif hasattr(item, 'origin') and item.origin:
row.add_column("Store", item.origin)
def _add_pipe_object(self, row: ResultRow, obj: Any) -> None: def _add_pipe_object(self, row: ResultRow, obj: Any) -> None:
@@ -549,7 +556,7 @@ class ResultTable:
Priority field groups (uses first match within each group): Priority field groups (uses first match within each group):
- title | name | filename - title | name | filename
- origin | source - store | table | source
- type | media_kind | kind - type | media_kind | kind
- target | path | url - target | path | url
- hash | hash_hex | file_hash - hash | hash_hex | file_hash
@@ -574,12 +581,12 @@ class ResultTable:
visible_data = {k: v for k, v in data.items() if not is_hidden_field(k)} visible_data = {k: v for k, v in data.items() if not is_hidden_field(k)}
# Handle extension separation for local files # Handle extension separation for local files
origin = str(visible_data.get('origin', '') or visible_data.get('source', '')).lower() store_val = str(visible_data.get('store', '') or visible_data.get('table', '') or visible_data.get('source', '')).lower()
# Debug logging # Debug logging
# print(f"DEBUG: Processing dict result. Origin: {origin}, Keys: {list(visible_data.keys())}") # print(f"DEBUG: Processing dict result. Store: {store_val}, Keys: {list(visible_data.keys())}")
if origin == 'local': if store_val == 'local':
# Find title field # Find title field
title_field = next((f for f in ['title', 'name', 'filename'] if f in visible_data), None) title_field = next((f for f in ['title', 'name', 'filename'] if f in visible_data), None)
if title_field: if title_field:
@@ -629,8 +636,8 @@ class ResultTable:
# Mark 'columns' as handled so we don't add it as a field # Mark 'columns' as handled so we don't add it as a field
added_fields.add('columns') added_fields.add('columns')
# Also mark common fields that shouldn't be re-displayed if they're in columns # Also mark common fields that shouldn't be re-displayed if they're in columns
# This prevents showing both "Store" (from columns) and "Origin" (from data fields) # This prevents showing both "Store" (from columns) and "Store" (from data fields)
added_fields.add('origin') added_fields.add('table')
added_fields.add('source') added_fields.add('source')
added_fields.add('target') added_fields.add('target')
added_fields.add('path') added_fields.add('path')
@@ -649,7 +656,7 @@ class ResultTable:
('title', ['title']), ('title', ['title']),
('ext', ['ext']), ('ext', ['ext']),
('size', ['size', 'size_bytes']), ('size', ['size', 'size_bytes']),
('store', ['store', 'origin', 'source']), ('store', ['store', 'table', 'source']),
] ]
# Add priority field groups first - use first match in each group # Add priority field groups first - use first match in each group
@@ -668,7 +675,7 @@ class ResultTable:
value_str = value_str[:57] + "..." value_str = value_str[:57] + "..."
# Map field names to display column names # Map field names to display column names
if field in ['store', 'origin', 'source']: if field in ['store', 'table', 'source']:
col_name = "Store" col_name = "Store"
elif field in ['size', 'size_bytes']: elif field in ['size', 'size_bytes']:
col_name = "Size (Mb)" col_name = "Size (Mb)"

View File

@@ -1,10 +0,0 @@
import importlib
import traceback
import sys
try:
importlib.import_module('cmdlets')
print('cmdlets imported OK')
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -1,8 +0,0 @@
import importlib, traceback, sys
try:
importlib.import_module('cmdlets.download_media')
print('download_media imported OK')
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -1,5 +0,0 @@
from pathlib import Path
p = Path('cmdlets/_shared.py')
for i, line in enumerate(p.read_text().splitlines(), start=1):
if 1708 <= i <= 1720:
print(f"{i:4}: {repr(line)}")

View File

@@ -1,24 +0,0 @@
from pathlib import Path
import re
p = Path('cmdlets/_shared.py')
src = p.read_text(encoding='utf-8')
lines = src.splitlines(True)
changed = False
new_lines = []
for line in lines:
m = re.match(r'^(?P<ws>[ \t]*)', line)
ws = m.group('ws') if m else ''
if '\t' in ws:
new_ws = ws.replace('\t', ' ')
new_line = new_ws + line[len(ws):]
new_lines.append(new_line)
changed = True
else:
new_lines.append(line)
if changed:
p.write_text(''.join(new_lines), encoding='utf-8')
print('Normalized leading tabs to spaces in', p)
else:
print('No leading tabs found; no changes made')

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""
Careful refactoring of download_data.py to class-based pattern.
Handles nested functions and inner definitions correctly.
"""
import re
from pathlib import Path
def refactor_download_data():
backup_file = Path('cmdlets/download_data_backup.py')
output_file = Path('cmdlets/download_data.py')
print(f"Reading: {backup_file}")
content = backup_file.read_text(encoding='utf-8')
lines = content.split('\n')
output = []
i = 0
in_cmdlet_def = False
skip_old_run_wrapper = False
class_added = False
while i < len(lines):
line = lines[i]
# Skip old _run wrapper function
if line.strip().startswith('def _run(result: Any'):
while i < len(lines):
i += 1
if lines[i] and not lines[i][0].isspace():
break
continue
# Skip old CMDLET definition
if line.strip().startswith('CMDLET = Cmdlet('):
while i < len(lines):
i += 1
if lines[i].strip() == ')':
i += 1
break
output.append('')
output.append('# Create and register the cmdlet')
output.append('CMDLET = Download_Data()')
output.append('')
continue
# Insert class definition before first top-level helper
if not class_added and line.strip().startswith('def _download_torrent_worker('):
# Add class header with __init__ and run()
output.extend([
'',
'',
'class Download_Data(Cmdlet):',
' """Class-based download-data cmdlet with self-registration."""',
'',
' def __init__(self) -> None:',
' """Initialize download-data cmdlet."""',
' super().__init__(',
' name="download-data",',
' summary="Download data from url with playlist/clip support using yt-dlp",',
' usage="download-data <url> [options] or search-file | download-data [options]",',
' alias=["download", "dl"],',
' arg=[',
' CmdletArg(name="url", type="string", required=False, description="URL to download (HTTP/HTTPS or file with URL list)", variadic=True),',
' CmdletArg(name="-url", type="string", description="URL to download (alias for positional argument)", variadic=True),',
' CmdletArg(name="list-formats", type="flag", description="List available formats without downloading"),',
' CmdletArg(name="audio", type="flag", alias="a", description="Download audio only (extract from video)"),',
' CmdletArg(name="video", type="flag", alias="v", description="Download video (default if not specified)"),',
' CmdletArg(name="format", type="string", alias="fmt", description="Explicit yt-dlp format selector (e.g., bestvideo+bestaudio)"),',
' CmdletArg(name="clip", type="string", description="Extract time range: MM:SS-MM:SS (e.g., 34:03-35:08) or seconds"),',
' CmdletArg(name="section", type="string", description="Download sections (yt-dlp only): TIME_RANGE[,TIME_RANGE...] (e.g., 1:30-1:35,0:05-0:15)"),',
' CmdletArg(name="cookies", type="string", description="Path to cookies.txt file for authentication"),',
' CmdletArg(name="torrent", type="flag", description="Download torrent/magnet via AllDebrid (requires API key in config)"),',
' CmdletArg(name="wait", type="float", description="Wait time (seconds) for magnet processing timeout"),',
' CmdletArg(name="background", type="flag", alias="bg", description="Start download in background and return to prompt immediately"),',
' CmdletArg(name="item", type="string", alias="items", description="Item selection for playlists/formats: use -item N to select format N, or -item to show table for @N selection in next command"),',
' SharedArgs.STORAGE,',
' ],',
' detail=["Download media from url with advanced features.", "", "See help for full usage examples."],',
' exec=self.run,',
' )',
' self.register()',
'',
' def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:',
' """Main execution method."""',
' stage_ctx = pipeline_context.get_stage_context()',
' in_pipeline = stage_ctx is not None and getattr(stage_ctx, "total_stages", 1) > 1',
' if in_pipeline and isinstance(config, dict):',
' config["_quiet_background_output"] = True',
' return self._run_impl(result, args, config, emit_results=True)',
'',
' # ' + '='*70,
' # HELPER METHODS',
' # ' + '='*70,
'',
])
class_added = True
# Convert top-level helper functions to static methods
if class_added and line and not line[0].isspace() and line.strip().startswith('def _'):
output.append(' @staticmethod')
output.append(f' {line}')
i += 1
# Copy function body with indentation
while i < len(lines):
next_line = lines[i]
# Stop at next top-level definition
if next_line and not next_line[0].isspace() and (next_line.strip().startswith(('def ', 'class ', 'CMDLET'))):
break
# Add indentation
if next_line.strip():
output.append(f' {next_line}')
else:
output.append(next_line)
i += 1
continue
output.append(line)
i += 1
result_text = '\n'.join(output)
# NOW: Update function calls carefully
# Only update calls in _run_impl, not in nested function definitions
# Pattern: match _func( but NOT when it's after "def " on the same line
helper_funcs = [
'_download_torrent_worker', '_guess_libgen_title', '_is_libgen_entry',
'_download_libgen_entry', '_libgen_background_worker',
'_start_libgen_background_worker', '_run_pipeline_tail',
'_download_http_background_worker', '_start_http_background_download',
'_parse_torrent_file', '_download_torrent_file', '_is_torrent_file_or_url',
'_process_torrent_input', '_show_playlist_table', '_parse_time_range',
'_parse_section_ranges', '_parse_playlist_selection_indices',
'_select_playlist_entries', '_sanitize_title_for_filename',
'_find_playlist_files_from_entries', '_snapshot_playlist_paths',
'_is_openlibrary_downloadable', '_as_dict', '_is_youtube_url',
]
# Split into lines for careful replacement
result_lines = result_text.split('\n')
for idx, line in enumerate(result_lines):
# Skip lines that are function definitions
if 'def ' in line:
continue
# Replace helper function calls with self.
for func in helper_funcs:
# Pattern: _func( with word boundary before
pattern = rf'\b({re.escape(func)})\('
if re.search(pattern, line):
result_lines[idx] = re.sub(pattern, r'self.\1(', line)
result_text = '\n'.join(result_lines)
output_file.write_text(result_text, encoding='utf-8')
print(f"✓ Written: {output_file}")
print(f"✓ Class-based refactor complete")
if __name__ == '__main__':
refactor_download_data()

View File

@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""
Automated refactoring script for download_data.py
Converts module-level functions to class-based cmdlet pattern.
"""
import re
from pathlib import Path
def main():
backup_file = Path('cmdlets/download_data_backup.py')
output_file = Path('cmdlets/download_data.py')
print(f"Reading: {backup_file}")
content = backup_file.read_text(encoding='utf-8')
lines = content.split('\n')
output = []
i = 0
in_cmdlet_def = False
skip_old_run_wrapper = False
class_section_added = False
# Track where to insert class definition
last_import_line = 0
while i < len(lines):
line = lines[i]
# Track imports
if line.strip().startswith(('import ', 'from ')):
last_import_line = len(output)
# Skip old _run wrapper function
if 'def _run(result: Any' in line:
skip_old_run_wrapper = True
i += 1
continue
if skip_old_run_wrapper:
if line and not line[0].isspace():
skip_old_run_wrapper = False
else:
i += 1
continue
# Skip old CMDLET definition
if line.strip().startswith('CMDLET = Cmdlet('):
in_cmdlet_def = True
i += 1
continue
if in_cmdlet_def:
if line.strip() == ')':
in_cmdlet_def = False
# Add class instantiation instead
output.append('')
output.append('# Create and register the cmdlet')
output.append('CMDLET = Download_Data()')
output.append('')
i += 1
continue
# Insert class definition before first helper function
if not class_section_added and line.strip().startswith('def _download_torrent_worker('):
output.append('')
output.append('')
output.append('class Download_Data(Cmdlet):')
output.append(' """Class-based download-data cmdlet with self-registration."""')
output.append('')
output.append(' # Full __init__ implementation to be added')
output.append(' # Full run() method to be added')
output.append('')
output.append(' # ' + '='*70)
output.append(' # HELPER METHODS')
output.append(' # ' + '='*70)
output.append('')
class_section_added = True
# Convert top-level helper functions to static methods
if class_section_added and line.strip().startswith('def _') and not line.strip().startswith('def __'):
# Check if this is a top-level function (no indentation)
if not line.startswith((' ', '\t')):
output.append(' @staticmethod')
output.append(f' {line}')
i += 1
# Copy function body with indentation
while i < len(lines):
next_line = lines[i]
# Stop at next top-level definition
if next_line and not next_line[0].isspace() and (next_line.strip().startswith('def ') or next_line.strip().startswith('class ') or next_line.strip().startswith('CMDLET')):
break
# Add indentation
if next_line.strip():
output.append(f' {next_line}')
else:
output.append(next_line)
i += 1
continue
# Convert _run_impl to method (but keep as-is for now, will be updated later)
if class_section_added and line.strip().startswith('def _run_impl('):
output.append(' def _run_impl(self, result: Any, args: Sequence[str], config: Dict[str, Any], emit_results: bool = True) -> int:')
i += 1
# Copy function body with indentation
while i < len(lines):
next_line = lines[i]
if next_line and not next_line[0].isspace() and next_line.strip():
break
if next_line.strip():
output.append(f' {next_line}')
else:
output.append(next_line)
i += 1
continue
output.append(line)
i += 1
# Write output
result_text = '\n'.join(output)
output_file.write_text(result_text, encoding='utf-8')
print(f"✓ Written: {output_file}")
print(f"✓ Converted {content.count('def _')} helper functions to static methods")
print("\nNext steps:")
print("1. Add full __init__ method with cmdlet args")
print("2. Add run() method that calls _run_impl")
print("3. Update function calls in _run_impl from _func() to self._func()")
if __name__ == '__main__':
main()

View File

@@ -57,7 +57,7 @@ from functools import wraps
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from helper.logger import log from SYS.logger import log
# ============================================================================ # ============================================================================
# CONFIGURATION # CONFIGURATION
@@ -159,8 +159,8 @@ def create_app():
status["storage_path"] = str(STORAGE_PATH) status["storage_path"] = str(STORAGE_PATH)
status["storage_exists"] = STORAGE_PATH.exists() status["storage_exists"] = STORAGE_PATH.exists()
try: try:
from helper.folder_store import FolderDB from API.folder import API_folder_store
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
status["database_accessible"] = True status["database_accessible"] = True
except Exception as e: except Exception as e:
status["database_accessible"] = False status["database_accessible"] = False
@@ -177,7 +177,7 @@ def create_app():
@require_storage() @require_storage()
def search_files(): def search_files():
"""Search for files by name or tag.""" """Search for files by name or tag."""
from helper.folder_store import LocalLibrarySearchOptimizer from API.folder import LocalLibrarySearchOptimizer
query = request.args.get('q', '') query = request.args.get('q', '')
limit = request.args.get('limit', 100, type=int) limit = request.args.get('limit', 100, type=int)
@@ -205,10 +205,10 @@ def create_app():
@require_storage() @require_storage()
def get_file_metadata(file_hash: str): def get_file_metadata(file_hash: str):
"""Get metadata for a specific file by hash.""" """Get metadata for a specific file by hash."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path or not file_path.exists(): if not file_path or not file_path.exists():
@@ -233,8 +233,8 @@ def create_app():
@require_storage() @require_storage()
def index_file(): def index_file():
"""Index a new file in the storage.""" """Index a new file in the storage."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
from helper.utils import sha256_file from SYS.utils import sha256_file
data = request.get_json() or {} data = request.get_json() or {}
file_path_str = data.get('path') file_path_str = data.get('path')
@@ -250,7 +250,7 @@ def create_app():
if not file_path.exists(): if not file_path.exists():
return jsonify({"error": "File does not exist"}), 404 return jsonify({"error": "File does not exist"}), 404
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
db.get_or_create_file_entry(file_path) db.get_or_create_file_entry(file_path)
if tags: if tags:
@@ -280,10 +280,10 @@ def create_app():
@require_storage() @require_storage()
def get_tags(file_hash: str): def get_tags(file_hash: str):
"""Get tags for a file.""" """Get tags for a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -299,7 +299,7 @@ def create_app():
@require_storage() @require_storage()
def add_tags(file_hash: str): def add_tags(file_hash: str):
"""Add tags to a file.""" """Add tags to a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
data = request.get_json() or {} data = request.get_json() or {}
tags = data.get('tags', []) tags = data.get('tags', [])
@@ -309,7 +309,7 @@ def create_app():
return jsonify({"error": "Tags required"}), 400 return jsonify({"error": "Tags required"}), 400
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -328,12 +328,12 @@ def create_app():
@require_storage() @require_storage()
def remove_tags(file_hash: str): def remove_tags(file_hash: str):
"""Remove tags from a file.""" """Remove tags from a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
tags_str = request.args.get('tags', '') tags_str = request.args.get('tags', '')
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -358,10 +358,10 @@ def create_app():
@require_storage() @require_storage()
def get_relationships(file_hash: str): def get_relationships(file_hash: str):
"""Get relationships for a file.""" """Get relationships for a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -378,7 +378,7 @@ def create_app():
@require_storage() @require_storage()
def set_relationship(): def set_relationship():
"""Set a relationship between two files.""" """Set a relationship between two files."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
data = request.get_json() or {} data = request.get_json() or {}
from_hash = data.get('from_hash') from_hash = data.get('from_hash')
@@ -389,7 +389,7 @@ def create_app():
return jsonify({"error": "from_hash and to_hash required"}), 400 return jsonify({"error": "from_hash and to_hash required"}), 400
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
from_path = db.search_hash(from_hash) from_path = db.search_hash(from_hash)
to_path = db.search_hash(to_hash) to_path = db.search_hash(to_hash)
@@ -411,10 +411,10 @@ def create_app():
@require_storage() @require_storage()
def get_url(file_hash: str): def get_url(file_hash: str):
"""Get known url for a file.""" """Get known url for a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -431,7 +431,7 @@ def create_app():
@require_storage() @require_storage()
def add_url(file_hash: str): def add_url(file_hash: str):
"""Add url to a file.""" """Add url to a file."""
from helper.folder_store import FolderDB from API.folder import API_folder_store
data = request.get_json() or {} data = request.get_json() or {}
url = data.get('url', []) url = data.get('url', [])
@@ -440,7 +440,7 @@ def create_app():
return jsonify({"error": "url required"}), 400 return jsonify({"error": "url required"}), 400
try: try:
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
file_path = db.search_hash(file_hash) file_path = db.search_hash(file_hash)
if not file_path: if not file_path:
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
@@ -509,8 +509,8 @@ def main():
print(f"\n{'='*70}\n") print(f"\n{'='*70}\n")
try: try:
from helper.folder_store import FolderDB from API.folder import API_folder_store
with FolderDB(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize database: {e}") logger.error(f"Failed to initialize database: {e}")

530
search_file.py Normal file
View File

@@ -0,0 +1,530 @@
"""Search-file cmdlet: Search for files by query, tags, size, type, duration, etc."""
from __future__ import annotations
from typing import Any, Dict, Sequence, List, Optional, Tuple
from pathlib import Path
from dataclasses import dataclass, field
from collections import OrderedDict
import re
import json
import sys
from SYS.logger import log, debug
from Provider.registry import get_search_provider
from cmdlets._shared import Cmdlet, CmdletArg, get_field, should_show_help
import pipeline as ctx
def get_origin(obj: Any, default: Any = None) -> Any:
"""Return the canonical origin/table identifier from a payload-like object."""
value = get_field(obj, "origin", None)
if value is not None:
return value
value = get_field(obj, "table", None)
if value is not None:
return value
value = get_field(obj, "store", None)
if value is not None:
return value
return default
# Optional dependencies
try:
import mutagen # type: ignore
except ImportError: # pragma: no cover
mutagen = None # type: ignore
try:
from config import get_hydrus_url, resolve_output_dir
except Exception: # pragma: no cover
get_hydrus_url = None # type: ignore
resolve_output_dir = None # type: ignore
try:
from API.HydrusNetwork import HydrusClient, HydrusRequestError
except ImportError: # pragma: no cover
HydrusClient = None # type: ignore
HydrusRequestError = RuntimeError # type: ignore
try:
from SYS.utils import sha256_file
except ImportError: # pragma: no cover
sha256_file = None # type: ignore
try:
from SYS.utils_constant import mime_maps
except ImportError: # pragma: no cover
mime_maps = {} # type: ignore
@dataclass(slots=True)
class SearchRecord:
path: str
size_bytes: int | None = None
duration_seconds: str | None = None
tags: str | None = None
hash: str | None = None
def as_dict(self) -> dict[str, str]:
payload: dict[str, str] = {"path": self.path}
if self.size_bytes is not None:
payload["size"] = str(self.size_bytes)
if self.duration_seconds:
payload["duration"] = self.duration_seconds
if self.tags:
payload["tags"] = self.tags
if self.hash:
payload["hash"] = self.hash
return payload
@dataclass
class ResultItem:
table: str # Renamed from origin
title: str
detail: str
annotations: List[str]
target: str
media_kind: str = "other"
hash: Optional[str] = None
columns: List[tuple[str, str]] = field(default_factory=list)
tag_summary: Optional[str] = None
duration_seconds: Optional[float] = None
size_bytes: Optional[int] = None
full_metadata: Optional[Dict[str, Any]] = None
tags: Optional[set[str]] = field(default_factory=set)
relationships: Optional[List[str]] = field(default_factory=list)
known_urls: Optional[List[str]] = field(default_factory=list)
@property
def origin(self) -> str:
return self.table
def to_dict(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"title": self.title,
}
# Always include these core fields for downstream cmdlets (get-file, download-data, etc)
payload["table"] = self.table
payload["target"] = self.target
payload["media_kind"] = self.media_kind
# Always include full_metadata if present (needed by download-data, etc)
# This is NOT for display, but for downstream processing
if self.full_metadata:
payload["full_metadata"] = self.full_metadata
# Include columns if defined (result renderer will use these for display)
if self.columns:
payload["columns"] = list(self.columns)
else:
# If no columns, include the detail for backwards compatibility
payload["detail"] = self.detail
payload["annotations"] = list(self.annotations)
# Include optional fields
if self.hash:
payload["hash"] = self.hash
if self.tag_summary:
payload["tags"] = self.tag_summary
if self.tags:
payload["tags_set"] = list(self.tags)
if self.relationships:
payload["relationships"] = self.relationships
if self.known_urls:
payload["known_urls"] = self.known_urls
return payload
STORAGE_ORIGINS = {"local", "hydrus", "debrid"}
class Search_File(Cmdlet):
"""Class-based search-file cmdlet with self-registration."""
def __init__(self) -> None:
super().__init__(
name="search-file",
summary="Unified search cmdlet for storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek).",
usage="search-file [query] [-tag TAG] [-size >100MB|<50MB] [-type audio|video|image] [-duration >10:00] [-store BACKEND] [-provider PROVIDER]",
arg=[
CmdletArg("query", description="Search query string"),
CmdletArg("tag", description="Filter by tag (can be used multiple times)"),
CmdletArg("size", description="Filter by size: >100MB, <50MB, =10MB"),
CmdletArg("type", description="Filter by type: audio, video, image, document"),
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
CmdletArg("limit", type="integer", description="Limit results (default: 45)"),
CmdletArg("store", description="Search storage backend: hydrus, local (default: all searchable storages)"),
CmdletArg("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
],
detail=[
"Search across storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek)",
"Use -provider to search a specific source, or -store to search file backends",
"Filter results by: tag, size, type, duration",
"Results can be piped to other commands",
"Examples:",
"search-file foo # Search all file backends",
"search-file -provider libgen 'python programming' # Search LibGen books",
"search-file -provider debrid 'movie' # Search AllDebrid magnets",
"search-file 'music' -provider soulseek # Search Soulseek P2P",
"search-file -provider openlibrary 'tolkien' # Search OpenLibrary",
"search-file song -store hydrus -type audio # Search only Hydrus audio",
"search-file movie -tag action -provider debrid # Debrid with filters",
],
exec=self.run,
)
self.register()
# --- Helper methods -------------------------------------------------
@staticmethod
def _normalize_extension(ext_value: Any) -> str:
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
ext = str(ext_value or "").strip().lstrip(".")
for sep in (" ", "|", "(", "[", "{", ",", ";"):
if sep in ext:
ext = ext.split(sep, 1)[0]
break
if "." in ext:
ext = ext.split(".")[-1]
ext = "".join(ch for ch in ext if ch.isalnum())
return ext[:5]
def _ensure_storage_columns(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure storage results have the necessary fields for result_table display."""
store_value = str(get_origin(payload, "") or "").lower()
if store_value not in STORAGE_ORIGINS:
return payload
# Ensure we have title field
if "title" not in payload:
payload["title"] = payload.get("name") or payload.get("target") or payload.get("path") or "Result"
# Ensure we have ext field
if "ext" not in payload:
title = str(payload.get("title", ""))
path_obj = Path(title)
if path_obj.suffix:
payload["ext"] = self._normalize_extension(path_obj.suffix.lstrip('.'))
else:
payload["ext"] = payload.get("ext", "")
# Ensure size_bytes is present for display (already set by search_file())
# result_table will handle formatting it
# Don't create manual columns - let result_table handle display
# This allows the table to respect max_columns and apply consistent formatting
return payload
# --- Execution ------------------------------------------------------
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc."""
if should_show_help(args):
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
return 0
args_list = [str(arg) for arg in (args or [])]
# Parse arguments
query = ""
tag_filters: List[str] = []
size_filter: Optional[Tuple[str, int]] = None
duration_filter: Optional[Tuple[str, float]] = None
type_filter: Optional[str] = None
storage_backend: Optional[str] = None
provider_name: Optional[str] = None
limit = 45
searched_backends: List[str] = []
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-provider", "--provider"} and i + 1 < len(args_list):
provider_name = args_list[i + 1].lower()
i += 2
elif low in {"-store", "--store", "-storage", "--storage"} and i + 1 < len(args_list):
storage_backend = args_list[i + 1].lower()
i += 2
elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
tag_filters.append(args_list[i + 1])
i += 2
elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
try:
limit = int(args_list[i + 1])
except ValueError:
limit = 100
i += 2
elif low in {"-type", "--type"} and i + 1 < len(args_list):
type_filter = args_list[i + 1].lower()
i += 2
elif not arg.startswith("-"):
query = f"{query} {arg}".strip() if query else arg
i += 1
else:
i += 1
store_filter: Optional[str] = None
if query:
match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
if match:
store_filter = match.group(1).strip().lower() or None
query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
query = re.sub(r"\s{2,}", " ", query)
query = query.strip().strip(',')
if storage_backend and storage_backend.lower() == "debrid":
log("Use -provider debrid instead of -store debrid (debrid is provider-only)", file=sys.stderr)
return 1
if store_filter and not provider_name and not storage_backend:
if store_filter in {"hydrus", "local", "debrid"}:
storage_backend = store_filter
# --- Feature: Filter provider result table by Name column ---
filter_after_search: Optional[str] = None
if result:
actual_result = result[0] if isinstance(result, list) and result else result
origin = get_origin(actual_result)
target = get_field(actual_result, 'target')
# If the incoming result is from a provider (not storage) AND this invocation looks like a filter (no flags)
positional_args = [a for a in args_list if not a.startswith('-')]
no_flags = len(positional_args) == len(args_list)
looks_like_filter = no_flags and len(positional_args) == 1 and not provider_name and not storage_backend and not tag_filters and not size_filter and not duration_filter and not type_filter
if origin and origin.lower() not in STORAGE_ORIGINS and looks_like_filter and query:
# Save the filter string to apply AFTER loading the provider data
filter_after_search = query.strip()
query = "" # Clear query so we load the target URL instead
# If result is from a provider, extract the target as query and set provider
if not query:
if origin == 'bandcamp' and target:
query = target
if not provider_name:
provider_name = 'bandcamp'
elif origin == 'youtube' and target:
query = target
if not provider_name:
provider_name = 'youtube'
elif target and str(target).startswith(('http://', 'https://')):
query = target
if not provider_name:
if 'bandcamp.com' in target:
provider_name = 'bandcamp'
elif 'youtube.com' in target or 'youtu.be' in target:
provider_name = 'youtube'
if not query:
log("Provide a search query", file=sys.stderr)
return 1
from API.folder import API_folder_store
from config import get_local_storage_path
import uuid
worker_id = str(uuid.uuid4())
library_root = get_local_storage_path(config or {})
if not library_root:
log("No library root configured", file=sys.stderr)
return 1
db = None
try:
db = API_folder_store(library_root)
db.insert_worker(
worker_id,
"search",
title=f"Search: {query}",
description=f"Query: {query}",
pipe=ctx.get_current_command_text()
)
results_list = []
import result_table
import importlib
importlib.reload(result_table)
from result_table import ResultTable
table_title = f"Search: {query}"
if provider_name:
table_title += f" [{provider_name}]"
elif storage_backend:
table_title += f" [{storage_backend}]"
preserve_order = provider_name and provider_name.lower() in ('youtube', 'openlibrary')
# Avoid setting source_command so @N does not re-run search-file; preserve row order when needed
table = ResultTable(table_title).set_preserve_order(preserve_order)
if provider_name:
debug(f"[search_file] Attempting provider search with: {provider_name}")
provider = get_search_provider(provider_name, config)
if not provider:
log(f"Provider '{provider_name}' not available", file=sys.stderr)
db.update_worker_status(worker_id, 'error')
return 1
debug(f"[search_file] Provider loaded, calling search with query: {query}")
search_result = provider.search(query, limit=limit)
debug(f"[search_file] Provider search returned {len(search_result)} results")
# Apply post-search filter if one was set
if filter_after_search:
debug(f"[search_file] Applying filter: {filter_after_search}")
filtered_result = []
for item in search_result:
item_dict = item.to_dict() if hasattr(item, 'to_dict') else dict(item)
title_val = get_field(item_dict, 'title') or get_field(item_dict, 'name') or ""
if filter_after_search.lower() in str(title_val).lower():
filtered_result.append(item)
search_result = filtered_result
if not search_result:
log(f"No results match filter: '{filter_after_search}'", file=sys.stderr)
db.update_worker_status(worker_id, 'completed')
return 0
debug(f"[search_file] Filter matched {len(search_result)} results")
table.title = f"Filter: {filter_after_search}"
for item in search_result:
table.add_result(item)
item_dict = item.to_dict()
results_list.append(item_dict)
ctx.emit(item_dict)
ctx.set_last_result_table(table, results_list)
debug(f"[search_file] Emitted {len(results_list)} results")
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
db.update_worker_status(worker_id, 'completed')
return 0
from Store import Store
storage = Store(config=config or {}, suppress_debug=True)
backend_to_search = storage_backend or None
if backend_to_search:
if backend_to_search == "hydrus":
from API.HydrusNetwork import is_hydrus_available
if not is_hydrus_available(config or {}):
log(f"Backend 'hydrus' is not available (Hydrus service not running)", file=sys.stderr)
db.update_worker_status(worker_id, 'error')
return 1
searched_backends.append(backend_to_search)
target_backend = storage[backend_to_search]
results = target_backend.search_store(query, limit=limit)
else:
from API.HydrusNetwork import is_hydrus_available
hydrus_available = is_hydrus_available(config or {})
all_results = []
for backend_name in storage.list_searchable_backends():
if backend_name == "hydrus" and not hydrus_available:
continue
searched_backends.append(backend_name)
try:
backend_results = storage[backend_name].search_store(query, limit=limit - len(all_results))
if backend_results:
all_results.extend(backend_results)
if len(all_results) >= limit:
break
except Exception as exc:
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
results = all_results[:limit]
if not provider_name and not storage_backend:
try:
debrid_provider = get_search_provider("debrid", config)
if debrid_provider and debrid_provider.validate():
remaining = max(0, limit - len(results)) if isinstance(results, list) else limit
if remaining > 0:
debrid_results = debrid_provider.search(query, limit=remaining)
if debrid_results:
if "debrid" not in searched_backends:
searched_backends.append("debrid")
if results is None:
results = []
results.extend(debrid_results)
except Exception as exc:
log(f"Debrid provider search failed: {exc}", file=sys.stderr)
def _format_storage_label(name: str) -> str:
clean = str(name or "").strip()
if not clean:
return "Unknown"
return clean.replace("_", " ").title()
storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
for item in results or []:
origin = get_origin(item)
if not origin:
continue
key = str(origin).lower()
if key not in storage_counts:
storage_counts[key] = 0
storage_counts[key] += 1
if storage_counts or query:
display_counts = OrderedDict((_format_storage_label(name), count) for name, count in storage_counts.items())
summary_line = table.set_storage_summary(display_counts, query, inline=True)
if summary_line:
table.title = summary_line
if results:
for item in results:
def _as_dict(obj: Any) -> Dict[str, Any]:
if isinstance(obj, dict):
return dict(obj)
if hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict")):
return obj.to_dict() # type: ignore[arg-type]
return {"title": str(obj)}
item_dict = _as_dict(item)
if store_filter:
origin_val = str(get_origin(item_dict) or "").lower()
if store_filter != origin_val:
continue
normalized = self._ensure_storage_columns(item_dict)
# Make hash/store available for downstream cmdlets without rerunning search-file
hash_val = normalized.get("hash")
store_val = normalized.get("store") or get_origin(item_dict)
if hash_val and not normalized.get("hash"):
normalized["hash"] = hash_val
if store_val and not normalized.get("store"):
normalized["store"] = store_val
table.add_result(normalized)
results_list.append(normalized)
ctx.emit(normalized)
ctx.set_last_result_table(table, results_list)
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
else:
log("No results found", file=sys.stderr)
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
db.update_worker_status(worker_id, 'completed')
return 0
except Exception as exc:
log(f"Search failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
if db:
try:
db.update_worker_status(worker_id, 'error')
except Exception:
pass
return 1
finally:
if db:
try:
db.close()
except Exception:
pass
CMDLET = Search_File()

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More