Fix cookie fallback breaking yt-dlp in Docker; add OAuth2 auth flow
- _cookie_args() no longer falls through to --cookies-from-browser when cookies_file is configured but missing. Firefox isn't installed in the Docker image, so that fallback caused yt-dlp to exit with empty stdout and every metadata fetch to return "Video not found on YouTube". - fetch_video_metadata() now retries without auth args if the first call fails, so a broken cookie config can't block public video fetches. - Add use_oauth2 setting + full device-auth flow (POST /settings/oauth2-init, GET /settings/oauth2-status) with OAuth2Section UI in Settings page. - Add GET /settings/ytdlp-test diagnostics endpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -213,17 +213,20 @@ def fetch_video_metadata(video_id: str) -> dict | None:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cookie_args = _cookie_args()
|
||||
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
||||
stdout, stderr, code = _run([
|
||||
"yt-dlp",
|
||||
url,
|
||||
"--dump-json",
|
||||
"--no-download",
|
||||
"--no-playlist",
|
||||
base_cmd = [
|
||||
"yt-dlp", url,
|
||||
"--dump-json", "--no-download", "--no-playlist",
|
||||
"--extractor-args", "youtube:player_client=tv,ios,web",
|
||||
*cookie_args,
|
||||
], timeout=30)
|
||||
]
|
||||
stdout, stderr, code = _run([*base_cmd, *cookie_args], timeout=30)
|
||||
if code != 0:
|
||||
print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True)
|
||||
# Retry without auth args — broken cookie config shouldn't block public videos
|
||||
if cookie_args:
|
||||
print(f"[fetch_meta] retrying without cookie args", flush=True)
|
||||
stdout, stderr, code = _run(base_cmd, timeout=30)
|
||||
if code != 0:
|
||||
print(f"[fetch_meta] retry also FAILED code={code}", flush=True)
|
||||
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
@@ -406,10 +409,15 @@ _SEMAPHORE = threading.Semaphore(3)
|
||||
_semaphore_lock = threading.Lock()
|
||||
_cookies_browser: str = ""
|
||||
_cookies_file: str = ""
|
||||
_use_oauth2: bool = False
|
||||
_cookies_lock = threading.Lock()
|
||||
|
||||
_AUTO_COOKIES_PATHS = ["/data/cookies.txt"]
|
||||
|
||||
# OAuth2 device-auth flow state (shared across threads)
|
||||
_oauth2_state: dict = {"status": "idle", "device_url": None, "code": None, "error": None}
|
||||
_oauth2_state_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_max_concurrent(n: int) -> None:
|
||||
global _SEMAPHORE
|
||||
@@ -429,23 +437,103 @@ def set_cookies_file(path: str) -> None:
|
||||
_cookies_file = path.strip()
|
||||
|
||||
|
||||
def set_oauth2(enabled: bool) -> None:
|
||||
global _use_oauth2
|
||||
with _cookies_lock:
|
||||
_use_oauth2 = bool(enabled)
|
||||
|
||||
|
||||
def _cookie_args() -> list[str]:
|
||||
with _cookies_lock:
|
||||
cf = _cookies_file
|
||||
b = _cookies_browser
|
||||
# Prefer explicit cookies file
|
||||
oauth2 = _use_oauth2
|
||||
# OAuth2 token auth — IP-independent, works on datacenter servers
|
||||
if oauth2:
|
||||
return ["--username", "oauth2", "--password", ""]
|
||||
# Explicit cookies file
|
||||
if cf and Path(cf).exists():
|
||||
return ["--cookies", cf]
|
||||
# Auto-detect cookies.txt in well-known Docker locations
|
||||
for candidate in _AUTO_COOKIES_PATHS:
|
||||
if Path(candidate).exists():
|
||||
return ["--cookies", candidate]
|
||||
# Fall back to browser (works in local dev, not in Docker)
|
||||
if b:
|
||||
# Browser cookies — only when no file path was ever configured.
|
||||
# If cookies_file is set but missing, the user intended file auth; falling
|
||||
# through to a browser that isn't installed in Docker would silently break
|
||||
# all yt-dlp calls with an empty-stdout failure.
|
||||
if b and not cf:
|
||||
return ["--cookies-from-browser", b]
|
||||
return []
|
||||
|
||||
|
||||
def get_oauth2_status() -> dict:
|
||||
with _oauth2_state_lock:
|
||||
return dict(_oauth2_state)
|
||||
|
||||
|
||||
def start_oauth2_flow() -> dict:
|
||||
"""Start yt-dlp OAuth2 device-auth flow in a background thread.
|
||||
|
||||
yt-dlp prints a Google device URL + code to stderr, then polls until the user
|
||||
completes sign-in on their phone/browser. Token is cached to /data/yt-dlp-cache
|
||||
(set globally via /etc/yt-dlp.conf) and reused on every subsequent call that
|
||||
passes --username oauth2 --password "".
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
with _oauth2_state_lock:
|
||||
if _oauth2_state["status"] == "pending":
|
||||
return dict(_oauth2_state)
|
||||
_oauth2_state.update({"status": "pending", "device_url": None, "code": None, "error": None})
|
||||
|
||||
def _run_flow():
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"yt-dlp",
|
||||
"--username", "oauth2", "--password", "",
|
||||
"https://www.youtube.com/",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
for line in process.stderr:
|
||||
line = line.strip()
|
||||
print(f"[oauth2] {line}", flush=True)
|
||||
if "google.com/device" in line or "youtube.com/device" in line:
|
||||
url_m = re.search(r"(https://[^\s]+)", line)
|
||||
code_m = re.search(r"code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4}|[A-Z0-9-]{6,})", line, re.IGNORECASE)
|
||||
with _oauth2_state_lock:
|
||||
_oauth2_state["device_url"] = (url_m.group(1) if url_m else "https://www.google.com/device")
|
||||
_oauth2_state["code"] = code_m.group(1) if code_m else None
|
||||
process.wait()
|
||||
with _oauth2_state_lock:
|
||||
if process.returncode == 0:
|
||||
_oauth2_state["status"] = "complete"
|
||||
else:
|
||||
_oauth2_state["status"] = "error"
|
||||
_oauth2_state["error"] = f"yt-dlp exited with code {process.returncode}"
|
||||
except Exception as exc:
|
||||
with _oauth2_state_lock:
|
||||
_oauth2_state["status"] = "error"
|
||||
_oauth2_state["error"] = str(exc)
|
||||
|
||||
threading.Thread(target=_run_flow, daemon=True).start()
|
||||
|
||||
# Wait up to 10 s for the device URL to appear in stderr
|
||||
import time as _time
|
||||
for _ in range(100):
|
||||
with _oauth2_state_lock:
|
||||
if _oauth2_state["device_url"] or _oauth2_state["status"] in ("complete", "error"):
|
||||
break
|
||||
_time.sleep(0.1)
|
||||
|
||||
with _oauth2_state_lock:
|
||||
return dict(_oauth2_state)
|
||||
|
||||
|
||||
def start_download(
|
||||
video_id: str,
|
||||
download_id: int,
|
||||
|
||||
Reference in New Issue
Block a user