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

View File

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