This commit is contained in:
2026-03-27 15:45:05 -07:00
parent 37bb4ca685
commit 6ef5b645a8
6 changed files with 325 additions and 15 deletions

View File

@@ -345,6 +345,14 @@ def _get_extractors() -> List[Any]:
return _EXTRACTOR_CACHE
def _yt_dlp_cli_prefix() -> List[str]:
"""Return a subprocess argv prefix that uses the same yt-dlp implementation as this process."""
python_exe = str(sys.executable or "").strip()
if python_exe:
return [python_exe, "-m", "yt_dlp"]
return ["yt-dlp"]
def is_url_supported_by_ytdlp(url: str) -> bool:
"""Return True if yt-dlp has a non-generic extractor for the URL."""
@@ -802,6 +810,45 @@ class YtDlpTool:
return None
def resolve_clip_safe_format(self, format_str: Optional[str]) -> Optional[str]:
"""Normalize clip downloads to a single-stream selector yt-dlp can section-download reliably."""
if format_str is None or not isinstance(format_str, str):
return format_str
raw = format_str.strip()
if not raw:
return raw
low = raw.lower()
if low in {"bestvideo+bestaudio/best", "bestvideo+bestaudio", "bv+ba/b", "bv+ba"}:
return "best/b"
if low in {"best", "best/b", "best/best", "b"}:
return "best/b"
# Height preferences like 720/1080p should prefer a progressive single-file stream for clips.
height_match = re.fullmatch(r"(?i)\s*(\d{3,4})p?\s*", raw)
if height_match:
try:
height = int(height_match.group(1))
except Exception:
height = 0
if height > 0:
return f"best[height<={height}]/best"
merged_height_match = re.fullmatch(
r"bestvideo(?:\[height<=?(\d+)\])?\+bestaudio(?:/best(?:\[height<=?(\d+)\])?)?",
low,
)
if merged_height_match:
height = merged_height_match.group(1) or merged_height_match.group(2)
if height:
return f"best[height<={height}]/best"
return "best/b"
return raw
def _load_defaults(self) -> YtDlpDefaults:
cfg = self._config
@@ -1047,6 +1094,24 @@ class YtDlpTool:
default_fmt = self.default_format(opts.mode)
fmt = ytdl_format or default_fmt
if opts.clip_sections and opts.mode != "audio":
clip_basis = fmt
if isinstance(ytdl_format, str):
incoming = ytdl_format.strip().lower()
if incoming in {
"bestvideo+bestaudio/best",
"bestvideo+bestaudio",
"best",
"best/b",
"best/best",
"b",
} and default_fmt:
clip_basis = default_fmt
clip_safe_fmt = self.resolve_clip_safe_format(clip_basis)
if clip_safe_fmt:
if clip_safe_fmt != fmt:
debug(f"[ytdlp] clip format normalized: {fmt} -> {clip_safe_fmt}")
fmt = clip_safe_fmt
debug(
"[ytdlp] build options: "
f"mode={opts.mode}, ytdl_format={ytdl_format}, default_format={default_fmt}, final_format={fmt}, "
@@ -1154,7 +1219,7 @@ class YtDlpTool:
This is primarily for debug output or subprocess execution.
"""
argv: List[str] = ["yt-dlp"]
argv: List[str] = _yt_dlp_cli_prefix()
if quiet:
argv.extend(["--quiet", "--no-warnings"])
argv.append("--no-progress")
@@ -1606,7 +1671,7 @@ def _download_with_sections_via_cli(
section_outtmpl = str(output_dir_path / filename_tmpl)
if section_idx == 1:
metadata_cmd = ["yt-dlp", "--dump-json", "--skip-download"]
metadata_cmd = _yt_dlp_cli_prefix() + ["--dump-json", "--skip-download"]
if ytdl_options.get("cookiefile"):
cookies_path = ytdl_options["cookiefile"].replace("\\", "/")
metadata_cmd.extend(["--cookies", cookies_path])
@@ -1628,7 +1693,7 @@ def _download_with_sections_via_cli(
if not quiet:
debug(f"Error extracting metadata: {exc}")
cmd = ["yt-dlp"]
cmd = _yt_dlp_cli_prefix()
if quiet:
cmd.append("--no-warnings")
# Emit line-oriented progress and capture it for real clip activity tracking.
@@ -1698,6 +1763,163 @@ def _download_with_sections_via_cli(
return session_id, first_section_info or {}
def _is_remote_section_ffmpeg_failure(exc: Exception) -> bool:
parts: List[str] = []
for candidate in (exc, getattr(exc, "__cause__", None), getattr(exc, "__context__", None)):
if candidate is None:
continue
try:
parts.append(str(candidate))
except Exception:
continue
detail = "\n".join(parts)
return (
"Error opening input file" in detail
or "Error opening input files" in detail
or "Connection to tcp://" in detail
or "Error number -138 occurred" in detail
)
def _resolve_ffmpeg_binary(ytdl_options: Dict[str, Any]) -> Optional[str]:
location = str(ytdl_options.get("ffmpeg_location") or "").strip()
if location:
ffmpeg_path = Path(location)
candidates = [ffmpeg_path]
if ffmpeg_path.is_dir():
candidates = [ffmpeg_path / "ffmpeg.exe", ffmpeg_path / "ffmpeg"]
for candidate in candidates:
try:
if candidate.exists() and candidate.is_file():
return str(candidate)
except Exception:
continue
for name in ("ffmpeg", "ffmpeg.exe"):
resolved = shutil.which(name)
if resolved:
return resolved
return None
def _parse_section_bounds(section_text: str) -> Optional[tuple[float, float]]:
match = re.search(r"\*?(\d{2}:\d{2}:\d{2})-(\d{2}:\d{2}:\d{2})", str(section_text or "").strip())
if not match:
return None
def _to_seconds(value: str) -> float:
hours, minutes, seconds = value.split(":", 2)
return (int(hours) * 3600) + (int(minutes) * 60) + float(seconds)
start = _to_seconds(match.group(1))
end = _to_seconds(match.group(2))
if end <= start:
return None
return start, end
def _trim_sections_from_local_file(
*,
source_path: Path,
output_dir: Path,
sections: List[str],
session_id: str,
ytdl_options: Dict[str, Any],
quiet: bool,
) -> List[Path]:
ffmpeg_bin = _resolve_ffmpeg_binary(ytdl_options)
if not ffmpeg_bin:
raise DownloadError("ffmpeg not found for local clip fallback")
generated: List[Path] = []
for index, section_text in enumerate(sections, 1):
bounds = _parse_section_bounds(section_text)
if bounds is None:
raise DownloadError(f"Invalid clip section: {section_text}")
start_seconds, end_seconds = bounds
duration_seconds = end_seconds - start_seconds
output_path = output_dir / f"{session_id}_{index}{source_path.suffix or '.mp4'}"
cmd = [
ffmpeg_bin,
"-y",
"-ss",
f"{start_seconds:.3f}",
"-i",
str(source_path),
"-t",
f"{duration_seconds:.3f}",
"-map_metadata",
"0",
]
if source_path.suffix.lower() in {".mp3", ".m4a", ".aac", ".opus", ".ogg", ".wav", ".flac"}:
cmd.extend(["-c", "copy"])
else:
cmd.extend([
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"18",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
])
cmd.append(str(output_path))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
stderr_text = str(result.stderr or "").strip()
raise DownloadError(
f"ffmpeg local clip fallback failed for section {section_text}: {stderr_text}"
)
if not quiet:
debug(f"Local clip fallback wrote: {output_path.name}")
generated.append(output_path)
return generated
def _download_with_sections_via_local_trim(
url: str,
ytdl_options: Dict[str, Any],
sections: List[str],
quiet: bool = False,
) -> tuple[Optional[str], Dict[str, Any]]:
download_options = dict(ytdl_options)
download_options.pop("download_sections", None)
download_options.pop("force_keyframes_at_cuts", None)
assert yt_dlp is not None
with yt_dlp.YoutubeDL(download_options) as ydl: # type: ignore[arg-type]
info = cast(Dict[str, Any], ydl.extract_info(url, download=True))
output_dir = Path(str(download_options.get("outtmpl") or "%(_title)s.%(ext)s")).parent
entry, media_path = _resolve_entry_and_path(info, output_dir)
session_id = hashlib.md5((url + str(time.time()) + "localtrim").encode()).hexdigest()[:12]
generated = _trim_sections_from_local_file(
source_path=media_path,
output_dir=output_dir,
sections=sections,
session_id=session_id,
ytdl_options=ytdl_options,
quiet=quiet,
)
try:
media_path.unlink()
except Exception:
pass
first_section_info = entry if isinstance(entry, dict) else info
return session_id, first_section_info or {}
def _iter_download_entries(info: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
queue: List[Dict[str, Any]] = [info]
seen: set[int] = set()
@@ -1950,12 +2172,24 @@ def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] =
if ytdl_options.get("download_sections"):
live_ui, _ = PipelineProgress(pipeline_context).ui_and_pipe_index()
quiet_sections = bool(opts.quiet) or (live_ui is not None)
session_id, first_section_info = _download_with_sections_via_cli(
opts.url,
ytdl_options,
ytdl_options.get("download_sections", []),
quiet=quiet_sections,
)
section_list = ytdl_options.get("download_sections", [])
try:
session_id, first_section_info = _download_with_sections_via_cli(
opts.url,
ytdl_options,
section_list,
quiet=quiet_sections,
)
except Exception as clip_exc:
if not _is_remote_section_ffmpeg_failure(clip_exc):
raise
debug("[yt-dlp] remote section clipping failed; retrying via local trim fallback")
session_id, first_section_info = _download_with_sections_via_local_trim(
opts.url,
ytdl_options,
section_list,
quiet=quiet_sections,
)
info = None
else:
with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type]