Fix YouTube 403: cookie injection, configurable extractor_args, better errors

- 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
This commit is contained in:
xpltd 2026-03-21 22:13:25 -05:00
parent 245ec0e567
commit f3d1f29ca1
2 changed files with 44 additions and 5 deletions

View file

@ -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

View file

@ -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)