fdf
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
162
API/folder.py
162
API/folder.py
@@ -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
189
CLI.py
@@ -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 = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user