diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index d8f7aae..7123815 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -71,7 +71,7 @@ "(wayupload\\.com/[a-z0-9]{12}\\.html)" ], "regexp": "(turbobit5?a?\\.(net|cc|com)/([a-z0-9]{12}))|(turbobif\\.(net|cc|com)/([a-z0-9]{12}))|(turb[o]?\\.(to|cc|pw)\\/([a-z0-9]{12}))|(turbobit\\.(net|cc)/download/free/([a-z0-9]{12}))|((trbbt|tourbobit|torbobit|tbit|turbobita|trbt)\\.(net|cc|com|to)/([a-z0-9]{12}))|((turbobit\\.cloud/turbo/[a-z0-9]+))|((wayupload\\.com/[a-z0-9]{12}\\.html))", - "status": false + "status": true }, "hitfile": { "name": "hitfile", @@ -375,7 +375,7 @@ "(filespace\\.com/[a-zA-Z0-9]{12})" ], "regexp": "(filespace\\.com/fd/([a-zA-Z0-9]{12}))|((filespace\\.com/[a-zA-Z0-9]{12}))", - "status": true + "status": false }, "filezip": { "name": "filezip", @@ -595,7 +595,7 @@ "(simfileshare\\.net/download/[0-9]+/)" ], "regexp": "(simfileshare\\.net/download/[0-9]+/)", - "status": true + "status": false }, "streamtape": { "name": "streamtape", diff --git a/CLI.py b/CLI.py index 9f5395a..5102cf5 100644 --- a/CLI.py +++ b/CLI.py @@ -1008,6 +1008,29 @@ class CmdletExecutor: pass cmd_fn = REGISTRY.get(cmd_name) + try: + mod = import_cmd_module(cmd_name, reload_loaded=True) + data = getattr(mod, "CMDLET", None) if mod else None + if data and hasattr(data, "exec") and callable(getattr(data, "exec")): + run_fn = getattr(data, "exec") + registered_names = set() + raw_name = getattr(data, "name", None) + if raw_name: + registered_names.add(str(raw_name).replace("_", "-").lower()) + registered_names.add(str(cmd_name).replace("_", "-").lower()) + for alias_attr in ("alias", "aliases"): + alias_values = getattr(data, alias_attr, None) + if alias_values: + for alias in alias_values: + alias_text = str(alias or "").replace("_", "-").lower().strip() + if alias_text: + registered_names.add(alias_text) + for registered_name in registered_names: + REGISTRY[registered_name] = run_fn + cmd_fn = run_fn + except Exception: + pass + if not cmd_fn: # Lazy-import module and register its CMDLET. try: diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index 4654fa6..4fc0b32 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -1,6 +1,7 @@ from __future__ import annotations -from importlib import import_module +import sys +from importlib import import_module, reload as reload_module from types import ModuleType from typing import Any, Dict, List, Optional import logging @@ -68,7 +69,7 @@ def _normalize_mod_name(mod_name: str) -> str: return normalized -def import_cmd_module(mod_name: str): +def import_cmd_module(mod_name: str, *, reload_loaded: bool = False): """Import a cmdlet/native module from cmdnat or cmdlet packages.""" normalized = _normalize_mod_name(mod_name) if not normalized: @@ -81,12 +82,16 @@ def import_cmd_module(mod_name: str): # OSError if system libmpv is missing. if package is None and normalized == "mpv": try: + if reload_loaded and "MPV" in sys.modules: + return reload_module(sys.modules["MPV"]) return import_module("MPV") except ModuleNotFoundError: # Local MPV package not present; fall back to the normal bare import. pass qualified = f"{package}.{normalized}" if package else normalized + if reload_loaded and qualified in sys.modules: + return reload_module(sys.modules[qualified]) return import_module(qualified) except ModuleNotFoundError: # Module not available in this package prefix; try the next. diff --git a/SYS/pipeline.py b/SYS/pipeline.py index 5d90518..67fb031 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -2944,6 +2944,29 @@ class PipelineExecutor: continue cmd_fn = REGISTRY.get(cmd_name) + try: + mod = import_cmd_module(cmd_name, reload_loaded=True) + data = getattr(mod, "CMDLET", None) if mod else None + if data and hasattr(data, "exec") and callable(getattr(data, "exec")): + run_fn = getattr(data, "exec") + registered_names = set() + raw_name = getattr(data, "name", None) + if raw_name: + registered_names.add(str(raw_name).replace("_", "-").lower()) + registered_names.add(str(cmd_name).replace("_", "-").lower()) + for alias_attr in ("alias", "aliases"): + alias_values = getattr(data, alias_attr, None) + if alias_values: + for alias in alias_values: + alias_text = str(alias or "").replace("_", "-").lower().strip() + if alias_text: + registered_names.add(alias_text) + for registered_name in registered_names: + REGISTRY[registered_name] = run_fn + cmd_fn = run_fn + except Exception: + pass + if not cmd_fn: try: mod = import_cmd_module(cmd_name) diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 47e3478..a9dda96 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -1531,6 +1531,25 @@ class Download_File(Cmdlet): ): actual_format = "bestaudio" + if clip_sections_spec and mode != "audio": + clip_format_basis = actual_format + if not clip_format_basis or str(clip_format_basis).strip().lower() in { + "bestvideo+bestaudio/best", + "bestvideo+bestaudio", + "best", + "best/b", + "best/best", + "b", + }: + preferred_clip_format = str(getattr(ytdlp_tool.defaults, "format", "") or "").strip() + if preferred_clip_format and preferred_clip_format.lower() != "audio": + clip_format_basis = preferred_clip_format + else: + clip_format_basis = ytdlp_tool.default_format("video") + clip_safe_format = ytdlp_tool.resolve_clip_safe_format(clip_format_basis) + if clip_safe_format: + actual_format = clip_safe_format + # DEBUG: Render config panel for tracking pipeline state if is_debug_enabled(): try: @@ -1563,6 +1582,7 @@ class Download_File(Cmdlet): actual_format and isinstance(actual_format, str) and mode != "audio" + and not clip_sections_spec and "+" not in actual_format and "/" not in actual_format and "[" not in actual_format @@ -1727,7 +1747,12 @@ class Download_File(Cmdlet): try: vcodec = str(only.get("vcodec", "none")) acodec = str(only.get("acodec", "none")) - if vcodec != "none" and acodec == "none" and fallback_format: + if ( + not clip_sections_spec + and vcodec != "none" + and acodec == "none" + and fallback_format + ): selection_format_id = f"{fallback_format}+bestaudio" except Exception: selection_format_id = fallback_format diff --git a/tool/ytdlp.py b/tool/ytdlp.py index 5fff090..f2d4575 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -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]