From 89aa24961bbb5b67fa6fc4be14c5f3cca8395f46 Mon Sep 17 00:00:00 2001 From: nose Date: Mon, 1 Dec 2025 14:42:30 -0800 Subject: [PATCH] hh --- .gitignore | 2 +- CLI.py | 8 +- cmdlets/download_data.py | 180 ++++++++++++++++++++++++++++++++++- cmdlets/pipe.py | 194 ++++++++++++++++++++++++++++++-------- helper/download.py | 199 ++++++++++++++++++++++++++++++++++++++- helper/mpv_ipc.py | 8 ++ hydrus_health_check.py | 24 +++++ models.py | 1 + 8 files changed, 565 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index b37a6b1..01f86df 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ test_* # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec - +cookies.txt # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/CLI.py b/CLI.py index 2c8393c..eeb74fb 100644 --- a/CLI.py +++ b/CLI.py @@ -600,6 +600,10 @@ def _create_cmdlet_cli(): # Load config config = _load_cli_config() + # Initialize cookies check for yt-dlp + from hydrus_health_check import initialize_cookies_check + initialize_cookies_check() + # Initialize debug logging if enabled if config: from helper.logger import set_debug @@ -691,11 +695,13 @@ def _create_cmdlet_cli(): check_mpv_availability, initialize_matrix_health_check, initialize_hydrus_health_check, - initialize_local_library_scan + initialize_local_library_scan, + initialize_cookies_check ) check_mpv_availability() initialize_hydrus_health_check(config) initialize_matrix_health_check(config) + initialize_cookies_check() initialize_local_library_scan(config) # --- Startup File Counts --- diff --git a/cmdlets/download_data.py b/cmdlets/download_data.py index 033b8f5..b0726e6 100644 --- a/cmdlets/download_data.py +++ b/cmdlets/download_data.py @@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple import uuid from helper.logger import log, debug -from helper.download import download_media, probe_url +from helper.download import download_media, probe_url, is_url_supported_by_ytdlp from helper.utils import sha256_file from models import DownloadOptions @@ -710,6 +710,97 @@ def _parse_time_range(clip_spec: str) -> Optional[Tuple[int, int]]: return None +def _parse_section_ranges(section_spec: str) -> Optional[List[Tuple[int, int]]]: + """Parse section ranges from comma-separated time ranges. + + Args: + section_spec: Section ranges like "1:30-1:35,0:05-0:15" or "90-95,5-15" + May include quotes from CLI which will be stripped + + Returns: + List of (start_seconds, end_seconds) tuples or None if invalid + """ + try: + # Strip quotes if present (from CLI parsing) + section_spec = section_spec.strip('"\'') + + if not section_spec or ',' not in section_spec and '-' not in section_spec: + return None + + ranges = [] + # Handle both comma-separated ranges and single range + if ',' in section_spec: + section_parts = section_spec.split(',') + else: + section_parts = [section_spec] + + for part in section_parts: + part = part.strip() + if not part: + continue + + # Parse each range using the same logic as _parse_time_range + # Handle format like "1:30-1:35" or "90-95" + if '-' not in part: + return None + + # Split carefully to handle cases like "1:30-1:35" + # We need to find the dash that separates start and end + # Look for pattern: something-something where first something may have colons + dash_pos = -1 + colon_count = 0 + for i, char in enumerate(part): + if char == ':': + colon_count += 1 + elif char == '-': + # If we've seen a colon and this is a dash, check if it's the separator + # Could be "1:30-1:35" or just "90-95" + # The separator dash should come after the first number/time + if i > 0 and i < len(part) - 1: + dash_pos = i + break + + if dash_pos == -1: + return None + + start_str = part[:dash_pos] + end_str = part[dash_pos+1:] + + # Parse start time + if ':' in start_str: + start_parts = start_str.split(':') + if len(start_parts) == 2: + start_sec = int(start_parts[0]) * 60 + int(start_parts[1]) + elif len(start_parts) == 3: + start_sec = int(start_parts[0]) * 3600 + int(start_parts[1]) * 60 + int(start_parts[2]) + else: + return None + else: + start_sec = int(start_str) + + # Parse end time + if ':' in end_str: + end_parts = end_str.split(':') + if len(end_parts) == 2: + end_sec = int(end_parts[0]) * 60 + int(end_parts[1]) + elif len(end_parts) == 3: + end_sec = int(end_parts[0]) * 3600 + int(end_parts[1]) * 60 + int(end_parts[2]) + else: + return None + else: + end_sec = int(end_str) + + if start_sec >= end_sec: + return None + + ranges.append((start_sec, end_sec)) + + return ranges if ranges else None + + except (ValueError, AttributeError, IndexError): + return None + + MEDIA_EXTENSIONS = {'.mp3', '.m4a', '.mp4', '.mkv', '.webm', '.flac', '.wav', '.aac'} @@ -1023,6 +1114,23 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results: else: log(f"Invalid clip format: {clip_spec}", file=sys.stderr) return 1 + + # Section download (yt-dlp only) + section_spec = parsed.get("section") + section_ranges = None + if section_spec: + # Parse section spec like "1:30-1:35,0:05-0:15" into list of (start, end) tuples + section_ranges = _parse_section_ranges(section_spec) + if section_ranges: + debug(f"Section ranges: {section_spec} ({len(section_ranges)} sections)") + # When downloading sections, auto-select best format if not specified + # Since we're only getting portions, quality matters less than completeness + if not format_selector: + format_selector = "bestvideo+bestaudio/best" + debug(f"Auto-selecting format for sections: {format_selector}") + else: + log(f"Invalid section format: {section_spec}", file=sys.stderr) + return 1 cookies_path = parsed.get("cookies") storage_location = parsed.get("storage") @@ -2361,7 +2469,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results: if (not current_format_selector and not list_formats_mode and isinstance(url, str) and url.startswith(('http://', 'https://'))): # Check if this is a yt-dlp supported URL (YouTube, Vimeo, etc.) - from helper.download import is_url_supported_by_ytdlp, list_formats + from helper.download import list_formats from result_table import ResultTable if is_url_supported_by_ytdlp(url): @@ -2562,13 +2670,35 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results: # Detect YouTube URLs and set no_playlist to download only the single video is_youtube_url = isinstance(url, str) and ('youtube.com' in url or 'youtu.be' in url) + # Determine clip_sections to pass to yt-dlp + # Sections take precedence over clip if both are specified + # Sections are for yt-dlp download-sections (merge multiple clips at source) + # Clip is for post-download extraction + clip_sections_str = None + if section_ranges: + # Check if this is a yt-dlp URL + if is_url_supported_by_ytdlp(url): + # Convert section ranges to yt-dlp format: "start1-end1,start2-end2" + # Use * prefix to indicate download_sections (yt-dlp convention in some contexts) + # But here we just pass the string and let helper/download.py parse it + clip_sections_str = ",".join(f"{start}-{end}" for start, end in section_ranges) + debug(f"Using yt-dlp sections: {clip_sections_str}") + else: + log(f"Warning: -section only works with yt-dlp supported URLs. Use -clip for {url}", file=sys.stderr) + elif clip_range: + # For -clip, we use the same field but it's handled differently in helper/download.py + # Wait, helper/download.py treats clip_sections as download_sections for yt-dlp + # So -clip should also work as download_sections if it's a yt-dlp URL? + # Currently -clip is just one range. + clip_sections_str = f"{clip_range[0]}-{clip_range[1]}" + download_opts = DownloadOptions( url=url, mode=mode, output_dir=final_output_dir, cookies_path=final_cookies_path, ytdl_format=current_format_selector, # Use per-URL format override if available - clip_sections=f"{clip_range[0]}-{clip_range[1]}" if clip_range else None, + clip_sections=clip_sections_str, playlist_items=playlist_items, no_playlist=is_youtube_url, # For YouTube, ignore playlist URLs and download single video ) @@ -2584,8 +2714,36 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results: file_path = result_data.path if file_path.exists(): + # Check if we have multiple section files to emit + if result_data.paths: + # Section download - emit each section file separately for merge-file + debug(f"📋 Section download: emitting {len(result_data.paths)} file(s) to merge-file") + for section_file in result_data.paths: + if section_file.exists(): + file_hash = _compute_file_hash(section_file) + tags = result_data.tags if result_data.tags else [] + + pipe_obj = create_pipe_object_result( + source='download', + identifier=section_file.stem, + file_path=str(section_file), + cmdlet_name='download-data', + title=section_file.name, + file_hash=file_hash, + is_temp=False, + extra={ + 'url': url, + 'tags': tags, + 'audio_mode': audio_mode, + 'format': format_selector, + 'from_sections': True, + } + ) + + downloaded_files.append(section_file) + pipeline_context.emit(pipe_obj) # Check if this was a playlist download (is_actual_playlist tracks if we have a multi-item playlist) - if is_actual_playlist: + elif is_actual_playlist: if not selected_playlist_entries: debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.") exit_code = 1 @@ -2788,6 +2946,11 @@ CMDLET = Cmdlet( type="string", description="Extract time range: MM:SS-MM:SS (e.g., 34:03-35:08) or seconds" ), + CmdletArg( + name="section", + type="string", + description="Download sections (yt-dlp only): TIME_RANGE[,TIME_RANGE...] (e.g., '1:30-1:35,0:05-0:15')" + ), CmdletArg( name="cookies", type="string", @@ -2841,6 +3004,12 @@ CMDLET = Cmdlet( " Format: MM:SS-MM:SS (e.g., 34:03-35:08)", " Also accepts: 2043-2108 (seconds)", "", + "SECTION DOWNLOAD (yt-dlp only):", + " -section RANGES Download specific time sections and merge them", + " Format: HH:MM:SS-HH:MM:SS[,HH:MM:SS-HH:MM:SS...]", + " Example: -section '1:30-1:35,0:05-0:15'", + " Each section is downloaded separately then merged in order", + "", "PLAYLIST MODE:", " Automatically detects playlists", " Shows numbered list of tracks", @@ -2866,6 +3035,9 @@ CMDLET = Cmdlet( " # Extract specific clip from video", " download-data https://vimeo.com/123456 -clip 1:30-2:45 -format best", "", + " # Download multiple sections and merge them", + " download-data https://youtube.com/watch?v=xyz -section '1:30-1:35,0:05-0:15' | merge-file | add-file -storage local", + "", " # Download specific tracks from playlist", " download-data https://youtube.com/playlist?list=xyz -item '1,3,5-8'", "", diff --git a/cmdlets/pipe.py b/cmdlets/pipe.py index 8f74940..a023654 100644 --- a/cmdlets/pipe.py +++ b/cmdlets/pipe.py @@ -10,9 +10,11 @@ from helper.logger import log, debug from result_table import ResultTable from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient import pipeline as ctx +from helper.download import is_url_supported_by_ytdlp from helper.local_library import LocalLibrarySearchOptimizer from config import get_local_storage_path +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.""" @@ -70,6 +72,62 @@ def _extract_title_from_item(item: Dict[str, Any]) -> str: return title or filename or "Unknown" +def _ensure_ytdl_cookies() -> None: + """Ensure yt-dlp options are set correctly for this session.""" + from pathlib import Path + cookies_path = get_cookies_file_path() + if cookies_path: + # Check if file exists and has content (use forward slashes for path checking) + check_path = cookies_path.replace('\\', '/') + file_obj = Path(cookies_path) + if file_obj.exists(): + file_size = file_obj.stat().st_size + debug(f"Cookies file verified: {check_path} ({file_size} bytes)") + else: + debug(f"WARNING: Cookies file does not exist: {check_path}", file=sys.stderr) + else: + debug("No cookies file configured") + +def _monitor_mpv_logs(duration: float = 3.0) -> None: + """Monitor MPV logs for a short duration to capture errors.""" + try: + client = MPVIPCClient() + if not client.connect(): + debug("Failed to connect to MPV for log monitoring", file=sys.stderr) + return + + # Request log messages + client.send_command({"command": ["request_log_messages", "warn"]}) + + import time + start_time = time.time() + while time.time() - start_time < duration: + # We need to read raw lines from the socket + if client.is_windows: + try: + line = client.sock.readline() + if line: + try: + msg = json.loads(line) + if msg.get("event") == "log-message": + text = msg.get("text", "").strip() + prefix = msg.get("prefix", "") + level = msg.get("level", "") + if "ytdl" in prefix or level == "error": + debug(f"[MPV {prefix}] {text}", file=sys.stderr) + except json.JSONDecodeError: + pass + except Exception: + break + else: + # Unix socket handling (simplified) + break + time.sleep(0.05) + + client.disconnect() + except Exception: + pass + def _queue_items(items: List[Any], clear_first: bool = False) -> bool: """Queue items to MPV, starting it if necessary. @@ -80,6 +138,9 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool: Returns: True if MPV was started, False if items were queued via IPC. """ + # Just verify cookies are configured, don't try to set via IPC + _ensure_ytdl_cookies() + for i, item in enumerate(items): # Extract URL/Path target = None @@ -95,11 +156,14 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool: target = item if target: - # Add to MPV playlist - # We use loadfile with append flag (or replace if clear_first is set) - + # Check if it's a yt-dlp supported URL + is_ytdlp = False + if target.startswith("http") and is_url_supported_by_ytdlp(target): + is_ytdlp = True + # Use memory:// M3U hack to pass title to MPV - if title: + # Skip for yt-dlp URLs to ensure proper handling + if title and not is_ytdlp: # Sanitize title for M3U (remove newlines) safe_title = title.replace('\n', ' ').replace('\r', '') m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" @@ -164,10 +228,26 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: mpv_started = False if url_arg: mpv_started = _queue_items([url_arg]) - # If we just queued a URL, we probably want to list the playlist to show it was added + # Auto-play the URL when it's queued via .pipe "url" (without explicit flags) # unless other flags are present if not (clear_mode or play_mode or pause_mode or save_mode or load_mode): - list_mode = True + if mpv_started: + # MPV was just started, wait a moment for it to be ready, then play first item + import time + time.sleep(0.5) + index_arg = "1" # 1-based index for first item + play_mode = True + else: + # MPV was already running, get playlist and play the newly added item + playlist = _get_playlist(silent=True) + if playlist and len(playlist) > 0: + # Auto-play the last item in the playlist (the one we just added) + # Use 1-based indexing + index_arg = str(len(playlist)) + play_mode = True + else: + # Fallback: just list the playlist if we can't determine index + list_mode = True # Handle Save Playlist if save_mode: @@ -290,8 +370,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: print(table) return 0 - # Handle Play/Pause commands - if play_mode: + # Handle Play/Pause commands (but skip if we have index_arg to play a specific item) + if play_mode and index_arg is None: cmd = {"command": ["set_property", "pause", False], "request_id": 103} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": @@ -345,13 +425,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if items is None: if mpv_started: - # MPV was just started, so we can't list items yet. - # But we know it's running (or trying to start), so don't start another instance. - return 0 + # MPV was just started, retry getting playlist after a brief delay + import time + time.sleep(0.3) + items = _get_playlist(silent=True) - debug("MPV is not running. Starting new instance...") - _start_mpv([]) - return 0 + if items is None: + # Still can't connect, but MPV is starting + debug("MPV is starting up...") + return 0 + else: + debug("MPV is not running. Starting new instance...") + _start_mpv([]) + return 0 if not items: debug("MPV playlist is empty.") @@ -393,11 +479,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: _send_ipc_command(unpause_cmd) debug(f"Playing: {title}") + + # Monitor logs briefly for errors (e.g. ytdl failures) + _monitor_mpv_logs(3.0) return 0 else: debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}") return 1 - except ValueError: debug(f"Invalid index: {index_arg}") return 1 @@ -443,44 +531,70 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _start_mpv(items: List[Any]) -> 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]') - # Add items - for item in items: - target = None - title = None - - if isinstance(item, dict): - target = item.get("target") or item.get("url") or item.get("path") or item.get("filename") - title = item.get("title") or item.get("name") - elif hasattr(item, "target"): - target = item.target - title = getattr(item, "title", None) - elif isinstance(item, str): - target = item - - if target: - if title: - # Use memory:// M3U hack to pass title - safe_title = title.replace('\n', ' ').replace('\r', '') - m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" - cmd.append(f"memory://{m3u_content}") - else: - cmd.append(target) - + # Use cookies.txt if available, otherwise fallback to browser cookies + cookies_path = get_cookies_file_path() + if cookies_path: + # yt-dlp on Windows needs forward slashes OR properly escaped backslashes + # Using forward slashes is more reliable across systems + cookies_path_normalized = cookies_path.replace('\\', '/') + debug(f"Starting MPV with cookies file: {cookies_path_normalized}") + # yt-dlp expects the cookies option with file path + cmd.append(f'--ytdl-raw-options=cookies={cookies_path_normalized}') + else: + # Use cookies from browser (Chrome) to handle age-restricted content + debug("Starting MPV with browser cookies: chrome") + cmd.append('--ytdl-raw-options=cookies-from-browser=chrome') + try: kwargs = {} if platform.system() == 'Windows': kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS - subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs) - debug(f"Started MPV with {len(items)} items") + # Log the complete MPV command being executed + debug(f"DEBUG: Full MPV command: {' '.join(cmd)}") + + subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) + debug(f"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: + debug("Timed out waiting for MPV IPC connection", file=sys.stderr) + return + + # Queue items via IPC + if items: + _queue_items(items) + except Exception as e: debug(f"Error starting MPV: {e}", file=sys.stderr) + CMDLET = Cmdlet( name=".pipe", aliases=["pipe", "playlist", "queue", "ls-pipe"], diff --git a/helper/download.py b/helper/download.py index fc43ee4..3153e85 100644 --- a/helper/download.py +++ b/helper/download.py @@ -9,6 +9,7 @@ Lean, focused downloader without event infrastructure overhead. from __future__ import annotations import re # noqa: F401 +import subprocess import sys import time import traceback @@ -22,6 +23,7 @@ from helper.logger import log, debug from .utils import ensure_directory, sha256_file from .http_client import HTTPClient from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar +from hydrus_health_check import get_cookies_file_path try: import yt_dlp # type: ignore @@ -153,10 +155,79 @@ def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[s except Exception as e: log(f"✗ Error fetching formats: {e}", file=sys.stderr) return None + + +def _download_with_sections_via_cli(url: str, ytdl_options: Dict[str, Any], sections: List[str]) -> None: + """Download each section separately so merge-file can combine them. + + yt-dlp with multiple --download-sections args merges them into one file. + We need separate files for merge-file, so download each section individually. + """ + + sections_list = ytdl_options.get("download_sections", []) + if not sections_list: + return + + # Download each section separately with unique output template + for section_idx, section in enumerate(sections_list, 1): + # Build unique output template for this section + # e.g., "title.section_1_of_3.ext" for the first section + base_outtmpl = ytdl_options.get("outtmpl", "%(title)s.%(ext)s") + + # Insert section number before extension + # e.g., "/path/title.hash.webm" → "/path/title.hash.section_1_of_3.webm" + if base_outtmpl.endswith(".%(ext)s"): + section_outtmpl = base_outtmpl.replace(".%(ext)s", f".section_{section_idx}_of_{len(sections_list)}.%(ext)s") + else: + section_outtmpl = base_outtmpl + f".section_{section_idx}_of_{len(sections_list)}" + + # Build yt-dlp command for this section + cmd = ["yt-dlp"] + + # Add format + if ytdl_options.get("format"): + cmd.extend(["-f", ytdl_options["format"]]) + + # Add ONLY this section (not all sections) + cmd.extend(["--download-sections", section]) + + # Add force-keyframes-at-cuts if specified + if ytdl_options.get("force_keyframes_at_cuts"): + cmd.append("--force-keyframes-at-cuts") + + # Add output template for this section + cmd.extend(["-o", section_outtmpl]) + + # Add cookies file if present + if ytdl_options.get("cookiefile"): + # Convert backslashes to forward slashes for better compatibility + cookies_path = ytdl_options["cookiefile"].replace("\\", "/") + cmd.extend(["--cookies", cookies_path]) + + # Add no-playlist if specified + if ytdl_options.get("noplaylist"): + cmd.append("--no-playlist") + + # Add the URL + cmd.append(url) + + debug(f"Running yt-dlp for section {section_idx}/{len(sections_list)}: {section}") + + # Run the subprocess + try: + result = subprocess.run(cmd, capture_output=False, text=True) + if result.returncode != 0: + raise DownloadError(f"yt-dlp subprocess failed for section {section_idx} with code {result.returncode}") + except Exception as exc: + raise DownloadError(f"yt-dlp subprocess error for section {section_idx}: {exc}") from exc + + def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]: """Build yt-dlp download options.""" ensure_directory(opts.output_dir) + # Build output template + # When downloading sections, each section will have .section_N_of_M added by _download_with_sections_via_cli outtmpl = str((opts.output_dir / "%(title)s.%(ext)s").resolve()) base_options: Dict[str, Any] = { @@ -174,6 +245,14 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]: if opts.cookies_path and opts.cookies_path.is_file(): base_options["cookiefile"] = str(opts.cookies_path) + else: + # Check global cookies file + global_cookies = get_cookies_file_path() + if global_cookies: + base_options["cookiefile"] = global_cookies + else: + # Fallback to browser cookies + base_options["cookiesfrombrowser"] = ("chrome",) # Add no-playlist option if specified (for single video from playlist URLs) if opts.no_playlist: @@ -189,9 +268,36 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]: "res:4320", "res:2880", "res:2160", "res:1440", "res:1080", "res:720", "res" ] - # Add clip sections if provided + # Add clip sections if provided (yt-dlp will download only these sections) if opts.clip_sections: - base_options["download_sections"] = opts.clip_sections + # Parse section ranges like "48-65,120-152,196-205" (seconds) + # and convert to yt-dlp format: "*HH:MM:SS-HH:MM:SS,*HH:MM:SS-HH:MM:SS" + sections = [] + for section_range in opts.clip_sections.split(','): + try: + start_str, end_str = section_range.strip().split('-') + start_sec = float(start_str) + end_sec = float(end_str) + + # Convert seconds to HH:MM:SS format + def sec_to_hhmmss(seconds): + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + start_time = sec_to_hhmmss(start_sec) + end_time = sec_to_hhmmss(end_sec) + sections.append(f"*{start_time}-{end_time}") + except (ValueError, AttributeError): + pass + + if sections: + # Pass each section as a separate element in the list (yt-dlp expects multiple --download-sections args) + base_options["download_sections"] = sections + debug(f"Download sections configured: {', '.join(sections)}") + # Force keyframes at cuts for accurate section boundaries + base_options["force_keyframes_at_cuts"] = True # Add playlist items selection if provided if opts.playlist_items: @@ -547,9 +653,13 @@ def probe_url(url: str, no_playlist: bool = False) -> Optional[Dict[str, Any]]: "skip_download": True, # Don't actually download "extract_flat": "in_playlist", # Get playlist with metadata for each entry "noprogress": True, # No progress bars - "quiet": True, } + # Add cookies if available + global_cookies = get_cookies_file_path() + if global_cookies: + ydl_opts["cookiefile"] = global_cookies + # Add no_playlist option if specified if no_playlist: ydl_opts["noplaylist"] = True @@ -635,8 +745,18 @@ def download_media( assert yt_dlp is not None try: - with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type] - info = ydl.extract_info(opts.url, download=True) + # Debug: show what options we're using + if ytdl_options.get("download_sections"): + debug(f"[yt-dlp] download_sections: {ytdl_options['download_sections']}") + debug(f"[yt-dlp] force_keyframes_at_cuts: {ytdl_options.get('force_keyframes_at_cuts', False)}") + + # Use subprocess when download_sections are present (Python API doesn't support them properly) + if ytdl_options.get("download_sections"): + _download_with_sections_via_cli(opts.url, ytdl_options, ytdl_options.get("download_sections", [])) + info = None + else: + with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type] + info = ydl.extract_info(opts.url, download=True) except Exception as exc: log(f"yt-dlp failed: {exc}", file=sys.stderr) if debug_logger is not None: @@ -650,6 +770,75 @@ def download_media( ) raise DownloadError("yt-dlp download failed") from exc + # If we used subprocess, we need to find the file manually + if info is None: + # Find files created/modified during this download (after we started) + # Look for files matching the expected output template pattern + try: + import glob + import time + import re + + # Get the expected filename pattern from outtmpl + # For sections: "C:\path\title.section_1_of_3.ext", "C:\path\title.section_2_of_3.ext", etc. + # For non-sections: "C:\path\title.ext" + + # Wait a moment to ensure files are fully written + time.sleep(0.5) + + # List all files in output_dir, sorted by modification time + files = sorted(opts.output_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True) + if not files: + raise FileNotFoundError(f"No files found in {opts.output_dir}") + + # If we downloaded sections, look for files with .section_N_of_M pattern + if opts.clip_sections: + # Pattern: "title.section_1_of_3.ext", "title.section_2_of_3.ext", etc. + section_pattern = re.compile(r'\.section_(\d+)_of_(\d+)\.') + matching_files = [f for f in files if section_pattern.search(f.name)] + + if matching_files: + # Sort by section number to ensure correct order + def extract_section_num(path: Path) -> int: + match = section_pattern.search(path.name) + return int(match.group(1)) if match else 999 + + matching_files.sort(key=extract_section_num) + media_path = matching_files[0] # First section + media_paths = matching_files # All sections + debug(f"✓ Downloaded {len(media_paths)} section file(s)") + else: + # Fallback to most recent file if pattern not found + media_path = files[0] + media_paths = None + else: + # No sections, just take the most recent file + media_path = files[0] + media_paths = None + + debug(f"✓ Downloaded: {media_path.name}") + if debug_logger is not None: + debug_logger.write_record("ytdlp-file-found", {"path": str(media_path)}) + except Exception as exc: + log(f"Error finding downloaded file: {exc}", file=sys.stderr) + if debug_logger is not None: + debug_logger.write_record( + "exception", + {"phase": "find-file", "error": str(exc)}, + ) + raise DownloadError(str(exc)) from exc + + # Create result with minimal data extracted from filename + file_hash = sha256_file(media_path) + return DownloadMediaResult( + path=media_path, + info={"id": media_path.stem, "title": media_path.stem, "ext": media_path.suffix.lstrip(".")}, + tags=[], + source_url=opts.url, + hash_value=file_hash, + paths=media_paths, # Include all section files if present + ) + if not isinstance(info, dict): log(f"Unexpected yt-dlp response: {type(info)}", file=sys.stderr) raise DownloadError("Unexpected yt-dlp response type") diff --git a/helper/mpv_ipc.py b/helper/mpv_ipc.py index f922d9c..42d50e0 100644 --- a/helper/mpv_ipc.py +++ b/helper/mpv_ipc.py @@ -121,6 +121,10 @@ class MPVIPCClient: payload = json.dumps(request) + "\n" + # Debug: log the command being sent + from helper.logger import debug as _debug + _debug(f"[IPC] Sending: {payload.strip()}") + # Send command if self.is_windows: self.sock.write(payload.encode('utf-8')) @@ -160,6 +164,10 @@ class MPVIPCClient: if not line: continue resp = json.loads(line) + # Debug: log responses + from helper.logger import debug as _debug + _debug(f"[IPC] Received: {line}") + # Check if this is the response to our request if resp.get("request_id") == request.get("request_id"): return resp diff --git a/hydrus_health_check.py b/hydrus_health_check.py index 727068d..7fcdd54 100644 --- a/hydrus_health_check.py +++ b/hydrus_health_check.py @@ -33,6 +33,9 @@ _MATRIX_AVAILABLE: Optional[bool] = None _MATRIX_UNAVAILABLE_REASON: Optional[str] = None _MATRIX_CHECK_COMPLETE = False +# Global state for Cookies availability +_COOKIES_FILE_PATH: Optional[str] = None + def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """Check if Hydrus API is available by pinging it. @@ -575,3 +578,24 @@ def initialize_local_library_scan(config: Dict[str, Any]) -> None: except Exception as e: logger.error(f"[Startup] Failed to scan local library: {e}", exc_info=True) debug(f"âš ī¸ Local Library: ERROR - Scan failed: {e}", file=sys.stderr) + + +def initialize_cookies_check() -> None: + """Check for cookies.txt in the application root directory.""" + global _COOKIES_FILE_PATH + + # Assume CLI.py is in the root + root_dir = Path(__file__).parent + cookies_path = root_dir / "cookies.txt" + + if cookies_path.exists(): + _COOKIES_FILE_PATH = str(cookies_path) + debug(f"✅ Cookies: ENABLED - Found cookies.txt", file=sys.stderr) + else: + _COOKIES_FILE_PATH = None + # debug("â„šī¸ Cookies: Using browser cookies (fallback)", file=sys.stderr) + + +def get_cookies_file_path() -> Optional[str]: + """Get the path to the cookies.txt file if it exists.""" + return _COOKIES_FILE_PATH diff --git a/models.py b/models.py index 48102c5..df0433b 100644 --- a/models.py +++ b/models.py @@ -251,6 +251,7 @@ class DownloadMediaResult: tags: List[str] source_url: Optional[str] hash_value: Optional[str] = None + paths: Optional[List[Path]] = None # For multiple files (e.g., section downloads) @dataclass(slots=True)