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:
@@ -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 (
|
||||
<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() {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
@@ -341,6 +490,7 @@ export default function SettingsPage() {
|
||||
|
||||
{/* YouTube authentication */}
|
||||
<CookiesSection s={s} qc={qc} set={set} />
|
||||
<OAuth2Section s={s} qc={qc} />
|
||||
|
||||
{/* Download quality */}
|
||||
<Section title="Download quality">
|
||||
|
||||
Reference in New Issue
Block a user