fdf
This commit is contained in:
@@ -106,16 +106,7 @@ class HydrusNetwork:
|
||||
|
||||
def _perform_request(self, spec: HydrusRequestSpec) -> Any:
|
||||
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
|
||||
if self._session_key:
|
||||
headers["Hydrus-Client-API-Session-Key"] = self._session_key
|
||||
@@ -496,6 +487,7 @@ class HydrusNetwork:
|
||||
file_service_name: str | None = None,
|
||||
return_hashes: bool = False,
|
||||
return_file_ids: bool = True,
|
||||
return_file_count: bool = False,
|
||||
include_current_tags: bool | None = None,
|
||||
include_pending_tags: bool | None = None,
|
||||
file_sort_type: int | None = None,
|
||||
@@ -511,6 +503,7 @@ class HydrusNetwork:
|
||||
("file_service_name", file_service_name, lambda v: v),
|
||||
("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_count", return_file_count, lambda v: "true" if v else None),
|
||||
(
|
||||
"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:
|
||||
"""Create and return a Hydrus client with session key authentication.
|
||||
|
||||
Reuses cached client instance to preserve session keys across requests.
|
||||
"""Create and return a Hydrus client.
|
||||
|
||||
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:
|
||||
config: Configuration dict with Hydrus settings
|
||||
|
||||
Returns:
|
||||
HydrusClient instance (with active session key)
|
||||
HydrusClient instance
|
||||
|
||||
Raises:
|
||||
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
|
||||
if cache_key in _hydrus_client_cache:
|
||||
cached_client = _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]
|
||||
return _hydrus_client_cache[cache_key]
|
||||
|
||||
# Create new client
|
||||
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
|
||||
_hydrus_client_cache[cache_key] = client
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import sys
|
||||
from SYS.logger import log, debug
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set, List, Sequence
|
||||
from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from .HTTP import HTTPClient
|
||||
|
||||
@@ -31,6 +30,24 @@ _CACHE_TIMESTAMP: float = 0
|
||||
_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:
|
||||
"""Client for AllDebrid API."""
|
||||
|
||||
@@ -50,6 +67,18 @@ class AllDebridClient:
|
||||
if not self.api_key:
|
||||
raise AllDebridError("AllDebrid API key is empty")
|
||||
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]:
|
||||
"""Make a request to AllDebrid API.
|
||||
|
||||
162
API/folder.py
162
API/folder.py
@@ -1842,8 +1842,21 @@ class LocalLibraryInitializer:
|
||||
self.db.connection.commit()
|
||||
self._import_sidecars_batch()
|
||||
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.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}")
|
||||
return self.stats
|
||||
@@ -1853,12 +1866,140 @@ class LocalLibraryInitializer:
|
||||
raise
|
||||
finally:
|
||||
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]:
|
||||
"""Find all media files in the library folder."""
|
||||
media_files = []
|
||||
try:
|
||||
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:
|
||||
media_files.append(file_path)
|
||||
except Exception as e:
|
||||
@@ -1882,7 +2023,7 @@ class LocalLibraryInitializer:
|
||||
logger.error(f"Error getting database files: {e}", exc_info=True)
|
||||
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."""
|
||||
try:
|
||||
normalized = str(file_path.resolve()).lower()
|
||||
@@ -1890,8 +2031,23 @@ class LocalLibraryInitializer:
|
||||
if normalized in db_files:
|
||||
self.stats['files_existing'] += 1
|
||||
else:
|
||||
self.db.get_or_create_file_entry(file_path)
|
||||
self.stats['files_new'] += 1
|
||||
# Path not known. If this file's hash is already in DB, it's duplicate content and
|
||||
# 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
|
||||
except Exception as e:
|
||||
|
||||
189
CLI.py
189
CLI.py
@@ -779,14 +779,14 @@ def _create_cmdlet_cli():
|
||||
if startup_table:
|
||||
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:
|
||||
return
|
||||
row = startup_table.add_row()
|
||||
row.add_column("Check", name)
|
||||
row.add_column("Status", status)
|
||||
if detail:
|
||||
row.add_column("Detail", detail)
|
||||
row.add_column("Name", name)
|
||||
row.add_column("Store/Provi", store_or_provider)
|
||||
row.add_column("Detail", detail or "")
|
||||
|
||||
def _has_store_subtype(cfg: dict, subtype: str) -> bool:
|
||||
store_cfg = cfg.get("store")
|
||||
@@ -831,67 +831,150 @@ def _create_cmdlet_cli():
|
||||
|
||||
# Run startup checks and render table
|
||||
try:
|
||||
from hydrus_health_check import (
|
||||
initialize_mpv_health_check,
|
||||
initialize_matrix_health_check,
|
||||
initialize_hydrus_health_check,
|
||||
initialize_local_library_scan,
|
||||
initialize_cookies_check,
|
||||
initialize_debrid_health_check,
|
||||
)
|
||||
from hydrus_health_check import initialize_cookies_check
|
||||
|
||||
def _run_check(name: str, fn: Callable[[], Tuple[bool, Optional[str]]], skip_reason: Optional[str] = None) -> None:
|
||||
if skip_reason:
|
||||
_add_startup_check(name, "SKIPPED", skip_reason)
|
||||
return
|
||||
# MPV availability is validated by MPV.MPV.__init__.
|
||||
try:
|
||||
from MPV.mpv_ipc import MPV
|
||||
|
||||
MPV()
|
||||
try:
|
||||
ok, detail = fn()
|
||||
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))
|
||||
import shutil
|
||||
|
||||
_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:
|
||||
# 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
|
||||
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
|
||||
from hydrus_health_check import _SERVICE_STATE
|
||||
for instance_name, instance_info in _SERVICE_STATE.get("hydrusnetwork_stores", {}).items():
|
||||
status = "ENABLED" if instance_info.get("ok") else "DISABLED"
|
||||
_add_startup_check(f" {instance_name}", status, f"{instance_info.get('url')} - {instance_info.get('detail')}")
|
||||
ok = bool(store_registry and store_registry.is_available(name_key))
|
||||
status = "ENABLED" if ok else "DISABLED"
|
||||
if ok:
|
||||
total = None
|
||||
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"):
|
||||
_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"):
|
||||
# Folder stores - add individual rows for each configured store
|
||||
ok, detail = initialize_local_library_scan(config, emit_debug=False)
|
||||
if ok or detail != "No folder stores configured":
|
||||
from hydrus_health_check import _SERVICE_STATE
|
||||
for store_name, store_info in _SERVICE_STATE.get("folder_stores", {}).items():
|
||||
status = "SCANNED" if store_info.get("ok") else "ERROR"
|
||||
_add_startup_check(f" {store_name}", status, f"{store_info.get('path')} - {store_info.get('detail')}")
|
||||
if not _SERVICE_STATE.get("folder_stores"):
|
||||
_add_startup_check("Folder Stores", "SCANNED", detail)
|
||||
# Folder local scan/index is performed by Store.Folder.__init__.
|
||||
store_cfg = config.get("store")
|
||||
folder_cfg = store_cfg.get("folder", {}) if isinstance(store_cfg, dict) else {}
|
||||
if isinstance(folder_cfg, dict) and folder_cfg:
|
||||
for instance_name, instance_cfg in folder_cfg.items():
|
||||
if not isinstance(instance_cfg, dict):
|
||||
continue
|
||||
name_key = str(instance_cfg.get("NAME") or instance_name)
|
||||
path_val = str(instance_cfg.get("PATH") or instance_cfg.get("path") or "").strip()
|
||||
|
||||
ok = bool(store_registry and store_registry.is_available(name_key))
|
||||
if ok and store_registry:
|
||||
backend = store_registry[name_key]
|
||||
scan_ok = bool(getattr(backend, "scan_ok", True))
|
||||
scan_detail = str(getattr(backend, "scan_detail", "") or "")
|
||||
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:
|
||||
_add_startup_check("Folder Stores", "SKIPPED", detail)
|
||||
_add_startup_check("SKIPPED", "Folder", "folder", "No folder stores configured")
|
||||
|
||||
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:
|
||||
print()
|
||||
@@ -1156,14 +1239,14 @@ def _execute_pipeline(tokens: list):
|
||||
and the actual items being selected to help diagnose reordering issues.
|
||||
"""
|
||||
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:
|
||||
# Show correspondence: displayed row # -> source_index -> item hash/title
|
||||
for i in selection_indices:
|
||||
if 0 <= i < len(table_obj.rows):
|
||||
row = table_obj.rows[i]
|
||||
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):
|
||||
item = items_list[src_idx]
|
||||
# Try to show hash/title for verification
|
||||
@@ -1181,9 +1264,9 @@ def _execute_pipeline(tokens: list):
|
||||
else:
|
||||
print(" -> [source_index out of range]")
|
||||
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:
|
||||
print(f"[debug] error in _debug_selection: {e}")
|
||||
debug(f"[debug] error in _debug_selection: {e}")
|
||||
|
||||
# Split tokens by pipe operator
|
||||
stages = []
|
||||
|
||||
@@ -14,8 +14,9 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time as _time
|
||||
import shutil
|
||||
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
|
||||
|
||||
@@ -29,6 +30,44 @@ _LYRIC_PROCESS: Optional[subprocess.Popen] = 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]:
|
||||
"""Return PIDs of `python -m MPV.lyric --ipc <ipc_path>` helpers (Windows only)."""
|
||||
if platform.system() != "Windows":
|
||||
@@ -130,6 +169,11 @@ class MPV:
|
||||
lua_script_path: Optional[str | Path] = None,
|
||||
timeout: float = 5.0,
|
||||
) -> None:
|
||||
|
||||
ok, reason = _check_mpv_availability()
|
||||
if not ok:
|
||||
raise MPVIPCError(reason or "MPV unavailable")
|
||||
|
||||
self.timeout = timeout
|
||||
self.ipc_path = ipc_path or get_ipc_pipe_path()
|
||||
|
||||
|
||||
@@ -2,19 +2,89 @@ from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
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):
|
||||
"""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:
|
||||
if not self.config:
|
||||
return False
|
||||
if self._init_ok is False:
|
||||
return False
|
||||
matrix_conf = self.config.get("provider", {}).get("matrix", {})
|
||||
return bool(
|
||||
matrix_conf.get("homeserver")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
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:
|
||||
@@ -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:
|
||||
"""Print download progress to stderr (doesn't interfere with piped output)."""
|
||||
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:
|
||||
@@ -86,7 +86,7 @@ def print_final_progress(filename: str, total: int, elapsed: float) -> None:
|
||||
hours = elapsed / 3600
|
||||
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__":
|
||||
|
||||
@@ -34,6 +34,8 @@ class Folder(Store):
|
||||
""""""
|
||||
# Track which locations have already been migrated to avoid repeated migrations
|
||||
_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":
|
||||
return super().__new__(cls)
|
||||
@@ -55,10 +57,16 @@ class Folder(Store):
|
||||
|
||||
self._location = location
|
||||
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:
|
||||
try:
|
||||
from API.folder import API_folder_store
|
||||
from API.folder import LocalLibraryInitializer
|
||||
from pathlib import Path
|
||||
location_path = Path(self._location).expanduser()
|
||||
|
||||
@@ -69,6 +77,29 @@ class Folder(Store):
|
||||
|
||||
# Call migration and discovery at startup
|
||||
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:
|
||||
debug(f"Failed to initialize database for '{name}': {exc}")
|
||||
|
||||
@@ -87,12 +118,11 @@ class Folder(Store):
|
||||
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:
|
||||
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.
|
||||
|
||||
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():
|
||||
debug(f"Migrating: {file_path.name} -> {hash_filename}", file=sys.stderr)
|
||||
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
|
||||
db.get_or_create_file_entry(hash_path)
|
||||
|
||||
@@ -5,17 +5,22 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from SYS.logger import debug, log
|
||||
from SYS.utils_constant import mime_maps
|
||||
|
||||
from Store._base import Store
|
||||
|
||||
|
||||
_HYDRUS_INIT_CHECK_CACHE: dict[tuple[str, str], tuple[bool, Optional[str]]] = {}
|
||||
|
||||
|
||||
class HydrusNetwork(Store):
|
||||
"""File storage backend for Hydrus client.
|
||||
|
||||
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":
|
||||
@@ -64,22 +69,67 @@ class HydrusNetwork(Store):
|
||||
|
||||
self.NAME = instance_name
|
||||
self.API = api_key
|
||||
self.URL = url
|
||||
# Create persistent client with session key for this instance
|
||||
self._client = HydrusClient(url=url, access_key=api_key)
|
||||
self.URL = url.rstrip("/")
|
||||
|
||||
# Self health-check: acquire a session key immediately so broken configs
|
||||
# fail-fast and the registry can skip registering this backend.
|
||||
try:
|
||||
if self._client is not None:
|
||||
self._client.ensure_session_key()
|
||||
except Exception as exc:
|
||||
# Best-effort cleanup so partially constructed objects don't linger.
|
||||
# Total count (best-effort, used for startup diagnostics)
|
||||
self.total_count: Optional[int] = None
|
||||
|
||||
# Self health-check: validate the URL is reachable and the access key is accepted.
|
||||
# This MUST NOT attempt to acquire a session key.
|
||||
cache_key = (self.URL, self.API)
|
||||
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:
|
||||
self._client = None
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {exc}") from exc
|
||||
with httpx.Client(timeout=5.0, verify=False, follow_redirects=True) as client:
|
||||
version_resp = client.get(api_version_url)
|
||||
version_resp.raise_for_status()
|
||||
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:
|
||||
return self.NAME
|
||||
|
||||
@@ -22,6 +22,11 @@ from SYS.logger import debug
|
||||
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:
|
||||
return "".join(ch.lower() for ch in str(value or "") if ch.isalnum())
|
||||
|
||||
@@ -111,6 +116,7 @@ class Store:
|
||||
self._config = config or {}
|
||||
self._suppress_debug = suppress_debug
|
||||
self._backends: Dict[str, BaseStore] = {}
|
||||
self._backend_errors: Dict[str, str] = {}
|
||||
self._load_backends()
|
||||
|
||||
def _load_backends(self) -> None:
|
||||
@@ -131,6 +137,18 @@ class Store:
|
||||
continue
|
||||
|
||||
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:
|
||||
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)
|
||||
self._backends[backend_name] = backend
|
||||
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:
|
||||
debug(
|
||||
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]:
|
||||
return sorted(self._backends.keys())
|
||||
|
||||
|
||||
@@ -441,6 +441,35 @@ class Add_File(Cmdlet):
|
||||
ctx.emit(pipe_obj.to_dict())
|
||||
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
|
||||
def _prepare_metadata(
|
||||
result: Any,
|
||||
@@ -788,7 +817,55 @@ class Add_File(Cmdlet):
|
||||
"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)
|
||||
return 0
|
||||
|
||||
@@ -57,6 +57,9 @@ except ImportError:
|
||||
|
||||
_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:
|
||||
if yt_dlp is not None:
|
||||
@@ -248,7 +251,8 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
|
||||
"fragment_retries": 10,
|
||||
"http_chunk_size": 10_485_760,
|
||||
"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():
|
||||
@@ -423,17 +427,36 @@ def _progress_callback(status: Dict[str, Any]) -> None:
|
||||
"""Simple progress callback using logger."""
|
||||
event = status.get("status")
|
||||
if event == "downloading":
|
||||
percent = status.get("_percent_str", "?")
|
||||
speed = status.get("_speed_str", "?")
|
||||
eta = status.get("_eta_str", "?")
|
||||
sys.stdout.write(f"\r[download] {percent} at {speed} ETA {eta} ")
|
||||
sys.stdout.flush()
|
||||
# Always print progress to stderr so piped stdout remains clean.
|
||||
percent = status.get("_percent_str")
|
||||
downloaded = status.get("downloaded_bytes")
|
||||
total = status.get("total_bytes") or status.get("total_bytes_estimate")
|
||||
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":
|
||||
sys.stdout.write("\r" + " " * 70 + "\r")
|
||||
sys.stdout.flush()
|
||||
debug(f"✓ Download finished: {status.get('filename')}")
|
||||
# Clear the in-place progress line.
|
||||
sys.stderr.write("\r" + (" " * 140) + "\r")
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
elif event in ("postprocessing", "processing"):
|
||||
debug(f"Post-processing: {status.get('postprocessor')}")
|
||||
return
|
||||
|
||||
|
||||
def _download_direct_file(
|
||||
@@ -530,17 +553,17 @@ def _download_direct_file(
|
||||
speed_str=speed_str,
|
||||
eta_str=eta_str,
|
||||
)
|
||||
if not quiet:
|
||||
debug(progress_line)
|
||||
sys.stderr.write("\r" + progress_line + " ")
|
||||
sys.stderr.flush()
|
||||
last_progress_time[0] = now
|
||||
|
||||
with HTTPClient(timeout=30.0) as client:
|
||||
client.download(url, str(file_path), progress_callback=progress_callback)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
avg_speed_str = progress_bar.format_bytes(downloaded_bytes[0] / elapsed if elapsed > 0 else 0) + "/s"
|
||||
if not quiet:
|
||||
debug(f"✓ Downloaded in {elapsed:.1f}s at {avg_speed_str}")
|
||||
# Clear progress line after completion.
|
||||
sys.stderr.write("\r" + (" " * 140) + "\r")
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
# For direct file downloads, create minimal info dict without filename as title
|
||||
# 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)
|
||||
results_to_emit = result_obj if isinstance(result_obj, list) else [result_obj]
|
||||
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:
|
||||
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
|
||||
if pipe_obj_dict.get("url"):
|
||||
|
||||
@@ -769,7 +769,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
pass
|
||||
except Exception:
|
||||
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.
|
||||
try:
|
||||
|
||||
@@ -1,527 +1,20 @@
|
||||
"""Hydrus API health check and initialization.
|
||||
"""Cookies availability helpers.
|
||||
|
||||
Provides startup health checks for Hydrus API availability and gracefully
|
||||
disables Hydrus features if the API is unavailable.
|
||||
This module is intentionally limited to cookie-file resolution used by yt-dlp.
|
||||
Other service availability checks live in their owning store/provider objects.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from SYS.logger import log, debug
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# 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},
|
||||
}
|
||||
from SYS.logger import debug
|
||||
|
||||
# Global state for Cookies availability
|
||||
_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]:
|
||||
"""Resolve cookies file path from config, falling back to cookies.txt in app root.
|
||||
|
||||
|
||||
@@ -532,6 +532,13 @@ class ProgressBar:
|
||||
Formatted progress string.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Format sizes
|
||||
|
||||
Reference in New Issue
Block a user