This commit is contained in:
nose
2025-12-13 12:09:50 -08:00
parent 30eb628aa3
commit 52a79b0086
16 changed files with 729 additions and 655 deletions

View File

@@ -106,16 +106,7 @@ class HydrusNetwork:
def _perform_request(self, spec: HydrusRequestSpec) -> Any: def _perform_request(self, spec: HydrusRequestSpec) -> Any:
headers: dict[str, str] = {} headers: dict[str, str] = {}
# On first request, try to acquire session key for security
if not self._session_key and self.access_key and spec.endpoint != "/session_key":
try:
logger.debug(f"[Hydrus] Acquiring session key on first request...")
self._acquire_session_key()
except Exception as e:
# If session key acquisition fails, fall back to access key
logger.debug(f"[Hydrus] Session key acquisition failed: {e}. Using access key instead.")
# Use session key if available, otherwise use access key # Use session key if available, otherwise use access key
if self._session_key: if self._session_key:
headers["Hydrus-Client-API-Session-Key"] = self._session_key headers["Hydrus-Client-API-Session-Key"] = self._session_key
@@ -496,6 +487,7 @@ class HydrusNetwork:
file_service_name: str | None = None, file_service_name: str | None = None,
return_hashes: bool = False, return_hashes: bool = False,
return_file_ids: bool = True, return_file_ids: bool = True,
return_file_count: bool = False,
include_current_tags: bool | None = None, include_current_tags: bool | None = None,
include_pending_tags: bool | None = None, include_pending_tags: bool | None = None,
file_sort_type: int | None = None, file_sort_type: int | None = None,
@@ -511,6 +503,7 @@ class HydrusNetwork:
("file_service_name", file_service_name, lambda v: v), ("file_service_name", file_service_name, lambda v: v),
("return_hashes", return_hashes, lambda v: "true" if v else None), ("return_hashes", return_hashes, lambda v: "true" if v else None),
("return_file_ids", return_file_ids, lambda v: "true" if v else None), ("return_file_ids", return_file_ids, lambda v: "true" if v else None),
("return_file_count", return_file_count, lambda v: "true" if v else None),
( (
"include_current_tags", "include_current_tags",
include_current_tags, include_current_tags,
@@ -1222,15 +1215,17 @@ def is_hydrus_available(config: dict[str, Any]) -> bool:
def get_client(config: dict[str, Any]) -> HydrusNetwork: def get_client(config: dict[str, Any]) -> HydrusNetwork:
"""Create and return a Hydrus client with session key authentication. """Create and return a Hydrus client.
Reuses cached client instance to preserve session keys across requests. Uses access-key authentication by default (no session key acquisition).
A session key may still be acquired explicitly by calling
`HydrusNetwork.ensure_session_key()`.
Args: Args:
config: Configuration dict with Hydrus settings config: Configuration dict with Hydrus settings
Returns: Returns:
HydrusClient instance (with active session key) HydrusClient instance
Raises: Raises:
RuntimeError: If Hydrus is not configured or unavailable RuntimeError: If Hydrus is not configured or unavailable
@@ -1259,34 +1254,11 @@ def get_client(config: dict[str, Any]) -> HydrusNetwork:
# Check if we have a cached client # Check if we have a cached client
if cache_key in _hydrus_client_cache: if cache_key in _hydrus_client_cache:
cached_client = _hydrus_client_cache[cache_key] return _hydrus_client_cache[cache_key]
# If cached client has a session key, reuse it (don't re-acquire)
if hasattr(cached_client, '_session_key') and cached_client._session_key:
# debug(f"Reusing cached session key for {hydrus_url}")
return cached_client
# If no session key in cache, try to get one
try:
cached_client.ensure_session_key()
return cached_client
except Exception as e:
# If verification fails, remove from cache and create new one
debug(f"Cached client invalid, creating new: {e}")
del _hydrus_client_cache[cache_key]
# Create new client # Create new client
client = HydrusNetwork(hydrus_url, access_key, timeout) client = HydrusNetwork(hydrus_url, access_key, timeout)
# Acquire session key for secure authentication
try:
client.ensure_session_key()
except HydrusConnectionError:
# This should not happen since we checked availability above
debug(f"Hydrus service unavailable during client creation")
raise RuntimeError("Hydrus is unavailable") from None
except Exception as e:
# Log other exceptions but don't fail - client can still work with access_key
debug(f"Warning: Could not acquire session key: {e}")
# Cache the client # Cache the client
_hydrus_client_cache[cache_key] = client _hydrus_client_cache[cache_key] = client

View File

@@ -12,8 +12,7 @@ import sys
from SYS.logger import log, debug from SYS.logger import log, debug
import time import time
import logging import logging
from pathlib import Path from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
from typing import Any, Dict, Optional, Set, List, Sequence
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from .HTTP import HTTPClient from .HTTP import HTTPClient
@@ -31,6 +30,24 @@ _CACHE_TIMESTAMP: float = 0
_CACHE_DURATION: float = 3600 # 1 hour _CACHE_DURATION: float = 3600 # 1 hour
# Cache for init-time connectivity checks (api_key fingerprint -> (ok, reason))
_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {}
def _ping_alldebrid(base_url: str) -> Tuple[bool, Optional[str]]:
"""Ping the AllDebrid API base URL (no API key required)."""
try:
url = str(base_url or "").rstrip("/") + "/ping"
with HTTPClient(timeout=10.0, headers={'User-Agent': 'downlow/1.0'}) as client:
response = client.get(url)
data = json.loads(response.content.decode('utf-8'))
if data.get('status') == 'success' and data.get('data', {}).get('ping') == 'pong':
return True, None
return False, "Invalid API response"
except Exception as exc:
return False, str(exc)
class AllDebridClient: class AllDebridClient:
"""Client for AllDebrid API.""" """Client for AllDebrid API."""
@@ -50,6 +67,18 @@ class AllDebridClient:
if not self.api_key: if not self.api_key:
raise AllDebridError("AllDebrid API key is empty") raise AllDebridError("AllDebrid API key is empty")
self.base_url = self.BASE_url[0] # Start with v4 self.base_url = self.BASE_url[0] # Start with v4
# Init-time availability validation (cached per process)
fingerprint = f"base:{self.base_url}" # /ping does not require the api key
cached = _INIT_CHECK_CACHE.get(fingerprint)
if cached is None:
ok, reason = _ping_alldebrid(self.base_url)
_INIT_CHECK_CACHE[fingerprint] = (ok, reason)
else:
ok, reason = cached
if not ok:
raise AllDebridError(reason or "AllDebrid unavailable")
def _request(self, endpoint: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]: def _request(self, endpoint: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Make a request to AllDebrid API. """Make a request to AllDebrid API.

View File

@@ -1842,8 +1842,21 @@ class LocalLibraryInitializer:
self.db.connection.commit() self.db.connection.commit()
self._import_sidecars_batch() self._import_sidecars_batch()
self.db.connection.commit() self.db.connection.commit()
# Ensure files without sidecars are still imported + renamed to hash.
self._hash_and_rename_non_sidecar_media_files()
self.db.connection.commit()
self._cleanup_orphaned_sidecars() self._cleanup_orphaned_sidecars()
self.db.connection.commit() self.db.connection.commit()
try:
cursor = self.db.connection.cursor()
cursor.execute("SELECT COUNT(*) FROM files")
row = cursor.fetchone()
self.stats['files_total_db'] = int(row[0]) if row and row[0] is not None else 0
except Exception:
self.stats['files_total_db'] = 0
logger.info(f"Library scan complete. Stats: {self.stats}") logger.info(f"Library scan complete. Stats: {self.stats}")
return self.stats return self.stats
@@ -1853,12 +1866,140 @@ class LocalLibraryInitializer:
raise raise
finally: finally:
self.db.close() self.db.close()
def _hash_and_rename_non_sidecar_media_files(self) -> None:
"""Ensure media files are hash-named even when they have no sidecars.
This keeps the library stable across restarts:
- New files get hashed + renamed to <sha256><ext>
- DB file_path is updated by hash so the same file isn't re-counted as "new".
"""
try:
renamed = 0
skipped_existing_target = 0
duplicates_quarantined = 0
for file_path in self._find_media_files():
try:
if not file_path.is_file():
continue
stem = file_path.stem.lower()
is_hash_named = len(stem) == 64 and all(ch in "0123456789abcdef" for ch in stem)
if is_hash_named:
continue
# If any sidecars exist for this file, let the sidecar importer handle it.
if (
file_path.with_name(file_path.name + ".tag").exists()
or file_path.with_name(file_path.name + ".metadata").exists()
or file_path.with_name(file_path.name + ".notes").exists()
):
continue
file_hash = sha256_file(file_path)
target_path = file_path.with_name(f"{file_hash}{file_path.suffix}")
# Ensure the DB entry exists with a title tag derived from the original filename.
# This intentionally happens BEFORE rename.
self.db.get_or_create_file_entry(file_path, file_hash)
if target_path == file_path:
continue
if target_path.exists():
skipped_existing_target += 1
# The canonical file already exists as a hash-named file. Keep the DB pointing
# at the canonical hash-named path and quarantine this duplicate so it doesn't
# get counted as "new" again on future restarts.
try:
cursor = self.db.connection.cursor()
cursor.execute(
"UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?",
(str(target_path.resolve()), file_hash),
)
except Exception as exc:
logger.debug(f"Failed to reset DB path to canonical file for {file_hash}: {exc}")
try:
dup_dir = self.library_root / ".duplicates"
dup_dir.mkdir(parents=True, exist_ok=True)
dest = dup_dir / file_path.name
if dest.exists():
ts = int(datetime.now().timestamp())
dest = dup_dir / f"{file_path.stem}__dup__{ts}{file_path.suffix}"
logger.warning(
f"Duplicate content (hash={file_hash}) detected; moving {file_path} -> {dest}"
)
file_path.rename(dest)
duplicates_quarantined += 1
except Exception as exc:
logger.warning(
f"Duplicate content (hash={file_hash}) detected but could not quarantine {file_path}: {exc}"
)
continue
try:
file_path.rename(target_path)
except Exception as exc:
logger.warning(f"Failed to rename {file_path} -> {target_path}: {exc}")
self.stats['errors'] += 1
continue
# Update DB path by hash (more robust than matching the old path).
try:
cursor = self.db.connection.cursor()
cursor.execute(
"UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?",
(str(target_path.resolve()), file_hash),
)
except Exception:
pass
# Ensure basic metadata exists.
try:
stat_result = target_path.stat()
self.db.save_metadata(
target_path,
{
"hash": file_hash,
"ext": target_path.suffix,
"size": stat_result.st_size,
},
)
except Exception:
pass
renamed += 1
except Exception as exc:
logger.warning(f"Error hashing/renaming file {file_path}: {exc}")
self.stats['errors'] += 1
if renamed:
self.stats['files_hashed_renamed'] = int(self.stats.get('files_hashed_renamed', 0) or 0) + renamed
if skipped_existing_target:
self.stats['files_hashed_skipped_target_exists'] = int(
self.stats.get('files_hashed_skipped_target_exists', 0) or 0
) + skipped_existing_target
if duplicates_quarantined:
self.stats['duplicates_quarantined'] = int(self.stats.get('duplicates_quarantined', 0) or 0) + duplicates_quarantined
except Exception as exc:
logger.error(f"Error hashing/renaming non-sidecar media files: {exc}", exc_info=True)
self.stats['errors'] += 1
def _find_media_files(self) -> List[Path]: def _find_media_files(self) -> List[Path]:
"""Find all media files in the library folder.""" """Find all media files in the library folder."""
media_files = [] media_files = []
try: try:
for file_path in self.library_root.rglob("*"): for file_path in self.library_root.rglob("*"):
# Don't repeatedly re-scan quarantined duplicates.
try:
if ".duplicates" in file_path.parts:
continue
except Exception:
pass
if file_path.is_file() and file_path.suffix.lower() in MEDIA_EXTENSIONS: if file_path.is_file() and file_path.suffix.lower() in MEDIA_EXTENSIONS:
media_files.append(file_path) media_files.append(file_path)
except Exception as e: except Exception as e:
@@ -1882,7 +2023,7 @@ class LocalLibraryInitializer:
logger.error(f"Error getting database files: {e}", exc_info=True) logger.error(f"Error getting database files: {e}", exc_info=True)
return {} return {}
def _process_file(self, file_path: Path, db_files: Dict[str, int]) -> None: def _process_file(self, file_path: Path, db_files: Dict[str, str]) -> None:
"""Process a single media file.""" """Process a single media file."""
try: try:
normalized = str(file_path.resolve()).lower() normalized = str(file_path.resolve()).lower()
@@ -1890,8 +2031,23 @@ class LocalLibraryInitializer:
if normalized in db_files: if normalized in db_files:
self.stats['files_existing'] += 1 self.stats['files_existing'] += 1
else: else:
self.db.get_or_create_file_entry(file_path) # Path not known. If this file's hash is already in DB, it's duplicate content and
self.stats['files_new'] += 1 # should not be counted as "new".
file_hash = sha256_file(file_path)
try:
cursor = self.db.connection.cursor()
cursor.execute("SELECT 1 FROM files WHERE hash = ?", (file_hash,))
exists_by_hash = cursor.fetchone() is not None
except Exception:
exists_by_hash = False
if exists_by_hash:
self.stats['files_existing'] += 1
self.stats['duplicates_found'] = int(self.stats.get('duplicates_found', 0) or 0) + 1
logger.info(f"Duplicate content detected during scan (hash={file_hash}): {file_path}")
else:
self.db.get_or_create_file_entry(file_path, file_hash)
self.stats['files_new'] += 1
self.stats['files_scanned'] += 1 self.stats['files_scanned'] += 1
except Exception as e: except Exception as e:

189
CLI.py
View File

@@ -779,14 +779,14 @@ def _create_cmdlet_cli():
if startup_table: if startup_table:
startup_table.set_no_choice(True).set_preserve_order(True) startup_table.set_no_choice(True).set_preserve_order(True)
def _add_startup_check(name: str, status: str, detail: str = "") -> None: def _add_startup_check(status: str, name: str, store_or_provider: str, detail: str = "") -> None:
if startup_table is None: if startup_table is None:
return return
row = startup_table.add_row() row = startup_table.add_row()
row.add_column("Check", name)
row.add_column("Status", status) row.add_column("Status", status)
if detail: row.add_column("Name", name)
row.add_column("Detail", detail) row.add_column("Store/Provi", store_or_provider)
row.add_column("Detail", detail or "")
def _has_store_subtype(cfg: dict, subtype: str) -> bool: def _has_store_subtype(cfg: dict, subtype: str) -> bool:
store_cfg = cfg.get("store") store_cfg = cfg.get("store")
@@ -831,67 +831,150 @@ def _create_cmdlet_cli():
# Run startup checks and render table # Run startup checks and render table
try: try:
from hydrus_health_check import ( from hydrus_health_check import initialize_cookies_check
initialize_mpv_health_check,
initialize_matrix_health_check,
initialize_hydrus_health_check,
initialize_local_library_scan,
initialize_cookies_check,
initialize_debrid_health_check,
)
def _run_check(name: str, fn: Callable[[], Tuple[bool, Optional[str]]], skip_reason: Optional[str] = None) -> None: # MPV availability is validated by MPV.MPV.__init__.
if skip_reason: try:
_add_startup_check(name, "SKIPPED", skip_reason) from MPV.mpv_ipc import MPV
return
MPV()
try: try:
ok, detail = fn() import shutil
status = "ENABLED" if name in {"MPV", "Hydrus", "Matrix", "Debrid"} else ("FOUND" if name == "Cookies" else "SCANNED")
if name == "Matrix":
status = "ENABLED" if ok else "DISABLED"
elif name == "Folder Stores":
status = "SCANNED" if ok else "SKIPPED"
elif name == "Cookies":
status = "FOUND" if ok else "MISSING"
elif name in {"MPV", "Hydrus", "Debrid"}:
status = "ENABLED" if ok else "DISABLED"
_add_startup_check(name, status, detail or "")
except Exception as exc: # Best-effort: never block startup
_add_startup_check(name, "ERROR", str(exc))
_run_check("MPV", lambda: initialize_mpv_health_check(emit_debug=False)) mpv_path = shutil.which("mpv")
except Exception:
mpv_path = None
_add_startup_check("ENABLED", "MPV", "N/A", mpv_path or "Available")
except Exception as exc:
_add_startup_check("DISABLED", "MPV", "N/A", str(exc))
store_registry = None
if config: if config:
# Instantiate store registry once; store __init__ performs its own validation.
try:
from Store import Store as StoreRegistry
store_registry = StoreRegistry(config=config, suppress_debug=True)
except Exception:
store_registry = None
# Only show checks that are configured in config.conf # Only show checks that are configured in config.conf
if _has_store_subtype(config, "hydrusnetwork"): if _has_store_subtype(config, "hydrusnetwork"):
_run_check("Hydrus", lambda: initialize_hydrus_health_check(config, emit_debug=False)) # HydrusNetwork self-validates in its __init__. We derive instance status from
# store instantiation rather than a separate Hydrus-specific health check.
store_cfg = config.get("store")
hydrus_cfg = store_cfg.get("hydrusnetwork", {}) if isinstance(store_cfg, dict) else {}
if isinstance(hydrus_cfg, dict):
for instance_name, instance_cfg in hydrus_cfg.items():
if not isinstance(instance_cfg, dict):
continue
name_key = str(instance_cfg.get("NAME") or instance_name)
url_val = str(instance_cfg.get("URL") or "").strip()
# Hydrus instances - add individual rows for each configured instance ok = bool(store_registry and store_registry.is_available(name_key))
from hydrus_health_check import _SERVICE_STATE status = "ENABLED" if ok else "DISABLED"
for instance_name, instance_info in _SERVICE_STATE.get("hydrusnetwork_stores", {}).items(): if ok:
status = "ENABLED" if instance_info.get("ok") else "DISABLED" total = None
_add_startup_check(f" {instance_name}", status, f"{instance_info.get('url')} - {instance_info.get('detail')}") try:
if store_registry:
backend = store_registry[name_key]
total = getattr(backend, "total_count", None)
except Exception:
total = None
detail = (url_val + (" - " if url_val else "")) + "Connected"
if isinstance(total, int) and total >= 0:
detail += f" (Total: {total})"
else:
err = None
if store_registry:
err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key)
detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable")
_add_startup_check(status, name_key, "hydrusnetwork", detail)
if _has_provider(config, "matrix"): if _has_provider(config, "matrix"):
_run_check("Matrix", lambda: initialize_matrix_health_check(config, emit_debug=False)) # Matrix availability is validated by Provider.matrix.Matrix.__init__.
try:
from Provider.matrix import Matrix
provider = Matrix(config)
matrix_conf = config.get("provider", {}).get("matrix", {}) if isinstance(config, dict) else {}
homeserver = str(matrix_conf.get("homeserver") or "").strip()
room_id = str(matrix_conf.get("room_id") or "").strip()
if homeserver and not homeserver.startswith("http"):
homeserver = f"https://{homeserver}"
target = homeserver.rstrip("/")
if room_id:
target = (target + (" " if target else "")) + f"room:{room_id}"
if provider.validate():
_add_startup_check("ENABLED", "Matrix", "matrix", target or "Connected")
else:
missing: list[str] = []
if not homeserver:
missing.append("homeserver")
if not room_id:
missing.append("room_id")
if not (matrix_conf.get("access_token") or matrix_conf.get("password")):
missing.append("access_token/password")
detail = "Not configured" + (f" ({', '.join(missing)})" if missing else "")
_add_startup_check("DISABLED", "Matrix", "matrix", detail)
except Exception as exc:
_add_startup_check("DISABLED", "Matrix", "matrix", str(exc))
if _has_store_subtype(config, "folder"): if _has_store_subtype(config, "folder"):
# Folder stores - add individual rows for each configured store # Folder local scan/index is performed by Store.Folder.__init__.
ok, detail = initialize_local_library_scan(config, emit_debug=False) store_cfg = config.get("store")
if ok or detail != "No folder stores configured": folder_cfg = store_cfg.get("folder", {}) if isinstance(store_cfg, dict) else {}
from hydrus_health_check import _SERVICE_STATE if isinstance(folder_cfg, dict) and folder_cfg:
for store_name, store_info in _SERVICE_STATE.get("folder_stores", {}).items(): for instance_name, instance_cfg in folder_cfg.items():
status = "SCANNED" if store_info.get("ok") else "ERROR" if not isinstance(instance_cfg, dict):
_add_startup_check(f" {store_name}", status, f"{store_info.get('path')} - {store_info.get('detail')}") continue
if not _SERVICE_STATE.get("folder_stores"): name_key = str(instance_cfg.get("NAME") or instance_name)
_add_startup_check("Folder Stores", "SCANNED", detail) path_val = str(instance_cfg.get("PATH") or instance_cfg.get("path") or "").strip()
ok = bool(store_registry and store_registry.is_available(name_key))
if ok and store_registry:
backend = store_registry[name_key]
scan_ok = bool(getattr(backend, "scan_ok", True))
scan_detail = str(getattr(backend, "scan_detail", "") or "")
status = "SCANNED" if scan_ok else "ERROR"
detail = (path_val + (" - " if path_val else "")) + (scan_detail or "Up to date")
_add_startup_check(status, name_key, "folder", detail)
else:
err = None
if store_registry:
err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key)
detail = (path_val + (" - " if path_val else "")) + (err or "Unavailable")
_add_startup_check("ERROR", name_key, "folder", detail)
else: else:
_add_startup_check("Folder Stores", "SKIPPED", detail) _add_startup_check("SKIPPED", "Folder", "folder", "No folder stores configured")
if _has_store_subtype(config, "debrid"): if _has_store_subtype(config, "debrid"):
_run_check("Debrid", lambda: initialize_debrid_health_check(config, emit_debug=False)) # Debrid availability is validated by API.alldebrid.AllDebridClient.__init__.
try:
from config import get_debrid_api_key
_run_check("Cookies", lambda: initialize_cookies_check(config, emit_debug=False)) api_key = get_debrid_api_key(config)
if not api_key:
_add_startup_check("DISABLED", "Debrid", "debrid", "Not configured")
else:
from API.alldebrid import AllDebridClient
client = AllDebridClient(api_key)
base_url = str(getattr(client, "base_url", "") or "").strip()
_add_startup_check("ENABLED", "Debrid", "debrid", base_url or "Connected")
except Exception as exc:
_add_startup_check("DISABLED", "Debrid", "debrid", str(exc))
# Cookies are used by yt-dlp; keep this centralized utility.
try:
ok, detail = initialize_cookies_check(config, emit_debug=False)
_add_startup_check("FOUND" if ok else "MISSING", "Cookies", "N/A", detail or "Not found")
except Exception as exc:
_add_startup_check("ERROR", "Cookies", "N/A", str(exc))
if startup_table is not None and startup_table.rows: if startup_table is not None and startup_table.rows:
print() print()
@@ -1156,14 +1239,14 @@ def _execute_pipeline(tokens: list):
and the actual items being selected to help diagnose reordering issues. and the actual items being selected to help diagnose reordering issues.
""" """
try: try:
print(f"[debug] {label}: sel={selection_indices} rows={len(table_obj.rows) if table_obj and hasattr(table_obj, 'rows') else 'n/a'} items={len(items_list) if items_list is not None else 'n/a'}") debug(f"[debug] {label}: sel={selection_indices} rows={len(table_obj.rows) if table_obj and hasattr(table_obj, 'rows') else 'n/a'} items={len(items_list) if items_list is not None else 'n/a'}")
if table_obj and hasattr(table_obj, 'rows') and items_list: if table_obj and hasattr(table_obj, 'rows') and items_list:
# Show correspondence: displayed row # -> source_index -> item hash/title # Show correspondence: displayed row # -> source_index -> item hash/title
for i in selection_indices: for i in selection_indices:
if 0 <= i < len(table_obj.rows): if 0 <= i < len(table_obj.rows):
row = table_obj.rows[i] row = table_obj.rows[i]
src_idx = getattr(row, 'source_index', None) src_idx = getattr(row, 'source_index', None)
print(f"[debug] @{i+1} -> row_index={i}, source_index={src_idx}", end='') debug(f"[debug] @{i+1} -> row_index={i}, source_index={src_idx}", end='')
if src_idx is not None and 0 <= src_idx < len(items_list): if src_idx is not None and 0 <= src_idx < len(items_list):
item = items_list[src_idx] item = items_list[src_idx]
# Try to show hash/title for verification # Try to show hash/title for verification
@@ -1181,9 +1264,9 @@ def _execute_pipeline(tokens: list):
else: else:
print(" -> [source_index out of range]") print(" -> [source_index out of range]")
if resolved_list is not None: if resolved_list is not None:
print(f"[debug] resolved_len={len(resolved_list)}") debug(f"[debug] resolved_len={len(resolved_list)}")
except Exception as e: except Exception as e:
print(f"[debug] error in _debug_selection: {e}") debug(f"[debug] error in _debug_selection: {e}")
# Split tokens by pipe operator # Split tokens by pipe operator
stages = [] stages = []

View File

@@ -14,8 +14,9 @@ import socket
import subprocess import subprocess
import sys import sys
import time as _time import time as _time
import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, List, BinaryIO, cast from typing import Any, Dict, Optional, List, BinaryIO, Tuple, cast
from SYS.logger import debug from SYS.logger import debug
@@ -29,6 +30,44 @@ _LYRIC_PROCESS: Optional[subprocess.Popen] = None
_LYRIC_LOG_FH: Optional[Any] = None _LYRIC_LOG_FH: Optional[Any] = None
_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None
def _check_mpv_availability() -> Tuple[bool, Optional[str]]:
"""Return (available, reason) for the mpv executable.
This checks that:
- `mpv` is present in PATH
- `mpv --version` can run successfully
Result is cached per-process to avoid repeated subprocess calls.
"""
global _MPV_AVAILABILITY_CACHE
if _MPV_AVAILABILITY_CACHE is not None:
return _MPV_AVAILABILITY_CACHE
mpv_path = shutil.which("mpv")
if not mpv_path:
_MPV_AVAILABILITY_CACHE = (False, "Executable 'mpv' not found in PATH")
return _MPV_AVAILABILITY_CACHE
try:
result = subprocess.run(
[mpv_path, "--version"],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0:
_MPV_AVAILABILITY_CACHE = (True, None)
return _MPV_AVAILABILITY_CACHE
_MPV_AVAILABILITY_CACHE = (False, f"MPV returned non-zero exit code: {result.returncode}")
return _MPV_AVAILABILITY_CACHE
except Exception as exc:
_MPV_AVAILABILITY_CACHE = (False, f"Error running MPV: {exc}")
return _MPV_AVAILABILITY_CACHE
def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]: def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]:
"""Return PIDs of `python -m MPV.lyric --ipc <ipc_path>` helpers (Windows only).""" """Return PIDs of `python -m MPV.lyric --ipc <ipc_path>` helpers (Windows only)."""
if platform.system() != "Windows": if platform.system() != "Windows":
@@ -130,6 +169,11 @@ class MPV:
lua_script_path: Optional[str | Path] = None, lua_script_path: Optional[str | Path] = None,
timeout: float = 5.0, timeout: float = 5.0,
) -> None: ) -> None:
ok, reason = _check_mpv_availability()
if not ok:
raise MPVIPCError(reason or "MPV unavailable")
self.timeout = timeout self.timeout = timeout
self.ipc_path = ipc_path or get_ipc_pipe_path() self.ipc_path = ipc_path or get_ipc_pipe_path()

View File

@@ -2,19 +2,89 @@ from __future__ import annotations
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Dict, Optional, Tuple
import requests import requests
from ProviderCore.base import FileProvider from ProviderCore.base import FileProvider
_MATRIX_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {}
def _normalize_homeserver(value: str) -> str:
text = str(value or "").strip()
if not text:
return ""
if not text.startswith("http"):
text = f"https://{text}"
return text.rstrip("/")
def _matrix_health_check(*, homeserver: str, access_token: Optional[str]) -> Tuple[bool, Optional[str]]:
"""Lightweight Matrix reachability/auth validation.
- Always checks `/versions` (no auth).
- If `access_token` is present, also checks `/whoami`.
"""
try:
base = _normalize_homeserver(homeserver)
if not base:
return False, "Matrix homeserver missing"
resp = requests.get(f"{base}/_matrix/client/versions", timeout=5)
if resp.status_code != 200:
return False, f"Homeserver returned {resp.status_code}"
if access_token:
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(f"{base}/_matrix/client/v3/account/whoami", headers=headers, timeout=5)
if resp.status_code != 200:
return False, f"Authentication failed: {resp.status_code}"
return True, None
except Exception as exc:
return False, str(exc)
class Matrix(FileProvider): class Matrix(FileProvider):
"""File provider for Matrix (Element) chat rooms.""" """File provider for Matrix (Element) chat rooms."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
self._init_ok: Optional[bool] = None
self._init_reason: Optional[str] = None
matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
homeserver = matrix_conf.get("homeserver")
room_id = matrix_conf.get("room_id")
access_token = matrix_conf.get("access_token")
password = matrix_conf.get("password")
# Not configured: keep instance but mark invalid via validate().
if not (homeserver and room_id and (access_token or password)):
self._init_ok = None
self._init_reason = None
return
cache_key = f"{_normalize_homeserver(str(homeserver))}|room:{room_id}|has_token:{bool(access_token)}"
cached = _MATRIX_INIT_CHECK_CACHE.get(cache_key)
if cached is None:
ok, reason = _matrix_health_check(homeserver=str(homeserver), access_token=str(access_token) if access_token else None)
_MATRIX_INIT_CHECK_CACHE[cache_key] = (ok, reason)
else:
ok, reason = cached
self._init_ok = ok
self._init_reason = reason
if not ok:
raise Exception(reason or "Matrix unavailable")
def validate(self) -> bool: def validate(self) -> bool:
if not self.config: if not self.config:
return False return False
if self._init_ok is False:
return False
matrix_conf = self.config.get("provider", {}).get("matrix", {}) matrix_conf = self.config.get("provider", {}).get("matrix", {})
return bool( return bool(
matrix_conf.get("homeserver") matrix_conf.get("homeserver")

View File

@@ -3,7 +3,7 @@
import sys import sys
from SYS.logger import log, debug from SYS.logger import log
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:
@@ -69,7 +69,7 @@ def format_download_status(filename: str, current: int, total: int, speed: float
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)."""
status = format_download_status(filename, current, total, speed) status = format_download_status(filename, current, total, speed)
debug(status, end=end, flush=True) print(status, file=sys.stderr, 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:
@@ -86,7 +86,7 @@ def print_final_progress(filename: str, total: int, elapsed: float) -> None:
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}") print(f"{bar} ({size_str}) - {time_str}", file=sys.stderr, flush=True)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -34,6 +34,8 @@ class Folder(Store):
"""""" """"""
# Track which locations have already been migrated to avoid repeated migrations # Track which locations have already been migrated to avoid repeated migrations
_migrated_locations = set() _migrated_locations = set()
# Cache scan results to avoid repeated full scans across repeated instantiations
_scan_cache: Dict[str, Tuple[bool, str, Dict[str, int]]] = {}
def __new__(cls, *args: Any, **kwargs: Any) -> "Folder": def __new__(cls, *args: Any, **kwargs: Any) -> "Folder":
return super().__new__(cls) return super().__new__(cls)
@@ -55,10 +57,16 @@ class Folder(Store):
self._location = location self._location = location
self._name = name self._name = name
# Scan status (set during init)
self.scan_ok: bool = True
self.scan_detail: str = ""
self.scan_stats: Dict[str, int] = {}
if self._location: if self._location:
try: try:
from API.folder import API_folder_store from API.folder import API_folder_store
from API.folder import LocalLibraryInitializer
from pathlib import Path from pathlib import Path
location_path = Path(self._location).expanduser() location_path = Path(self._location).expanduser()
@@ -69,6 +77,29 @@ class Folder(Store):
# Call migration and discovery at startup # Call migration and discovery at startup
Folder.migrate_location(self._location) Folder.migrate_location(self._location)
# Local library scan/index (one-time per location per process)
location_key = str(location_path)
cached = Folder._scan_cache.get(location_key)
if cached is None:
try:
initializer = LocalLibraryInitializer(location_path)
stats = initializer.scan_and_index() or {}
files_new = int(stats.get('files_new', 0) or 0)
sidecars = int(stats.get('sidecars_imported', 0) or 0)
total_db = int(stats.get('files_total_db', 0) or 0)
if files_new > 0 or sidecars > 0:
detail = f"New: {files_new}, Sidecars: {sidecars}" + (f" (Total: {total_db})" if total_db else "")
else:
detail = ("Up to date" + (f" (Total: {total_db})" if total_db else ""))
Folder._scan_cache[location_key] = (True, detail, dict(stats))
except Exception as exc:
Folder._scan_cache[location_key] = (False, f"Scan failed: {exc}", {})
ok, detail, stats = Folder._scan_cache.get(location_key, (True, "", {}))
self.scan_ok = bool(ok)
self.scan_detail = str(detail or "")
self.scan_stats = dict(stats or {})
except Exception as exc: except Exception as exc:
debug(f"Failed to initialize database for '{name}': {exc}") debug(f"Failed to initialize database for '{name}': {exc}")
@@ -87,12 +118,11 @@ class Folder(Store):
return return
cls._migrated_locations.add(location_str) 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: cls._migrate_to_hash_storage(location_path)
@classmethod
def _migrate_to_hash_storage(cls, location_path: Path) -> None:
"""Migrate existing files from filename-based to hash-based storage. """Migrate existing files from filename-based to hash-based storage.
Checks for sidecars (.metadata, .tag) and imports them before renaming. Checks for sidecars (.metadata, .tag) and imports them before renaming.
@@ -158,6 +188,15 @@ class Folder(Store):
if hash_path != file_path and not hash_path.exists(): if hash_path != file_path and not hash_path.exists():
debug(f"Migrating: {file_path.name} -> {hash_filename}", file=sys.stderr) debug(f"Migrating: {file_path.name} -> {hash_filename}", file=sys.stderr)
file_path.rename(hash_path) file_path.rename(hash_path)
# Ensure DB points to the renamed path (update by hash).
try:
cursor.execute(
"UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?",
(str(hash_path.resolve()), file_hash),
)
except Exception:
pass
# Create or update database entry # Create or update database entry
db.get_or_create_file_entry(hash_path) db.get_or_create_file_entry(hash_path)

View File

@@ -5,17 +5,22 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import httpx
from SYS.logger import debug, log from SYS.logger import debug, log
from SYS.utils_constant import mime_maps from SYS.utils_constant import mime_maps
from Store._base import Store from Store._base import Store
_HYDRUS_INIT_CHECK_CACHE: dict[tuple[str, str], tuple[bool, Optional[str]]] = {}
class HydrusNetwork(Store): class HydrusNetwork(Store):
"""File storage backend for Hydrus client. """File storage backend for Hydrus client.
Each instance represents a specific Hydrus client connection. Each instance represents a specific Hydrus client connection.
Maintains its own HydrusClient with session key. Maintains its own HydrusClient.
""" """
def __new__(cls, *args: Any, **kwargs: Any) -> "HydrusNetwork": def __new__(cls, *args: Any, **kwargs: Any) -> "HydrusNetwork":
@@ -64,22 +69,67 @@ class HydrusNetwork(Store):
self.NAME = instance_name self.NAME = instance_name
self.API = api_key self.API = api_key
self.URL = url self.URL = url.rstrip("/")
# Create persistent client with session key for this instance
self._client = HydrusClient(url=url, access_key=api_key)
# Self health-check: acquire a session key immediately so broken configs # Total count (best-effort, used for startup diagnostics)
# fail-fast and the registry can skip registering this backend. self.total_count: Optional[int] = None
try:
if self._client is not None: # Self health-check: validate the URL is reachable and the access key is accepted.
self._client.ensure_session_key() # This MUST NOT attempt to acquire a session key.
except Exception as exc: cache_key = (self.URL, self.API)
# Best-effort cleanup so partially constructed objects don't linger. cached = _HYDRUS_INIT_CHECK_CACHE.get(cache_key)
if cached is not None:
ok, err = cached
if not ok:
raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {err or 'Unavailable'}")
else:
api_version_url = f"{self.URL}/api_version"
verify_key_url = f"{self.URL}/verify_access_key"
try: try:
self._client = None with httpx.Client(timeout=5.0, verify=False, follow_redirects=True) as client:
except Exception: version_resp = client.get(api_version_url)
pass version_resp.raise_for_status()
raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {exc}") from exc version_payload = version_resp.json()
if not isinstance(version_payload, dict):
raise RuntimeError("Hydrus /api_version returned an unexpected response")
verify_resp = client.get(
verify_key_url,
headers={"Hydrus-Client-API-Access-Key": self.API},
)
verify_resp.raise_for_status()
verify_payload = verify_resp.json()
if not isinstance(verify_payload, dict):
raise RuntimeError("Hydrus /verify_access_key returned an unexpected response")
_HYDRUS_INIT_CHECK_CACHE[cache_key] = (True, None)
except Exception as exc:
err = str(exc)
_HYDRUS_INIT_CHECK_CACHE[cache_key] = (False, err)
raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {err}") from exc
# Create a persistent client for this instance (auth via access key by default).
self._client = HydrusClient(url=self.URL, access_key=self.API)
# Best-effort total count (fast on Hydrus side; does not fetch IDs/hashes).
try:
payload = self._client.search_files(
tags=["system:everything"],
return_hashes=False,
return_file_ids=False,
return_file_count=True,
)
count_val = None
if isinstance(payload, dict):
count_val = payload.get("file_count")
if count_val is None:
count_val = payload.get("file_count_inclusive")
if count_val is None:
count_val = payload.get("num_files")
if isinstance(count_val, int):
self.total_count = count_val
except Exception as exc:
debug(f"Hydrus total count unavailable for '{self.NAME}': {exc}", file=sys.stderr)
def name(self) -> str: def name(self) -> str:
return self.NAME return self.NAME

View File

@@ -22,6 +22,11 @@ from SYS.logger import debug
from Store._base import Store as BaseStore from Store._base import Store as BaseStore
# Backends that failed to initialize earlier in the current process.
# Keyed by (store_type, instance_key) where instance_key is the name used under config.store.<type>.<instance_key>.
_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
def _normalize_store_type(value: str) -> str: def _normalize_store_type(value: str) -> str:
return "".join(ch.lower() for ch in str(value or "") if ch.isalnum()) return "".join(ch.lower() for ch in str(value or "") if ch.isalnum())
@@ -111,6 +116,7 @@ class Store:
self._config = config or {} self._config = config or {}
self._suppress_debug = suppress_debug self._suppress_debug = suppress_debug
self._backends: Dict[str, BaseStore] = {} self._backends: Dict[str, BaseStore] = {}
self._backend_errors: Dict[str, str] = {}
self._load_backends() self._load_backends()
def _load_backends(self) -> None: def _load_backends(self) -> None:
@@ -131,6 +137,18 @@ class Store:
continue continue
for instance_name, instance_config in instances.items(): for instance_name, instance_config in instances.items():
backend_name = str(instance_name)
# If this backend already failed earlier in this process, skip re-instantiation.
cache_key = (store_type, str(instance_name))
cached_error = _FAILED_BACKEND_CACHE.get(cache_key)
if cached_error:
self._backend_errors[str(instance_name)] = str(cached_error)
if isinstance(instance_config, dict):
override_name = _get_case_insensitive(dict(instance_config), "NAME")
if override_name:
self._backend_errors[str(override_name)] = str(cached_error)
continue
try: try:
kwargs = _build_kwargs(store_cls, str(instance_name), instance_config) kwargs = _build_kwargs(store_cls, str(instance_name), instance_config)
@@ -144,11 +162,17 @@ class Store:
backend_name = str(kwargs.get("NAME") or instance_name) backend_name = str(kwargs.get("NAME") or instance_name)
self._backends[backend_name] = backend self._backends[backend_name] = backend
except Exception as exc: except Exception as exc:
err_text = str(exc)
self._backend_errors[str(instance_name)] = err_text
_FAILED_BACKEND_CACHE[cache_key] = err_text
if not self._suppress_debug: if not self._suppress_debug:
debug( debug(
f"[Store] Failed to register {store_cls.__name__} instance '{instance_name}': {exc}" f"[Store] Failed to register {store_cls.__name__} instance '{instance_name}': {exc}"
) )
def get_backend_error(self, backend_name: str) -> Optional[str]:
return self._backend_errors.get(str(backend_name))
def list_backends(self) -> list[str]: def list_backends(self) -> list[str]:
return sorted(self._backends.keys()) return sorted(self._backends.keys())

View File

@@ -441,6 +441,35 @@ class Add_File(Cmdlet):
ctx.emit(pipe_obj.to_dict()) ctx.emit(pipe_obj.to_dict())
ctx.set_current_stage_table(None) ctx.set_current_stage_table(None)
@staticmethod
def _emit_storage_result(payload: Dict[str, Any]) -> None:
"""Emit a storage-style result payload.
- Always emits the dict downstream (when in a pipeline).
- If this is the last stage (or not in a pipeline), prints a search-store-like table
and sets an overlay table/items for @N selection.
"""
# Always emit for downstream commands (no-op if not in a pipeline)
ctx.emit(payload)
stage_ctx = ctx.get_stage_context()
is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
if not is_last:
return
try:
from result_table import ResultTable
table = ResultTable("Result")
table.add_result(payload)
# Overlay so @1 refers to this add-file result without overwriting search history
ctx.set_last_result_table_overlay(table, [payload], subject=payload)
except Exception:
# If table rendering fails, still keep @ selection items
try:
ctx.set_last_result_items_only([payload])
except Exception:
pass
@staticmethod @staticmethod
def _prepare_metadata( def _prepare_metadata(
result: Any, result: Any,
@@ -788,7 +817,55 @@ class Add_File(Cmdlet):
"url": url, "url": url,
}, },
) )
Add_File._emit_pipe_object(pipe_obj)
# Emit a search-store-like payload for consistent tables and natural piping.
# Keep hash/store for downstream commands (get-tag, get-file, etc.).
resolved_hash = file_identifier if len(file_identifier) == 64 else (f_hash or file_identifier or "unknown")
meta: Dict[str, Any] = {}
try:
meta = backend.get_metadata(resolved_hash) or {}
except Exception:
meta = {}
# Determine size bytes
size_bytes: Optional[int] = None
for key in ("size_bytes", "size", "filesize", "file_size"):
try:
raw_size = meta.get(key)
if raw_size is not None:
size_bytes = int(raw_size)
break
except Exception:
pass
if size_bytes is None:
try:
size_bytes = int(media_path.stat().st_size)
except Exception:
size_bytes = None
# Determine title/ext
title_out = (
meta.get("title")
or title
or pipe_obj.title
or media_path.stem
or media_path.name
)
ext_out = (meta.get("ext") or media_path.suffix.lstrip("."))
payload: Dict[str, Any] = {
"title": title_out,
"ext": str(ext_out or ""),
"size_bytes": size_bytes,
"store": backend_name,
"hash": resolved_hash,
# Preserve extra fields for downstream commands (kept hidden by default table rules)
"path": stored_path,
"tag": list(tags or []),
"url": list(url or []),
}
Add_File._emit_storage_result(payload)
Add_File._cleanup_after_success(media_path, delete_source=delete_after) Add_File._cleanup_after_success(media_path, delete_source=delete_after)
return 0 return 0

View File

@@ -57,6 +57,9 @@ except ImportError:
_EXTRACTOR_CACHE: List[Any] | None = None _EXTRACTOR_CACHE: List[Any] | None = None
# Reused progress formatter for yt-dlp callbacks (stderr only).
_YTDLP_PROGRESS_BAR = ProgressBar()
def _ensure_yt_dlp_ready() -> None: def _ensure_yt_dlp_ready() -> None:
if yt_dlp is not None: if yt_dlp is not None:
@@ -248,7 +251,8 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
"fragment_retries": 10, "fragment_retries": 10,
"http_chunk_size": 10_485_760, "http_chunk_size": 10_485_760,
"restrictfilenames": True, "restrictfilenames": True,
"progress_hooks": [] if opts.quiet else [_progress_callback], # Always show a progress indicator; do not tie it to debug logging.
"progress_hooks": [_progress_callback],
} }
if opts.cookies_path and opts.cookies_path.is_file(): if opts.cookies_path and opts.cookies_path.is_file():
@@ -423,17 +427,36 @@ def _progress_callback(status: Dict[str, Any]) -> None:
"""Simple progress callback using logger.""" """Simple progress callback using logger."""
event = status.get("status") event = status.get("status")
if event == "downloading": if event == "downloading":
percent = status.get("_percent_str", "?") # Always print progress to stderr so piped stdout remains clean.
speed = status.get("_speed_str", "?") percent = status.get("_percent_str")
eta = status.get("_eta_str", "?") downloaded = status.get("downloaded_bytes")
sys.stdout.write(f"\r[download] {percent} at {speed} ETA {eta} ") total = status.get("total_bytes") or status.get("total_bytes_estimate")
sys.stdout.flush() speed = status.get("_speed_str")
eta = status.get("_eta_str")
try:
line = _YTDLP_PROGRESS_BAR.format_progress(
percent_str=str(percent) if percent is not None else None,
downloaded=int(downloaded) if downloaded is not None else None,
total=int(total) if total is not None else None,
speed_str=str(speed) if speed is not None else None,
eta_str=str(eta) if eta is not None else None,
)
except Exception:
pct = str(percent) if percent is not None else "?"
spd = str(speed) if speed is not None else "?"
et = str(eta) if eta is not None else "?"
line = f"[download] {pct} at {spd} ETA {et}"
sys.stderr.write("\r" + line + " ")
sys.stderr.flush()
elif event == "finished": elif event == "finished":
sys.stdout.write("\r" + " " * 70 + "\r") # Clear the in-place progress line.
sys.stdout.flush() sys.stderr.write("\r" + (" " * 140) + "\r")
debug(f"✓ Download finished: {status.get('filename')}") sys.stderr.write("\n")
sys.stderr.flush()
elif event in ("postprocessing", "processing"): elif event in ("postprocessing", "processing"):
debug(f"Post-processing: {status.get('postprocessor')}") return
def _download_direct_file( def _download_direct_file(
@@ -530,17 +553,17 @@ def _download_direct_file(
speed_str=speed_str, speed_str=speed_str,
eta_str=eta_str, eta_str=eta_str,
) )
if not quiet: sys.stderr.write("\r" + progress_line + " ")
debug(progress_line) sys.stderr.flush()
last_progress_time[0] = now last_progress_time[0] = now
with HTTPClient(timeout=30.0) as client: with HTTPClient(timeout=30.0) as client:
client.download(url, str(file_path), progress_callback=progress_callback) client.download(url, str(file_path), progress_callback=progress_callback)
elapsed = time.time() - start_time # Clear progress line after completion.
avg_speed_str = progress_bar.format_bytes(downloaded_bytes[0] / elapsed if elapsed > 0 else 0) + "/s" sys.stderr.write("\r" + (" " * 140) + "\r")
if not quiet: sys.stderr.write("\n")
debug(f"✓ Downloaded in {elapsed:.1f}s at {avg_speed_str}") sys.stderr.flush()
# For direct file downloads, create minimal info dict without filename as title # For direct file downloads, create minimal info dict without filename as title
# This prevents creating duplicate title: tags when filename gets auto-generated # This prevents creating duplicate title: tags when filename gets auto-generated
@@ -1403,9 +1426,16 @@ class Download_Media(Cmdlet):
# Emit one PipeObject per downloaded file (playlists/albums return a list) # Emit one PipeObject per downloaded file (playlists/albums return a list)
results_to_emit = result_obj if isinstance(result_obj, list) else [result_obj] results_to_emit = result_obj if isinstance(result_obj, list) else [result_obj]
debug(f"Emitting {len(results_to_emit)} result(s) to pipeline...") debug(f"Emitting {len(results_to_emit)} result(s) to pipeline...")
stage_ctx = pipeline_context.get_stage_context()
emit_enabled = bool(stage_ctx is not None and not getattr(stage_ctx, "is_last_stage", False))
for downloaded in results_to_emit: for downloaded in results_to_emit:
pipe_obj_dict = self._build_pipe_object(downloaded, url, opts) pipe_obj_dict = self._build_pipe_object(downloaded, url, opts)
pipeline_context.emit(pipe_obj_dict)
# Only emit when there is a downstream stage.
# This keeps `download-media` from producing a result table when run standalone.
if emit_enabled:
pipeline_context.emit(pipe_obj_dict)
# Automatically register url with local library # Automatically register url with local library
if pipe_obj_dict.get("url"): if pipe_obj_dict.get("url"):

View File

@@ -769,7 +769,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
pass pass
except Exception: except Exception:
pass pass
print(f"MPV log file: {mpv_log_path}") debug(f"MPV log file: {mpv_log_path}")
# If mpv is already running, set log options live via IPC. # If mpv is already running, set log options live via IPC.
try: try:

View File

@@ -1,527 +1,20 @@
"""Hydrus API health check and initialization. """Cookies availability helpers.
Provides startup health checks for Hydrus API availability and gracefully This module is intentionally limited to cookie-file resolution used by yt-dlp.
disables Hydrus features if the API is unavailable. Other service availability checks live in their owning store/provider objects.
""" """
import logging
import sys import sys
from SYS.logger import log, debug
from typing import Tuple, Optional, Dict, Any
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__) from SYS.logger import debug
# Global state for all service availability checks - consolidated from 12 separate globals
_SERVICE_STATE = {
"hydrus": {"available": None, "reason": None, "complete": False},
"hydrusnetwork_stores": {}, # Track individual Hydrus instances
"debrid": {"available": None, "reason": None, "complete": False},
"mpv": {"available": None, "reason": None, "complete": False},
"matrix": {"available": None, "reason": None, "complete": False},
}
# Global state for Cookies availability # Global state for Cookies availability
_COOKIES_FILE_PATH: Optional[str] = None _COOKIES_FILE_PATH: Optional[str] = None
def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check Hydrus availability by instantiating configured HydrusNetwork stores.
HydrusNetwork now self-checks in its __init__ (it acquires a session key).
If construction fails, the store is treated as unavailable and the error
message becomes the failure reason.
"""
store_config = config.get("store", {})
hydrusnetwork = store_config.get("hydrusnetwork", {}) if isinstance(store_config, dict) else {}
if not isinstance(hydrusnetwork, dict) or not hydrusnetwork:
return False, "Not configured"
from Store.HydrusNetwork import HydrusNetwork
any_ok = False
last_reason: Optional[str] = None
for instance_name, instance_config in hydrusnetwork.items():
if not isinstance(instance_config, dict):
continue
url = instance_config.get("URL")
access_key = instance_config.get("API")
if not url or not access_key:
last_reason = "Missing credentials"
continue
try:
HydrusNetwork(NAME=str(instance_name), API=str(access_key), URL=str(url))
any_ok = True
except Exception as exc:
last_reason = str(exc)
if any_ok:
return True, None
return False, last_reason or "No reachable Hydrus instances"
def initialize_hydrus_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]:
"""Initialize Hydrus health check at startup."""
global _SERVICE_STATE
logger.info("[Startup] Starting Hydrus health check...")
is_available = False
reason: Optional[str] = None
# Track individual Hydrus instances (per-instance construction to capture reasons)
_SERVICE_STATE["hydrusnetwork_stores"] = {}
try:
store_config = config.get("store", {})
hydrusnetwork = store_config.get("hydrusnetwork", {}) if isinstance(store_config, dict) else {}
if isinstance(hydrusnetwork, dict):
from Store.HydrusNetwork import HydrusNetwork
first_error: Optional[str] = None
for instance_name, instance_config in hydrusnetwork.items():
if not isinstance(instance_config, dict):
continue
url = instance_config.get("URL")
access_key = instance_config.get("API")
if not url or not access_key:
_SERVICE_STATE["hydrusnetwork_stores"][instance_name] = {
"ok": False,
"url": url or "Not configured",
"detail": "Missing credentials",
}
continue
try:
HydrusNetwork(NAME=str(instance_name), API=str(access_key), URL=str(url))
is_available = True
_SERVICE_STATE["hydrusnetwork_stores"][instance_name] = {
"ok": True,
"url": str(url),
"detail": "Connected",
}
except Exception as exc:
if first_error is None:
first_error = str(exc)
_SERVICE_STATE["hydrusnetwork_stores"][instance_name] = {
"ok": False,
"url": str(url),
"detail": str(exc),
}
if not is_available:
reason = first_error or "No reachable Hydrus instances"
except Exception as e:
logger.debug(f"Could not enumerate Hydrus instances: {e}")
is_available = False
reason = str(e)
_SERVICE_STATE["hydrus"]["available"] = is_available
_SERVICE_STATE["hydrus"]["reason"] = reason
_SERVICE_STATE["hydrus"]["complete"] = True
if emit_debug:
status = 'ENABLED' if is_available else f'DISABLED - {reason or "Connection failed"}'
debug(f"Hydrus: {status}", file=sys.stderr)
return is_available, reason
def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Debrid API is available."""
try:
try:
from config import get_debrid_api_key
# Require at least one configured key to consider Debrid configured.
if not get_debrid_api_key(config):
return False, "Not configured"
except Exception:
return False, "Not configured"
from API.HTTP import HTTPClient
logger.info("[Debrid Health Check] Pinging Debrid API...")
with HTTPClient(timeout=10.0, verify_ssl=True) as client:
response = client.get('https://api.alldebrid.com/v4/ping')
result = response.json()
if result.get('status') == 'success' and result.get('data', {}).get('ping') == 'pong':
logger.info("[Debrid Health Check] Debrid API is AVAILABLE")
return True, None
return False, "Invalid API response"
except Exception as e:
logger.warning(f"[Debrid Health Check] Debrid API error: {e}")
return False, str(e)
def initialize_debrid_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]:
"""Initialize Debrid health check at startup."""
global _SERVICE_STATE
logger.info("[Startup] Starting Debrid health check...")
is_available, reason = check_debrid_availability(config)
_SERVICE_STATE["debrid"]["available"] = is_available
_SERVICE_STATE["debrid"]["reason"] = reason
_SERVICE_STATE["debrid"]["complete"] = True
if emit_debug:
status = 'ENABLED' if is_available else f'DISABLED - {reason or "Connection failed"}'
debug(f"Debrid: {status}", file=sys.stderr)
return is_available, reason
def check_mpv_availability() -> Tuple[bool, Optional[str]]:
"""Check if MPV is available (installed and runnable).
Returns:
Tuple of (is_available: bool, reason: Optional[str])
"""
global _SERVICE_STATE
if _SERVICE_STATE["mpv"]["complete"] and _SERVICE_STATE["mpv"]["available"] is not None:
return _SERVICE_STATE["mpv"]["available"], _SERVICE_STATE["mpv"]["reason"]
import shutil
import subprocess
logger.info("[MPV Health Check] Checking for MPV executable...")
mpv_path = shutil.which("mpv")
if not mpv_path:
logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: Executable 'mpv' not found in PATH")
return False, "Executable 'mpv' not found in PATH"
# Try to get version to confirm it works
try:
result = subprocess.run(
[mpv_path, "--version"],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0:
version_line = result.stdout.split('\n')[0]
logger.info(f"[MPV Health Check] MPV is AVAILABLE ({version_line})")
return True, None
else:
reason = f"MPV returned non-zero exit code: {result.returncode}"
logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: {reason}")
return False, reason
except Exception as e:
reason = f"Error running MPV: {e}"
logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: {reason}")
return False, reason
def initialize_mpv_health_check(emit_debug: bool = True) -> Tuple[bool, Optional[str]]:
"""Initialize MPV health check at startup and return (is_available, reason)."""
global _SERVICE_STATE
logger.info("[Startup] Starting MPV health check...")
is_available, reason = check_mpv_availability()
_SERVICE_STATE["mpv"]["available"] = is_available
_SERVICE_STATE["mpv"]["reason"] = reason
_SERVICE_STATE["mpv"]["complete"] = True
if emit_debug:
if is_available:
debug("MPV: ENABLED - All MPV features available", file=sys.stderr)
elif reason != "Not configured":
debug(f"MPV: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
return is_available, reason
def check_matrix_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Matrix homeserver is reachable and credentials are valid.
Args:
config: Application configuration dictionary
Returns:
Tuple of (is_available: bool, reason: Optional[str])
"""
try:
import requests
matrix_conf = config.get('provider', {}).get('matrix', {})
homeserver = matrix_conf.get('homeserver')
access_token = matrix_conf.get('access_token')
if not homeserver:
return False, "Not configured"
if not homeserver.startswith('http'):
homeserver = f"https://{homeserver}"
# Check versions endpoint (no auth required)
try:
resp = requests.get(f"{homeserver}/_matrix/client/versions", timeout=5)
if resp.status_code != 200:
return False, f"Homeserver returned {resp.status_code}"
except Exception as e:
return False, f"Homeserver unreachable: {e}"
# Check auth if token provided (whoami)
if access_token:
try:
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(f"{homeserver}/_matrix/client/v3/account/whoami", headers=headers, timeout=5)
if resp.status_code != 200:
return False, f"Authentication failed: {resp.status_code}"
except Exception as e:
return False, f"Auth check failed: {e}"
return True, None
except Exception as e:
return False, str(e)
def initialize_matrix_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]:
"""Initialize Matrix health check at startup and return (is_available, reason)."""
global _SERVICE_STATE
logger.info("[Startup] Starting Matrix health check...")
is_available, reason = check_matrix_availability(config)
_SERVICE_STATE["matrix"]["available"] = is_available
_SERVICE_STATE["matrix"]["reason"] = reason
_SERVICE_STATE["matrix"]["complete"] = True
if emit_debug:
if is_available:
debug("Matrix: ENABLED - Homeserver reachable", file=sys.stderr)
elif reason != "Not configured":
debug(f"Matrix: DISABLED - {reason}", file=sys.stderr)
return is_available, reason
# Unified getter functions for service availability - all use _SERVICE_STATE
def is_hydrus_available() -> bool:
"""Check if Hydrus is available (from cached health check)."""
return _SERVICE_STATE["hydrus"]["available"] is True
def get_hydrus_unavailable_reason() -> Optional[str]:
"""Get the reason why Hydrus is unavailable."""
return _SERVICE_STATE["hydrus"]["reason"] if not is_hydrus_available() else None
def is_hydrus_check_complete() -> bool:
"""Check if the Hydrus health check has been completed."""
return _SERVICE_STATE["hydrus"]["complete"]
def disable_hydrus_features() -> None:
"""Manually disable all Hydrus features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["hydrus"]["available"] = False
_SERVICE_STATE["hydrus"]["reason"] = "Manually disabled or lost connection"
logger.warning("[Hydrus] Features manually disabled")
def enable_hydrus_features() -> None:
"""Manually enable Hydrus features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["hydrus"]["available"] = True
_SERVICE_STATE["hydrus"]["reason"] = None
logger.info("[Hydrus] Features manually enabled")
def is_debrid_available() -> bool:
"""Check if Debrid is available (from cached health check)."""
return _SERVICE_STATE["debrid"]["available"] is True
def get_debrid_unavailable_reason() -> Optional[str]:
"""Get the reason why Debrid is unavailable."""
return _SERVICE_STATE["debrid"]["reason"] if not is_debrid_available() else None
def is_debrid_check_complete() -> bool:
"""Check if the Debrid health check has been completed."""
return _SERVICE_STATE["debrid"]["complete"]
def disable_debrid_features() -> None:
"""Manually disable all Debrid features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["debrid"]["available"] = False
_SERVICE_STATE["debrid"]["reason"] = "Manually disabled or lost connection"
logger.warning("[Debrid] Features manually disabled")
def enable_debrid_features() -> None:
"""Manually enable Debrid features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["debrid"]["available"] = True
_SERVICE_STATE["debrid"]["reason"] = None
logger.info("[Debrid] Features manually enabled")
def is_mpv_available() -> bool:
"""Check if MPV is available (from cached health check)."""
return _SERVICE_STATE["mpv"]["available"] is True
def get_mpv_unavailable_reason() -> Optional[str]:
"""Get the reason why MPV is unavailable."""
return _SERVICE_STATE["mpv"]["reason"] if not is_mpv_available() else None
def is_mpv_check_complete() -> bool:
"""Check if the MPV health check has been completed."""
return _SERVICE_STATE["mpv"]["complete"]
def disable_mpv_features() -> None:
"""Manually disable all MPV features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["mpv"]["available"] = False
_SERVICE_STATE["mpv"]["reason"] = "Manually disabled or lost connection"
logger.warning("[MPV] Features manually disabled")
def enable_mpv_features() -> None:
"""Manually enable MPV features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["mpv"]["available"] = True
_SERVICE_STATE["mpv"]["reason"] = None
logger.info("[MPV] Features manually enabled")
def is_matrix_available() -> bool:
"""Check if Matrix is available (from cached health check)."""
return _SERVICE_STATE["matrix"]["available"] is True
def get_matrix_unavailable_reason() -> Optional[str]:
"""Get the reason why Matrix is unavailable."""
return _SERVICE_STATE["matrix"]["reason"] if not is_matrix_available() else None
def is_matrix_check_complete() -> bool:
"""Check if the Matrix health check has been completed."""
return _SERVICE_STATE["matrix"]["complete"]
def disable_matrix_features() -> None:
"""Manually disable all Matrix features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["matrix"]["available"] = False
_SERVICE_STATE["matrix"]["reason"] = "Manually disabled or lost connection"
logger.warning("[Matrix] Features manually disabled")
def enable_matrix_features() -> None:
"""Manually enable Matrix features (for testing/fallback)."""
global _SERVICE_STATE
_SERVICE_STATE["matrix"]["available"] = True
_SERVICE_STATE["matrix"]["reason"] = None
logger.info("[Matrix] Features manually enabled")
def initialize_local_library_scan(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, str]:
"""Initialize and scan all folder stores at startup.
Returns a tuple of (success, detail_message).
Note: Individual store results are stored in _SERVICE_STATE["folder_stores"]
for the CLI to display as separate table rows.
This ensures that any new files in configured folder stores are indexed
and their sidecar files are imported and cleaned up.
"""
from API.folder import LocalLibraryInitializer
from Store.Folder import Folder
logger.info("[Startup] Starting folder store scans...")
try:
# Get all configured folder stores from config
folder_sources = config.get("store", {}).get("folder", {})
if not isinstance(folder_sources, dict) or not folder_sources:
if emit_debug:
debug("⚠️ Folder stores: SKIPPED - No folder stores configured", file=sys.stderr)
return False, "No folder stores configured"
results = []
total_new_files = 0
total_sidecars = 0
failed_stores = []
store_results = {}
for store_name, store_config in folder_sources.items():
if not isinstance(store_config, dict):
continue
store_path = store_config.get("path")
if not store_path:
continue
try:
from pathlib import Path
storage_path = Path(str(store_path)).expanduser()
if emit_debug:
debug(f"Scanning folder store '{store_name}' at: {storage_path}", file=sys.stderr)
# Migrate the folder store to hash-based naming (only runs once per location)
Folder.migrate_location(str(storage_path))
initializer = LocalLibraryInitializer(storage_path)
stats = initializer.scan_and_index()
# Accumulate stats
new_files = stats.get('files_new', 0)
sidecars = stats.get('sidecars_imported', 0)
total_new_files += new_files
total_sidecars += sidecars
# Record result for this store
if new_files > 0 or sidecars > 0:
result_detail = f"New: {new_files}, Sidecars: {sidecars}"
if emit_debug:
debug(f" {store_name}: {result_detail}", file=sys.stderr)
else:
result_detail = "Up to date"
if emit_debug:
debug(f" {store_name}: {result_detail}", file=sys.stderr)
results.append(f"{store_name}: {result_detail}")
store_results[store_name] = {
"path": str(storage_path),
"detail": result_detail,
"ok": True
}
except Exception as e:
logger.error(f"[Startup] Failed to scan folder store '{store_name}': {e}", exc_info=True)
if emit_debug:
debug(f" {store_name}: ERROR - {e}", file=sys.stderr)
failed_stores.append(store_name)
store_results[store_name] = {
"path": str(store_config.get("path", "?")),
"detail": f"ERROR - {e}",
"ok": False
}
# Store individual results for CLI to display
_SERVICE_STATE["folder_stores"] = store_results
# Build detail message
if failed_stores:
detail = f"Scanned {len(results)} stores ({len(failed_stores)} failed); Total new: {total_new_files}, Sidecars: {total_sidecars}"
if emit_debug:
debug(f"Folder stores scan complete: {detail}", file=sys.stderr)
return len(failed_stores) < len(results), detail
else:
detail = f"Scanned {len(results)} stores; Total new: {total_new_files}, Sidecars: {total_sidecars}"
if emit_debug:
debug(f"Folder stores scan complete: {detail}", file=sys.stderr)
return True, detail
except Exception as e:
logger.error(f"[Startup] Failed to scan folder stores: {e}", exc_info=True)
if emit_debug:
debug(f"⚠️ Folder stores: ERROR - Scan failed: {e}", file=sys.stderr)
return False, f"Scan failed: {e}"
def initialize_cookies_check(config: Optional[Dict[str, Any]] = None, emit_debug: bool = True) -> Tuple[bool, str]: def initialize_cookies_check(config: Optional[Dict[str, Any]] = None, emit_debug: bool = True) -> Tuple[bool, str]:
"""Resolve cookies file path from config, falling back to cookies.txt in app root. """Resolve cookies file path from config, falling back to cookies.txt in app root.

View File

@@ -532,6 +532,13 @@ class ProgressBar:
Formatted progress string. Formatted progress string.
""" """
percent = self.format_percent(percent_str) percent = self.format_percent(percent_str)
# Some callers (e.g. yt-dlp hooks) may not provide a stable percent string.
# When we have byte counts, derive percent from them so the bar advances.
if (not percent_str or percent == 0.0) and downloaded is not None and total is not None and total > 0:
try:
percent = (float(downloaded) / float(total)) * 100.0
except Exception:
percent = percent
bar = self.build_bar(percent) bar = self.build_bar(percent)
# Format sizes # Format sizes

View File

@@ -22,9 +22,9 @@ name="default"
path="C:\\Media Machina" path="C:\\Media Machina"
[store=hydrusnetwork] [store=hydrusnetwork]
name="home" NAME="home"
Hydrus-Client-API-Access-Key="..." API="..."
url="http://localhost:45869" URL="http://localhost:45869"
[provider=OpenLibrary] [provider=OpenLibrary]
email="user@example.com" email="user@example.com"