Files
patreon-downloader/frontend.html

361 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yt-dlp UI</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body { font-family: 'Space Grotesk', sans-serif; background: #0a0a0f; color: #e2e8f0; }
.mono { font-family: 'JetBrains Mono', monospace; }
.glass {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(12px);
}
.accent { color: #a78bfa; }
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
transition: all 0.2s;
}
.btn-primary:hover { filter: brightness(1.15); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); }
.terminal {
background: #050508;
border: 1px solid rgba(167,139,250,0.2);
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.format-row { transition: background 0.15s; }
.format-row:hover { background: rgba(167,139,250,0.08); }
.format-row.selected { background: rgba(124,58,237,0.2); border-color: rgba(124,58,237,0.5); }
input, textarea {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
transition: border-color 0.2s;
}
input:focus, textarea:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124,58,237,0.15);
}
.progress-bar {
height: 3px;
background: linear-gradient(90deg, #7c3aed, #818cf8);
border-radius: 2px;
transition: width 0.3s;
}
.tag {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
.glow { text-shadow: 0 0 20px rgba(167,139,250,0.5); }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.3} }
.pulse-dot { animation: pulse-dot 1s infinite; }
@keyframes slide-in { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.slide-in { animation: slide-in 0.25s ease forwards; }
</style>
</head>
<body class="min-h-screen p-4 md:p-8">
<!-- Header -->
<div class="max-w-4xl mx-auto mb-8">
<div class="flex items-center gap-3 mb-1">
<div class="w-2 h-2 rounded-full bg-violet-400 pulse-dot"></div>
<h1 class="text-2xl font-bold glow">yt-dlp <span class="accent">UI</span></h1>
</div>
<p class="text-slate-500 mono text-xs ml-5">Because Patreon won't let you download videos directly (for whatever reason)</p>
</div>
<div class="max-w-4xl mx-auto space-y-4">
<!-- Input Card -->
<div class="glass rounded-2xl p-6">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">URL</label>
<input id="urlInput" type="text" placeholder="https://www.patreon.com/posts/..."
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">Cookies file</label>
<input id="cookiesInput" type="text" placeholder="cookies.txt"
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2">Extra args <span class="normal-case text-slate-600">(optional)</span></label>
<input id="extraArgs" type="text" placeholder="--write-subs --embed-thumbnail"
class="w-full rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 mono" />
</div>
</div>
<div class="flex gap-3">
<button onclick="fetchInfo()" class="btn-primary text-white font-semibold px-6 py-2.5 rounded-xl text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Fetch formats
</button>
<button onclick="downloadDirect()" class="glass text-slate-300 hover:text-white font-semibold px-6 py-2.5 rounded-xl text-sm border border-white/10 hover:border-violet-500/40 transition-all flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Best quality
</button>
</div>
</div>
</div>
<!-- Video Info -->
<div id="infoCard" class="glass rounded-2xl p-6 hidden slide-in">
<div class="flex gap-4">
<img id="thumbnail" src="" alt="" class="w-32 h-20 object-cover rounded-lg flex-shrink-0 hidden" />
<div class="flex-1 min-w-0">
<h2 id="videoTitle" class="font-bold text-white text-lg leading-tight truncate"></h2>
<p id="videoMeta" class="text-slate-500 text-sm mt-1 mono"></p>
<p id="formatCount" class="text-xs text-slate-600 mt-1"></p>
</div>
</div>
</div>
<!-- Formats -->
<div id="formatsCard" class="glass rounded-2xl overflow-hidden hidden slide-in">
<div class="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-300">Available formats</span>
<div class="flex gap-2">
<button onclick="filterFormats('all')" id="fAll" class="text-xs px-3 py-1 rounded-lg bg-violet-600 text-white">All</button>
<button onclick="filterFormats('video')" id="fVideo" class="text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white">Video</button>
<button onclick="filterFormats('audio')" id="fAudio" class="text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white">Audio</button>
</div>
</div>
<div id="formatsTable" class="divide-y divide-white/5 max-h-80 overflow-y-auto"></div>
<div class="px-6 py-4 border-t border-white/5 flex items-center justify-between">
<span id="selectedLabel" class="mono text-xs text-slate-500">no format selected</span>
<button onclick="downloadSelected()" id="dlBtn" disabled
class="btn-primary disabled:opacity-40 disabled:cursor-not-allowed disabled:transform-none text-white font-semibold px-6 py-2 rounded-xl text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Download
</button>
</div>
</div>
<!-- Terminal Output -->
<div id="terminalCard" class="hidden slide-in">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div id="termDot" class="w-2 h-2 rounded-full bg-yellow-400 pulse-dot"></div>
<span class="text-xs font-semibold text-slate-400 uppercase tracking-widest">Output</span>
</div>
<div id="progressWrap" class="w-40 hidden">
<div class="text-right mono text-xs text-slate-500 mb-1" id="progressLabel"></div>
<div class="bg-white/5 rounded-full overflow-hidden"><div id="progressBar" class="progress-bar" style="width:0%"></div></div>
</div>
</div>
<div id="terminal" class="terminal rounded-xl p-4 h-64 overflow-y-auto"></div>
</div>
</div>
<script>
let selectedFormat = null;
let allFormats = [];
let currentFilter = 'all';
function formatSize(bytes) {
if (!bytes) return '?';
if (bytes > 1e9) return (bytes/1e9).toFixed(1) + ' GB';
if (bytes > 1e6) return (bytes/1e6).toFixed(1) + ' MB';
return (bytes/1e3).toFixed(0) + ' KB';
}
function formatDuration(s) {
if (!s) return '';
const m = Math.floor(s/60), sec = s%60;
return `${m}:${String(sec).padStart(2,'0')}`;
}
function codecTag(codec, type) {
if (!codec || codec === 'none') return '';
const colors = type === 'v' ? 'bg-blue-900/50 text-blue-300' : 'bg-emerald-900/50 text-emerald-300';
return `<span class="tag ${colors}">${codec.split('.')[0]}</span>`;
}
async function fetchInfo() {
const url = document.getElementById('urlInput').value.trim();
const cookies = document.getElementById('cookiesInput').value.trim();
if (!url) return;
setStatus('Fetching video info…');
showTerminal();
appendLine('$ yt-dlp --dump-json ' + url);
try {
const res = await fetch('/api/info', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url, cookies})
});
const data = await res.json();
if (data.error) { appendLine('[ERROR] ' + data.error, true); setDone(true); return; }
// Show info card
document.getElementById('infoCard').classList.remove('hidden');
document.getElementById('videoTitle').textContent = data.title;
document.getElementById('videoMeta').textContent =
(data.uploader ? data.uploader + ' · ' : '') + formatDuration(data.duration);
document.getElementById('formatCount').textContent = data.formats.length + ' formats available';
if (data.thumbnail) {
const img = document.getElementById('thumbnail');
img.src = data.thumbnail; img.classList.remove('hidden');
}
allFormats = data.formats;
renderFormats(allFormats);
appendLine('[info] Found ' + data.formats.length + ' formats', false, 'text-violet-400');
setDone(false);
} catch(e) {
appendLine('[ERROR] ' + e.message, true);
setDone(true);
}
}
function filterFormats(type) {
currentFilter = type;
['all','video','audio'].forEach(t => {
const btn = document.getElementById('f' + t.charAt(0).toUpperCase() + t.slice(1));
btn.className = t === type
? 'text-xs px-3 py-1 rounded-lg bg-violet-600 text-white'
: 'text-xs px-3 py-1 rounded-lg glass text-slate-400 hover:text-white';
});
let filtered = allFormats;
if (type === 'video') filtered = allFormats.filter(f => f.vcodec && f.vcodec !== 'none');
if (type === 'audio') filtered = allFormats.filter(f => (!f.vcodec || f.vcodec === 'none') && f.acodec && f.acodec !== 'none');
renderFormats(filtered);
}
function renderFormats(formats) {
const card = document.getElementById('formatsCard');
card.classList.remove('hidden');
const table = document.getElementById('formatsTable');
table.innerHTML = formats.map(f => `
<div class="format-row px-6 py-3 cursor-pointer flex items-center gap-4 ${selectedFormat === f.id ? 'selected' : ''}"
onclick="selectFormat('${f.id}', this)" data-id="${f.id}">
<span class="mono text-violet-400 text-xs w-16 flex-shrink-0">${f.id}</span>
<span class="text-sm text-white flex-1 min-w-0 truncate">${f.resolution || f.label || '—'}</span>
<div class="flex gap-1 flex-shrink-0">
${codecTag(f.vcodec, 'v')}
${codecTag(f.acodec, 'a')}
<span class="tag bg-slate-800 text-slate-400">${f.ext || '?'}</span>
</div>
<span class="mono text-xs text-slate-500 w-16 text-right flex-shrink-0">${formatSize(f.filesize)}</span>
</div>
`).join('');
}
function selectFormat(id, el) {
selectedFormat = id;
document.querySelectorAll('.format-row').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('selectedLabel').textContent = 'format: ' + id;
document.getElementById('dlBtn').disabled = false;
}
function downloadSelected() {
if (!selectedFormat) return;
startDownload(selectedFormat);
}
function downloadDirect() {
startDownload(null);
}
async function startDownload(formatId) {
const url = document.getElementById('urlInput').value.trim();
const cookies = document.getElementById('cookiesInput').value.trim();
const extraArgs = document.getElementById('extraArgs').value.trim();
if (!url) return;
showTerminal();
setStatus('Starting download…');
const res = await fetch('/api/download', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url, cookies, format_id: formatId || '', extra_args: extraArgs})
});
const {job_id, error} = await res.json();
if (error) { appendLine('[ERROR] ' + error, true); setDone(true); return; }
const evts = new EventSource('/api/status/' + job_id);
evts.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.line !== undefined) {
appendLine(msg.line);
tryParseProgress(msg.line);
}
if (msg.done) {
setDone(msg.error);
evts.close();
}
if (msg.error && !msg.done) { appendLine('[ERROR] Job not found', true); evts.close(); }
};
}
function tryParseProgress(line) {
// [download] 72.3% of 327.45MiB at 35.20MiB/s
const m = line.match(/\[download\]\s+([\d.]+)%/);
if (m) {
const pct = parseFloat(m[1]);
document.getElementById('progressWrap').classList.remove('hidden');
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressLabel').textContent = pct.toFixed(1) + '%';
}
}
function showTerminal() {
document.getElementById('terminalCard').classList.remove('hidden');
document.getElementById('terminal').innerHTML = '';
document.getElementById('termDot').className = 'w-2 h-2 rounded-full bg-yellow-400 pulse-dot';
document.getElementById('progressWrap').classList.add('hidden');
document.getElementById('progressBar').style.width = '0%';
}
function appendLine(text, isErr = false, extraClass = '') {
const term = document.getElementById('terminal');
const line = document.createElement('div');
line.className = isErr ? 'text-red-400' : ('text-slate-300 ' + extraClass);
// Colorize common prefixes
const colored = text
.replace(/^\[download\]/, '<span class="text-violet-400">[download]</span>')
.replace(/^\[info\]/, '<span class="text-sky-400">[info]</span>')
.replace(/^\[patreon\]/, '<span class="text-orange-400">[patreon]</span>')
.replace(/^\[hlsnative\]/, '<span class="text-teal-400">[hlsnative]</span>')
.replace(/^\[FixupM3u8\]/, '<span class="text-yellow-400">[FixupM3u8]</span>')
.replace(/^\[ERROR\]/, '<span class="text-red-400">[ERROR]</span>');
line.innerHTML = colored;
term.appendChild(line);
term.scrollTop = term.scrollHeight;
}
function setStatus(msg) {
appendLine('# ' + msg, false, 'text-slate-500');
}
function setDone(isErr) {
const dot = document.getElementById('termDot');
dot.className = 'w-2 h-2 rounded-full ' + (isErr ? 'bg-red-500' : 'bg-emerald-400');
if (!isErr) {
document.getElementById('progressBar').style.width = '100%';
appendLine('✓ Done', false, 'text-emerald-400 font-semibold');
}
}
</script>
</body>
</html>