h
This commit is contained in:
@@ -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
390
Provider/ytdlp.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -2374,6 +2362,9 @@ class Download_File(Cmdlet):
|
|||||||
table = ResultTable(title=f"Available formats for {url}", max_columns=10, preserve_order=True)
|
table = ResultTable(title=f"Available formats for {url}", max_columns=10, preserve_order=True)
|
||||||
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):
|
||||||
@@ -2392,65 +2383,28 @@ 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:
|
# Add base command for display
|
||||||
size_mb = float(size_bytes) / (1024 * 1024)
|
format_dict["cmd"] = base_cmd
|
||||||
size_str = f"{size_prefix}{size_mb:.1f}MB"
|
|
||||||
except Exception:
|
# Append clip values to selection args if needed
|
||||||
size_str = ""
|
selection_args: List[str] = format_dict["_selection_args"].copy()
|
||||||
|
|
||||||
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]
|
|
||||||
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])}"])
|
||||||
except Exception:
|
except Exception:
|
||||||
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)
|
||||||
@@ -2470,6 +2424,9 @@ class Download_File(Cmdlet):
|
|||||||
setattr(table, "_rendered_by_cmdlet", True)
|
setattr(table, "_rendered_by_cmdlet", True)
|
||||||
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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
137
tool/ytdlp.py
137
tool/ytdlp.py
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user