hh
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user