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): class Settings(BaseSettings):
max_upload_size_mb: int = 500 max_upload_size_mb: int = 500
yt_dlp_cookies_file: str = ""
@property @property
def max_upload_size_bytes(self) -> int: def max_upload_size_bytes(self) -> int:

View File

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

View File

@@ -2,11 +2,12 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from app import downloader from app import downloader
from app.config import settings
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
@@ -74,6 +75,25 @@ def _delete_file(path):
pass 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("/") @app.get("/")
async def index(): async def index():
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")

View File

@@ -255,9 +255,23 @@
<div class="container"> <div class="container">
<header> <header>
<h1><span>apoena</span> transcript</h1> <h1><span>apoena</span> transcript</h1>
<div style="display:flex;align-items:center;gap:0.6rem">
<span id="device-badge" class="badge badge-loading">Loading...</span> <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> </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 --> <!-- Model status -->
<div id="model-status" class="model-status"> <div id="model-status" class="model-status">
Loading model — first visit downloads ~100 MB, then it's cached locally. 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) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js'); 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> </script>
</body> </body>
</html> </html>

View File

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