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:
Mattias Tall
2026-05-26 09:53:02 +02:00
parent b3284b35da
commit 98d986cd95
6 changed files with 336 additions and 13 deletions

View File

@@ -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) =>

View File

@@ -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">