361 lines
16 KiB
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> |