From d047ed7e1553284daae95aea9623ad13841b7641 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 00:32:17 +0200 Subject: [PATCH] feat(youtube): optional egress proxy for YouTube API calls New youtube_api_proxy setting (env fallback + admin Config UI, env YOUTUBE_API_PROXY): when set, the YouTubeClient routes all its httpx traffic through that HTTP(S) proxy. Lets a dynamic-IP host send API calls through a fixed-IP host (e.g. the server over private tunnel) so an IP-restricted API key keeps working. Empty = direct. --- backend/app/config.py | 4 ++++ backend/app/sysconfig.py | 2 ++ backend/app/youtube/client.py | 5 ++++- frontend/src/i18n/locales/de/config.json | 1 + frontend/src/i18n/locales/en/config.json | 1 + frontend/src/i18n/locales/hu/config.json | 1 + 6 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index cce9b1c..5564ee0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -65,6 +65,10 @@ class Settings(BaseSettings): # Optional API key for public reads (channels/videos/playlistItems). When set it is # preferred for shared backfill/enrichment so it doesn't depend on a specific user. youtube_api_key: str = "" + # Optional HTTP(S) proxy for outbound YouTube Data API calls. Set this to send the + # scheduler's googleapis traffic through a fixed-IP host (e.g. the VPS over WireGuard) so an + # IP-restricted API key keeps working even when this host's public IP is dynamic. + youtube_api_proxy: str = "" # Daily quota budget (units). Default leaves headroom under the 10,000/day free limit. quota_daily_budget: int = 9000 # Recent-first backfill: how far back to fetch on the first pass per channel. diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 51b3ad3..8ad8c53 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), + # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key). + ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy"), # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the # install wizard / Configuration page so no restart is needed when the creds change. --- ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True), diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index aecd93b..aa1490a 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -44,7 +44,10 @@ class YouTubeClient: def __init__(self, db, user: User): self.db = db self.user = user - self._http = httpx.Client(timeout=30.0) + # Optionally route all YouTube traffic through a fixed-IP egress proxy (e.g. the VPS + # over WireGuard) so an IP-restricted API key keeps working from a dynamic-IP host. + proxy = sysconfig.get_str(db, "youtube_api_proxy") or None + self._http = httpx.Client(timeout=30.0, proxy=proxy) def __enter__(self) -> "YouTubeClient": return self diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index e2bed7a..571c8ae 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -26,6 +26,7 @@ "enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, "autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." }, + "youtube_api_proxy": { "label": "YouTube-API-Egress-Proxy", "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." }, "google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." }, "google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." }, "allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." } diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 656a3e4..f67175d 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -26,6 +26,7 @@ "enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, "autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." }, + "youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." }, "google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." }, "google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." }, "allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." } diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 651200a..9d40c0c 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -26,6 +26,7 @@ "enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, "autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }, + "youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." }, "google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." }, "google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." }, "allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }