Initial commit: add core functionality for Patreon media downloading with UI and batch processing support
This commit is contained in:
361
frontend.html
Normal file
361
frontend.html
Normal file
@@ -0,0 +1,361 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user