Add cookies.txt upload UI — drag/drop or click to upload, stored in data volume

This commit is contained in:
inputnoise
2026-05-25 21:01:02 +02:00
parent 56dd5f8360
commit 5f5ca52b95
3 changed files with 155 additions and 37 deletions

View File

@@ -1,9 +1,11 @@
from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..auth_utils import get_current_user from ..auth_utils import get_current_user
from ..config import settings
from ..database import get_db from ..database import get_db
from ..models import User, UserSettings from ..models import User, UserSettings
from ..services import ytdlp from ..services import ytdlp
@@ -120,3 +122,46 @@ def update_settings(
db.commit() db.commit()
db.refresh(s) db.refresh(s)
return s return s
def _cookies_path() -> Path:
db_file = settings.database_url.replace("sqlite:///", "")
return Path(db_file).parent / "cookies.txt"
@router.post("/cookies-file", response_model=SettingsOut)
async def upload_cookies_file(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not file.filename or not file.filename.endswith(".txt"):
raise HTTPException(status_code=400, detail="Upload a .txt cookies file")
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 5 MB)")
path = _cookies_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
s = _get_or_create(db, current_user.id)
s.cookies_file = str(path)
ytdlp.set_cookies_file(str(path))
db.commit()
db.refresh(s)
return s
@router.delete("/cookies-file", response_model=SettingsOut)
def delete_cookies_file(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
path = _cookies_path()
if path.exists():
path.unlink()
s = _get_or_create(db, current_user.id)
s.cookies_file = ""
ytdlp.set_cookies_file("")
db.commit()
db.refresh(s)
return s

View File

@@ -101,6 +101,12 @@ export const exportData = () => api.get("/export", { responseType: "blob" });
// Settings // Settings
export const getSettings = () => api.get("/settings"); export const getSettings = () => api.get("/settings");
export const updateSettings = (data) => api.patch("/settings", data); export const updateSettings = (data) => api.patch("/settings", data);
export const uploadCookiesFile = (file) => {
const form = new FormData();
form.append("file", file);
return api.post("/settings/cookies-file", form);
};
export const deleteCookiesFile = () => api.delete("/settings/cookies-file");
// Discovery // Discovery
export const getDiscovery = (offset = 0, limit = 50) => export const getDiscovery = (offset = 0, limit = 50) =>

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings, exportData, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig } from "../api"; import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig } from "../api";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
const REGION_OPTIONS = [ const REGION_OPTIONS = [
@@ -195,6 +195,106 @@ function AdminSection() {
); );
} }
function CookiesSection({ s, qc, set }) {
const fileRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
const uploadMut = useMutation({
mutationFn: (file) => uploadCookiesFile(file),
onSuccess: (res) => qc.setQueryData(["settings"], res.data),
});
const deleteMut = useMutation({
mutationFn: deleteCookiesFile,
onSuccess: (res) => qc.setQueryData(["settings"], res.data),
});
const handleFile = (file) => {
if (file) uploadMut.mutate(file);
};
const hasFile = !!s?.cookies_file;
return (
<Section title="YouTube authentication">
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<p className="text-sm font-medium text-zinc-200">Cookies file</p>
<p className="text-xs text-zinc-500 mt-0.5">
Export your YouTube cookies as <span className="font-mono text-zinc-400 text-[11px]">cookies.txt</span> using
the <span className="text-zinc-400">"Get cookies.txt LOCALLY"</span> browser extension,
then upload it here. Required for age-restricted or bot-detected videos.
</p>
</div>
{hasFile ? (
<div className="flex items-center justify-between gap-3 bg-zinc-800 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 min-w-0">
<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 font-mono truncate">{s.cookies_file}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => fileRef.current?.click()}
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
replace
</button>
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
remove
</button>
</div>
</div>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
onClick={() => fileRef.current?.click()}
className={`flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-xl px-4 py-6 cursor-pointer transition-colors ${
dragOver ? "border-accent bg-accent/5" : "border-zinc-700 hover:border-zinc-500"
}`}
>
{uploadMut.isPending ? (
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
) : (
<>
<svg className="w-6 h-6 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<p className="text-xs text-zinc-500">Drop <span className="font-mono text-zinc-400">cookies.txt</span> here or click to upload</p>
</>
)}
{uploadMut.isError && (
<p className="text-xs text-red-400">{uploadMut.error?.response?.data?.detail ?? "Upload failed"}</p>
)}
</div>
)}
<input ref={fileRef} type="file" accept=".txt" className="hidden" onChange={(e) => handleFile(e.target.files[0])} />
</div>
<Row
label="Browser cookies"
hint="Only works outside Docker. Pass cookies from a local browser install."
>
<select
value={s?.cookies_browser ?? ""}
onChange={(e) => set({ cookies_browser: e.target.value })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
>
{BROWSER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Row>
</Section>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const { user } = useAuth(); const { user } = useAuth();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -240,40 +340,7 @@ export default function SettingsPage() {
</Section> </Section>
{/* YouTube authentication */} {/* YouTube authentication */}
<Section title="YouTube authentication"> <CookiesSection s={s} qc={qc} set={set} />
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<p className="text-sm font-medium text-zinc-200">Cookies file</p>
<p className="text-xs text-zinc-500 mt-0.5">
Recommended for Docker. Export your YouTube cookies as <span className="text-zinc-400 font-mono text-[11px]">cookies.txt</span> using
the <span className="text-zinc-400">"Get cookies.txt LOCALLY"</span> browser extension, then
place the file at <span className="text-zinc-400 font-mono text-[11px]">/data/cookies.txt</span> inside
the data volume it will be picked up automatically. Or enter a custom path below.
</p>
</div>
<input
type="text"
placeholder="e.g. /data/cookies.txt"
value={s?.cookies_file ?? ""}
onChange={(e) => set({ cookies_file: e.target.value })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-2 border border-zinc-700 focus:outline-none focus:border-accent font-mono placeholder:text-zinc-600 placeholder:font-sans"
/>
</div>
<Row
label="Browser cookies"
hint="Only works outside Docker. Pass cookies from a local browser install to bypass bot detection."
>
<select
value={s?.cookies_browser ?? ""}
onChange={(e) => set({ cookies_browser: e.target.value })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
>
{BROWSER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Row>
</Section>
{/* Download quality */} {/* Download quality */}
<Section title="Download quality"> <Section title="Download quality">