diff --git a/.env.example b/.env.example index fc65296..bbeea6a 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,14 @@ OPENROUTER_API_KEY= OPENROUTER_MODEL=google/gemini-2.5-flash # YouTube IP-Block umgehen (optional) -# Statischer Proxy aus dem Dashboard, z.B. http://USER:PASS@HOST:PORT +# Option A — volle URL (http:// + Port aus Dashboard, z.B. 6641): +# YOUTUBE_PROXY=http://USER:PASS@64.137.96.74:6641 YOUTUBE_PROXY= +# Option B — Felder aus dem Dashboard (einfacher, kein URL-Format): +# YOUTUBE_PROXY_HOST=64.137.96.74 +# YOUTUBE_PROXY_PORT=6641 +# YOUTUBE_PROXY_USER= +# YOUTUBE_PROXY_PASSWORD= # Alternativ Webshare *Residential* (kostenpflichtig, rotating — NICHT Free/Proxy-Server): # WEBSHARE_PROXY_USERNAME= # WEBSHARE_PROXY_PASSWORD= diff --git a/README.md b/README.md index 744225b..001e89e 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,14 @@ Traefik übernimmt TLS via `myresolver`. Persistente Daten liegen in `./data/`. YouTube blockiert oft Server-IPs (Hetzner, AWS, …). Optional in `.env`: ```bash -# Statischer Proxy (z.B. aus Webshare „Proxy Server“ / Dashboard-Zeile) -YOUTUBE_PROXY=http://USER:PASS@HOST:PORT +# Volle URL — http:// und Port aus Dashboard (z.B. 6641, NICHT 443) +YOUTUBE_PROXY=http://USER:PASS@HOST:6641 + +# Oder einzelne Felder aus dem Dashboard: +YOUTUBE_PROXY_HOST=HOST +YOUTUBE_PROXY_PORT=6641 +YOUTUBE_PROXY_USER=USER +YOUTUBE_PROXY_PASSWORD=PASS ``` Für yt-dlp-Fallback und VTT-Downloads wird dieselbe URL genutzt. diff --git a/compose.dev.yml b/compose.dev.yml index ac42cb2..2841820 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -9,6 +9,10 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.5-flash} - YOUTUBE_PROXY=${YOUTUBE_PROXY:-} + - YOUTUBE_PROXY_HOST=${YOUTUBE_PROXY_HOST:-} + - YOUTUBE_PROXY_PORT=${YOUTUBE_PROXY_PORT:-0} + - YOUTUBE_PROXY_USER=${YOUTUBE_PROXY_USER:-} + - YOUTUBE_PROXY_PASSWORD=${YOUTUBE_PROXY_PASSWORD:-} - WEBSHARE_PROXY_USERNAME=${WEBSHARE_PROXY_USERNAME:-} - WEBSHARE_PROXY_PASSWORD=${WEBSHARE_PROXY_PASSWORD:-} - WEBSHARE_PROXY_LOCATIONS=${WEBSHARE_PROXY_LOCATIONS:-} diff --git a/compose.yml b/compose.yml index 0dd7f8f..5930ab1 100644 --- a/compose.yml +++ b/compose.yml @@ -9,6 +9,10 @@ services: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.5-flash} - YOUTUBE_PROXY=${YOUTUBE_PROXY:-} + - YOUTUBE_PROXY_HOST=${YOUTUBE_PROXY_HOST:-} + - YOUTUBE_PROXY_PORT=${YOUTUBE_PROXY_PORT:-0} + - YOUTUBE_PROXY_USER=${YOUTUBE_PROXY_USER:-} + - YOUTUBE_PROXY_PASSWORD=${YOUTUBE_PROXY_PASSWORD:-} - WEBSHARE_PROXY_USERNAME=${WEBSHARE_PROXY_USERNAME:-} - WEBSHARE_PROXY_PASSWORD=${WEBSHARE_PROXY_PASSWORD:-} - WEBSHARE_PROXY_LOCATIONS=${WEBSHARE_PROXY_LOCATIONS:-} diff --git a/server/settings.py b/server/settings.py index f0a4065..4b47eca 100644 --- a/server/settings.py +++ b/server/settings.py @@ -19,8 +19,13 @@ class Settings(BaseSettings): app_version: str = "1.0.0" # YouTube transcript proxy (optional — for IP blocks) - # Single URL used for HTTP+HTTPS, e.g. http://user:pass@host:port + # Option A: full URL — MUST include port, use http:// (not https://) youtube_proxy: str = "" + # Option B: dashboard fields (Webshare etc.) — combined into a proxy URL + youtube_proxy_host: str = "" + youtube_proxy_port: int = 0 + youtube_proxy_user: str = "" + youtube_proxy_password: str = "" # Webshare rotating residential (paid package — NOT free "Proxy Server" tier) webshare_proxy_username: str = "" webshare_proxy_password: str = "" diff --git a/server/transcripts.py b/server/transcripts.py index 53cf95f..e149cd4 100644 --- a/server/transcripts.py +++ b/server/transcripts.py @@ -5,6 +5,7 @@ import logging import re from dataclasses import dataclass from typing import Any +from urllib.parse import quote from youtube_transcript_api import YouTubeTranscriptApi from youtube_transcript_api._errors import ( @@ -72,6 +73,41 @@ def _proxy_dict() -> dict[str, str] | None: return config.to_requests_dict() +def normalize_proxy_url(url: str) -> str: + """Normalize common proxy URL mistakes (https scheme, missing port hint).""" + cleaned = url.strip() + if cleaned.startswith("https://"): + cleaned = "http://" + cleaned[len("https://") :] + logger.info("YOUTUBE_PROXY: https:// → http:// (Proxy-Protokoll ist HTTP)") + return cleaned + + +def resolve_proxy_url() -> str | None: + if settings.youtube_proxy.strip(): + return normalize_proxy_url(settings.youtube_proxy) + + host = settings.youtube_proxy_host.strip() + port = settings.youtube_proxy_port + user = settings.youtube_proxy_user.strip() + password = settings.youtube_proxy_password + if host and port and user and password: + return f"http://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}" + + return None + + +def _proxy_error_message(exc: Exception) -> str: + msg = str(exc) + if "Connection refused" in msg and ":443" in msg: + return ( + "Proxy-Verbindung fehlgeschlagen (Port 443). " + "Webshare-Dashboard-Proxies nutzen meist einen anderen Port (z.B. 6641). " + "Setze YOUTUBE_PROXY=http://USER:PASS@HOST:PORT oder " + "YOUTUBE_PROXY_HOST/PORT/USER/PASSWORD — Schema http://, nicht https://." + ) + return f"Proxy-Verbindung fehlgeschlagen: {exc}" + + def build_proxy_config() -> ProxyConfig | None: if settings.webshare_proxy_username and settings.webshare_proxy_password: locations = [ @@ -85,7 +121,7 @@ def build_proxy_config() -> ProxyConfig | None: filter_ip_locations=locations or None, ) - proxy_url = settings.youtube_proxy.strip() + proxy_url = resolve_proxy_url() if proxy_url: return GenericProxyConfig(http_url=proxy_url, https_url=proxy_url) @@ -93,7 +129,7 @@ def build_proxy_config() -> ProxyConfig | None: def proxy_url_for_ytdlp() -> str | None: - direct = settings.youtube_proxy.strip() + direct = resolve_proxy_url() if direct: return direct config = build_proxy_config() @@ -138,6 +174,8 @@ def _fetch_with_youtube_transcript_api(video_id: str) -> Transcript: raise except YouTubeRequestFailed as exc: logger.warning("youtube-transcript-api failed for %s: %s", video_id, exc) + if "ProxyError" in str(exc.reason) or "Unable to connect to proxy" in str(exc.reason): + raise ValueError(_proxy_error_message(exc)) from exc raise @@ -243,7 +281,11 @@ def fetch_transcript(video_id: str) -> Transcript: try: return _fetch_with_ytdlp(video_id) + except ValueError: + raise except Exception as exc: + if "ProxyError" in type(exc).__name__ or "Unable to connect to proxy" in str(exc): + raise ValueError(_proxy_error_message(exc)) from exc logger.exception("yt-dlp transcript fallback failed for %s", video_id) raise ValueError( "Dieses Video hat keine Captions. Dein Goldfisch kann leider nicht ins Leere starren."