diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index f88c33c..cc243ed 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -426,7 +426,7 @@ "google\\.com/uc\\?id=([0-9A-Za-z_-]+)" ], "regexp": "(((drive|docs)\\.google\\.com/open\\?id=([0-9A-Za-z_-]+)))|((drive|docs)\\.google\\.com/file/d/([0-9A-Za-z_-]+))|(google\\.com/uc\\?id=([0-9A-Za-z_-]+))", - "status": true + "status": false }, "hexupload": { "name": "hexupload", @@ -645,7 +645,7 @@ "(upload42\\.com/[0-9a-zA-Z]{12})" ], "regexp": "(upload42\\.com/[0-9a-zA-Z]{12})", - "status": false + "status": true }, "uploadbank": { "name": "uploadbank", diff --git a/Provider/ytdlp.py b/Provider/ytdlp.py new file mode 100644 index 0000000..1d3cabf --- /dev/null +++ b/Provider/ytdlp.py @@ -0,0 +1,390 @@ +"""ytdlp format selector provider. + +When a URL is passed through download-file, this provider displays available formats +in a table and routes format selection back to download-file with the chosen format +already specified via -format, skipping the format table on the second invocation. + +This keeps format selection logic in ytdlp and leaves add-file plug-and-play. +""" + +from __future__ import annotations + +import sys +from typing import Any, Dict, Iterable, List, Optional + +from ProviderCore.base import Provider, SearchResult +from SYS.provider_helpers import TableProviderMixin +from SYS.logger import log, debug +from tool.ytdlp import list_formats, is_url_supported_by_ytdlp + + +class ytdlp(TableProviderMixin, Provider): + """ytdlp format selector and video search provider. + + DUAL FUNCTIONALITY: + 1. FORMAT SELECTION: When download-file is used with a yt-dlp supported URL, + displays available formats in a table for user selection. + 2. SEARCH: When search-file is used with -provider ytdlp, searches YouTube + (and other yt-dlp supported sites) for videos. + + FORMAT SELECTION USAGE: + - User runs: download-file "https://example.com/video" + - If URL is ytdlp-supported and no format specified, displays format table + - User selects @N (e.g., @3 for format index 3) + - Selection args include -format , re-invoking download-file + - Second download-file call sees -format and skips table, downloads directly + + SEARCH USAGE: + - User runs: search-file -provider ytdlp "linux tutorial" + - Shows YouTube search results as a table + - User selects @1 to download that video + - Selection args route to download-file for streaming download + + SELECTION FLOW (Format): + 1. download-file receives URL without -format + 2. Calls ytdlp to list formats + 3. Returns formats as ResultTable (from this provider) + 4. User selects @N + 5. Selection args: ["-format", ""] route back to download-file + 6. Second download-file invocation with -format skips table + + SELECTION FLOW (Search): + 1. search-file lists YouTube videos via yt_dlp + 2. Returns videos as ResultTable (from this provider) + 3. User selects @N + 4. Selection args: ["-url", ""] route to download-file + 5. download-file downloads the selected video + + TABLE AUTO-STAGES: + - Format selection: ytdlp.formatlist -> download-file (with -format) + - Video search: ytdlp.search -> download-file (with -url) + + SUPPORTED URLS: + This provider dynamically discovers all yt-dlp supported sites via yt_dlp.gen_extractors(). + """ + + # Dynamically load URL domains from yt-dlp's extractors + # This enables provider auto-discovery for format selection routing + @property + def URL(self) -> List[str]: + """Get list of supported domains from yt-dlp extractors.""" + try: + import yt_dlp + # Build a comprehensive list from known extractors and fallback domains + domains = set(self._fallback_domains) + + # Try to get extractors and extract domain info + try: + extractors = yt_dlp.gen_extractors() + for extractor_class in extractors: + # Get extractor name and try to convert to domain + name = getattr(extractor_class, 'IE_NAME', '') + if name and name not in ('generic', 'http'): + # Convert extractor name to domain (e.g., 'YouTube' -> 'youtube.com') + name_lower = name.lower().replace('ie', '').strip() + if name_lower and len(name_lower) > 2: + domains.add(f"{name_lower}.com") + except Exception: + pass + + return list(domains) if domains else self._fallback_domains + except Exception: + return self._fallback_domains + + # Fallback common domains in case extraction fails + _fallback_domains = [ + "youtube.com", "youtu.be", + "bandcamp.com", + "vimeo.com", + "twitch.tv", + "dailymotion.com", + "rumble.com", + "odysee.com", + ] + + TABLE_AUTO_STAGES = { + "ytdlp.formatlist": ["download-file"], + "ytdlp.search": ["download-file"], + } + # Forward selection args (including -format or -url) to the next stage + AUTO_STAGE_USE_SELECTION_ARGS = True + + def search( + self, + query: str, + limit: int = 50, + filters: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> List[SearchResult]: + """ + NOT USED: This provider is invoked via ResultTable integration, not search. + Formats are fetched directly in download-file and returned as ResultTable rows + with this provider registered as the handler. + """ + return [] + + def search( + self, + query: str, + limit: int = 10, + filters: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> List[SearchResult]: + """Search YouTube and other yt-dlp supported sites for videos. + + Uses yt-dlp's ytsearch capability to find videos, then returns them + as SearchResult rows for table display and selection. + """ + try: + import yt_dlp # type: ignore + + ydl_opts: Dict[str, Any] = { + "quiet": True, + "skip_download": True, + "extract_flat": True + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type] + search_query = f"ytsearch{limit}:{query}" + info = ydl.extract_info(search_query, download=False) + entries = info.get("entries") or [] + results: List[SearchResult] = [] + for video_data in entries[:limit]: + title = video_data.get("title", "Unknown") + video_id = video_data.get("id", "") + url = video_data.get( + "url" + ) or f"https://youtube.com/watch?v={video_id}" + uploader = video_data.get("uploader", "Unknown") + duration = video_data.get("duration", 0) + view_count = video_data.get("view_count", 0) + + duration_str = ( + f"{int(duration // 60)}:{int(duration % 60):02d}" + if duration else "" + ) + views_str = f"{view_count:,}" if view_count else "" + + results.append( + SearchResult( + table="ytdlp.search", + title=title, + path=url, + detail=f"By: {uploader}", + annotations=[duration_str, f"{views_str} views"], + media_kind="video", + columns=[ + ("Title", title), + ("Uploader", uploader), + ("Duration", duration_str), + ("Views", views_str), + ], + full_metadata={ + "video_id": video_id, + "uploader": uploader, + "duration": duration, + "view_count": view_count, + # Selection metadata for table system and @N expansion + "_selection_args": ["-url", url], + }, + ) + ) + return results + except Exception: + debug("[ytdlp] yt_dlp import or search failed") + return [] + + def validate(self) -> bool: + """Validate yt-dlp availability.""" + try: + import yt_dlp # type: ignore + return True + except Exception: + return False + + +# Minimal provider registration for the new table system +try: + from SYS.result_table_adapters import register_provider + from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column + + def _convert_format_result_to_model(sr: Any) -> ResultModel: + """Convert format dict to ResultModel for strict table display.""" + d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {}) + title = d.get("title") or f"Format {d.get('format_id', 'unknown')}" + + # Extract metadata from columns and full_metadata + metadata: Dict[str, Any] = {} + columns = d.get("columns") or [] + for name, value in columns: + key = str(name or "").strip().lower() + if key in ("id", "resolution", "ext", "size", "video", "audio", "format_id"): + metadata[key] = value + + try: + fm = d.get("full_metadata") or {} + if isinstance(fm, dict): + for k, v in fm.items(): + metadata[str(k).strip().lower()] = v + except Exception: + pass + + return ResultModel( + title=str(title), + path=d.get("url") or d.get("target"), + ext=d.get("ext"), + size_bytes=None, + metadata=metadata, + source="ytdlp" + ) + + def _adapter(items: Iterable[Any]) -> Iterable[ResultModel]: + """Adapter to convert format results to ResultModels.""" + for it in items: + try: + yield _convert_format_result_to_model(it) + except Exception: + continue + + def _has_metadata(rows: List[ResultModel], key: str) -> bool: + """Check if any row has a given metadata key with a non-empty value.""" + for row in rows: + md = row.metadata or {} + if key in md: + val = md[key] + if val is None: + continue + if isinstance(val, str) and not val.strip(): + continue + return True + return False + + def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]: + """Build column specifications from available metadata in rows.""" + cols = [title_column()] + if _has_metadata(rows, "resolution"): + cols.append(metadata_column("resolution", "Resolution")) + if _has_metadata(rows, "ext"): + cols.append(metadata_column("ext", "Ext")) + if _has_metadata(rows, "size"): + cols.append(metadata_column("size", "Size")) + if _has_metadata(rows, "video"): + cols.append(metadata_column("video", "Video")) + if _has_metadata(rows, "audio"): + cols.append(metadata_column("audio", "Audio")) + return cols + + def _selection_fn(row: ResultModel) -> List[str]: + """Return selection args for format selection. + + When user selects @N, these args are passed to download-file which sees + the -format specifier and skips the format table, downloading directly. + """ + metadata = row.metadata or {} + + # Check for explicit selection args first + args = metadata.get("_selection_args") or metadata.get("selection_args") + if isinstance(args, (list, tuple)) and args: + result_args = [str(x) for x in args if x is not None] + debug(f"[ytdlp] Selection routed with args: {result_args}") + return result_args + + # Fallback: use format_id + format_id = metadata.get("format_id") or metadata.get("id") + if format_id: + result_args = ["-format", str(format_id)] + debug(f"[ytdlp] Selection routed with format_id: {format_id}") + return result_args + + debug(f"[ytdlp] Warning: No selection args or format_id found in row") + return [] + + register_provider( + "ytdlp.formatlist", + _adapter, + columns=_columns_factory, + selection_fn=_selection_fn, + metadata={"description": "ytdlp format selector for streaming media"}, + ) + debug("[ytdlp] Provider registered successfully with TABLE_AUTO_STAGES routing to download-file") + + # Also register the search table + def _convert_search_result_to_model(sr: Any) -> ResultModel: + """Convert YouTube SearchResult to ResultModel for strict table display.""" + d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {"title": getattr(sr, "title", str(sr))}) + title = d.get("title") or "" + path = d.get("path") or None + columns = d.get("columns") or getattr(sr, "columns", None) or [] + + # Extract metadata from columns and full_metadata + metadata: Dict[str, Any] = {} + for name, value in columns: + key = str(name or "").strip().lower() + if key in ("uploader", "duration", "views", "video_id"): + metadata[key] = value + + try: + fm = d.get("full_metadata") or {} + if isinstance(fm, dict): + for k, v in fm.items(): + metadata[str(k).strip().lower()] = v + except Exception: + pass + + return ResultModel( + title=str(title), + path=str(path) if path else None, + ext=None, + size_bytes=None, + metadata=metadata, + source="ytdlp" + ) + + def _search_adapter(items: Iterable[Any]) -> Iterable[ResultModel]: + """Adapter to convert search results to ResultModels.""" + for it in items: + try: + yield _convert_search_result_to_model(it) + except Exception: + continue + + def _search_columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]: + """Build column specifications for search results.""" + cols = [title_column()] + if _has_metadata(rows, "uploader"): + cols.append(metadata_column("uploader", "Uploader")) + if _has_metadata(rows, "duration"): + cols.append(metadata_column("duration", "Duration")) + if _has_metadata(rows, "views"): + cols.append(metadata_column("views", "Views")) + return cols + + def _search_selection_fn(row: ResultModel) -> List[str]: + """Return selection args for search results. + + When user selects @N from search results, route to download-file with -url. + """ + metadata = row.metadata or {} + + # Check for explicit selection args first + args = metadata.get("_selection_args") or metadata.get("selection_args") + if isinstance(args, (list, tuple)) and args: + return [str(x) for x in args if x is not None] + + # Fallback to direct URL + if row.path: + return ["-url", row.path] + + return ["-title", row.title or ""] + + register_provider( + "ytdlp.search", + _search_adapter, + columns=_search_columns_factory, + selection_fn=_search_selection_fn, + metadata={"description": "ytdlp video search using yt-dlp"}, + ) + debug("[ytdlp] Search provider registered successfully") +except Exception as e: + # best-effort registration + debug(f"[ytdlp] Provider registration note: {e}") + pass diff --git a/SYS/rich_display.py b/SYS/rich_display.py index bd27ec1..c2047c3 100644 --- a/SYS/rich_display.py +++ b/SYS/rich_display.py @@ -102,3 +102,34 @@ def show_provider_config_panel( ) ) stderr_console().print(footer) + + +def show_store_config_panel( + store_type: str, + keys: Sequence[str] | None = None, + *, + config_hint: str = "config.conf" +) -> None: + """Show a Rich panel explaining how to configure a storage backend.""" + + normalized = str(store_type or "").strip().lower() or "store" + pre = Text("Add this to your config", style="bold") + footer = Text( + f"Place this block in {config_hint} or config.d/*.conf", + style="dim" + ) + body = Text() + body.append(f"[store={normalized}]\n", style="bold cyan") + for key in keys or []: + body.append(f'{key}=""\n', style="yellow") + + stderr_console().print(pre) + stderr_console().print( + Panel( + body, + title=f"{normalized} storage configuration", + expand=False + ) + ) + stderr_console().print(footer) + diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 3604a4a..2aa1b40 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -140,10 +140,11 @@ class HydrusNetwork(Store): # Best-effort total count (used for startup diagnostics). Avoid heavy payloads. # Some Hydrus setups appear to return no count via the CBOR client for this endpoint, # so prefer a direct JSON request with a short timeout. - try: - self.get_total_count(refresh=True) - except Exception: - pass + # NOTE: Disabled to avoid unnecessary API call during init; count will be retrieved on first search/list if needed. + # try: + # self.get_total_count(refresh=True) + # except Exception: + # pass def _get_service_key(self, service_name: str, *, refresh: bool = False) -> Optional[str]: """Resolve (and cache) the Hydrus service key for the given service name.""" diff --git a/TUI/modalscreen/download.py b/TUI/modalscreen/download.py index 477ac9c..9713feb 100644 --- a/TUI/modalscreen/download.py +++ b/TUI/modalscreen/download.py @@ -1380,8 +1380,8 @@ class DownloadModal(ModalScreen): logger.info(f"Downloading {len(selected_url)} selected PDFs for merge") # Download PDFs to temporary directory - temp_dir = Path.home() / ".downlow_temp_pdfs" - temp_dir.mkdir(exist_ok=True) + import tempfile + temp_dir = Path(tempfile.mkdtemp(prefix="Medios-Macina-pdfs_")) downloaded_files = [] for idx, url in enumerate(selected_url, 1): diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 8d7d403..a4cd2a7 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -1033,6 +1033,7 @@ class Add_File(Cmdlet): except Exception as exc: debug(f"[add-file] Failed to retrieve via hash+store: {exc}") + # PRIORITY 2: Try explicit path argument if path_arg: media_path = Path(path_arg) diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 1631934..52daaa5 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -40,6 +40,8 @@ from tool.ytdlp import ( _format_chapters_note, _read_text_file, is_url_supported_by_ytdlp, + is_browseable_format, + format_for_table_selection, list_formats, probe_url, ) @@ -1553,22 +1555,8 @@ class Download_File(Cmdlet): return fmts def _is_browseable_format(self, fmt: Any) -> bool: - if not isinstance(fmt, dict): - return False - format_id = str(fmt.get("format_id") or "").strip() - if not format_id: - return False - ext = str(fmt.get("ext") or "").strip().lower() - if ext in {"mhtml", "json"}: - return False - note = str(fmt.get("format_note") or "").lower() - if "storyboard" in note: - return False - if format_id.lower().startswith("sb"): - return False - vcodec = str(fmt.get("vcodec", "none")) - acodec = str(fmt.get("acodec", "none")) - return not (vcodec == "none" and acodec == "none") + """Check if format is user-browseable. Delegates to ytdlp helper.""" + return is_browseable_format(fmt) def _format_id_for_query_index( self, @@ -2374,6 +2362,9 @@ class Download_File(Cmdlet): table = ResultTable(title=f"Available formats for {url}", max_columns=10, preserve_order=True) table.set_table("ytdlp.formatlist") table.set_source_command("download-file", [url]) + + debug(f"[ytdlp.formatlist] Displaying format selection table for {url}") + debug(f"[ytdlp.formatlist] Provider: ytdlp (routing to download-file via TABLE_AUTO_STAGES)") results_list: List[Dict[str, Any]] = [] for idx, fmt in enumerate(filtered_formats, 1): @@ -2392,65 +2383,28 @@ class Download_File(Cmdlet): except Exception: selection_format_id = format_id - size_str = "" - size_prefix = "" - size_bytes = filesize - if not size_bytes: - size_bytes = filesize_approx - if size_bytes: - size_prefix = "~" - try: - if isinstance(size_bytes, (int, float)) and size_bytes > 0: - size_mb = float(size_bytes) / (1024 * 1024) - size_str = f"{size_prefix}{size_mb:.1f}MB" - except Exception: - size_str = "" - - desc_parts: List[str] = [] - if resolution and resolution != "audio only": - desc_parts.append(resolution) - if ext: - desc_parts.append(str(ext).upper()) - if vcodec != "none": - desc_parts.append(f"v:{vcodec}") - if acodec != "none": - desc_parts.append(f"a:{acodec}") - if size_str: - desc_parts.append(size_str) - format_desc = " | ".join(desc_parts) - - format_dict = { - "table": "download-file", - "title": f"Format {format_id}", - "url": url, - "target": url, - "detail": format_desc, - "annotations": [ext, resolution] if resolution else [ext], - "media_kind": "format", - "cmd": base_cmd, - "columns": [ - ("ID", format_id), - ("Resolution", resolution or "N/A"), - ("Ext", ext), - ("Size", size_str or ""), - ("Video", vcodec), - ("Audio", acodec), - ], - "full_metadata": { - "format_id": format_id, - "url": url, - "item_selector": selection_format_id, - }, - "_selection_args": None, - } - - selection_args: List[str] = ["-format", selection_format_id] + # Use ytdlp helper to format for table + format_dict = format_for_table_selection( + fmt, + url, + idx, + selection_format_id=selection_format_id, + ) + + # Add base command for display + format_dict["cmd"] = base_cmd + + # Append clip values to selection args if needed + selection_args: List[str] = format_dict["_selection_args"].copy() try: if (not clip_spec) and clip_values: selection_args.extend(["-query", f"clip:{','.join([v for v in clip_values if v])}"]) except Exception: pass format_dict["_selection_args"] = selection_args + + # Also update in full_metadata for provider registration + format_dict["full_metadata"]["_selection_args"] = selection_args results_list.append(format_dict) table.add_result(format_dict) @@ -2470,6 +2424,9 @@ class Download_File(Cmdlet): setattr(table, "_rendered_by_cmdlet", True) pipeline_context.set_current_stage_table(table) pipeline_context.set_last_result_table(table, results_list) + + debug(f"[ytdlp.formatlist] Format table registered with {len(results_list)} formats") + debug(f"[ytdlp.formatlist] When user selects @N, will invoke: download-file {url} -format ") log(f"", file=sys.stderr) return 0 @@ -3675,6 +3632,27 @@ class Download_File(Cmdlet): raw_url = self._normalize_urls(parsed) piped_items = self._collect_piped_items_if_no_urls(result, raw_url) + # Handle TABLE_AUTO_STAGES routing: if a piped PipeObject has _selection_args, + # re-invoke download-file with those args instead of processing the PipeObject itself + if piped_items and not raw_url: + for item in piped_items: + try: + if hasattr(item, 'metadata') and isinstance(item.metadata, dict): + selection_args = item.metadata.get('_selection_args') + if selection_args and isinstance(selection_args, (list, tuple)): + # Found selection args - extract URL and re-invoke with format args + item_url = getattr(item, 'url', None) or item.metadata.get('url') + if item_url: + debug(f"[ytdlp] Detected selection args from table selection: {selection_args}") + # Reconstruct args: URL + selection args + new_args = [str(item_url)] + [str(arg) for arg in selection_args] + debug(f"[ytdlp] Re-invoking download-file with: {new_args}") + # Recursively call _run_impl with the new args + return self._run_impl(None, new_args, config) + except Exception as e: + debug(f"[ytdlp] Error handling selection args: {e}") + pass + had_piped_input = False try: if isinstance(result, list): @@ -3962,13 +3940,21 @@ class Download_File(Cmdlet): log(f"Invalid storage location: {e}", file=sys.stderr) return None - # Priority 2: Config default output/temp directory + # Priority 2: Config default output/temp directory, then OS temp try: from SYS.config import resolve_output_dir final_output_dir = resolve_output_dir(config) except Exception: - final_output_dir = Path.home() / "Downloads" + final_output_dir = None + + # If config resolution failed, use OS temp directory + if not final_output_dir: + try: + import tempfile + final_output_dir = Path(tempfile.gettempdir()) / "Medios-Macina" + except Exception: + final_output_dir = Path.home() / ".Medios-Macina-temp" debug(f"Using default directory: {final_output_dir}") diff --git a/cmdlet/search_file.py b/cmdlet/search_file.py index cf904cf..8a68591 100644 --- a/cmdlet/search_file.py +++ b/cmdlet/search_file.py @@ -556,6 +556,33 @@ class search_file(Cmdlet): library_root = get_local_storage_path(config or {}) if not library_root: log("No library root configured", file=sys.stderr) + log("", file=sys.stderr) + + # Show config panel for FolderStore + try: + from SYS.rich_display import show_store_config_panel + show_store_config_panel("folder", ["NAME", "PATH"]) + log("", file=sys.stderr) + except Exception: + log("Example config for FolderStore:", file=sys.stderr) + log("[store=folder]", file=sys.stderr) + log('NAME="default"', file=sys.stderr) + log('PATH="/path/to/library"', file=sys.stderr) + log("", file=sys.stderr) + + # Show config panel for HydrusNetworkStore + try: + from SYS.rich_display import show_store_config_panel + show_store_config_panel("hydrusnetwork", ["NAME", "API", "URL"]) + log("", file=sys.stderr) + except Exception: + log("Example config for HydrusNetworkStore:", file=sys.stderr) + log("[store=hydrusnetwork]", file=sys.stderr) + log('NAME="default"', file=sys.stderr) + log('API="your-api-key"', file=sys.stderr) + log('URL="http://localhost:45869"', file=sys.stderr) + log("", file=sys.stderr) + return 1 # Use context manager to ensure database is always closed diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 97e4858..5c83d38 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -39,7 +39,9 @@ Optional flags: --playwright-only Install only Playwright browsers (installs playwright package if missing) --browsers Comma-separated list of Playwright browsers to install (default: chromium) --install-editable Install the project in editable mode (pip install -e scripts) for running tests - --install-deno Install the Deno runtime using the official installer + --install-mpv Install MPV player if not already installed (default) + --no-mpv Skip installing MPV player + --install-deno Install the Deno runtime using the official installer (default) --no-deno Skip installing the Deno runtime --deno-version Pin a specific Deno version to install (e.g., v1.34.3) --upgrade-pip Upgrade pip, setuptools, and wheel before installing deps @@ -174,6 +176,82 @@ def _build_playwright_install_cmd(browsers: str | None) -> list[str]: return base + items +def _check_deno_installed() -> bool: + """Check if Deno is already installed and accessible in PATH.""" + return shutil.which("deno") is not None + + +def _check_mpv_installed() -> bool: + """Check if MPV is already installed and accessible in PATH.""" + return shutil.which("mpv") is not None + + +def _install_mpv() -> int: + """Install MPV player for the current platform. + + Returns exit code 0 on success, non-zero otherwise. + """ + system = platform.system().lower() + + try: + if system == "windows": + # Windows: use winget (built-in package manager) + if shutil.which("winget"): + print("Installing MPV via winget...") + run(["winget", "install", "--id=mpv.net", "-e"]) + else: + print( + "MPV not found and winget not available.\n" + "Please install MPV manually from https://mpv.io/installation/", + file=sys.stderr + ) + return 1 + elif system == "darwin": + # macOS: use Homebrew + if shutil.which("brew"): + print("Installing MPV via Homebrew...") + run(["brew", "install", "mpv"]) + else: + print( + "MPV not found and Homebrew not available.\n" + "Install Homebrew from https://brew.sh then run: brew install mpv", + file=sys.stderr + ) + return 1 + else: + # Linux: use apt, dnf, or pacman + if shutil.which("apt"): + print("Installing MPV via apt...") + run(["sudo", "apt", "install", "-y", "mpv"]) + elif shutil.which("dnf"): + print("Installing MPV via dnf...") + run(["sudo", "dnf", "install", "-y", "mpv"]) + elif shutil.which("pacman"): + print("Installing MPV via pacman...") + run(["sudo", "pacman", "-S", "mpv"]) + else: + print( + "MPV not found and no recognized package manager available.\n" + "Please install MPV manually for your distribution.", + file=sys.stderr + ) + return 1 + + # Verify installation + if shutil.which("mpv"): + print(f"MPV installed at: {shutil.which('mpv')}") + return 0 + + print("MPV installation completed but 'mpv' not found in PATH.", file=sys.stderr) + return 1 + except subprocess.CalledProcessError as exc: + print(f"MPV install failed: {exc}", file=sys.stderr) + return int(exc.returncode or 1) + except Exception as exc: + print(f"MPV install error: {exc}", file=sys.stderr) + return 1 + + def _install_deno(version: str | None = None) -> int: """Install Deno runtime for the current platform. @@ -270,6 +348,17 @@ def main() -> int: action="store_true", help="Install the project in editable mode (pip install -e scripts) for running tests", ) + mpv_group = parser.add_mutually_exclusive_group() + mpv_group.add_argument( + "--install-mpv", + action="store_true", + help="Install MPV player if not already installed (default behavior)", + ) + mpv_group.add_argument( + "--no-mpv", + action="store_true", + help="Skip installing MPV player (opt out)" + ) deno_group = parser.add_mutually_exclusive_group() deno_group.add_argument( "--install-deno", @@ -930,6 +1019,24 @@ def main() -> int: f"Warning: failed to verify or modify site-packages for top-level CLI: {exc}" ) + # Check and install MPV if needed + install_mpv_requested = True + if getattr(args, "no_mpv", False): + install_mpv_requested = False + elif getattr(args, "install_mpv", False): + install_mpv_requested = True + + if install_mpv_requested: + if _check_mpv_installed(): + if not args.quiet: + print("MPV is already installed.") + else: + if not args.quiet: + print("MPV not found in PATH. Attempting to install...") + rc = _install_mpv() + if rc != 0: + print("Warning: MPV installation failed. Install it manually from https://mpv.io/installation/", file=sys.stderr) + # Optional: install Deno runtime (default: install unless --no-deno is passed) install_deno_requested = True if getattr(args, "no_deno", False): @@ -938,12 +1045,15 @@ def main() -> int: install_deno_requested = True if install_deno_requested: - if not args.quiet: - print("Installing Deno runtime (local/system)...") - rc = _install_deno(args.deno_version) - if rc != 0: - print("Deno installation failed.", file=sys.stderr) - return rc + if _check_deno_installed(): + if not args.quiet: + print("Deno is already installed.") + else: + if not args.quiet: + print("Installing Deno runtime (local/system)...") + rc = _install_deno(args.deno_version) + if rc != 0: + print("Warning: Deno installation failed.", file=sys.stderr) # Write project-local launcher script under scripts/ to keep the repo root uncluttered. def _write_launchers() -> None: diff --git a/tool/ytdlp.py b/tool/ytdlp.py index 4d0751c..3454cd4 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -268,6 +268,143 @@ def probe_url( return cast(Optional[Dict[str, Any]], result_container[0]) +def is_browseable_format(fmt: Any) -> bool: + """Check if a format is user-browseable (not storyboard, metadata, etc). + + Used by the ytdlp format selector to filter out non-downloadable formats. + Returns False for: + - MHTML, JSON sidecar metadata + - Storyboard/thumbnail formats + - Audio-only or video-only when both available + + Args: + fmt: Format dict from yt-dlp with keys like format_id, ext, vcodec, acodec, format_note + + Returns: + bool: True if format is suitable for browsing/selection + """ + if not isinstance(fmt, dict): + return False + + format_id = str(fmt.get("format_id") or "").strip() + if not format_id: + return False + + # Filter out metadata/sidecar formats + ext = str(fmt.get("ext") or "").strip().lower() + if ext in {"mhtml", "json"}: + return False + + # Filter out storyboard/thumbnail formats + note = str(fmt.get("format_note") or "").lower() + if "storyboard" in note: + return False + + if format_id.lower().startswith("sb"): + return False + + # Filter out formats with no audio and no video + vcodec = str(fmt.get("vcodec", "none")) + acodec = str(fmt.get("acodec", "none")) + return not (vcodec == "none" and acodec == "none") + + +def format_for_table_selection( + fmt: Dict[str, Any], + url: str, + index: int, + *, + selection_format_id: Optional[str] = None, +) -> Dict[str, Any]: + """Format a yt-dlp format dict into a table result row for selection. + + This helper formats a single format from list_formats() into the shape + expected by the ResultTable system, ready for user selection and routing + to download-file with -format argument. + + Args: + fmt: Format dict from yt-dlp + url: The URL this format came from + index: Row number for display (1-indexed) + selection_format_id: Override format_id for selection (e.g., with +ba suffix) + + Returns: + dict: Format result row with _selection_args for table system + + Example: + fmts = list_formats("https://youtube.com/watch?v=abc") + browseable = [f for f in fmts if is_browseable_format(f)] + results = [format_for_table_selection(f, url, i+1) for i, f in enumerate(browseable)] + """ + format_id = fmt.get("format_id", "") + resolution = fmt.get("resolution", "") + ext = fmt.get("ext", "") + vcodec = fmt.get("vcodec", "none") + acodec = fmt.get("acodec", "none") + filesize = fmt.get("filesize") + filesize_approx = fmt.get("filesize_approx") + + # If not provided, compute selection format ID (add +ba for video-only) + if selection_format_id is None: + selection_format_id = format_id + try: + if vcodec != "none" and acodec == "none" and format_id: + selection_format_id = f"{format_id}+ba" + except Exception: + pass + + # Format file size + size_str = "" + size_prefix = "" + size_bytes = filesize or filesize_approx + try: + if isinstance(size_bytes, (int, float)) and size_bytes > 0: + size_mb = float(size_bytes) / (1024 * 1024) + size_str = f"{size_prefix}{size_mb:.1f}MB" + except Exception: + pass + + # Build description + desc_parts: List[str] = [] + if resolution and resolution != "audio only": + desc_parts.append(resolution) + if ext: + desc_parts.append(str(ext).upper()) + if vcodec != "none": + desc_parts.append(f"v:{vcodec}") + if acodec != "none": + desc_parts.append(f"a:{acodec}") + if size_str: + desc_parts.append(size_str) + format_desc = " | ".join(desc_parts) + + # Build table row + return { + "table": "download-file", + "title": f"Format {format_id}", + "url": url, + "target": url, + "detail": format_desc, + "annotations": [ext, resolution] if resolution else [ext], + "media_kind": "format", + "columns": [ + ("ID", format_id), + ("Resolution", resolution or "N/A"), + ("Ext", ext), + ("Size", size_str or ""), + ("Video", vcodec), + ("Audio", acodec), + ], + "full_metadata": { + "format_id": format_id, + "url": url, + "item_selector": selection_format_id, + "_selection_args": ["-format", selection_format_id], + }, + "_selection_args": ["-format", selection_format_id], + } + + @dataclass(slots=True) class YtDlpDefaults: """User-tunable defaults for yt-dlp behavior.