feat: add YouTube cookies upload via web UI

Adds a Settings panel to upload a cookies.txt file directly from the
browser, persisted in a named Docker volume. yt-dlp uses the file
when present to bypass YouTube bot detection.
This commit is contained in:
Julien Calixte
2026-03-23 19:32:51 +01:00
parent c49ecab33f
commit 57910462e4
5 changed files with 91 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
max_upload_size_mb: int = 500
yt_dlp_cookies_file: str = ""
@property
def max_upload_size_bytes(self) -> int:

View File

@@ -2,6 +2,7 @@ import asyncio
import uuid
import os
from pathlib import Path
from app.config import settings
AUDIO_TMP_DIR = "/tmp/apoena-audio"
@@ -21,9 +22,13 @@ async def extract_audio(url: str) -> Path:
"--audio-quality", "128K",
"--format", "bestaudio/best",
"--output", outtmpl,
url,
]
if settings.yt_dlp_cookies_file:
cmd += ["--cookies", settings.yt_dlp_cookies_file]
cmd.append(url)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,

View File

@@ -2,11 +2,12 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app import downloader
from app.config import settings
STATIC_DIR = Path(__file__).parent / "static"
@@ -74,6 +75,25 @@ def _delete_file(path):
pass
@app.post("/admin/cookies")
async def upload_cookies(file: UploadFile):
if not settings.yt_dlp_cookies_file:
raise HTTPException(status_code=500, detail="YT_DLP_COOKIES_FILE not configured")
cookies_path = Path(settings.yt_dlp_cookies_file)
cookies_path.parent.mkdir(parents=True, exist_ok=True)
content = await file.read()
cookies_path.write_bytes(content)
return {"status": "ok", "path": str(cookies_path)}
@app.get("/admin/cookies/status")
async def cookies_status():
if not settings.yt_dlp_cookies_file:
return {"present": False}
path = Path(settings.yt_dlp_cookies_file)
return {"present": path.exists(), "size": path.stat().st_size if path.exists() else 0}
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -255,9 +255,23 @@
<div class="container">
<header>
<h1><span>apoena</span> transcript</h1>
<span id="device-badge" class="badge badge-loading">Loading...</span>
<div style="display:flex;align-items:center;gap:0.6rem">
<span id="device-badge" class="badge badge-loading">Loading...</span>
<button id="settings-toggle" style="background:transparent;border:1px solid #2a2a38;color:#64748b;padding:0.3rem 0.6rem;font-size:0.8rem;font-weight:400">⚙ Settings</button>
</div>
</header>
<!-- Settings panel -->
<div id="settings-panel" class="card" style="display:none;margin-bottom:1rem">
<h2>YouTube Cookies</h2>
<p style="font-size:0.82rem;color:#64748b;margin-bottom:0.8rem">Upload a <code>cookies.txt</code> (Netscape format) to bypass YouTube bot detection. Export it from Chrome using the <em>Get cookies.txt LOCALLY</em> extension while logged in to YouTube.</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap">
<input type="file" id="cookies-input" accept=".txt,text/plain" style="font-size:0.85rem;color:#94a3b8" />
<button id="cookies-btn" disabled>Upload</button>
</div>
<div id="cookies-status" style="font-size:0.82rem;color:#64748b;margin-top:0.6rem"></div>
</div>
<!-- Model status -->
<div id="model-status" class="model-status">
Loading model — first visit downloads ~100 MB, then it's cached locally.
@@ -684,6 +698,49 @@ function pad(n, len = 2) { return String(Math.floor(n)).padStart(len, '0'); }
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// ── Settings (cookies upload) ───────────────────────────────────────────────
const settingsToggle = document.getElementById('settings-toggle');
const settingsPanel = document.getElementById('settings-panel');
const cookiesInput = document.getElementById('cookies-input');
const cookiesBtn = document.getElementById('cookies-btn');
const cookiesStatus = document.getElementById('cookies-status');
settingsToggle.addEventListener('click', async () => {
const open = settingsPanel.style.display !== 'none';
settingsPanel.style.display = open ? 'none' : 'block';
if (!open) {
const res = await fetch('/admin/cookies/status').then(r => r.json()).catch(() => null);
if (res) {
cookiesStatus.textContent = res.present
? `✓ Cookies file present (${(res.size / 1024).toFixed(1)} KB)`
: 'No cookies file uploaded yet.';
}
}
});
cookiesInput.addEventListener('change', () => {
cookiesBtn.disabled = !cookiesInput.files.length;
});
cookiesBtn.addEventListener('click', async () => {
const file = cookiesInput.files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
cookiesBtn.disabled = true;
cookiesStatus.textContent = 'Uploading…';
try {
const res = await fetch('/admin/cookies', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
cookiesStatus.textContent = '✓ Cookies uploaded successfully.';
cookiesInput.value = '';
} catch (err) {
cookiesStatus.textContent = 'Error: ' + err.message;
} finally {
cookiesBtn.disabled = false;
}
});
</script>
</body>
</html>

View File

@@ -3,8 +3,13 @@ services:
build: .
environment:
- MAX_UPLOAD_SIZE_MB=500
- YT_DLP_COOKIES_FILE=/data/yt-cookies.txt
volumes:
- /tmp/apoena-audio:/tmp/apoena-audio
- apoena-data:/data
volumes:
apoena-data:
labels:
- 'traefik.http.middlewares.myauth.basicauth.users=julien:$$2y$$05$$1AWK6NyxNNnAX4opcBftnutdMEPTH422uKkNs/NoB3S8i9Bze4GSS'
- 'traefik.http.routers.http-0-p10gcndwy2krf8zu7swn3i2w-app.middlewares=myauth'