Add cookies.txt upload UI — drag/drop or click to upload, stored in data volume
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user