This commit is contained in:
2026-01-10 17:30:18 -08:00
parent 08fef4a5d3
commit c2edd5139f
10 changed files with 769 additions and 86 deletions

View File

@@ -426,7 +426,7 @@
"google\\.com/uc\\?id=([0-9A-Za-z_-]+)" "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_-]+))", "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": { "hexupload": {
"name": "hexupload", "name": "hexupload",
@@ -645,7 +645,7 @@
"(upload42\\.com/[0-9a-zA-Z]{12})" "(upload42\\.com/[0-9a-zA-Z]{12})"
], ],
"regexp": "(upload42\\.com/[0-9a-zA-Z]{12})", "regexp": "(upload42\\.com/[0-9a-zA-Z]{12})",
"status": false "status": true
}, },
"uploadbank": { "uploadbank": {
"name": "uploadbank", "name": "uploadbank",

390
Provider/ytdlp.py Normal file
View File

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

View File

@@ -102,3 +102,34 @@ def show_provider_config_panel(
) )
) )
stderr_console().print(footer) 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)

View File

@@ -140,10 +140,11 @@ class HydrusNetwork(Store):
# Best-effort total count (used for startup diagnostics). Avoid heavy payloads. # 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, # 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. # so prefer a direct JSON request with a short timeout.
try: # NOTE: Disabled to avoid unnecessary API call during init; count will be retrieved on first search/list if needed.
self.get_total_count(refresh=True) # try:
except Exception: # self.get_total_count(refresh=True)
pass # except Exception:
# pass
def _get_service_key(self, service_name: str, *, refresh: bool = False) -> Optional[str]: 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.""" """Resolve (and cache) the Hydrus service key for the given service name."""

View File

@@ -1380,8 +1380,8 @@ class DownloadModal(ModalScreen):
logger.info(f"Downloading {len(selected_url)} selected PDFs for merge") logger.info(f"Downloading {len(selected_url)} selected PDFs for merge")
# Download PDFs to temporary directory # Download PDFs to temporary directory
temp_dir = Path.home() / ".downlow_temp_pdfs" import tempfile
temp_dir.mkdir(exist_ok=True) temp_dir = Path(tempfile.mkdtemp(prefix="Medios-Macina-pdfs_"))
downloaded_files = [] downloaded_files = []
for idx, url in enumerate(selected_url, 1): for idx, url in enumerate(selected_url, 1):

View File

@@ -1033,6 +1033,7 @@ class Add_File(Cmdlet):
except Exception as exc: except Exception as exc:
debug(f"[add-file] Failed to retrieve via hash+store: {exc}") debug(f"[add-file] Failed to retrieve via hash+store: {exc}")
# PRIORITY 2: Try explicit path argument # PRIORITY 2: Try explicit path argument
if path_arg: if path_arg:
media_path = Path(path_arg) media_path = Path(path_arg)

View File

@@ -40,6 +40,8 @@ from tool.ytdlp import (
_format_chapters_note, _format_chapters_note,
_read_text_file, _read_text_file,
is_url_supported_by_ytdlp, is_url_supported_by_ytdlp,
is_browseable_format,
format_for_table_selection,
list_formats, list_formats,
probe_url, probe_url,
) )
@@ -1553,22 +1555,8 @@ class Download_File(Cmdlet):
return fmts return fmts
def _is_browseable_format(self, fmt: Any) -> bool: def _is_browseable_format(self, fmt: Any) -> bool:
if not isinstance(fmt, dict): """Check if format is user-browseable. Delegates to ytdlp helper."""
return False return is_browseable_format(fmt)
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")
def _format_id_for_query_index( def _format_id_for_query_index(
self, self,
@@ -2375,6 +2363,9 @@ class Download_File(Cmdlet):
table.set_table("ytdlp.formatlist") table.set_table("ytdlp.formatlist")
table.set_source_command("download-file", [url]) 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]] = [] results_list: List[Dict[str, Any]] = []
for idx, fmt in enumerate(filtered_formats, 1): for idx, fmt in enumerate(filtered_formats, 1):
resolution = fmt.get("resolution", "") resolution = fmt.get("resolution", "")
@@ -2392,59 +2383,19 @@ class Download_File(Cmdlet):
except Exception: except Exception:
selection_format_id = format_id selection_format_id = format_id
size_str = "" # Use ytdlp helper to format for table
size_prefix = "" format_dict = format_for_table_selection(
size_bytes = filesize fmt,
if not size_bytes: url,
size_bytes = filesize_approx idx,
if size_bytes: selection_format_id=selection_format_id,
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] = [] # Add base command for display
if resolution and resolution != "audio only": format_dict["cmd"] = base_cmd
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 = { # Append clip values to selection args if needed
"table": "download-file", selection_args: List[str] = format_dict["_selection_args"].copy()
"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]
try: try:
if (not clip_spec) and clip_values: if (not clip_spec) and clip_values:
selection_args.extend(["-query", f"clip:{','.join([v for v in clip_values if v])}"]) selection_args.extend(["-query", f"clip:{','.join([v for v in clip_values if v])}"])
@@ -2452,6 +2403,9 @@ class Download_File(Cmdlet):
pass pass
format_dict["_selection_args"] = selection_args 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) results_list.append(format_dict)
table.add_result(format_dict) table.add_result(format_dict)
@@ -2471,6 +2425,9 @@ class Download_File(Cmdlet):
pipeline_context.set_current_stage_table(table) pipeline_context.set_current_stage_table(table)
pipeline_context.set_last_result_table(table, results_list) 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 <format_id>")
log(f"", file=sys.stderr) log(f"", file=sys.stderr)
return 0 return 0
@@ -3675,6 +3632,27 @@ class Download_File(Cmdlet):
raw_url = self._normalize_urls(parsed) raw_url = self._normalize_urls(parsed)
piped_items = self._collect_piped_items_if_no_urls(result, raw_url) 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 had_piped_input = False
try: try:
if isinstance(result, list): if isinstance(result, list):
@@ -3962,13 +3940,21 @@ class Download_File(Cmdlet):
log(f"Invalid storage location: {e}", file=sys.stderr) log(f"Invalid storage location: {e}", file=sys.stderr)
return None return None
# Priority 2: Config default output/temp directory # Priority 2: Config default output/temp directory, then OS temp
try: try:
from SYS.config import resolve_output_dir from SYS.config import resolve_output_dir
final_output_dir = resolve_output_dir(config) final_output_dir = resolve_output_dir(config)
except Exception: 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}") debug(f"Using default directory: {final_output_dir}")

View File

@@ -556,6 +556,33 @@ class search_file(Cmdlet):
library_root = get_local_storage_path(config or {}) library_root = get_local_storage_path(config or {})
if not library_root: if not library_root:
log("No library root configured", file=sys.stderr) 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 return 1
# Use context manager to ensure database is always closed # Use context manager to ensure database is always closed

View File

@@ -39,7 +39,9 @@ Optional flags:
--playwright-only Install only Playwright browsers (installs playwright package if missing) --playwright-only Install only Playwright browsers (installs playwright package if missing)
--browsers Comma-separated list of Playwright browsers to install (default: chromium) --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-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 --no-deno Skip installing the Deno runtime
--deno-version Pin a specific Deno version to install (e.g., v1.34.3) --deno-version Pin a specific Deno version to install (e.g., v1.34.3)
--upgrade-pip Upgrade pip, setuptools, and wheel before installing deps --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 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: def _install_deno(version: str | None = None) -> int:
"""Install Deno runtime for the current platform. """Install Deno runtime for the current platform.
@@ -270,6 +348,17 @@ def main() -> int:
action="store_true", action="store_true",
help="Install the project in editable mode (pip install -e scripts) for running tests", 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 = parser.add_mutually_exclusive_group()
deno_group.add_argument( deno_group.add_argument(
"--install-deno", "--install-deno",
@@ -930,6 +1019,24 @@ def main() -> int:
f"Warning: failed to verify or modify site-packages for top-level CLI: {exc}" 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) # Optional: install Deno runtime (default: install unless --no-deno is passed)
install_deno_requested = True install_deno_requested = True
if getattr(args, "no_deno", False): if getattr(args, "no_deno", False):
@@ -938,12 +1045,15 @@ def main() -> int:
install_deno_requested = True install_deno_requested = True
if install_deno_requested: if install_deno_requested:
if not args.quiet: if _check_deno_installed():
print("Installing Deno runtime (local/system)...") if not args.quiet:
rc = _install_deno(args.deno_version) print("Deno is already installed.")
if rc != 0: else:
print("Deno installation failed.", file=sys.stderr) if not args.quiet:
return rc 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. # Write project-local launcher script under scripts/ to keep the repo root uncluttered.
def _write_launchers() -> None: def _write_launchers() -> None:

View File

@@ -268,6 +268,143 @@ def probe_url(
return cast(Optional[Dict[str, Any]], result_container[0]) 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) @dataclass(slots=True)
class YtDlpDefaults: class YtDlpDefaults:
"""User-tunable defaults for yt-dlp behavior. """User-tunable defaults for yt-dlp behavior.