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

View File

@@ -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'",
"",

View File

@@ -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"],