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.
+