This commit is contained in:
nose
2025-12-01 14:42:30 -08:00
parent 6b9ed7d4ab
commit 89aa24961b
8 changed files with 565 additions and 51 deletions

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ test_*
# before PyInstaller builds the exe, so as to inject date/other infos into it. # before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest *.manifest
*.spec *.spec
cookies.txt
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt

8
CLI.py
View File

@@ -600,6 +600,10 @@ def _create_cmdlet_cli():
# Load config # Load config
config = _load_cli_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 # Initialize debug logging if enabled
if config: if config:
from helper.logger import set_debug from helper.logger import set_debug
@@ -691,11 +695,13 @@ def _create_cmdlet_cli():
check_mpv_availability, check_mpv_availability,
initialize_matrix_health_check, initialize_matrix_health_check,
initialize_hydrus_health_check, initialize_hydrus_health_check,
initialize_local_library_scan initialize_local_library_scan,
initialize_cookies_check
) )
check_mpv_availability() check_mpv_availability()
initialize_hydrus_health_check(config) initialize_hydrus_health_check(config)
initialize_matrix_health_check(config) initialize_matrix_health_check(config)
initialize_cookies_check()
initialize_local_library_scan(config) initialize_local_library_scan(config)
# --- Startup File Counts --- # --- Startup File Counts ---

View File

@@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
import uuid import uuid
from helper.logger import log, debug 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 helper.utils import sha256_file
from models import DownloadOptions from models import DownloadOptions
@@ -710,6 +710,97 @@ def _parse_time_range(clip_spec: str) -> Optional[Tuple[int, int]]:
return None 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'} MEDIA_EXTENSIONS = {'.mp3', '.m4a', '.mp4', '.mkv', '.webm', '.flac', '.wav', '.aac'}
@@ -1024,6 +1115,23 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
log(f"Invalid clip format: {clip_spec}", file=sys.stderr) log(f"Invalid clip format: {clip_spec}", file=sys.stderr)
return 1 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") cookies_path = parsed.get("cookies")
storage_location = parsed.get("storage") 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 if (not current_format_selector and not list_formats_mode and
isinstance(url, str) and url.startswith(('http://', 'https://'))): isinstance(url, str) and url.startswith(('http://', 'https://'))):
# Check if this is a yt-dlp supported URL (YouTube, Vimeo, etc.) # 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 from result_table import ResultTable
if is_url_supported_by_ytdlp(url): 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 # 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) 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( download_opts = DownloadOptions(
url=url, url=url,
mode=mode, mode=mode,
output_dir=final_output_dir, output_dir=final_output_dir,
cookies_path=final_cookies_path, cookies_path=final_cookies_path,
ytdl_format=current_format_selector, # Use per-URL format override if available 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, playlist_items=playlist_items,
no_playlist=is_youtube_url, # For YouTube, ignore playlist URLs and download single video 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 file_path = result_data.path
if file_path.exists(): 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) # 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: if not selected_playlist_entries:
debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.") debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.")
exit_code = 1 exit_code = 1
@@ -2788,6 +2946,11 @@ CMDLET = Cmdlet(
type="string", type="string",
description="Extract time range: MM:SS-MM:SS (e.g., 34:03-35:08) or seconds" 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( CmdletArg(
name="cookies", name="cookies",
type="string", type="string",
@@ -2841,6 +3004,12 @@ CMDLET = Cmdlet(
" Format: MM:SS-MM:SS (e.g., 34:03-35:08)", " Format: MM:SS-MM:SS (e.g., 34:03-35:08)",
" Also accepts: 2043-2108 (seconds)", " 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:", "PLAYLIST MODE:",
" Automatically detects playlists", " Automatically detects playlists",
" Shows numbered list of tracks", " Shows numbered list of tracks",
@@ -2866,6 +3035,9 @@ CMDLET = Cmdlet(
" # Extract specific clip from video", " # Extract specific clip from video",
" download-data https://vimeo.com/123456 -clip 1:30-2:45 -format best", " 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 specific tracks from playlist",
" download-data https://youtube.com/playlist?list=xyz -item '1,3,5-8'", " download-data https://youtube.com/playlist?list=xyz -item '1,3,5-8'",
"", "",

View File

@@ -10,9 +10,11 @@ from helper.logger import log, debug
from result_table import ResultTable from result_table import ResultTable
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
import pipeline as ctx import pipeline as ctx
from helper.download import is_url_supported_by_ytdlp
from helper.local_library import LocalLibrarySearchOptimizer from helper.local_library import LocalLibrarySearchOptimizer
from config import get_local_storage_path 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]: 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.""" """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" 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: def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
"""Queue items to MPV, starting it if necessary. """Queue items to MPV, starting it if necessary.
@@ -80,6 +138,9 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
Returns: Returns:
True if MPV was started, False if items were queued via IPC. 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): for i, item in enumerate(items):
# Extract URL/Path # Extract URL/Path
target = None target = None
@@ -95,11 +156,14 @@ def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
target = item target = item
if target: if target:
# Add to MPV playlist # Check if it's a yt-dlp supported URL
# We use loadfile with append flag (or replace if clear_first is set) 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 # 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) # Sanitize title for M3U (remove newlines)
safe_title = title.replace('\n', ' ').replace('\r', '') safe_title = title.replace('\n', ' ').replace('\r', '')
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" 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 mpv_started = False
if url_arg: if url_arg:
mpv_started = _queue_items([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 # unless other flags are present
if not (clear_mode or play_mode or pause_mode or save_mode or load_mode): 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 # Handle Save Playlist
if save_mode: if save_mode:
@@ -290,8 +370,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
print(table) print(table)
return 0 return 0
# Handle Play/Pause commands # Handle Play/Pause commands (but skip if we have index_arg to play a specific item)
if play_mode: if play_mode and index_arg is None:
cmd = {"command": ["set_property", "pause", False], "request_id": 103} cmd = {"command": ["set_property", "pause", False], "request_id": 103}
resp = _send_ipc_command(cmd) resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success": 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 items is None:
if mpv_started: if mpv_started:
# MPV was just started, so we can't list items yet. # MPV was just started, retry getting playlist after a brief delay
# But we know it's running (or trying to start), so don't start another instance. import time
return 0 time.sleep(0.3)
items = _get_playlist(silent=True)
debug("MPV is not running. Starting new instance...") if items is None:
_start_mpv([]) # Still can't connect, but MPV is starting
return 0 debug("MPV is starting up...")
return 0
else:
debug("MPV is not running. Starting new instance...")
_start_mpv([])
return 0
if not items: if not items:
debug("MPV playlist is empty.") 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) _send_ipc_command(unpause_cmd)
debug(f"Playing: {title}") debug(f"Playing: {title}")
# Monitor logs briefly for errors (e.g. ytdl failures)
_monitor_mpv_logs(3.0)
return 0 return 0
else: else:
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}") debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
return 1 return 1
except ValueError: except ValueError:
debug(f"Invalid index: {index_arg}") debug(f"Invalid index: {index_arg}")
return 1 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: def _start_mpv(items: List[Any]) -> None:
"""Start MPV with a list of items.""" """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() 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 = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]') cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
# Add items # Use cookies.txt if available, otherwise fallback to browser cookies
for item in items: cookies_path = get_cookies_file_path()
target = None if cookies_path:
title = None # yt-dlp on Windows needs forward slashes OR properly escaped backslashes
# Using forward slashes is more reliable across systems
if isinstance(item, dict): cookies_path_normalized = cookies_path.replace('\\', '/')
target = item.get("target") or item.get("url") or item.get("path") or item.get("filename") debug(f"Starting MPV with cookies file: {cookies_path_normalized}")
title = item.get("title") or item.get("name") # yt-dlp expects the cookies option with file path
elif hasattr(item, "target"): cmd.append(f'--ytdl-raw-options=cookies={cookies_path_normalized}')
target = item.target else:
title = getattr(item, "title", None) # Use cookies from browser (Chrome) to handle age-restricted content
elif isinstance(item, str): debug("Starting MPV with browser cookies: chrome")
target = item cmd.append('--ytdl-raw-options=cookies-from-browser=chrome')
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)
try: try:
kwargs = {} kwargs = {}
if platform.system() == 'Windows': if platform.system() == 'Windows':
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs) # Log the complete MPV command being executed
debug(f"Started MPV with {len(items)} items") 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: except Exception as e:
debug(f"Error starting MPV: {e}", file=sys.stderr) debug(f"Error starting MPV: {e}", file=sys.stderr)
CMDLET = Cmdlet( CMDLET = Cmdlet(
name=".pipe", name=".pipe",
aliases=["pipe", "playlist", "queue", "ls-pipe"], aliases=["pipe", "playlist", "queue", "ls-pipe"],

View File

@@ -9,6 +9,7 @@ Lean, focused downloader without event infrastructure overhead.
from __future__ import annotations from __future__ import annotations
import re # noqa: F401 import re # noqa: F401
import subprocess
import sys import sys
import time import time
import traceback import traceback
@@ -22,6 +23,7 @@ from helper.logger import log, debug
from .utils import ensure_directory, sha256_file from .utils import ensure_directory, sha256_file
from .http_client import HTTPClient from .http_client import HTTPClient
from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar from models import DownloadError, DownloadOptions, DownloadMediaResult, DebugLogger, ProgressBar
from hydrus_health_check import get_cookies_file_path
try: try:
import yt_dlp # type: ignore 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: except Exception as e:
log(f"✗ Error fetching formats: {e}", file=sys.stderr) log(f"✗ Error fetching formats: {e}", file=sys.stderr)
return None 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]: def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
"""Build yt-dlp download options.""" """Build yt-dlp download options."""
ensure_directory(opts.output_dir) 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()) outtmpl = str((opts.output_dir / "%(title)s.%(ext)s").resolve())
base_options: Dict[str, Any] = { 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(): if opts.cookies_path and opts.cookies_path.is_file():
base_options["cookiefile"] = str(opts.cookies_path) 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) # Add no-playlist option if specified (for single video from playlist URLs)
if opts.no_playlist: 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" "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: 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 # Add playlist items selection if provided
if opts.playlist_items: 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 "skip_download": True, # Don't actually download
"extract_flat": "in_playlist", # Get playlist with metadata for each entry "extract_flat": "in_playlist", # Get playlist with metadata for each entry
"noprogress": True, # No progress bars "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 # Add no_playlist option if specified
if no_playlist: if no_playlist:
ydl_opts["noplaylist"] = True ydl_opts["noplaylist"] = True
@@ -635,8 +745,18 @@ def download_media(
assert yt_dlp is not None assert yt_dlp is not None
try: try:
with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type] # Debug: show what options we're using
info = ydl.extract_info(opts.url, download=True) 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: except Exception as exc:
log(f"yt-dlp failed: {exc}", file=sys.stderr) log(f"yt-dlp failed: {exc}", file=sys.stderr)
if debug_logger is not None: if debug_logger is not None:
@@ -650,6 +770,75 @@ def download_media(
) )
raise DownloadError("yt-dlp download failed") from exc 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): if not isinstance(info, dict):
log(f"Unexpected yt-dlp response: {type(info)}", file=sys.stderr) log(f"Unexpected yt-dlp response: {type(info)}", file=sys.stderr)
raise DownloadError("Unexpected yt-dlp response type") raise DownloadError("Unexpected yt-dlp response type")

View File

@@ -121,6 +121,10 @@ class MPVIPCClient:
payload = json.dumps(request) + "\n" 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 # Send command
if self.is_windows: if self.is_windows:
self.sock.write(payload.encode('utf-8')) self.sock.write(payload.encode('utf-8'))
@@ -160,6 +164,10 @@ class MPVIPCClient:
if not line: continue if not line: continue
resp = json.loads(line) 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 # Check if this is the response to our request
if resp.get("request_id") == request.get("request_id"): if resp.get("request_id") == request.get("request_id"):
return resp return resp

View File

@@ -33,6 +33,9 @@ _MATRIX_AVAILABLE: Optional[bool] = None
_MATRIX_UNAVAILABLE_REASON: Optional[str] = None _MATRIX_UNAVAILABLE_REASON: Optional[str] = None
_MATRIX_CHECK_COMPLETE = False _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]]: def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Hydrus API is available by pinging it. """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: except Exception as e:
logger.error(f"[Startup] Failed to scan local library: {e}", exc_info=True) logger.error(f"[Startup] Failed to scan local library: {e}", exc_info=True)
debug(f"⚠️ Local Library: ERROR - Scan failed: {e}", file=sys.stderr) 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

View File

@@ -251,6 +251,7 @@ class DownloadMediaResult:
tags: List[str] tags: List[str]
source_url: Optional[str] source_url: Optional[str]
hash_value: Optional[str] = None hash_value: Optional[str] = None
paths: Optional[List[Path]] = None # For multiple files (e.g., section downloads)
@dataclass(slots=True) @dataclass(slots=True)