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:
@@ -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_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_affinity REAL DEFAULT 5.0",
|
||||||
"ALTER TABLE user_settings ADD COLUMN feed_weight_channel 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 (
|
"""CREATE TABLE IF NOT EXISTS search_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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_max_concurrent(first_user_settings.max_concurrent_downloads)
|
||||||
ytdlp_service.set_cookies_browser(first_user_settings.cookies_browser or "")
|
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_cookies_file(first_user_settings.cookies_file or "")
|
||||||
|
ytdlp_service.set_oauth2(bool(getattr(first_user_settings, "use_oauth2", False)))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class UserSettings(Base):
|
|||||||
feed_weight_recency = Column(Float, default=5.0) # 0–10
|
feed_weight_recency = Column(Float, default=5.0) # 0–10
|
||||||
feed_weight_affinity = 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
|
feed_weight_channel = Column(Float, default=5.0) # 0–10
|
||||||
|
use_oauth2 = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
class DiscoveryQueue(Base):
|
class DiscoveryQueue(Base):
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class SettingsOut(BaseModel):
|
|||||||
feed_weight_recency: float = 5.0
|
feed_weight_recency: float = 5.0
|
||||||
feed_weight_affinity: float = 5.0
|
feed_weight_affinity: float = 5.0
|
||||||
feed_weight_channel: float = 5.0
|
feed_weight_channel: float = 5.0
|
||||||
|
use_oauth2: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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_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_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||||
feed_weight_channel: 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:
|
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
|
s.feed_weight_affinity = body.feed_weight_affinity
|
||||||
if body.feed_weight_channel is not None:
|
if body.feed_weight_channel is not None:
|
||||||
s.feed_weight_channel = body.feed_weight_channel
|
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.commit()
|
||||||
db.refresh(s)
|
db.refresh(s)
|
||||||
@@ -165,3 +170,75 @@ def delete_cookies_file(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(s)
|
db.refresh(s)
|
||||||
return 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,17 +213,20 @@ def fetch_video_metadata(video_id: str) -> dict | None:
|
|||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
cookie_args = _cookie_args()
|
cookie_args = _cookie_args()
|
||||||
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
print(f"[fetch_meta] video={video_id} cookie_args={cookie_args!r}", flush=True)
|
||||||
stdout, stderr, code = _run([
|
base_cmd = [
|
||||||
"yt-dlp",
|
"yt-dlp", url,
|
||||||
url,
|
"--dump-json", "--no-download", "--no-playlist",
|
||||||
"--dump-json",
|
|
||||||
"--no-download",
|
|
||||||
"--no-playlist",
|
|
||||||
"--extractor-args", "youtube:player_client=tv,ios,web",
|
"--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:
|
if code != 0:
|
||||||
print(f"[fetch_meta] FAILED code={code} stderr={stderr[:500]!r}", flush=True)
|
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():
|
for line in stdout.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -406,10 +409,15 @@ _SEMAPHORE = threading.Semaphore(3)
|
|||||||
_semaphore_lock = threading.Lock()
|
_semaphore_lock = threading.Lock()
|
||||||
_cookies_browser: str = ""
|
_cookies_browser: str = ""
|
||||||
_cookies_file: str = ""
|
_cookies_file: str = ""
|
||||||
|
_use_oauth2: bool = False
|
||||||
_cookies_lock = threading.Lock()
|
_cookies_lock = threading.Lock()
|
||||||
|
|
||||||
_AUTO_COOKIES_PATHS = ["/data/cookies.txt"]
|
_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:
|
def set_max_concurrent(n: int) -> None:
|
||||||
global _SEMAPHORE
|
global _SEMAPHORE
|
||||||
@@ -429,23 +437,103 @@ def set_cookies_file(path: str) -> None:
|
|||||||
_cookies_file = path.strip()
|
_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]:
|
def _cookie_args() -> list[str]:
|
||||||
with _cookies_lock:
|
with _cookies_lock:
|
||||||
cf = _cookies_file
|
cf = _cookies_file
|
||||||
b = _cookies_browser
|
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():
|
if cf and Path(cf).exists():
|
||||||
return ["--cookies", cf]
|
return ["--cookies", cf]
|
||||||
# Auto-detect cookies.txt in well-known Docker locations
|
# Auto-detect cookies.txt in well-known Docker locations
|
||||||
for candidate in _AUTO_COOKIES_PATHS:
|
for candidate in _AUTO_COOKIES_PATHS:
|
||||||
if Path(candidate).exists():
|
if Path(candidate).exists():
|
||||||
return ["--cookies", candidate]
|
return ["--cookies", candidate]
|
||||||
# Fall back to browser (works in local dev, not in Docker)
|
# Browser cookies — only when no file path was ever configured.
|
||||||
if b:
|
# 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 ["--cookies-from-browser", b]
|
||||||
return []
|
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(
|
def start_download(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
download_id: int,
|
download_id: int,
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export const uploadCookiesFile = (file) => {
|
|||||||
return api.post("/settings/cookies-file", form);
|
return api.post("/settings/cookies-file", form);
|
||||||
};
|
};
|
||||||
export const deleteCookiesFile = () => api.delete("/settings/cookies-file");
|
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
|
// Discovery
|
||||||
export const getDiscovery = (offset = 0, limit = 50) =>
|
export const getDiscovery = (offset = 0, limit = 50) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
const REGION_OPTIONS = [
|
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 (
|
||||||
|
<Section title="Google OAuth2 (advanced)">
|
||||||
|
<div className="px-5 py-4 flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isEnabled && !flowState && (
|
||||||
|
<div className="flex items-center justify-between bg-zinc-800 rounded-xl px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-zinc-300">OAuth2 active</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => initMut.mutate()}
|
||||||
|
disabled={initMut.isPending}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
reauthorize
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => disableMut.mutate()}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEnabled && !flowState && (
|
||||||
|
<button
|
||||||
|
onClick={() => initMut.mutate()}
|
||||||
|
disabled={initMut.isPending}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sm text-zinc-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{initMut.isPending ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{initMut.isPending ? "Starting…" : "Connect with Google OAuth2"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flowState && flowState.status === "pending" && (
|
||||||
|
<div className="flex flex-col gap-3 bg-zinc-800 rounded-xl px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
|
||||||
|
<p className="text-xs text-zinc-300 font-medium">Waiting for authorization…</p>
|
||||||
|
</div>
|
||||||
|
{flowState.device_url && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Open this URL on any device and sign into your Google account:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={flowState.device_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-accent font-mono break-all hover:underline"
|
||||||
|
>
|
||||||
|
{flowState.device_url}
|
||||||
|
</a>
|
||||||
|
{flowState.code && (
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">
|
||||||
|
Code: <span className="font-mono text-zinc-200 font-semibold">{flowState.code}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flowState && flowState.status === "complete" && (
|
||||||
|
<div className="flex items-center gap-2 bg-green-950/40 rounded-xl px-4 py-3">
|
||||||
|
<svg className="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs text-green-400">Authorized! OAuth2 is now active.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flowState && flowState.status === "error" && (
|
||||||
|
<div className="flex flex-col gap-1 bg-red-950/40 rounded-xl px-4 py-3">
|
||||||
|
<p className="text-xs text-red-400">OAuth2 flow failed.</p>
|
||||||
|
{flowState.error && <p className="text-[11px] text-red-500 font-mono">{flowState.error}</p>}
|
||||||
|
<button
|
||||||
|
onClick={() => { setFlowState(null); initMut.mutate(); }}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-200 mt-1 text-left"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -341,6 +490,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* YouTube authentication */}
|
{/* YouTube authentication */}
|
||||||
<CookiesSection s={s} qc={qc} set={set} />
|
<CookiesSection s={s} qc={qc} set={set} />
|
||||||
|
<OAuth2Section s={s} qc={qc} />
|
||||||
|
|
||||||
{/* Download quality */}
|
{/* Download quality */}
|
||||||
<Section title="Download quality">
|
<Section title="Download quality">
|
||||||
|
|||||||
Reference in New Issue
Block a user