Files
Downloader/index.html
T
R3ap3randClaude Sonnet 5 453141ed6a first commit
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-24 18:36:29 -05:00

390 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETN Downloader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0e0f; --surface: #161618; --surface2: #1e1e21;
--border: #2a2a2e; --accent: #e8ff47; --accent2: #ff6b35;
--text: #f0f0f0; --muted: #6b6b75; --success: #4ade80;
--error: #f87171; --warning: #fbbf24; --info: #60a5fa;
--font-display: 'Syne', sans-serif; --font-mono: 'DM Mono', monospace;
}
@media (prefers-color-scheme: light) {
:root { --bg: #f5f5f0; --surface: #ffffff; --surface2: #ebebeb;
--border: #d8d8d8; --accent: #2a2a2a; --text: #1a1a1a; --muted: #888888; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-display); background: var(--bg); color: var(--text); min-height: 100vh; }
header { padding: 24px 32px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; background: var(--surface); position: sticky; top: 0; z-index: 100; }
.logo { width: 36px; height: 36px; background: var(--accent); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
header h1 { font-size: 18px; font-weight: 800; letter-spacing: -0.5px; }
header p { font-size: 12px; color: var(--muted); font-family: var(--font-mono); margin-top: 1px; }
.header-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.badge { font-family: var(--font-mono); font-size: 11px; padding: 3px 8px; border-radius: 4px; background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
.container { max-width: 900px; margin: 0 auto; padding: 32px 24px; }
.input-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-bottom: 28px; }
.input-card h2 { font-size: 13px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-bottom: 16px; font-family: var(--font-mono); }
.url-row { display: flex; gap: 10px; margin-bottom: 14px; }
.url-input { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; color: var(--text); outline: none; transition: border-color 0.15s; }
.url-input:focus { border-color: var(--accent); }
.url-input::placeholder { color: var(--muted); }
.btn { font-family: var(--font-display); font-size: 14px; font-weight: 700; padding: 12px 22px; border-radius: 10px; border: none; cursor: pointer; transition: all 0.15s; white-space: nowrap; letter-spacing: -0.2px; }
.btn-primary { background: var(--accent); color: #0e0e0f; }
.btn-primary:hover { opacity: 0.85; transform: translateY(-1px); }
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); font-size: 13px; padding: 10px 14px; }
.btn-secondary:hover { border-color: var(--muted); }
.btn-import { background: color-mix(in srgb, var(--info) 15%, transparent); color: var(--info); border: 1px solid color-mix(in srgb, var(--info) 40%, transparent); font-size: 13px; padding: 10px 14px; }
.btn-import:hover { background: color-mix(in srgb, var(--info) 25%, transparent); }
.folder-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.folder-display { flex: 1; font-family: var(--font-mono); font-size: 12px; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.supported-sites { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.site-tag { font-family: var(--font-mono); font-size: 11px; padding: 4px 10px; border-radius: 20px; background: var(--surface2); color: var(--muted); border: 1px solid var(--border); }
.site-tag.active { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-title { font-size: 13px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-family: var(--font-mono); display: flex; align-items: center; gap: 8px; }
.count-badge { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; font-size: 11px; padding: 1px 6px; color: var(--muted); }
.downloads-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
.download-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; transition: border-color 0.2s; animation: slideIn 0.25s ease; }
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.download-card.status-complete { border-color: color-mix(in srgb, var(--success) 30%, transparent); }
.download-card.status-error { border-color: color-mix(in srgb, var(--error) 30%, transparent); }
.download-card.status-downloading, .download-card.status-capturing { border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.card-top { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 14px; }
.status-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; background: var(--surface2); }
.status-icon.queued { background: color-mix(in srgb, var(--warning) 15%, transparent); }
.status-icon.capturing { background: color-mix(in srgb, var(--warning) 15%, transparent); }
.status-icon.downloading { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.status-icon.complete { background: color-mix(in srgb, var(--success) 15%, transparent); }
.status-icon.error { background: color-mix(in srgb, var(--error) 15%, transparent); }
.card-info { flex: 1; min-width: 0; }
.card-title { font-size: 15px; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.card-meta .dot { opacity: 0.4; }
.card-message { font-size: 12px; color: var(--muted); font-family: var(--font-mono); margin-top: 2px; }
.card-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.btn-icon { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all 0.15s; }
.btn-icon:hover { border-color: var(--error); color: var(--error); }
.btn-sub { height: 32px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; font-family: var(--font-mono); font-size: 11px; transition: all 0.15s; white-space: nowrap; }
.btn-sub:hover { border-color: var(--info); color: var(--info); }
.btn-sub:disabled { opacity: 0.4; cursor: not-allowed; }
.progress-section { margin-top: 4px; }
.progress-bar-wrap { background: var(--surface2); border-radius: 4px; height: 6px; overflow: hidden; margin-bottom: 10px; }
.progress-bar-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.5s ease; position: relative; overflow: hidden; }
.progress-bar-fill.complete { background: var(--success); }
.progress-bar-fill.error { background: var(--error); }
.progress-bar-fill.whisper { background: var(--info); }
.progress-bar-fill::after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; width: 40px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3)); animation: shimmer 1.5s infinite; }
.progress-bar-fill.complete::after, .progress-bar-fill.error::after { display: none; }
@keyframes shimmer { 0% { transform: translateX(-40px); opacity: 0; } 50% { opacity: 1; } 100% { transform: translateX(40px); opacity: 0; } }
.progress-stats { display: flex; gap: 20px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); flex-wrap: wrap; }
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; }
.stat-value { color: var(--text); font-weight: 500; }
.subtitle-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.subtitle-label { color: var(--muted); }
.subtitle-status { color: var(--info); }
.subtitle-status.done { color: var(--success); }
.subtitle-status.error { color: var(--error); }
.subtitle-status.burned { color: var(--accent2); }
.history-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; display: flex; align-items: center; gap: 14px; opacity: 0.7; transition: opacity 0.15s; }
.history-card:hover { opacity: 1; }
.history-icon { width: 28px; height: 28px; border-radius: 6px; background: color-mix(in srgb, var(--success) 15%, transparent); display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; }
.history-info { flex: 1; min-width: 0; }
.history-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.history-meta { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 2px; }
.empty { text-align: center; padding: 48px 24px; color: var(--muted); font-family: var(--font-mono); font-size: 13px; border: 1px dashed var(--border); border-radius: 12px; }
.empty-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.4; }
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 18px; font-size: 13px; font-family: var(--font-mono); box-shadow: 0 8px 32px rgba(0,0,0,0.3); animation: toastIn 0.25s ease; z-index: 999; max-width: 320px; }
.toast.error { border-color: var(--error); color: var(--error); }
.toast.success { border-color: var(--success); color: var(--success); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.btn-link { background: none; border: none; color: var(--muted); font-family: var(--font-mono); font-size: 12px; cursor: pointer; text-decoration: underline; padding: 0; }
.btn-link:hover { color: var(--error); }
</style>
</head>
<body>
<header>
<div class="logo">🎬</div>
<div>
<h1>AETN Downloader</h1>
<p>Lifetime · Movie Club · History Vault · Crime Central · Prime Video</p>
</div>
<div class="header-right">
<span class="badge" id="active-count">0 active</span>
<button class="btn btn-secondary" style="font-size:12px;padding:6px 12px" onclick="refreshCookies()">🔑 Refresh Login</button>
</div>
</header>
<div class="container">
<!-- URL Input -->
<div class="input-card">
<h2>New Download</h2>
<div class="url-row">
<input type="text" class="url-input" id="url-input"
placeholder="Paste a URL from Lifetime, Movie Club, History Vault, Crime Central, or Prime Video..."
onkeydown="if(event.key==='Enter') startDownload()">
<button class="btn btn-primary" onclick="startDownload()">Download</button>
</div>
<div class="folder-row">
<span style="font-size:12px;color:var(--muted);font-family:var(--font-mono);white-space:nowrap;">Save to:</span>
<div class="folder-display" id="folder-display">~/video_output</div>
<button class="btn btn-secondary" onclick="browseFolder()">Browse</button>
<button class="btn btn-import" onclick="importVideo()">📂 Import Video</button>
<button class="btn btn-import" onclick="batchSubtitles()" style="background:color-mix(in srgb,var(--info) 15%,transparent);color:var(--info);border-color:color-mix(in srgb,var(--info) 40%,transparent)">🎙 Batch Subtitles</button>
</div>
<div class="supported-sites">
<span class="site-tag active">watch.lifetimemovieclub.com</span>
<span class="site-tag active">watch.historyvault.com</span>
<span class="site-tag active">watch.aecrimecentral.com</span>
<span class="site-tag active">play.mylifetime.com</span>
<span class="site-tag active">amazon.com/gp/video</span>
<span class="site-tag active">primevideo.com</span>
<span class="site-tag" style="color:var(--info);border-color:color-mix(in srgb,var(--info) 40%,transparent);background:color-mix(in srgb,var(--info) 10%,transparent)">📂 import .mp4 / .mkv</span>
</div>
</div>
<!-- Downloads -->
<div id="downloads-section">
<div class="section-header">
<div class="section-title">Downloads <span class="count-badge" id="dl-count">0</span></div>
</div>
<div class="downloads-list" id="downloads-list">
<div class="empty"><div class="empty-icon">⬇</div>No active downloads</div>
</div>
</div>
<!-- History -->
<div id="history-section">
<div class="section-header">
<div class="section-title">History <span class="count-badge" id="history-count">0</span></div>
<button class="btn-link" onclick="clearHistory()">Clear history</button>
</div>
<div class="downloads-list" id="history-list">
<div class="empty"><div class="empty-icon">📂</div>No download history yet</div>
</div>
</div>
</div>
<script>
let outputDir = '{{ home }}/video_output';
async function browseFolder() {
const res = await fetch('/api/browse', { method: 'POST' });
const data = await res.json();
if (data.path) { outputDir = data.path; document.getElementById('folder-display').textContent = data.path; }
}
async function importVideo() {
const res = await fetch('/api/browse_file', { method: 'POST' });
const data = await res.json();
if (!data.path) return;
const res2 = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: data.path })
});
const data2 = await res2.json();
if (data2.error) { showToast(data2.error, 'error'); return; }
showToast(`Imported: ${data2.title}`, 'success');
fetchStatus();
}
async function startDownload() {
const url = document.getElementById('url-input').value.trim();
if (!url) return;
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, output_dir: outputDir })
});
const data = await res.json();
if (data.error) { showToast(data.error, 'error'); return; }
document.getElementById('url-input').value = '';
showToast(`Started: ${data.title}`, 'success');
fetchStatus();
}
async function cancelDownload(id) {
await fetch(`/api/cancel/${id}`, { method: 'POST' });
fetchStatus();
}
async function clearHistory() {
await fetch('/api/clear_history', { method: 'POST' });
fetchStatus();
}
async function batchSubtitles() {
const res = await fetch('/api/browse', { method: 'POST' });
const data = await res.json();
if (!data.path) return;
const res2 = await fetch('/api/batch_subtitles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: data.path })
});
const data2 = await res2.json();
if (data2.error) { showToast(data2.error, 'error'); return; }
showToast(`Queued ${data2.queued} file(s) — ${data2.skipped} already had subtitles`, 'success');
fetchStatus();
}
async function generateSubtitles(id) {
const res = await fetch(`/api/subtitles/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (data.error) { showToast(data.error, 'error'); return; }
showToast('Whisper started — this will take a while...', 'success');
fetchStatus();
}
async function refreshCookies() {
await fetch('/api/refresh_cookies', { method: 'POST' });
showToast('Firefox opened — log in then press Enter in terminal', 'success');
}
function statusIcon(status) {
return { queued:'⏳', capturing:'🦊', downloading:'⬇', complete:'✅', error:'❌', cancelled:'🚫' }[status] || '⏳';
}
function formatTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function subtitleSection(d) {
const ss = d.subtitle_status;
const sm = d.subtitle_message || '';
const isComplete = d.status === 'complete' || d.site === 'Imported';
if (!isComplete) return '';
let statusHtml = '';
let actionHtml = '';
if (!ss) {
actionHtml = `<button class="btn-sub" onclick="generateSubtitles('${d.id}')">🎙 Generate Subtitles</button>`;
} else if (ss === 'queued' || ss === 'running') {
statusHtml = `<span class="subtitle-status">⏳ ${sm}</span>`;
actionHtml = `<button class="btn-sub" disabled>🎙 Running...</button>`;
} else if (ss === 'complete') {
statusHtml = `<span class="subtitle-status done">✅ ${sm}</span>`;
} else if (ss === 'error') {
statusHtml = `<span class="subtitle-status error">❌ ${sm}</span>`;
actionHtml = `<button class="btn-sub" onclick="generateSubtitles('${d.id}')">🎙 Retry</button>`;
}
return `
<div class="subtitle-row">
<span class="subtitle-label">Subtitles:</span>
${statusHtml}
${actionHtml}
</div>`;
}
function renderDownload(d) {
const isActive = ['queued','capturing','downloading'].includes(d.status);
const pct = d.progress || 0;
const fillClass = d.status === 'complete' ? 'complete' : d.status === 'error' ? 'error' : '';
const isWhisper = d.subtitle_status === 'running';
return `
<div class="download-card status-${d.status}" id="card-${d.id}">
<div class="card-top">
<div class="status-icon ${d.status}">${statusIcon(d.status)}</div>
<div class="card-info">
<div class="card-title">${d.title}</div>
<div class="card-meta">
<span>${d.site}</span><span class="dot">·</span>
<span>${formatTime(d.started_at)}</span>
${d.status === 'complete' ? `<span class="dot">·</span><span style="color:var(--success)">Complete</span>` : ''}
${d.status === 'error' ? `<span class="dot">·</span><span style="color:var(--error)">Failed</span>` : ''}
</div>
<div class="card-message">${d.message || ''}</div>
</div>
<div class="card-actions">
${isActive ? `<button class="btn-icon" onclick="cancelDownload('${d.id}')" title="Cancel">✕</button>` : ''}
</div>
</div>
${isActive || d.status === 'complete' ? `
<div class="progress-section">
<div class="progress-bar-wrap">
<div class="progress-bar-fill ${fillClass}" style="width:${pct}%"></div>
</div>
<div class="progress-stats">
<div class="stat"><span class="stat-label">Progress</span><span class="stat-value">${pct.toFixed(1)}%</span></div>
<div class="stat"><span class="stat-label">Size</span><span class="stat-value">${d.size || '--'}</span></div>
<div class="stat"><span class="stat-label">Speed</span><span class="stat-value">${d.speed || '--'}</span></div>
<div class="stat"><span class="stat-label">ETA</span><span class="stat-value">${d.eta || '--'}</span></div>
${d.status === 'complete' ? `
<div class="stat" style="margin-left:auto">
<span class="stat-label">File</span>
<span class="stat-value" style="font-size:10px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.output_path}</span>
</div>` : ''}
</div>
</div>` : ''}
${isWhisper ? `
<div class="progress-section" style="margin-top:8px">
<div class="progress-bar-wrap"><div class="progress-bar-fill whisper" style="width:100%"></div></div>
</div>` : ''}
${subtitleSection(d)}
</div>`;
}
function renderHistory(d) {
return `
<div class="history-card">
<div class="history-icon">${d.status === 'complete' ? '✅' : '❌'}</div>
<div class="history-info">
<div class="history-title">${d.title}</div>
<div class="history-meta">${d.site} · ${formatTime(d.started_at)}</div>
</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px">${d.output_path || ''}</div>
</div>`;
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
const dlList = document.getElementById('downloads-list');
const histList = document.getElementById('history-list');
const downloads = data.downloads || [];
const activeDownloads = downloads.filter(d => ['queued','capturing','downloading'].includes(d.status));
document.getElementById('dl-count').textContent = downloads.length;
document.getElementById('active-count').textContent = `${activeDownloads.length} active`;
dlList.innerHTML = downloads.length ? downloads.map(renderDownload).join('') :
'<div class="empty"><div class="empty-icon">⬇</div>No active downloads</div>';
const hist = data.history || [];
document.getElementById('history-count').textContent = hist.length;
histList.innerHTML = hist.length ? hist.map(renderHistory).join('') :
'<div class="empty"><div class="empty-icon">📂</div>No download history yet</div>';
} catch(e) { console.error(e); }
}
function showToast(msg, type='') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
fetchStatus();
setInterval(fetchStatus, 2000);
</script>
</body>
</html>