From f3d1f29ca1ad075e2525875380a58907d1a7a9e6 Mon Sep 17 00:00:00 2001 From: xpltd Date: Sat, 21 Mar 2026 22:13:25 -0500 Subject: [PATCH] Fix YouTube 403: cookie injection, configurable extractor_args, better errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire up get_cookie_path_for_session() in download opts — session cookies.txt is now passed to yt-dlp as cookiefile when present - Add YtdlpConfig with extractor_args field, configurable via config.yaml or MEDIARIP__YTDLP__EXTRACTOR_ARGS env var (e.g. {"youtube": {"player_client": ["web_safari"]}}) - Inject extractor_args into all three yt-dlp call sites: _enqueue_single, _extract_info, _extract_url_info - Enhance 403 error messages with actionable guidance directing users to upload cookies.txt --- backend/app/core/config.py | 14 +++++++++++++ backend/app/services/download.py | 35 +++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7723403..a2fe4d3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -55,6 +55,19 @@ class DownloadsConfig(BaseModel): default_template: str = "%(title)s.%(ext)s" +class YtdlpConfig(BaseModel): + """yt-dlp tuning — operator-level knobs for YouTube and other extractors. + + ``extractor_args`` maps extractor names to dicts of arg lists, e.g.: + youtube: + player_client: ["web_safari", "android_vr"] + + These are passed through to yt-dlp as ``extractor_args``. + """ + + extractor_args: dict[str, dict[str, list[str]]] = {} + + class SessionConfig(BaseModel): """Session management settings.""" @@ -128,6 +141,7 @@ class AppConfig(BaseSettings): purge: PurgeConfig = PurgeConfig() ui: UIConfig = UIConfig() admin: AdminConfig = AdminConfig() + ytdlp: YtdlpConfig = YtdlpConfig() themes_dir: str = "./themes" @classmethod diff --git a/backend/app/services/download.py b/backend/app/services/download.py index f02d37b..07fc492 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -32,6 +32,7 @@ from app.models.job import ( JobStatus, ProgressEvent, ) +from app.routers.cookies import get_cookie_path_for_session from app.services.output_template import resolve_template logger = logging.getLogger("mediarip.download") @@ -196,6 +197,17 @@ class DownloadService: "preferedformat": "mp4", }] + # Inject session cookies if uploaded + cookie_path = get_cookie_path_for_session( + self._config.server.data_dir, session_id, + ) + if cookie_path: + opts["cookiefile"] = cookie_path + + # Operator-configured extractor_args (e.g. YouTube player_client) + if self._config.ytdlp.extractor_args: + opts["extractor_args"] = self._config.ytdlp.extractor_args + self._loop.run_in_executor( self._executor, self._run_download, @@ -443,11 +455,20 @@ class DownloadService: logger.info("Job %s completed", job_id) except Exception as e: + error_msg = str(e) + # Enhance 403 errors with actionable guidance + if "403" in error_msg: + error_msg = ( + f"{error_msg}\n\n" + "This usually means the site is blocking the download request. " + "Try uploading a cookies.txt file (Account menu → Upload cookies) " + "from a logged-in browser session." + ) logger.error("Job %s failed: %s", job_id, e, exc_info=True) try: asyncio.run_coroutine_threadsafe( update_job_status( - self._db, job_id, JobStatus.failed.value, str(e) + self._db, job_id, JobStatus.failed.value, error_msg ), self._loop, ).result(timeout=10) @@ -455,7 +476,7 @@ class DownloadService: "event": "job_update", "data": {"job_id": job_id, "status": "failed", "percent": 0, "speed": None, "eta": None, "filename": None, - "error_message": str(e)}, + "error_message": error_msg}, }) # Log to error_log table for admin visibility from app.core.database import log_download_error @@ -463,7 +484,7 @@ class DownloadService: log_download_error( self._db, url=url, - error=str(e), + error=error_msg, session_id=session_id, format_id=opts.get("format"), media_type=opts.get("_media_type"), @@ -478,11 +499,13 @@ class DownloadService: def _extract_info(self, url: str) -> dict | None: """Run yt-dlp extract_info synchronously (called from thread pool).""" - opts = { + opts: dict = { "quiet": True, "no_warnings": True, "skip_download": True, } + if self._config.ytdlp.extractor_args: + opts["extractor_args"] = self._config.ytdlp.extractor_args try: with yt_dlp.YoutubeDL(opts) as ydl: return ydl.extract_info(url, download=False) @@ -492,13 +515,15 @@ class DownloadService: def _extract_url_info(self, url: str) -> dict | None: """Extract URL metadata including playlist detection.""" - opts = { + opts: dict = { "quiet": True, "no_warnings": True, "skip_download": True, "extract_flat": "in_playlist", "noplaylist": False, } + if self._config.ytdlp.extractor_args: + opts["extractor_args"] = self._config.ytdlp.extractor_args try: with yt_dlp.YoutubeDL(opts) as ydl: return ydl.extract_info(url, download=False)