lklk
This commit is contained in:
@@ -118,9 +118,10 @@ class PlaywrightTool:
|
||||
|
||||
FFmpeg resolution (in order):
|
||||
1. Config key: playwright.ffmpeg_path
|
||||
2. Environment variable: PLAYWRIGHT_FFMPEG_PATH
|
||||
3. Project bundled: MPV/ffmpeg/bin/ffmpeg[.exe]
|
||||
4. System PATH: which ffmpeg
|
||||
2. Environment variable: FFMPEG_PATH (global shared env)
|
||||
3. Environment variable: PLAYWRIGHT_FFMPEG_PATH (legacy)
|
||||
4. Project bundled: MPV/ffmpeg/bin/ffmpeg[.exe]
|
||||
5. System PATH: which ffmpeg
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
@@ -158,7 +159,19 @@ class PlaywrightTool:
|
||||
headless_raw = _get("headless", defaults.headless)
|
||||
headless = bool(headless_raw)
|
||||
|
||||
ua = str(_get("user_agent", defaults.user_agent))
|
||||
# Resolve user_agent configuration. Support a 'custom' token which reads
|
||||
# the actual UA from `user_agent_custom` so the UI can offer a simple
|
||||
# dropdown without scattering long UA strings to users by default.
|
||||
ua_raw = _get("user_agent", None)
|
||||
if ua_raw is None:
|
||||
ua = defaults.user_agent
|
||||
else:
|
||||
ua_s = str(ua_raw).strip()
|
||||
if ua_s.lower() == "custom":
|
||||
ua_custom = _get("user_agent_custom", "")
|
||||
ua = str(ua_custom).strip() or defaults.user_agent
|
||||
else:
|
||||
ua = ua_s
|
||||
|
||||
def _int(name: str, fallback: int) -> int:
|
||||
raw = _get(name, fallback)
|
||||
@@ -173,26 +186,35 @@ class PlaywrightTool:
|
||||
|
||||
ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors))
|
||||
|
||||
# Try to find ffmpeg: config override, environment variable, bundled, then system
|
||||
# This checks if ffmpeg is actually available (not just the path to it)
|
||||
# Try to find ffmpeg: config override, global env FFMPEG_PATH, legacy
|
||||
# PLAYWRIGHT_FFMPEG_PATH, bundled, then system. This allows Playwright to
|
||||
# use the shared ffmpeg path used by other tools (FFMPEG_PATH env).
|
||||
ffmpeg_path: Optional[str] = None
|
||||
config_ffmpeg = _get("ffmpeg_path", None)
|
||||
|
||||
|
||||
if config_ffmpeg:
|
||||
# User explicitly configured ffmpeg path
|
||||
candidate = str(config_ffmpeg).strip()
|
||||
if Path(candidate).exists():
|
||||
if candidate and Path(candidate).exists():
|
||||
ffmpeg_path = candidate
|
||||
else:
|
||||
debug(f"Configured ffmpeg path does not exist: {candidate}")
|
||||
|
||||
|
||||
if not ffmpeg_path:
|
||||
# Check environment variable (supports project ffmpeg)
|
||||
env_ffmpeg = os.environ.get("PLAYWRIGHT_FFMPEG_PATH")
|
||||
# Prefer a global FFMPEG_PATH env var (shared by tools) before Playwright-specific one
|
||||
env_ffmpeg = os.environ.get("FFMPEG_PATH")
|
||||
if env_ffmpeg and Path(env_ffmpeg).exists():
|
||||
ffmpeg_path = env_ffmpeg
|
||||
elif env_ffmpeg:
|
||||
debug(f"PLAYWRIGHT_FFMPEG_PATH set but path does not exist: {env_ffmpeg}")
|
||||
debug(f"FFMPEG_PATH set but path does not exist: {env_ffmpeg}")
|
||||
|
||||
if not ffmpeg_path:
|
||||
# Backward-compatible Playwright-specific env var
|
||||
env_ffmpeg2 = os.environ.get("PLAYWRIGHT_FFMPEG_PATH")
|
||||
if env_ffmpeg2 and Path(env_ffmpeg2).exists():
|
||||
ffmpeg_path = env_ffmpeg2
|
||||
elif env_ffmpeg2:
|
||||
debug(f"PLAYWRIGHT_FFMPEG_PATH set but path does not exist: {env_ffmpeg2}")
|
||||
|
||||
if not ffmpeg_path:
|
||||
# Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin)
|
||||
@@ -229,6 +251,77 @@ class PlaywrightTool:
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
)
|
||||
|
||||
|
||||
def config_schema() -> List[Dict[str, Any]]:
|
||||
"""Return a schema describing editable Playwright tool defaults for the config UI.
|
||||
|
||||
Notes:
|
||||
- `user_agent` is a dropdown with a `custom` option; put the real UA in
|
||||
`user_agent_custom` when choosing `custom`.
|
||||
- Viewport dimensions are offered as convenient choices.
|
||||
- `ffmpeg_path` intentionally defaults to empty; Playwright will consult
|
||||
a global `FFMPEG_PATH` environment variable (or fallback to bundled/system).
|
||||
"""
|
||||
_defaults = PlaywrightDefaults()
|
||||
|
||||
browser_choices = ["chromium", "firefox", "webkit"]
|
||||
viewport_width_choices = [1920, 1366, 1280, 1024, 800]
|
||||
viewport_height_choices = [1080, 900, 768, 720, 600]
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "browser",
|
||||
"label": "Playwright browser",
|
||||
"default": _defaults.browser,
|
||||
"choices": browser_choices,
|
||||
},
|
||||
{
|
||||
"key": "headless",
|
||||
"label": "Headless",
|
||||
"default": str(_defaults.headless),
|
||||
"choices": ["true", "false"],
|
||||
},
|
||||
{
|
||||
"key": "user_agent",
|
||||
"label": "User Agent",
|
||||
"default": "default",
|
||||
"choices": ["default", "native", "custom"],
|
||||
},
|
||||
{
|
||||
"key": "user_agent_custom",
|
||||
"label": "Custom User Agent (used when User Agent = custom)",
|
||||
"default": "",
|
||||
},
|
||||
{
|
||||
"key": "viewport_width",
|
||||
"label": "Viewport width",
|
||||
"default": _defaults.viewport_width,
|
||||
"choices": viewport_width_choices,
|
||||
},
|
||||
{
|
||||
"key": "viewport_height",
|
||||
"label": "Viewport height",
|
||||
"default": _defaults.viewport_height,
|
||||
"choices": viewport_height_choices,
|
||||
},
|
||||
{
|
||||
"key": "navigation_timeout_ms",
|
||||
"label": "Navigation timeout (ms)",
|
||||
"default": _defaults.navigation_timeout_ms,
|
||||
},
|
||||
{
|
||||
"key": "ignore_https_errors",
|
||||
"label": "Ignore HTTPS errors",
|
||||
"default": str(_defaults.ignore_https_errors),
|
||||
"choices": ["true", "false"],
|
||||
},
|
||||
{
|
||||
"key": "ffmpeg_path",
|
||||
"label": "FFmpeg path (leave empty to use global/bundled)",
|
||||
"default": "",
|
||||
},
|
||||
]
|
||||
|
||||
def require(self) -> None:
|
||||
"""Ensure Playwright is present; raise a helpful RuntimeError if not."""
|
||||
try:
|
||||
|
||||
257
tool/ytdlp.py
257
tool/ytdlp.py
@@ -220,11 +220,96 @@ def _has_browser_cookie_database() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _add_browser_cookies_if_available(options: Dict[str, Any]) -> None:
|
||||
def _browser_cookie_path_for(browser_name: str) -> Optional[Path]:
|
||||
"""Return the cookie DB Path for a specific browser if present, else None.
|
||||
|
||||
Supported browsers (case-insensitive): "chrome", "chromium", "brave".
|
||||
"""
|
||||
name = str(browser_name or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
try:
|
||||
home = Path.home()
|
||||
except Exception:
|
||||
home = Path.cwd()
|
||||
|
||||
# Windows
|
||||
if os.name == "nt":
|
||||
for env_value in (os.getenv("LOCALAPPDATA"), os.getenv("APPDATA")):
|
||||
if not env_value:
|
||||
continue
|
||||
base = Path(env_value)
|
||||
if name in ("chrome", "google-chrome"):
|
||||
p = base / "Google" / "Chrome" / "User Data" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name == "chromium":
|
||||
p = base / "Chromium" / "User Data" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name in ("brave", "brave-browser"):
|
||||
p = base / "BraveSoftware" / "Brave-Browser" / "User Data" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
|
||||
# *nix and macOS
|
||||
if sys.platform == "darwin":
|
||||
if name in ("chrome", "google-chrome"):
|
||||
p = home / "Library" / "Application Support" / "Google" / "Chrome" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name == "chromium":
|
||||
p = home / "Library" / "Application Support" / "Chromium" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name in ("brave", "brave-browser"):
|
||||
p = home / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
|
||||
# Linux and other
|
||||
if name in ("chrome", "google-chrome"):
|
||||
p = home / ".config" / "google-chrome" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name == "chromium":
|
||||
p = home / ".config" / "chromium" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
if name in ("brave", "brave-browser"):
|
||||
p = home / ".config" / "BraveSoftware" / "Brave-Browser" / "Default" / "Cookies"
|
||||
if p.is_file():
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _add_browser_cookies_if_available(options: Dict[str, Any], preferred_browser: Optional[str] = None) -> None:
|
||||
global _BROWSER_COOKIE_WARNING_EMITTED
|
||||
if _has_browser_cookie_database():
|
||||
options["cookiesfrombrowser"] = ["chrome"]
|
||||
return
|
||||
|
||||
# If a preferred browser is specified, try to use it if available
|
||||
if preferred_browser:
|
||||
try:
|
||||
if _browser_cookie_path_for(preferred_browser) is not None:
|
||||
options["cookiesfrombrowser"] = [preferred_browser]
|
||||
return
|
||||
else:
|
||||
if not _BROWSER_COOKIE_WARNING_EMITTED:
|
||||
log(f"Requested browser cookie DB '{preferred_browser}' not found; falling back to autodetect.")
|
||||
_BROWSER_COOKIE_WARNING_EMITTED = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-detect in common order (chrome/chromium/brave)
|
||||
for candidate in ("chrome", "chromium", "brave"):
|
||||
try:
|
||||
if _browser_cookie_path_for(candidate) is not None:
|
||||
options["cookiesfrombrowser"] = [candidate]
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not _BROWSER_COOKIE_WARNING_EMITTED:
|
||||
log(
|
||||
"Browser cookie extraction skipped because no Chrome-compatible cookie database was found. "
|
||||
@@ -624,17 +709,19 @@ class YtDlpDefaults:
|
||||
"""User-tunable defaults for yt-dlp behavior.
|
||||
|
||||
Recommended config.conf keys (top-level dotted keys):
|
||||
- ytdlp.video_format="bestvideo+bestaudio/best"
|
||||
- ytdlp.audio_format="251/140/bestaudio"
|
||||
- format="best|1080|720|640|audio"
|
||||
- ytdlp.format_sort="res:2160,res:1440,res:1080,res:720,res"
|
||||
|
||||
Cookies:
|
||||
- cookies="C:\\path\\cookies.txt" (already supported by config.resolve_cookies_path)
|
||||
- cookies_from_browser="auto|none|chrome|brave|chromium"
|
||||
"""
|
||||
|
||||
format: str = "best"
|
||||
video_format: str = "bestvideo+bestaudio/best"
|
||||
audio_format: str = "251/140/bestaudio"
|
||||
format_sort: Optional[List[str]] = None
|
||||
cookies_from_browser: Optional[str] = None
|
||||
|
||||
|
||||
class YtDlpTool:
|
||||
@@ -737,7 +824,29 @@ class YtDlpTool:
|
||||
)
|
||||
fmt_sort = _parse_csv_list(fmt_sort_val)
|
||||
|
||||
# Cookie source preference: allow forcing a browser DB or 'auto'/'none'
|
||||
cookies_pref = (
|
||||
tool_block.get("cookies_from_browser")
|
||||
or tool_block.get("cookiesfrombrowser")
|
||||
or ytdlp_block.get("cookies_from_browser")
|
||||
or ytdlp_block.get("cookiesfrombrowser")
|
||||
or cfg.get("ytdlp_cookies_from_browser")
|
||||
or _get_nested(cfg, "ytdlp", "cookies_from_browser")
|
||||
)
|
||||
|
||||
# Unified format preference: prefer explicit 'format' key but accept legacy keys
|
||||
format_pref = (
|
||||
tool_block.get("format")
|
||||
or tool_block.get("video_format")
|
||||
or ytdlp_block.get("format")
|
||||
or ytdlp_block.get("video_format")
|
||||
or cfg.get("ytdlp_format")
|
||||
or cfg.get("ytdlp_video_format")
|
||||
or _get_nested(cfg, "ytdlp", "format")
|
||||
)
|
||||
|
||||
defaults = YtDlpDefaults(
|
||||
format=str(format_pref).strip() if format_pref else "best",
|
||||
video_format=str(
|
||||
nested_video or video_format or _fallback_defaults.video_format
|
||||
),
|
||||
@@ -745,18 +854,36 @@ class YtDlpTool:
|
||||
nested_audio or audio_format or _fallback_defaults.audio_format
|
||||
),
|
||||
format_sort=fmt_sort,
|
||||
cookies_from_browser=(str(cookies_pref).strip() if cookies_pref else None),
|
||||
)
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
|
||||
def resolve_cookiefile(self) -> Optional[Path]:
|
||||
return self._cookiefile
|
||||
|
||||
def default_format(self, mode: str) -> str:
|
||||
"""Determine the final yt-dlp format string.
|
||||
|
||||
Priority:
|
||||
- If caller explicitly requested audio mode (mode == 'audio'), return audio format.
|
||||
- If configured default format is 'audio', return audio format.
|
||||
- If configured default is 'best' or blank, return video_format.
|
||||
- Otherwise return the configured format value (e.g., '720').
|
||||
"""
|
||||
m = str(mode or "").lower().strip()
|
||||
if m == "audio":
|
||||
return self.defaults.audio_format
|
||||
return self.defaults.video_format
|
||||
|
||||
cfg = (str(self.defaults.format or "")).strip()
|
||||
lc = cfg.lower()
|
||||
if lc == "audio":
|
||||
return self.defaults.audio_format
|
||||
if not cfg or lc == "best":
|
||||
return self.defaults.video_format
|
||||
return cfg
|
||||
|
||||
def build_ytdlp_options(self, opts: DownloadOptions) -> Dict[str, Any]:
|
||||
"""Translate DownloadOptions into yt-dlp API options."""
|
||||
@@ -796,21 +923,69 @@ class YtDlpTool:
|
||||
if cookiefile is not None and cookiefile.is_file():
|
||||
base_options["cookiefile"] = str(cookiefile)
|
||||
else:
|
||||
# Add browser cookies support "just in case" if no file found (best effort)
|
||||
# This uses yt-dlp's support for extracting from common browsers.
|
||||
# Defaulting to 'chrome' as the most common path.
|
||||
_add_browser_cookies_if_available(base_options)
|
||||
# Respect configured browser cookie preference if provided; otherwise fall back to auto-detect.
|
||||
pref = (self.defaults.cookies_from_browser or "").lower().strip()
|
||||
if pref:
|
||||
if pref in {"none", "off", "false"}:
|
||||
# Explicitly disabled
|
||||
pass
|
||||
elif pref in {"auto", "detect"}:
|
||||
_add_browser_cookies_if_available(base_options)
|
||||
else:
|
||||
# Try the preferred browser first; fall back to auto-detect if not present
|
||||
_add_browser_cookies_if_available(base_options, preferred_browser=pref)
|
||||
else:
|
||||
# Add browser cookies support "just in case" if no file found (best effort)
|
||||
_add_browser_cookies_if_available(base_options)
|
||||
|
||||
# Special handling for format keywords
|
||||
# Special handling for format keywords explicitly passed in via options
|
||||
if opts.ytdl_format == "audio":
|
||||
opts = opts._replace(mode="audio", ytdl_format=None)
|
||||
try:
|
||||
opts = opts._replace(mode="audio", ytdl_format=None)
|
||||
except Exception:
|
||||
try:
|
||||
import dataclasses as _dc
|
||||
|
||||
opts = _dc.replace(opts, mode="audio", ytdl_format=None)
|
||||
except Exception:
|
||||
pass
|
||||
elif opts.ytdl_format == "video":
|
||||
opts = opts._replace(mode="video", ytdl_format=None)
|
||||
try:
|
||||
opts = opts._replace(mode="video", ytdl_format=None)
|
||||
except Exception:
|
||||
try:
|
||||
import dataclasses as _dc
|
||||
|
||||
opts = _dc.replace(opts, mode="video", ytdl_format=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if opts.no_playlist:
|
||||
base_options["noplaylist"] = True
|
||||
|
||||
# If no explicit format was provided, honor the configured default format
|
||||
ytdl_format = opts.ytdl_format
|
||||
if not ytdl_format:
|
||||
configured_format = (str(self.defaults.format or "")).strip()
|
||||
if configured_format:
|
||||
if configured_format.lower() == "audio":
|
||||
# Default to audio-only downloads
|
||||
try:
|
||||
opts = opts._replace(mode="audio")
|
||||
except Exception:
|
||||
try:
|
||||
import dataclasses as _dc
|
||||
|
||||
opts = _dc.replace(opts, mode="audio")
|
||||
except Exception:
|
||||
pass
|
||||
ytdl_format = None
|
||||
else:
|
||||
# Leave ytdl_format None so that default_format(opts.mode)
|
||||
# returns the configured format literally (e.g., '720') and
|
||||
# we don't auto-convert it to an internal selector.
|
||||
pass
|
||||
|
||||
if ytdl_format and opts.mode != "audio":
|
||||
resolved = self.resolve_height_selector(ytdl_format)
|
||||
if resolved:
|
||||
@@ -958,6 +1133,45 @@ class YtDlpTool:
|
||||
pass
|
||||
|
||||
|
||||
def config_schema() -> List[Dict[str, Any]]:
|
||||
"""Return a schema describing editable YT-DLP tool defaults for the config UI."""
|
||||
format_choices = [
|
||||
"best",
|
||||
"1080",
|
||||
"720",
|
||||
"640",
|
||||
"audio",
|
||||
]
|
||||
|
||||
# Offer browser choices depending on what's present on the host system
|
||||
browser_choices = ["auto", "none"]
|
||||
for b in ("chrome", "chromium", "brave"):
|
||||
try:
|
||||
if _browser_cookie_path_for(b) is not None:
|
||||
browser_choices.append(b)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "format",
|
||||
"label": "Default format",
|
||||
"default": YtDlpDefaults.format,
|
||||
"choices": format_choices,
|
||||
},
|
||||
{
|
||||
"key": "cookies",
|
||||
"label": "Cookie file (path)",
|
||||
"default": "",
|
||||
},
|
||||
{
|
||||
"key": "cookies_from_browser",
|
||||
"label": "Browser cookie source (used if no cookie file)",
|
||||
"default": "auto",
|
||||
"choices": browser_choices,
|
||||
},
|
||||
]
|
||||
|
||||
# Progress + utility helpers for yt-dlp driven downloads (previously in cmdlet/download_media).
|
||||
_YTDLP_PROGRESS_BAR = ProgressBar()
|
||||
_YTDLP_PROGRESS_ACTIVITY_LOCK = threading.Lock()
|
||||
@@ -1483,8 +1697,12 @@ except ImportError:
|
||||
extract_ytdlp_tags = None # type: ignore
|
||||
|
||||
|
||||
def download_media(opts: DownloadOptions, *, debug_logger: Optional[DebugLogger] = None) -> Any:
|
||||
"""Download streaming media exclusively via yt-dlp."""
|
||||
def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] = None, debug_logger: Optional[DebugLogger] = None) -> Any:
|
||||
"""Download streaming media exclusively via yt-dlp.
|
||||
|
||||
Optional `config` dict may be provided so tool defaults (e.g., cookies, default
|
||||
format) are applied when constructing the YtDlpTool instance.
|
||||
"""
|
||||
|
||||
debug(f"[download_media] start: {opts.url}")
|
||||
try:
|
||||
@@ -1533,7 +1751,8 @@ def download_media(opts: DownloadOptions, *, debug_logger: Optional[DebugLogger]
|
||||
|
||||
ensure_yt_dlp_ready()
|
||||
|
||||
ytdlp_tool = YtDlpTool()
|
||||
# Use provided config when available so user tool settings are honored
|
||||
ytdlp_tool = YtDlpTool(config or {})
|
||||
ytdl_options = ytdlp_tool.build_ytdlp_options(opts)
|
||||
hooks = ytdl_options.get("progress_hooks")
|
||||
if not isinstance(hooks, list):
|
||||
@@ -1817,7 +2036,7 @@ def download_media(opts: DownloadOptions, *, debug_logger: Optional[DebugLogger]
|
||||
return DownloadMediaResult(path=media_path, info=entry, tag=tags_res, source_url=source_url, hash_value=hash_value)
|
||||
|
||||
|
||||
def _download_with_timeout(opts: DownloadOptions, timeout_seconds: int = 300) -> Any:
|
||||
def _download_with_timeout(opts: DownloadOptions, timeout_seconds: int = 300, config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
@@ -1825,7 +2044,7 @@ def _download_with_timeout(opts: DownloadOptions, timeout_seconds: int = 300) ->
|
||||
|
||||
def _do_download() -> None:
|
||||
try:
|
||||
result_container[0] = download_media(opts)
|
||||
result_container[0] = download_media(opts, config=config)
|
||||
except Exception as exc:
|
||||
result_container[1] = exc
|
||||
|
||||
|
||||
Reference in New Issue
Block a user