diff --git a/backend/main.py b/backend/main.py index 636b05f..d8e9c8e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,6 +70,7 @@ def on_startup(): "ALTER TABLE user_settings ADD COLUMN feed_weight_recency REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0", + "ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 0", """CREATE TABLE IF NOT EXISTS search_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -144,6 +145,7 @@ def on_startup(): ytdlp_service.set_max_concurrent(first_user_settings.max_concurrent_downloads) ytdlp_service.set_cookies_browser(first_user_settings.cookies_browser or "") ytdlp_service.set_cookies_file(first_user_settings.cookies_file or "") + ytdlp_service.set_oauth2(bool(getattr(first_user_settings, "use_oauth2", False))) finally: db.close() diff --git a/backend/models.py b/backend/models.py index fbf87de..e14b057 100644 --- a/backend/models.py +++ b/backend/models.py @@ -118,6 +118,7 @@ class UserSettings(Base): feed_weight_recency = Column(Float, default=5.0) # 0–10 feed_weight_affinity = Column(Float, default=5.0) # 0–10 feed_weight_channel = Column(Float, default=5.0) # 0–10 + use_oauth2 = Column(Boolean, default=False) class DiscoveryQueue(Base): diff --git a/backend/routers/settings.py b/backend/routers/settings.py index af6fd17..cbb8711 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -33,6 +33,7 @@ class SettingsOut(BaseModel): feed_weight_recency: float = 5.0 feed_weight_affinity: float = 5.0 feed_weight_channel: float = 5.0 + use_oauth2: bool = False model_config = {"from_attributes": True} @@ -53,6 +54,7 @@ class SettingsPatch(BaseModel): feed_weight_recency: Optional[float] = Field(None, ge=0.0, le=10.0) feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0) feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0) + use_oauth2: Optional[bool] = None def _get_or_create(db: Session, user_id: int) -> UserSettings: @@ -118,6 +120,9 @@ def update_settings( s.feed_weight_affinity = body.feed_weight_affinity if body.feed_weight_channel is not None: s.feed_weight_channel = body.feed_weight_channel + if body.use_oauth2 is not None: + s.use_oauth2 = body.use_oauth2 + ytdlp.set_oauth2(body.use_oauth2) db.commit() db.refresh(s) @@ -165,3 +170,75 @@ def delete_cookies_file( db.commit() db.refresh(s) return s + + +@router.post("/oauth2-init") +def oauth2_init( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Start a yt-dlp OAuth2 device-auth flow. Returns the device URL and code immediately.""" + state = ytdlp.start_oauth2_flow() + return state + + +@router.get("/oauth2-status") +def oauth2_status( + current_user: User = Depends(get_current_user), +): + """Poll the current OAuth2 flow status.""" + return ytdlp.get_oauth2_status() + + +@router.post("/oauth2-enable", response_model=SettingsOut) +def oauth2_enable( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark OAuth2 as the active auth method after a successful flow.""" + s = _get_or_create(db, current_user.id) + s.use_oauth2 = True + ytdlp.set_oauth2(True) + db.commit() + db.refresh(s) + return s + + +@router.post("/oauth2-disable", response_model=SettingsOut) +def oauth2_disable( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Disable OAuth2 auth, falling back to cookies.""" + s = _get_or_create(db, current_user.id) + s.use_oauth2 = False + ytdlp.set_oauth2(False) + db.commit() + db.refresh(s) + return s + + +@router.get("/ytdlp-test") +def ytdlp_test( + current_user: User = Depends(get_current_user), +): + """Run a quick yt-dlp metadata fetch on a public video and return raw output for diagnostics.""" + import subprocess + cookie_args = ytdlp._cookie_args() + result = subprocess.run( + [ + "yt-dlp", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "--dump-json", "--no-download", "--no-playlist", + "--extractor-args", "youtube:player_client=tv,ios,web", + *cookie_args, + ], + capture_output=True, text=True, timeout=30, + ) + return { + "returncode": result.returncode, + "cookie_args": cookie_args, + "stdout_lines": result.stdout.splitlines()[:5], + "stderr_tail": result.stderr.splitlines()[-20:], + "success": result.returncode == 0, + } diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 9276c7c..ffd8e79 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -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, diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d13001c..5a0b5f4 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -107,6 +107,11 @@ export const uploadCookiesFile = (file) => { return api.post("/settings/cookies-file", form); }; export const deleteCookiesFile = () => api.delete("/settings/cookies-file"); +export const initOAuth2 = () => api.post("/settings/oauth2-init"); +export const getOAuth2Status = () => api.get("/settings/oauth2-status"); +export const enableOAuth2 = () => api.post("/settings/oauth2-enable"); +export const disableOAuth2 = () => api.post("/settings/oauth2-disable"); +export const testYtdlp = () => api.get("/settings/ytdlp-test"); // Discovery export const getDiscovery = (offset = 0, limit = 50) => diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 38016a5..f206c29 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,6 +1,6 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig } from "../api"; +import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig, initOAuth2, getOAuth2Status, enableOAuth2, disableOAuth2, testYtdlp } from "../api"; import { useAuth } from "../hooks/useAuth"; const REGION_OPTIONS = [ @@ -295,6 +295,155 @@ function CookiesSection({ s, qc, set }) { ); } +function OAuth2Section({ s, qc }) { + const [flowState, setFlowState] = useState(null); // null | {status, device_url, code} + const [polling, setPolling] = useState(false); + + const isEnabled = s?.use_oauth2 ?? false; + + const initMut = useMutation({ + mutationFn: initOAuth2, + onSuccess: (res) => { + setFlowState(res.data); + if (res.data.status === "pending") setPolling(true); + }, + }); + + const enableMut = useMutation({ + mutationFn: enableOAuth2, + onSuccess: (res) => qc.setQueryData(["settings"], res.data), + }); + + const disableMut = useMutation({ + mutationFn: disableOAuth2, + onSuccess: (res) => { qc.setQueryData(["settings"], res.data); setFlowState(null); }, + }); + + // Poll for completion when a flow is running + useEffect(() => { + if (!polling) return; + const id = setInterval(async () => { + try { + const res = await getOAuth2Status(); + const st = res.data; + setFlowState(st); + if (st.status === "complete") { + setPolling(false); + enableMut.mutate(); + } else if (st.status === "error") { + setPolling(false); + } + } catch {} + }, 2000); + return () => clearInterval(id); + }, [polling]); + + return ( +
+
+

+ OAuth2 lets yt-dlp authenticate directly with your Google account — useful if cookies keep expiring. + You'll need to visit a URL on any device to approve access. The token is cached on the server. +

+ + {isEnabled && !flowState && ( +
+
+ + + + OAuth2 active +
+
+ + +
+
+ )} + + {!isEnabled && !flowState && ( + + )} + + {flowState && flowState.status === "pending" && ( +
+
+
+

Waiting for authorization…

+
+ {flowState.device_url && ( +
+

+ Open this URL on any device and sign into your Google account: +

+ + {flowState.device_url} + + {flowState.code && ( +

+ Code: {flowState.code} +

+ )} +
+ )} +
+ )} + + {flowState && flowState.status === "complete" && ( +
+ + + +

Authorized! OAuth2 is now active.

+
+ )} + + {flowState && flowState.status === "error" && ( +
+

OAuth2 flow failed.

+ {flowState.error &&

{flowState.error}

} + +
+ )} +
+
+ ); +} + export default function SettingsPage() { const { user } = useAuth(); const qc = useQueryClient(); @@ -341,6 +490,7 @@ export default function SettingsPage() { {/* YouTube authentication */} + {/* Download quality */}