This commit is contained in:
nose
2025-12-16 01:45:01 -08:00
parent a03eb0d1be
commit 9873280f0e
36 changed files with 4911 additions and 1225 deletions

View File

@@ -12,7 +12,6 @@ from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, se
from result_table import ResultTable
from MPV.mpv_ipc import MPV
import pipeline as ctx
from SYS.download import is_url_supported_by_ytdlp
from models import PipeObject
from API.folder import LocalLibrarySearchOptimizer
@@ -20,6 +19,78 @@ from config import get_local_storage_path, get_hydrus_access_key, get_hydrus_url
from hydrus_health_check import get_cookies_file_path
_ALLDEBRID_UNLOCK_CACHE: Dict[str, str] = {}
def _get_alldebrid_api_key(config: Optional[Dict[str, Any]]) -> Optional[str]:
try:
if not isinstance(config, dict):
return None
provider_cfg = config.get("provider")
if not isinstance(provider_cfg, dict):
return None
ad_cfg = provider_cfg.get("alldebrid")
if not isinstance(ad_cfg, dict):
return None
key = ad_cfg.get("api_key")
if not isinstance(key, str):
return None
key = key.strip()
return key or None
except Exception:
return None
def _is_alldebrid_protected_url(url: str) -> bool:
try:
if not isinstance(url, str):
return False
u = url.strip()
if not u.startswith(("http://", "https://")):
return False
p = urlparse(u)
host = (p.netloc or "").lower()
path = p.path or ""
# AllDebrid file page links (require auth; not directly streamable by mpv)
return host == "alldebrid.com" and path.startswith("/f/")
except Exception:
return False
def _maybe_unlock_alldebrid_url(url: str, config: Optional[Dict[str, Any]]) -> str:
"""Convert AllDebrid protected file URLs into direct streamable links.
When AllDebrid returns `https://alldebrid.com/f/...`, that URL typically requires
authentication. MPV cannot access it without credentials. We transparently call
the AllDebrid API `link/unlock` (using the configured API key) to obtain a direct
URL that MPV can stream.
"""
if not _is_alldebrid_protected_url(url):
return url
cached = _ALLDEBRID_UNLOCK_CACHE.get(url)
if isinstance(cached, str) and cached:
return cached
api_key = _get_alldebrid_api_key(config)
if not api_key:
return url
try:
from API.alldebrid import AllDebridClient
client = AllDebridClient(api_key)
unlocked = client.unlock_link(url)
if isinstance(unlocked, str) and unlocked.strip():
unlocked = unlocked.strip()
_ALLDEBRID_UNLOCK_CACHE[url] = unlocked
return unlocked
except Exception as e:
debug(f"AllDebrid unlock failed for MPV target: {e}", file=sys.stderr)
return url
def _ensure_lyric_overlay(mpv: MPV) -> None:
try:
mpv.ensure_lyric_loader_running()
@@ -621,6 +692,13 @@ def _queue_items(
target, title = result
# If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV.
try:
if isinstance(target, str):
target = _maybe_unlock_alldebrid_url(target, config)
except Exception:
pass
# Prefer per-item Hydrus instance credentials when the item belongs to a Hydrus store.
effective_hydrus_url = hydrus_url
effective_hydrus_header = hydrus_header
@@ -665,21 +743,10 @@ def _queue_items(
continue
new_targets.add(norm_key)
# Check if it's a yt-dlp supported URL
is_ytdlp = False
# Treat any http(s) target as yt-dlp candidate. If the Python yt-dlp
# module is available we also check more deeply, but default to True
# so MPV can use its ytdl hooks for remote streaming sites.
is_hydrus_target = _is_hydrus_path(str(target), effective_hydrus_url)
try:
# Hydrus direct file URLs should not be treated as yt-dlp targets.
is_ytdlp = (not is_hydrus_target) and (target.startswith("http") or is_url_supported_by_ytdlp(target))
except Exception:
is_ytdlp = (not is_hydrus_target) and target.startswith("http")
# Use memory:// M3U hack to pass title to MPV
# Skip for yt-dlp url to ensure proper handling
if title and (is_hydrus_target or not is_ytdlp):
# Use memory:// M3U hack to pass title to MPV.
# This is especially important for remote URLs (e.g., YouTube) where MPV may otherwise
# show the raw URL as the playlist title.
if title:
# Sanitize title for M3U (remove newlines)
safe_title = title.replace('\n', ' ').replace('\r', '')
@@ -703,8 +770,9 @@ def _queue_items(
if clear_first and i == 0:
mode = "replace"
# If this is a Hydrus path, set header property and yt-dlp headers before loading
if effective_hydrus_header and _is_hydrus_path(target_to_send, effective_hydrus_url):
# If this is a Hydrus path, set header property and yt-dlp headers before loading.
# Use the real target (not the memory:// wrapper) for detection.
if effective_hydrus_header and _is_hydrus_path(str(target), effective_hydrus_url):
header_cmd = {"command": ["set_property", "http-header-fields", effective_hydrus_header], "request_id": 199}
_send_ipc_command(header_cmd, silent=True)
if effective_ytdl_opts:
@@ -727,10 +795,8 @@ def _queue_items(
_start_mpv(items[i:], config=config, start_opts=start_opts)
return True
elif resp.get("error") == "success":
# Also set property for good measure
if title:
title_cmd = {"command": ["set_property", "force-media-title", title], "request_id": 201}
_send_ipc_command(title_cmd)
# Do not set `force-media-title` when queueing items. It's a global property and
# would change the MPV window title even if the item isn't currently playing.
debug(f"Queued: {title or target}")
else:
error_msg = str(resp.get('error'))
@@ -1008,7 +1074,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
ctx.set_last_result_table_overlay(table, [p['items'] for p in playlists])
ctx.set_current_stage_table(table)
print(table)
# In pipeline mode, the CLI renders current-stage tables; printing here duplicates output.
suppress_direct_print = bool(isinstance(config, dict) and config.get("_quiet_background_output"))
if not suppress_direct_print:
print(table)
return 0
# Everything below was originally outside a try block; keep it inside so `start_opts` is in scope.
@@ -1153,9 +1222,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
debug("MPV is starting up...")
return 0
debug("MPV is not running. Starting new instance...")
_start_mpv([], config=config, start_opts=start_opts)
return 0
# IPC is ready; continue without restarting MPV again.
else:
debug("MPV is not running. Starting new instance...")
_start_mpv([], config=config, start_opts=start_opts)
return 0
if not items:
debug("MPV playlist is empty.")
@@ -1314,7 +1385,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
ctx.set_last_result_table_overlay(table, pipe_objects)
ctx.set_current_stage_table(table)
print(table)
# In pipeline mode, the CLI renders current-stage tables; printing here duplicates output.
suppress_direct_print = bool(isinstance(config, dict) and config.get("_quiet_background_output"))
if not suppress_direct_print:
print(table)
return 0
finally: