Files
Downloader/templates/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

710 lines
40 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; }
.drm-section { margin-bottom: 14px; padding: 14px; background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; display: none; }
.drm-section.visible { display: block; }
.drm-section h3 { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-family: var(--font-mono); margin-bottom: 10px; }
.drm-row { display: flex; gap: 10px; margin-bottom: 8px; align-items: center; }
.drm-label { font-family: var(--font-mono); font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 80px; }
.drm-input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--text); outline: none; }
.drm-input:focus { border-color: var(--accent); }
.drm-hint { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
.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); }
@media (prefers-color-scheme: light) { .btn-primary { color: #ffffff; } }
.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; flex-wrap: wrap; }
.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; min-width: 0; }
.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); }
.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); }
[hidden] { display: none !important; }
.folder-input { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 12px; color: var(--text); background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; outline: none; }
.folder-input:focus { border-color: var(--accent); }
.login-row { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; font-family: var(--font-mono); font-size: 12px; border-bottom: 1px solid var(--border); }
.login-row .ok { color: var(--success); }
.login-row .none { color: var(--error); }
.pick { cursor: pointer; }
.pick:hover { color: var(--accent); text-decoration: underline; }
.pick.busy { opacity: 0.5; cursor: wait; }
.pick.queued { color: var(--success); cursor: default; text-decoration: none; }
.pick.error { color: var(--error); }
</style>
</head>
<body>
<header>
<div class="logo">🎬</div>
<div>
<h1>AETN Downloader</h1>
<p>Lifetime · History · Movie Club · History Vault · Crime Central</p>
</div>
<div class="header-right">
<span class="badge" id="active-count">0 active</span>
<button class="btn btn-secondary" id="login-btn" style="font-size:12px;padding:6px 12px" onclick="toggleLoginPanel()">🔑 Login</button>
<button class="btn btn-secondary max-only" id="max-login-btn" style="font-size:12px;padding:6px 12px" onclick="hmaxLogin()" hidden>🎬 Login to Max</button>
</div>
</header>
<div class="container">
<!-- Login status -->
<div class="input-card" id="login-panel" hidden>
<h2>Login Cookies</h2>
<div id="login-status"></div>
<div class="drm-hint" style="margin-top:12px;line-height:1.6">
This server has no screen, so it can't open a login window. To refresh: log into the sites in Firefox on your
computer, run <b>save_cookies.sh</b> there, then copy <b>~/.aetn_cookies.json</b> to
<b>/mnt/storage/Aetv/aetv/data/</b> on this server. Downloads pick it up right away.
</div>
</div>
<!-- URL Input -->
<div class="input-card">
<h2>New Download</h2>
<div class="url-row">
<textarea class="url-input" id="url-input" rows="3"
placeholder="Paste one URL per line — all will be queued at once"
style="resize:vertical;line-height:1.5"
oninput="checkDrmVisibility()"></textarea>
<button class="btn btn-primary" id="download-btn" onclick="startDownload()">Download</button>
</div>
<div class="drm-section" id="drm-section">
<h3>DRM Keys (optional — leave blank to save encrypted files)</h3>
<div class="drm-hint" style="margin-bottom:8px">Max and Discovery+ are DRM-protected. This app can't extract a decryption key itself — get one per video from an outside tool, then paste it here to decrypt.</div>
<div class="drm-row">
<span class="drm-label">Video Key:</span>
<input class="drm-input" id="drm-video-key" type="text" placeholder="KID:KEY (hex)">
</div>
<div class="drm-row">
<span class="drm-label">Audio Key:</span>
<input class="drm-input" id="drm-audio-key" type="text" placeholder="KID:KEY (hex)">
</div>
<div class="drm-hint">Both keys required to decrypt. Without them, encrypted .mp4 files are saved separately.</div>
</div>
<div class="folder-row">
<span style="font-size:12px;color:var(--muted);font-family:var(--font-mono);white-space:nowrap;">Save to:</span>
<input class="folder-input" id="folder-input" type="text" value="" spellcheck="false"
placeholder="Automatic — each site saves to its own folder"
title="Leave blank to save into each site's own folder, or type a folder on the server to use for everything"
oninput="setOutputDir(this.value)">
<button class="btn btn-import" onclick="importVideo()">📂 Import Video</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">play.history.com</span>
<span class="site-tag active drm-tag" title="DRM-protected — needs a key from an outside tool to decrypt" style="color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 40%,transparent);background:color-mix(in srgb,var(--accent2) 10%,transparent)">discoveryplus.com</span>
<span class="site-tag active max-only drm-tag" hidden title="DRM-protected — needs a key from an outside tool to decrypt" style="color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 40%,transparent);background:color-mix(in srgb,var(--accent2) 10%,transparent)">play.max.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>
<!-- Library check -->
<div class="input-card">
<h2>Library Check</h2>
<div class="folder-row">
<span style="font-size:12px;color:var(--muted);font-family:var(--font-mono);">Compares every movie list (Lifetime, LMN, Lifetime Movie Club, Crime Central) with the files in that site's own movie folder. Click a missing title to download it.</span>
<select class="folder-input" id="library-list" style="flex:0 0 auto" title="Which list to check"></select>
<button class="btn btn-secondary" id="compare-btn" onclick="compareLibrary()">Check library</button>
<button class="btn btn-primary" id="add-missing-btn" onclick="addMissing()" style="display:none">Add missing to URL box</button>
</div>
<div id="compare-result" style="margin-top:12px;font-size:13px;line-height:1.6"></div>
</div>
<!-- Show check -->
<div class="input-card">
<h2>Show Check</h2>
<div class="folder-row">
<select class="folder-input" id="show-source" style="flex:0 0 auto"></select>
<input class="folder-input" id="show-filter" type="text" placeholder="Filter shows..." spellcheck="false" oninput="renderShows()">
<button class="btn btn-secondary" id="shows-btn" onclick="checkShows()">Check shows</button>
</div>
<div id="shows-result" style="margin-top:12px;font-size:13px;line-height:1.6"></div>
</div>
<!-- Captions -->
<div class="input-card">
<h2>Captions</h2>
<div class="folder-row">
<span style="font-size:12px;color:var(--muted);font-family:var(--font-mono);">Saves the captions built into each video as a subtitle file (.en.srt) beside it, so Plex shows them. New downloads do this automatically; use this for videos you already have.</span>
<select class="folder-input" id="caption-folder" style="flex:0 0 auto" title="Which folder to process"></select>
<button class="btn btn-secondary" id="captions-btn" onclick="startCaptions()">Extract captions</button>
<button class="btn btn-import whisper-only" id="whisper-btn" onclick="batchSubtitles()" hidden
title="Whisper listens to the audio and writes subtitles, one video at a time. It only touches videos with no subtitle file.">🎙 Transcribe the rest with Whisper</button>
</div>
<div id="captions-result" style="margin-top:12px;font-size:13px;line-height:1.6"></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>
<button class="btn-link" onclick="clearDownloads()">Clear downloads</button>
</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 = localStorage.getItem('aetv_output_override') || ''; // blank = automatic, by site
let cfg = { whisper: false, max: false }; // what this server can do, from /api/config
document.getElementById('folder-input').value = outputDir;
function setOutputDir(value) {
outputDir = value.trim();
localStorage.setItem('aetv_output_override', outputDir);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
async function loadConfig() {
try {
cfg = await (await fetch('/api/config')).json();
} catch (e) { console.error(e); return; }
document.querySelectorAll('.max-only').forEach(el => el.hidden = !cfg.max);
document.querySelectorAll('.whisper-only').forEach(el => el.hidden = !cfg.whisper);
document.getElementById('caption-folder').innerHTML = '<option value="all">All library folders</option>' +
(cfg.caption_folders || []).map(f => `<option value="${esc(f.path)}">${esc(f.label)} — ${esc(f.path)}</option>`).join('');
document.getElementById('library-list').innerHTML = '<option value="">All lists</option>' +
(cfg.movie_lists || []).map(l => `<option value="${esc(l.label)}">${esc(l.label)} (${l.count})</option>`).join('');
document.getElementById('show-source').innerHTML = (cfg.sources || []).map(s =>
`<option value="${esc(s.key)}">${esc(s.label)} TV → ${esc(s.tv)}</option>`).join('');
const sites = cfg.login_sites || [];
const ready = sites.filter(s => s.cookies > 0).length;
document.getElementById('login-btn').textContent = `🔑 Login: ${ready}/${sites.length} sites`;
const saved = cfg.cookies_saved_at ? new Date(cfg.cookies_saved_at).toLocaleString() : 'no cookie file yet';
document.getElementById('login-status').innerHTML =
sites.map(s => `<div class="login-row"><span>${esc(s.name)}</span>
<span class="${s.cookies ? 'ok' : 'none'}">${s.cookies ? `${s.cookies} cookies` : 'not logged in'}</span></div>`).join('') +
`<div class="drm-hint" style="margin-top:10px">Cookie file saved: ${esc(saved)}</div>`;
}
function toggleLoginPanel() {
const panel = document.getElementById('login-panel');
panel.hidden = !panel.hidden;
}
function isMaxUrl(url) {
return url.includes('max.com') || url.includes('hbomax.com');
}
function needsDrmKeys(url) {
return isMaxUrl(url) || url.includes('discoveryplus.com');
}
function checkDrmVisibility() {
const urls = document.getElementById('url-input').value.split('\n').map(u => u.trim()).filter(Boolean);
document.getElementById('drm-section').classList.toggle('visible', urls.some(needsDrmKeys));
}
async function hmaxLogin() {
await fetch('/api/hbomax_login', { method: 'POST' });
showToast('Chrome opened — log into Max then close it (waits 3 min)', 'success');
const btn = document.getElementById('max-login-btn');
btn.textContent = '⏳ Logging in...';
const poll = setInterval(async () => {
const r = await fetch('/api/hbomax_login_status');
const d = await r.json();
if (d.logged_in) {
clearInterval(poll);
btn.textContent = '✅ Max Logged In';
}
}, 5000);
}
async function importVideo() {
const path = (prompt('Full path of the .mp4 / .mkv file on the server:', (outputDir || '/mnt/storage') + '/') || '').trim();
if (!path) return;
const res2 = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data2 = await res2.json();
if (data2.error) { showToast(data2.error, 'error'); return; }
showToast(`Imported: ${data2.title}`, 'success');
fetchStatus();
}
async function batchSubtitles() {
const folder = document.getElementById('caption-folder').value;
const ask = async body => (await fetch('/api/batch_subtitles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder, ...body })
})).json();
const counted = await ask({ count_only: true });
if (counted.error) { showToast(counted.error, 'error'); return; }
if (!counted.count) { showToast('Every video there already has a subtitle file', 'success'); return; }
if (!confirm(`${counted.count} video(s) have no subtitle file.\n\nWhisper transcribes ONE video at a time and each can take an hour or more. Run "Extract captions" first — most videos have captions built in and don't need this.\n\nStart Whisper on all ${counted.count}?`)) return;
const started = await ask({});
if (started.error) { showToast(started.error, 'error'); return; }
showToast(`Queued ${started.queued} video(s) for Whisper`, 'success');
fetchStatus();
}
async function startDownload() {
const raw = document.getElementById('url-input').value;
const urls = raw.split('\n').map(u => u.trim()).filter(u => u.length > 0);
if (!urls.length) return;
if (urls.length > 25 && !confirm(`Queue ${urls.length} downloads${outputDir ? ` into ${outputDir}` : ''}?`)) return;
const videoKey = document.getElementById('drm-video-key').value.trim();
const audioKey = document.getElementById('drm-audio-key').value.trim();
const btn = document.getElementById('download-btn');
btn.disabled = true;
let queued = 0, skipped = 0, errors = [];
for (const [i, url] of urls.entries()) {
btn.textContent = `Queuing ${i + 1}/${urls.length}...`;
const body = { url, output_dir: outputDir };
if (needsDrmKeys(url)) { body.video_drm_key = videoKey; body.audio_drm_key = audioKey; }
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.error) errors.push(`${url}: ${data.error}`);
else if (data.skipped) skipped++;
else queued++;
}
btn.disabled = false;
btn.textContent = 'Download';
document.getElementById('url-input').value = '';
checkDrmVisibility();
const parts = [];
if (queued) parts.push(`${queued} queued`);
if (skipped) parts.push(`${skipped} already exist — skipped`);
if (parts.length) showToast(parts.join(', '), 'success');
if (errors.length) showToast(errors[0], 'error');
fetchStatus();
}
let missingUrls = [];
async function compareLibrary() {
const btn = document.getElementById('compare-btn');
const out = document.getElementById('compare-result');
btn.disabled = true;
out.textContent = 'Checking... the first run reads each movie page, so it can take a minute.';
try {
const res = await fetch('/api/compare?list=' + encodeURIComponent(document.getElementById('library-list').value));
const d = await res.json();
if (d.error) { out.textContent = d.error; btn.disabled = false; return; }
missingUrls = d.missing.map(m => m.url);
const line = m => `<li class="pick" data-url="${esc(m.url)}" onclick="queueOne(this)" title="Click to download this movie">${esc(m.title)}${m.year ? ` (${m.year})` : ''} <span style="color:var(--muted)">[${esc(m.source)}]</span>${m.library_years ? ` — library has ${m.library_years.join(', ')}` : ''}</li>`;
out.innerHTML = `
<div>${d.folders.map(f => `${esc(f.path)}: ${f.files} files`).join(' · ')}</div>
<div style="margin:6px 0">Checked ${d.checked} movies —
<b>${d.have}</b> in library, <b>${d.missing.length}</b> missing, <b>${d.year_differs.length}</b> with a different year, <b>${d.unresolved.length}</b> no longer on the site.</div>
${d.lists.map(l => `<div class="login-row"><span>${esc(l.label)} <span style="color:var(--muted)">${l.checked} checked</span></span>
<span><span class="ok">${l.have} in library</span> · ${l.missing} missing${l.year_differs ? ` · ${l.year_differs} other year` : ''}${l.gone ? ` · ${l.gone} gone from site` : ''}${l.dupes ? ` · ${l.dupes} also on another list` : ''}</span></div>`).join('')}
<div style="margin-top:10px;color:var(--muted)">Click a title to download just that movie.</div>
${d.missing.length ? `<details open><summary>Missing (${d.missing.length})</summary><ul>${d.missing.map(line).join('')}</ul></details>` : ''}
${d.year_differs.length ? `<details><summary>Same title, different year (${d.year_differs.length})</summary><ul>${d.year_differs.map(line).join('')}</ul></details>` : ''}
`;
document.getElementById('add-missing-btn').style.display = missingUrls.length ? '' : 'none';
} catch (e) {
out.textContent = 'Library check failed.';
}
btn.disabled = false;
}
let captionTimer = null;
async function startCaptions() {
const res = await fetch('/api/captions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: document.getElementById('caption-folder').value })
});
const d = await res.json();
if (d.error) { showToast(d.error, 'error'); return; }
pollCaptions();
}
async function pollCaptions() {
clearInterval(captionTimer);
const tick = async () => {
let j;
try { j = await (await fetch('/api/captions/status')).json(); } catch (e) { return; }
const out = document.getElementById('captions-result');
document.getElementById('captions-btn').disabled = j.running;
if (j.running || j.folder) {
out.innerHTML = j.total === 0 && !j.running
? 'Nothing to do — every video here already has a subtitle file, or was checked and has no captions.'
: `${j.running ? 'Working' : 'Finished'}: ${j.done} of ${j.total} videos — <b>${j.saved}</b> got captions, ${j.none} had none, ${j.errors} failed${j.running && j.current ? ` · now: ${esc(j.current)}` : ''}`;
}
if (!j.running) clearInterval(captionTimer);
};
await tick();
captionTimer = setInterval(tick, 3000);
}
async function queueOne(el) {
if (el.classList.contains('busy') || el.classList.contains('queued')) return;
el.classList.add('busy');
let note, cls;
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: el.dataset.url, output_dir: outputDir })
});
const data = await res.json();
if (data.error) { note = data.error; cls = 'error'; }
else if (data.skipped) { note = 'already in library — skipped'; cls = 'queued'; }
else { note = 'queued'; cls = 'queued'; }
} catch (e) {
note = 'request failed'; cls = 'error';
}
el.classList.remove('busy', 'error');
el.classList.add(cls);
const old = el.querySelector('.pick-note');
if (old) old.remove();
el.insertAdjacentHTML('beforeend', ` <span class="pick-note">— ${esc(note)}</span>`);
fetchStatus();
}
let showData = null;
async function checkShows() {
const btn = document.getElementById('shows-btn');
const out = document.getElementById('shows-result');
btn.disabled = true;
out.textContent = 'Checking...';
try {
const res = await fetch('/api/shows?source=' + encodeURIComponent(document.getElementById('show-source').value));
showData = await res.json();
if (showData.error) { out.textContent = showData.error; showData = null; }
else renderShows();
} catch (e) {
out.textContent = 'Show check failed.';
}
btn.disabled = false;
}
function renderShows() {
const out = document.getElementById('shows-result');
if (!showData) return;
if (!showData.has_episode_list) {
out.innerHTML = `<div style="color:var(--muted)">${esc(showData.label)} has no public episode catalog to scan — its shows only appear once you're logged in, so this list can't be built automatically. Paste video links directly into the box above instead.</div>`;
return;
}
const filter = document.getElementById('show-filter').value.trim().toLowerCase();
const shows = showData.shows.filter(s => s.show.toLowerCase().includes(filter));
const shown = shows.slice(0, 100);
out.innerHTML = `
<div>${esc(showData.folder)}: <b>${showData.have}</b> of ${showData.episodes} episodes in library across ${showData.shows.length} shows.</div>
${shown.map((s, i) => `<div class="login-row">
<span>${esc(s.show)} <span style="color:var(--muted)">${s.have}/${s.total}${s.locked ? ` · ${s.locked} need a login` : ''}</span></span>
${s.missing_urls.length ? `<button class="btn-sub" onclick="addShow(${showData.shows.indexOf(s)})">Add ${s.missing_urls.length} missing</button>` : '<span class="ok">complete</span>'}
</div>`).join('')}
${shows.length > shown.length ? `<div style="color:var(--muted);margin-top:6px">Showing ${shown.length} of ${shows.length} — use the filter to narrow.</div>` : ''}`;
}
function addShow(index) {
const show = showData.shows[index];
const box = document.getElementById('url-input');
box.value = (box.value.trim() ? box.value.trim() + '\n' : '') + show.missing_urls.join('\n');
checkDrmVisibility();
showToast(`${show.missing_urls.length} ${show.show} episode(s) added — press Download to queue them`, 'success');
}
function addMissing() {
const box = document.getElementById('url-input');
box.value = missingUrls.join('\n');
checkDrmVisibility();
showToast(`${missingUrls.length} URL(s) added — press Download to queue them`, 'success');
}
async function cancelDownload(id) {
await fetch(`/api/cancel/${id}`, { method: 'POST' });
fetchStatus();
}
async function clearDownloads() {
await fetch('/api/clear_downloads', { method: 'POST' });
fetchStatus();
}
async function clearHistory() {
await fetch('/api/clear_history', { method: 'POST' });
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();
}
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' || d.site === 'Batch';
if (!isComplete) return '';
if (!cfg.whisper) { // no Whisper here: just show what the caption step did
if (!ss) return '';
return `<div class="subtitle-row"><span class="subtitle-label">Subtitles:</span>
<span class="subtitle-status ${ss === 'complete' ? 'done' : ss === 'error' ? 'error' : ''}">${ss === 'complete' ? '✅' : '❌'} ${esc(sm)}</span></div>`;
}
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" title="${esc(d.title)}">${esc(d.title)}</div>
<div class="card-meta">
<span>${esc(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">${esc(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">${esc(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" style="flex-direction:column;align-items:stretch;opacity:1">
<div style="display:flex;align-items:center;gap:14px">
<div class="history-icon">${d.status === 'complete' ? '✅' : '❌'}</div>
<div class="history-info">
<div class="history-title">${esc(d.title)}</div>
<div class="history-meta">${esc(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">${esc(d.output_path)}</div>
</div>
${d.status === 'complete' ? subtitleSection(d) : ''}
</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);
}
loadConfig().then(() => {
pollCaptions(); // picks up a caption run that is already going
fetchStatus();
setInterval(fetchStatus, 2000);
if (cfg.max) {
fetch('/api/hbomax_login_status').then(r => r.json()).then(d => {
if (d.logged_in) document.getElementById('max-login-btn').textContent = '✅ Max Logged In';
});
}
});
</script>
</body>
</html>