catacomb/src/web_ui/index.html
Luna d9a7007f34 Classify yt-dlp failures into actionable buckets (2.3)
When a job fails, today the user sees a raw stderr line — often
something like "ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm
you're not a bot" that doesn't tell a non-expert what to do.

New src/error_class.rs scans the failed job's log buffer for the
nine well-known yt-dlp fingerprints and returns:
- an ErrorClass enum (rate-limited, members-only, geo-blocked,
  not-found, codec-missing, disk-full, network-error, bad-cookies,
  other)
- a one-line human-readable suggested action paired with each class

Auto-populated in Job::drain when state transitions to Failed.
Exposed on JobSnapshot as error_class + error_hint. Rendered in
the web UI Downloads modal (red badge + hint box) and the desktop
downloads panel (colored label + hint text).

Other (no fingerprint matched) is filtered out of the badge so the
existing raw last_line keeps doing the talking for unknown failures.

Includes 10 unit tests covering each pattern + the walks-from-end
priority case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:32:45 -07:00

1860 lines
111 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>yt-offline</title>
<style>
:root{--bg:#1a1a2e;--panel:#16213e;--card:#0f3460;--accent:#e94560;--text:#eee;--muted:#999;--border:#334}
.theme-light{--bg:#f5f5f5;--panel:#fff;--card:#e8e8e8;--accent:#e94560;--text:#111;--muted:#666;--border:#ccc}
.theme-solarized{--bg:#002b36;--panel:#073642;--card:#004052;--accent:#268bd2;--text:#839496;--muted:#586e75;--border:#144}
.theme-nord{--bg:#2e3440;--panel:#3b4252;--card:#434c5e;--accent:#88c0d0;--text:#eceff4;--muted:#9aa;--border:#4c566a}
.theme-amoled{--bg:#000;--panel:#0a0a0a;--card:#111;--accent:#e94560;--text:#eee;--muted:#666;--border:#222}
.theme-dracula{--bg:#282a36;--panel:#282a36;--card:#343746;--accent:#bd93f9;--text:#f8f8f2;--muted:#6272a4;--border:#44475a}
.theme-trans{--bg:#e8f7fd;--panel:#fef0f4;--card:#fce8f2;--accent:#55cdfc;--text:#cc0066;--muted:#888;--border:#f7a8b8}
.theme-emo-nocturnal{--bg:#0a0a0a;--panel:#0d0d0d;--card:#1a1a1a;--accent:#ff0090;--text:#e8e8e8;--muted:#888;--border:#2a2a2a}
.theme-emo-coffin{--bg:#0d0009;--panel:#110010;--card:#1a0018;--accent:#cc2222;--text:#c0c0c0;--muted:#666;--border:#3a0030}
.theme-emo-scene-queen{--bg:#080818;--panel:#0a0a1e;--card:#111128;--accent:#39ff14;--text:#c8c8ff;--muted:#666;--border:#222244}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{background:var(--panel);padding:8px 12px;display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}
header h1{font-size:1em;font-weight:700;white-space:nowrap}
#search{flex:1;min-width:100px;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:4px;font-size:13px}
#sort{background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 6px;border-radius:4px;font-size:12px}
#hdr-stats{font-size:11px;color:var(--muted);white-space:nowrap}
#status{font-size:11px;color:var(--muted);white-space:nowrap}
button{background:var(--card);color:var(--text);border:1px solid var(--border);padding:5px 10px;border-radius:4px;cursor:pointer;font-size:12px;touch-action:manipulation;white-space:nowrap}
button:hover,button:active{background:var(--accent);border-color:var(--accent);color:#fff}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
#menu-btn{display:none;font-size:18px;padding:3px 9px;line-height:1}
main{display:flex;flex:1;overflow:hidden;position:relative}
aside{width:210px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0;padding:6px 0;transition:transform .2s}
#sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:49}
.sidebar-label{padding:2px 10px;font-size:10px;text-transform:uppercase;color:var(--muted);letter-spacing:.07em;margin-top:6px}
.ch-item{padding:6px 10px;cursor:pointer;font-size:13px;border-left:3px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ch-item:hover{background:var(--card)}
.ch-item.active{border-left-color:var(--accent);background:rgba(128,128,128,.1)}
.ch-sub{padding:4px 10px 4px 22px;font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ch-sub:hover{color:var(--text);background:rgba(128,128,128,.08)}
.ch-sub.active{color:var(--text)}
section#content{flex:1;overflow-y:auto;padding:10px;min-width:0}
.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
/* Filter chip row above the grid. Each chip is a button that toggles
a single filter dimension; the active state inverts colors so it's
obvious what's currently narrowing the view. */
.filters{display:flex;align-items:center;gap:4px;margin-bottom:8px;flex-wrap:wrap;font-size:11px;color:var(--muted)}
.filter-grp{display:flex;align-items:center;gap:2px;padding:2px;background:var(--bg);border:1px solid var(--border);border-radius:4px}
.filter-grp-label{padding:0 4px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
.chip{background:transparent;color:var(--muted);border:none;border-radius:3px;padding:3px 8px;font-size:11px;cursor:pointer;line-height:1.3}
.chip:hover{color:var(--text)}
.chip.active{background:var(--accent);color:#000;font-weight:600}
.chip-toggle{background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer}
.chip-toggle.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.chip-clear{background:transparent;color:var(--muted);border:1px dashed var(--border);border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer}
.chip-clear:hover{color:var(--text);border-color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
.ch-card{background:var(--card);border-radius:8px;overflow:hidden;border:2px solid transparent;transition:border-color .15s;cursor:pointer;display:flex;flex-direction:column}
.ch-card:hover{border-color:var(--accent)}
.ch-card-thumb{width:100%;aspect-ratio:16/9;background:#111;overflow:hidden;position:relative}
.ch-card-thumb img{width:100%;height:100%;object-fit:cover;display:block}
.ch-card-thumb .nothumb{display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--muted);font-size:28px}
.ch-card-info{padding:8px 10px 10px;display:flex;flex-direction:column;gap:2px}
.ch-card-name{font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ch-card-meta{font-size:11px;color:var(--muted)}
.card{background:var(--card);border-radius:6px;overflow:hidden;border:2px solid transparent;transition:border-color .15s;display:flex;flex-direction:column;cursor:pointer}
.card:hover{border-color:rgba(233,69,96,.5)}
.card.selected{border-color:var(--accent)}
.card.watched{opacity:.55}
.card-thumb{position:relative;width:100%;aspect-ratio:16/9;background:#000;overflow:hidden}
.card-thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card-thumb .nothumb{display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--muted);font-size:11px}
.card-thumb .dur{position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,.75);color:#fff;font-size:11px;padding:1px 5px;border-radius:3px;font-weight:600}
.card-thumb .resume-bar{position:absolute;bottom:0;left:0;height:3px;background:var(--accent)}
.card-title{font-size:13px;font-weight:600;padding:7px 8px 2px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:34px}
.card-meta{font-size:11px;color:var(--muted);padding:0 8px 4px;display:flex;gap:5px;flex-wrap:wrap}
.card-foot{padding:4px 8px 8px;display:flex;gap:6px}
.card-foot button{font-size:11px;padding:4px 8px}
.card-foot .play{background:var(--accent);border-color:var(--accent);color:#fff;flex:1}
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:100;padding:10px}
.modal{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px;max-width:95vw;max-height:95vh;display:flex;flex-direction:column;gap:10px;width:100%;overflow:hidden}
.modal-hdr{display:flex;align-items:center;gap:8px;flex-shrink:0}
.modal-hdr h2{flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.modal-body{display:flex;gap:12px;overflow:hidden;flex:1;min-height:0}
.modal video{flex:1;min-width:0;max-height:75vh;background:#000}
.chapters-pane{width:200px;background:var(--bg);border:1px solid var(--border);border-radius:4px;overflow-y:auto;flex-shrink:0}
.chapters-pane h4{font-size:11px;padding:7px 10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg)}
.chapter{padding:6px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)}
.chapter:hover{background:var(--card)}
.chapter.active{background:var(--card);border-left:3px solid var(--accent)}
.chapter .ch-time{color:var(--muted);font-size:11px;font-family:monospace}
.chapter .ch-title{font-weight:500;margin-top:1px}
.settings-row{display:flex;align-items:center;gap:8px;padding:6px 0}
.settings-row label{flex:1;font-size:13px}
.settings-hint{font-size:11px;color:var(--muted)}
#details{background:var(--panel);border-top:2px solid var(--accent);padding:10px 14px;flex-shrink:0;max-height:200px;overflow-y:auto;display:none}
.det-head{display:flex;align-items:flex-start;gap:8px;margin-bottom:4px}
.det-head h3{flex:1;font-size:13px;font-weight:600;margin:0}
.det-actions{display:flex;gap:5px;flex-shrink:0;flex-wrap:wrap}
.det-meta{font-size:12px;color:var(--muted);margin-bottom:6px;display:flex;gap:8px;flex-wrap:wrap}
.det-desc{font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-y:auto;max-height:90px}
.meta-grid{display:grid;grid-template-columns:max-content 1fr;gap:5px 12px;font-size:12px;padding:4px}
.meta-grid dt{color:var(--muted);font-weight:600;white-space:nowrap}
.meta-grid dd{color:var(--text);word-break:break-word;overflow-wrap:anywhere}
.meta-grid dd a{color:var(--accent);text-decoration:none}
.meta-grid dd a:hover{text-decoration:underline}
.meta-tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:6px}
.meta-tab{padding:4px 10px;cursor:pointer;font-size:12px;border:1px solid transparent;border-bottom:none;border-radius:4px 4px 0 0}
.meta-tab.active{background:var(--card);border-color:var(--border)}
.meta-raw{font-family:monospace;font-size:11px;background:var(--bg);padding:10px;border-radius:4px;white-space:pre;overflow:auto;max-height:60vh}
/* Jobs render inside the Downloads modal now. The dl-btn badge
on the header shows the active count so users still get an
at-a-glance status without the old fixed bottom bar eating
screen real estate. */
#dl-btn{position:relative}
#dl-btn.has-active{color:var(--accent)}
#dl-badge{position:absolute;top:-4px;right:-4px;background:var(--accent);color:#000;font-size:10px;font-weight:700;border-radius:9px;padding:1px 5px;min-width:16px;text-align:center;line-height:1.2;display:none}
#dl-btn.has-active #dl-badge{display:inline-block}
.job-row{border-bottom:1px solid var(--border)}
.job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;flex-wrap:wrap;min-width:0}
.job-row .job{border-bottom:none}
.badge{font-weight:700;min-width:48px;flex-shrink:0}
.badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171}
/* Error-class badge sits next to the state badge. Subtle so the actual
hint below carries the user's attention. */
.err-class{font-size:10px;background:#7f1d1d;color:#fecaca;border-radius:3px;padding:1px 6px;text-transform:uppercase;letter-spacing:.4px;flex-shrink:0}
.job-hint{font-size:11px;color:#fecaca;background:rgba(127,29,29,.18);border-left:2px solid #f87171;padding:6px 14px 6px 12px;margin:0 14px 6px}
progress{flex:1;height:5px;accent-color:var(--accent);min-width:40px}
footer{background:var(--panel);border-top:1px solid var(--border);padding:8px 12px;display:flex;gap:8px;align-items:center;flex-shrink:0}
footer input{flex:1;min-width:0;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 9px;border-radius:4px;font-size:13px}
.preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
.empty{text-align:center;color:var(--muted);padding:40px;font-size:13px}
@media(max-width:640px){
#menu-btn{display:block}
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
aside.open{transform:translateX(0)}
#sidebar-overlay.open{display:block}
/* Hide non-essential header chrome on mobile. The remaining
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
are the ones users actually reach for. */
#hdr-stats,#sort,#agpl-notice{display:none}
header h1{display:none}
header{padding:6px 8px;gap:6px}
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
/* Bigger tap targets on cards. The five-button card-foot reflows
to a comfortable height instead of cramming icons together. */
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
.card-foot button:not(.play){min-width:34px}
.card-meta{font-size:10px}
/* Modals go full-screen so the cramped 10px page-margin we use on
desktop doesn't eat the iPhone safe area. */
.modal-bg{padding:0}
.modal{max-width:100vw;max-height:100vh;border-radius:0;border:none}
.modal video{max-height:55vh}
#details{max-height:160px;padding:8px 10px}
.chapters-pane{display:none}
section#content{padding:8px}
/* Download bar wraps onto two rows so the URL input + button
don't squeeze each other off-screen. */
footer{flex-wrap:wrap;padding:6px 10px;gap:6px}
footer input{flex-basis:100%;order:-1}
/* Bulk-mode actions stack vertically when active. */
.toolbar{flex-wrap:wrap}
#bulk-actions{flex-wrap:wrap;gap:4px}
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
/* Job rows: hide the last-line preview on small screens; the
progress bar + label are the only things that fit anyway. */
.job{padding:4px 10px;font-size:11px}
.job .last-line{display:none}
}
@media(max-width:380px){
/* Single-column on very narrow phones. */
.grid{grid-template-columns:1fr;gap:6px}
}
</style>
</head>
<body>
<header>
<button id="menu-btn" onclick="toggleSidebar()"></button>
<h1>yt-offline</h1>
<input type="search" id="search" placeholder="Filter…" oninput="saveUiState();renderGrid()">
<select id="sort" onchange="saveUiState();renderGrid()">
<option value="date-desc">Newest</option>
<option value="date-asc">Oldest</option>
<option value="title">Title</option>
<option value="dur-asc">Shortest</option>
<option value="dur-desc">Longest</option>
<option value="size-asc">Smallest</option>
<option value="size-desc">Largest</option>
</select>
<span id="hdr-stats"></span>
<button onclick="shufflePlay()" title="Play a random unwatched video">🎲</button>
<button onclick="rescan()" title="Rescan library"></button>
<button id="dl-btn" onclick="openDownloads()" title="Downloads"><span id="dl-badge">0</span></button>
<button onclick="openStats()" title="Library statistics">📊</button>
<button onclick="openMaintenance()" title="Library health">🩺</button>
<button onclick="showShortcutsHelp()" title="Keyboard shortcuts (?)">?</button>
<button onclick="openSettings()"></button>
<span id="status"></span>
</header>
<div id="sidebar-overlay" onclick="closeSidebar()"></div>
<main>
<aside id="sidebar"></aside>
<section id="content">
<div class="toolbar">
<label style="font-size:12px;color:var(--muted)"><input type="checkbox" id="bulk" onchange="toggleBulk()"> Select</label>
<span id="bulk-actions" style="display:none;gap:6px">
<button onclick="bulkWatched(true)">✓ Watched</button>
<button onclick="bulkWatched(false)">○ Unwatch</button>
<button onclick="bulkFlag('favourite')" title="Mark every selected video as favourite">★ Favourite</button>
<button onclick="bulkFlag('bookmark')" title="Mark every selected video as bookmarked">🔖 Bookmark</button>
<button onclick="bulkFlag('waiting')" title="Add every selected video to the waiting list">⏳ Waiting</button>
<span id="sel-count" style="font-size:12px;color:var(--muted)"></span>
</span>
</div>
<div class="filters" id="filters"></div>
<div class="grid" id="grid"></div>
</section>
</main>
<div id="details"></div>
<footer>
<input type="url" id="dl-url" placeholder="YouTube URL…" oninput="refreshTwitchClipsVisibility()" onkeydown="if(event.key==='Enter')previewDownload()">
<button class="primary" onclick="previewDownload()">⬇ Download</button>
<select id="dl-quality" title="Download quality" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-size:12px" onchange="updateDlMode()">
<option value="best">Best quality</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="music">🎵 Music</option>
</select>
<label id="fast-mode-label" style="display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;cursor:pointer" title="Stop at the first already-downloaded video. Faster for large channels but may miss new videos if gaps exist in the archive."><input type="checkbox" id="dl-full-scan"> Fast mode</label>
<label id="live-mode-label" style="display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;cursor:pointer" title="Record an ongoing live broadcast (Twitch/YouTube Live) from the start instead of joining live. Waits if the stream has not begun yet."><input type="checkbox" id="dl-live"> 🔴 Live</label>
<label id="twitch-clips-label" style="display:none;align-items:center;gap:4px;font-size:12px;white-space:nowrap;cursor:pointer" title="Twitch only: pull the channel's Clips listing instead of its VODs. Rewrites the URL to twitch.tv/&lt;channel&gt;/clips before submitting."><input type="checkbox" id="dl-twitch-clips"> Clips only</label>
<span id="agpl-notice" style="font-size:10px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
</footer>
<script>
'use strict';
let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, showContinue=false, showChannels=false, showMusic=false, showRecent=false;
// Folder list from the most-recent /api/library response; populated by
// loadLibrary() and read by renderSidebar() to draw the Folders group.
let librarySnapshotFolders=[];
// Smart-folder view selectors — at most one is true at a time.
let showFavourites=false, showBookmarks=false, showWaiting=false;
// Filter chips above the grid. Each is independent; the grid filters by
// the AND of all active chips. Values:
// watch: 'all' | 'unwatched' | 'in-progress' | 'watched'
// date: 'all' | 'today' | 'week' | 'month' | 'year' | 'older'
// size: 'all' | 'small' (<100MB) | 'med' (100MB-1GB) | 'large' (>1GB)
// hasSubs / hasChapters: boolean toggles
let filter={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};
let musicTracks=[];
let bulkMode=false, selected=new Set(), selectedId=null;
let currentPlayingId=null, saveTimer=null;
/* ── Sidebar (mobile) ──────────────────────────────────────────── */
function toggleSidebar(){document.getElementById('sidebar').classList.toggle('open');document.getElementById('sidebar-overlay').classList.toggle('open')}
function closeSidebar(){document.getElementById('sidebar').classList.remove('open');document.getElementById('sidebar-overlay').classList.remove('open')}
/* ── API ────────────────────────────────────────────────────────── */
async function api(path,opts){const r=await fetch(path,opts);if(!r.ok)throw new Error(await r.text());return r}
/* ── Library ────────────────────────────────────────────────────── */
// Cache the ETag of the most-recent library response so subsequent polls
// can short-circuit with 304 Not Modified when nothing has changed. Saves
// megabytes of JSON for large libraries on every periodic refresh.
let libraryEtag=null;
async function loadLibrary(){
try{
const opts=libraryEtag?{headers:{'If-None-Match':libraryEtag}}:{};
const r=await api('/api/library',opts);
if(r.status===304){
// Nothing changed; keep our existing `library` array.
loadMusic();
return;
}
libraryEtag=r.headers.get('ETag')||libraryEtag;
const data=await r.json();
library=data.channels;
librarySnapshotFolders=data.folders||[];
channelUrls=library.map(ch=>ch.channel_url||null);
const total=library.reduce((s,c)=>s+c.size_bytes,0);
document.getElementById('hdr-stats').textContent=total>0?fmtSize(total)+' total':'';
renderSidebar();renderGrid();
if(selectedId)renderDetails();
}catch(e){setStatus('Error: '+e.message)}
loadMusic();
}
function setStatus(s){document.getElementById('status').textContent=s}
/* ── Sidebar ────────────────────────────────────────────────────── */
function renderSidebar(){
const el=document.getElementById('sidebar');
const allVids=library.flatMap(ch=>[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]);
const contVids=allVids.filter(v=>v.resume_pos&&v.resume_pos>5&&!v.watched);
const total=library.reduce((s,c)=>s+c.total_videos,0);
const hasDated=allVids.some(v=>v.mtime_unix);
const favCount=allVids.filter(v=>v.favourite).length;
const bmkCount=allVids.filter(v=>v.bookmark).length;
const waitCount=allVids.filter(v=>v.waiting).length;
const anySmart=showFavourites||showBookmarks||showWaiting;
let h=`<div class="sidebar-label">Library</div>`;
if(contVids.length)h+=`<div class="ch-item${showContinue?' active':''}" onclick="setContinue()">▶ Continue (${contVids.length})</div>`;
if(hasDated)h+=`<div class="ch-item${showRecent?' active':''}" onclick="setRecent()">🕒 Recent additions</div>`;
if(favCount)h+=`<div class="ch-item${showFavourites?' active':''}" onclick="setFavourites()">★ Favourites (${favCount})</div>`;
if(bmkCount)h+=`<div class="ch-item${showBookmarks?' active':''}" onclick="setBookmarks()">🔖 Bookmarks (${bmkCount})</div>`;
if(waitCount)h+=`<div class="ch-item${showWaiting?' active':''}" onclick="setWaiting()">⏳ Waiting (${waitCount})</div>`;
h+=`<div class="ch-item${showChannels?' active':''}" onclick="setChannels()">⊟ Channels (${library.length})</div>`;
h+=`<div class="ch-item${!showContinue&&!showChannels&&!showRecent&&!anySmart&&activeChannelIdx===null&&!showMusic?' active':''}" onclick="setView(null,null)">⊞ All (${total})</div>`;
h+=`<div class="ch-item${showMusic?' active':''}" onclick="setMusic()">♫ Music (${musicTracks.length})</div>`;
// Render a single channel entry (collapsed or expanded with sub-actions).
// Returned HTML is appended at the right point — used both in the
// folder loop and in the per-platform Unfiled loop below.
const renderChannel=(i)=>{
const ch=library[i];
const active=activeChannelIdx===i&&activePlaylistIdx===null&&!showContinue;
const size=ch.size_bytes>0?' · '+fmtSize(ch.size_bytes):'';
let s=`<div class="ch-item${active?' active':''}" onclick="setView(${i},null)" title="${esc(ch.platform_label||'')}">${esc(ch.platform_icon||'')} ${esc(ch.name)} (${ch.total_videos}${size})</div>`;
if(activeChannelIdx===i&&!showContinue){
for(let pi=0;pi<ch.playlists.length;pi++){
const pl=ch.playlists[pi];
s+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
}
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
}
return s;
};
// User-defined folders (from the `folders` SQLite table). Channels with
// a `folder_id` matching the row land here. Folders override platform
// grouping for their members; Unfiled channels keep the platform
// sections below.
const folders=Array.isArray(librarySnapshotFolders)?librarySnapshotFolders:[];
if(folders.length){
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">manage</button>
</div>`;
for(const f of folders){
const members=[];
for(let i=0;i<library.length;i++){
if(library[i].folder_id===f.id)members.push(i);
}
const count=members.length;
h+=`<div class="sidebar-label" style="padding-left:8px;font-size:11px;color:var(--muted)">${esc(f.name)} (${count})</div>`;
for(const i of members)h+=renderChannel(i);
}
} else {
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">new</button>
</div>`;
}
// Channels not assigned to a folder still get platform grouping.
const unfiled=[];
for(let i=0;i<library.length;i++)if(!library[i].folder_id)unfiled.push(i);
let lastPlatform=null;
for(const i of unfiled){
const ch=library[i];
if(ch.platform!==lastPlatform){
h+=`<div class="sidebar-label" style="margin-top:8px">${esc(ch.platform_icon||'')} ${esc(ch.platform_label||'Channels')}</div>`;
lastPlatform=ch.platform;
}
h+=renderChannel(i);
}
el.innerHTML=h;
}
// Reset every view selector to off — call before flipping the new one on.
// Centralising avoids the combinatorial drift of seven view-mode booleans.
function resetViewSelectors(){
showContinue=false;showRecent=false;showChannels=false;showMusic=false;
showFavourites=false;showBookmarks=false;showWaiting=false;
activeChannelIdx=null;activePlaylistIdx=null;
}
// Persist the user's current view + search + sort to localStorage so
// reloading the tab doesn't blow away their context. Cheap to write on
// every interaction — localStorage is synchronous but the payload is tiny.
function currentViewToken(){
if(showContinue)return 'continue';
if(showRecent)return 'recent';
if(showFavourites)return 'favourites';
if(showBookmarks)return 'bookmarks';
if(showWaiting)return 'waiting';
if(showChannels)return 'channels';
if(showMusic)return 'music';
if(activeChannelIdx!=null)return `channel:${library[activeChannelIdx]?.name||''}|${activePlaylistIdx??''}`;
return 'all';
}
function saveUiState(){
try{
localStorage.setItem('ui-state',JSON.stringify({
view:currentViewToken(),
search:document.getElementById('search')?.value||'',
sort:document.getElementById('sort')?.value||'date-desc',
filter,
}));
}catch{}
}
function restoreUiState(){
let raw;try{raw=JSON.parse(localStorage.getItem('ui-state')||'null')}catch{}
if(!raw)return;
const s=document.getElementById('search'); if(s&&raw.search)s.value=raw.search;
const sortEl=document.getElementById('sort'); if(sortEl&&raw.sort)sortEl.value=raw.sort;
// Restore filter chips. Defensive merge so an older localStorage payload
// missing a new field doesn't break the predicate.
if(raw.filter&&typeof raw.filter==='object')filter={...filter,...raw.filter};
// Map the persisted view back to the matching show* boolean. Channels
// are stored by name (not by index) so a library re-order doesn't land
// the user on the wrong channel.
resetViewSelectors();
switch(raw.view){
case 'continue':showContinue=true;break;
case 'recent':showRecent=true;break;
case 'favourites':showFavourites=true;break;
case 'bookmarks':showBookmarks=true;break;
case 'waiting':showWaiting=true;break;
case 'channels':showChannels=true;break;
case 'music':showMusic=true;break;
case 'all':break; // default
default:
if(raw.view&&raw.view.startsWith('channel:')){
const rest=raw.view.slice('channel:'.length);
const [name,plStr]=rest.split('|');
const idx=library.findIndex(c=>c.name===name);
if(idx>=0){
activeChannelIdx=idx;
const pl=parseInt(plStr,10);
activePlaylistIdx=Number.isFinite(pl)?pl:null;
}
}
}
}
function setContinue(){resetViewSelectors();showContinue=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setRecent(){resetViewSelectors();showRecent=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setFavourites(){resetViewSelectors();showFavourites=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setBookmarks(){resetViewSelectors();showBookmarks=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setWaiting(){resetViewSelectors();showWaiting=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setChannels(){resetViewSelectors();showChannels=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setView(ci,pi){resetViewSelectors();activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
function setMusic(){resetViewSelectors();showMusic=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()}
/* ── Grid ───────────────────────────────────────────────────────── */
// Filter chip predicate. Returns true if a video passes every active chip.
// Date cutoffs use the upload_date (YYYYMMDD); videos with no date pass the
// 'all' bucket but get dropped by any specific date filter — same as
// Tartube's behavior.
function passesFilter(v){
// Watch state.
if(filter.watch==='unwatched'&&v.watched)return false;
if(filter.watch==='watched'&&!v.watched)return false;
if(filter.watch==='in-progress'&&!(v.resume_pos&&v.resume_pos>5&&!v.watched))return false;
// Date (compares upload_date YYYYMMDD lexically against cutoff strings).
if(filter.date!=='all'){
if(!v.upload_date)return false;
const now=new Date();
const ymd=d=>d.getFullYear().toString()+String(d.getMonth()+1).padStart(2,'0')+String(d.getDate()).padStart(2,'0');
const ago=(days)=>{const d=new Date(now);d.setDate(d.getDate()-days);return ymd(d)};
const cutoff=filter.date==='today'?ymd(now):
filter.date==='week'?ago(7):
filter.date==='month'?ago(30):
filter.date==='year'?ago(365):null;
if(filter.date==='older'){
// "Older" = anything more than a year old.
if(v.upload_date>=ago(365))return false;
} else if(cutoff && v.upload_date<cutoff)return false;
}
// Size buckets (file_size is bytes; null = no file, drop on any specific size).
if(filter.size!=='all'){
if(v.file_size==null)return false;
const MB=1048576, GB=1073741824;
if(filter.size==='small' && v.file_size>=100*MB)return false;
if(filter.size==='med' && (v.file_size<100*MB||v.file_size>=GB))return false;
if(filter.size==='large' && v.file_size<GB)return false;
}
if(filter.hasSubs && !(v.subtitles && v.subtitles.length))return false;
if(filter.hasChapters && !v.has_chapters)return false;
return true;
}
function currentVideos(){
const q=document.getElementById('search').value.toLowerCase();
const sort=document.getElementById('sort').value;
let vids=[];
if(showContinue){
for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||matchesSearch(v,ch?.name||v.channel,q))&&passesFilter(v))
vids.push({...v,channel:ch.name});
vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0));
return vids;
}
if(showRecent){
// Most-recently-modified across the whole library, capped at 100.
for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v.mtime_unix&&(!q||matchesSearch(v,ch?.name||v.channel,q))&&passesFilter(v))
vids.push({...v,channel:ch.name});
vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0));
return vids.slice(0,100);
}
// Smart folders driven by the per-video flag bits served in /api/library.
if(showFavourites||showBookmarks||showWaiting){
const want = showFavourites ? 'favourite' : showBookmarks ? 'bookmark' : 'waiting';
for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v[want]&&(!q||matchesSearch(v,ch?.name||v.channel,q))&&passesFilter(v))
vids.push({...v,channel:ch.name});
return vids;
}
for(let i=0;i<library.length;i++){
const ch=library[i];
if(activeChannelIdx!==null&&i!==activeChannelIdx)continue;
const pool=activePlaylistIdx!==null?(ch.playlists[activePlaylistIdx]?.videos||[]):[...ch.videos,...ch.playlists.flatMap(p=>p.videos)];
for(const v of pool)
if((!q||matchesSearch(v,ch?.name||v.channel,q))&&passesFilter(v))vids.push({...v,channel:ch.name});
}
if(sort==='date-desc')vids.sort((a,b)=>(b.upload_date||'').localeCompare(a.upload_date||''));
if(sort==='date-asc')vids.sort((a,b)=>{const ax=a.upload_date||'￿',bx=b.upload_date||'￿';return ax.localeCompare(bx)});
if(sort==='dur-asc')vids.sort((a,b)=>(a.duration_secs??0)-(b.duration_secs??0));
if(sort==='dur-desc')vids.sort((a,b)=>(b.duration_secs??0)-(a.duration_secs??0));
if(sort==='size-asc')vids.sort((a,b)=>(a.file_size??0)-(b.file_size??0));
if(sort==='size-desc')vids.sort((a,b)=>(b.file_size??0)-(a.file_size??0));
if(sort==='title')vids.sort((a,b)=>a.title.localeCompare(b.title));
return vids;
}
// Filter chips are rendered every grid pass so the active state always
// matches the underlying filter object. Hidden on channel-grid + music
// views where they don't apply.
function renderFilters(){
const el=document.getElementById('filters');
if(!el)return;
if(showChannels||showMusic){el.style.display='none';return}
el.style.display='';
const grp=(label,key,opts)=>{
const chips=opts.map(([val,txt])=>`<button class="chip${filter[key]===val?' active':''}" onclick="setFilter('${key}','${val}')">${txt}</button>`).join('');
return `<div class="filter-grp"><span class="filter-grp-label">${label}</span>${chips}</div>`;
};
const toggle=(label,key,title)=>`<button class="chip-toggle${filter[key]?' active':''}" title="${esc(title)}" onclick="toggleFilter('${key}')">${label}</button>`;
const anyActive=filter.watch!=='all'||filter.date!=='all'||filter.size!=='all'||filter.hasSubs||filter.hasChapters;
const clear=anyActive?`<button class="chip-clear" onclick="clearFilters()">✕ Clear filters</button>`:'';
el.innerHTML=
grp('Watch','watch',[['all','All'],['unwatched','Unwatched'],['in-progress','In progress'],['watched','Watched']])+
grp('Date','date',[['all','All'],['today','Today'],['week','Week'],['month','Month'],['year','Year'],['older','Older']])+
grp('Size','size',[['all','All'],['small','< 100 MB'],['med','100 MB 1 GB'],['large','> 1 GB']])+
toggle('💬 Subs','hasSubs','Only videos with subtitle tracks')+
toggle('🔖 Chapters','hasChapters','Only videos with chapter markers')+
clear;
}
function setFilter(key,val){filter[key]=val;saveUiState();renderFilters();renderGrid()}
function toggleFilter(key){filter[key]=!filter[key];saveUiState();renderFilters();renderGrid()}
function clearFilters(){filter={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};saveUiState();renderFilters();renderGrid()}
function renderGrid(){
renderFilters();
if(showChannels){renderChannelGrid();return}
if(showMusic){renderMusicGrid();return}
const vids=currentVideos();
setStatus(vids.length+' video'+(vids.length!==1?'s':''));
const grid=document.getElementById('grid');
if(!vids.length){
const anyFilter=filter.watch!=='all'||filter.date!=='all'||filter.size!=='all'||filter.hasSubs||filter.hasChapters;
grid.innerHTML=anyFilter
? `<div class="empty">Nothing matches the current filters. <button class="chip-clear" style="margin-left:8px" onclick="clearFilters()">✕ Clear filters</button></div>`
: '<div class="empty">Nothing here.</div>';
return;
}
const showChCol=activeChannelIdx===null&&!showContinue;
grid.innerHTML=vids.map(v=>{
const chk=bulkMode?`<input type="checkbox" ${selected.has(v.id)?'checked':''} onchange="toggleSel('${v.id}',this.checked)">`:'';
const meta=[
showChCol?esc(v.channel):null,
v.upload_date?fmtDate(v.upload_date):null,
v.duration_secs!=null?fmtDur(v.duration_secs):null,
v.file_size!=null?fmtSize(v.file_size):null,
v.has_live_chat?'💬':null,
!v.has_video?'<span style="color:#f87171">no file</span>':null,
].filter(Boolean).join(' · ');
const thumb=v.thumb_url?`<img src="${v.thumb_url}" loading="lazy" alt="">`:'<div class="nothumb">no thumbnail</div>';
const dur=v.duration_secs!=null?`<span class="dur">${fmtDur(v.duration_secs)}</span>`:'';
const resumeBar=v.resume_pos&&v.duration_secs?`<div class="resume-bar" style="width:${Math.min(100,v.resume_pos/v.duration_secs*100).toFixed(1)}%"></div>`:'';
const playBtn=v.has_video&&v.video_url?`<button class="play" onclick="playVideo('${v.id}')">▶ Play</button>`:'';
return `<div class="card${v.watched?' watched':''}${selectedId===v.id?' selected':''}" onclick="selectVideo('${v.id}')">
<div class="card-thumb">${thumb}${dur}${resumeBar}</div>
<div class="card-title">${chk} ${esc(v.title)}</div>
<div class="card-meta">${meta||'&nbsp;'}</div>
<div class="card-foot" onclick="event.stopPropagation()">
${playBtn}
<button onclick="toggleWatched('${v.id}')" title="Watched">${v.watched?'✓':'○'}</button>
<button onclick="toggleFlag('${v.id}','favourite',this)" title="Favourite" style="${v.favourite?'color:#facc15':''}">${v.favourite?'★':'☆'}</button>
<button onclick="toggleFlag('${v.id}','bookmark',this)" title="Bookmark" style="${v.bookmark?'color:var(--accent)':''}">🔖</button>
<button onclick="toggleFlag('${v.id}','waiting',this)" title="Waiting list" style="${v.waiting?'color:var(--accent)':''}">⏳</button>
</div>
</div>`;
}).join('');
}
/* ── Watched / bulk ─────────────────────────────────────────────── */
async function toggleWatched(id){try{await api('/api/watched/'+id,{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
/* ── Per-video flags ────────────────────────────────────────────── */
async function toggleFlag(videoId,flag,btn){
// Optimistically flip the visual state; reload from server afterwards.
const v=findVideo(videoId); if(!v){setStatus('Video not found');return}
const newValue=!v[flag];
v[flag]=newValue;
try{
const r=await api(`/api/videos/${encodeURIComponent(videoId)}/flags/${encodeURIComponent(flag)}`,{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:newValue})
});
if(!r.ok)throw new Error(await r.text());
// Reload to update sidebar counts; the in-memory `library` already
// reflects the toggle thanks to the optimistic update above.
await loadLibrary();
}catch(e){
// Undo on failure.
v[flag]=!newValue;
setStatus('Error: '+e.message);
}
}
function renderChannelGrid(){
const q=(document.getElementById('search')?.value||'').toLowerCase();
const grid=document.getElementById('grid');
const chs=q?library.filter(ch=>ch.name.toLowerCase().includes(q)||ch.uploader?.toLowerCase().includes(q)):library;
setStatus(chs.length+' channel'+(chs.length!==1?'s':''));
if(!chs.length){grid.innerHTML='<div class="empty">Nothing here.</div>';return}
grid.innerHTML='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;padding:4px">'+chs.map((ch,i)=>{
const idx=library.indexOf(ch);
const thumb=ch.thumb_url?`<img src="${ch.thumb_url}" loading="lazy" alt="">`:'<div class="nothumb">📺</div>';
const size=ch.size_bytes>0?` · ${fmtSize(ch.size_bytes)}`:'';
const label=ch.uploader&&ch.uploader!==ch.name?esc(ch.uploader):esc(ch.name);
return `<div class="ch-card" onclick="setView(${idx},null)">
<div class="ch-card-thumb">${thumb}</div>
<div class="ch-card-info">
<div class="ch-card-name" title="${esc(ch.name)}">${label}</div>
<div class="ch-card-meta">${ch.total_videos} video${ch.total_videos!==1?'s':''}${size}</div>
</div>
</div>`;
}).join('')+'</div>';
}
async function loadMusic(){
try{const d=await(await api('/api/music')).json();musicTracks=d.tracks||[];renderSidebar();if(showMusic)renderMusicGrid()}catch(e){console.warn('music load:',e)}
}
function renderMusicGrid(){
const q=(document.getElementById('search')?.value||'').toLowerCase();
const grid=document.getElementById('grid');
const tracks=q?musicTracks.filter(t=>t.title.toLowerCase().includes(q)||t.artist.toLowerCase().includes(q)):musicTracks;
setStatus(tracks.length+' track'+(tracks.length!==1?'s':''));
if(!tracks.length){grid.innerHTML='<div class="empty">No music yet. Use 🎵 Music mode in the download bar.</div>';return}
let currentArtist='',h='<div style="width:100%;padding:4px">';
for(const t of tracks){
if(t.artist!==currentArtist){
currentArtist=t.artist;
h+=`<div style="font-weight:700;margin:12px 0 4px;color:var(--text)">${esc(t.artist)}</div><hr style="border-color:var(--border);margin:0 0 8px">`;
}
const dur=t.duration_secs!=null?fmtDur(t.duration_secs):'';
const playBtn=t.audio_url?`<button class="play" onclick="playAudio('${esc(t.id)}','${esc(t.title)}','${esc(safeUrl(t.audio_url))}')">▶</button>`:'';
h+=`<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border)">
${playBtn}
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
<span style="color:var(--muted);font-size:12px;flex-shrink:0">${dur}</span>
</div>`;
}
h+='</div>';
grid.innerHTML=h;
}
function playAudio(id,title,url){
const bg=document.createElement('div');bg.className='modal-bg';
bg.onclick=e=>{if(e.target===bg)closeModal(bg)};
bg.innerHTML=`<div class="modal" style="max-width:420px">
<div class="modal-hdr"><h2>${esc(title)}</h2><button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button></div>
<div class="modal-body"><audio id="audio-player" src="${url}" controls autoplay style="width:100%"></audio></div>
</div>`;
document.body.appendChild(bg);
}
function toggleBulk(){bulkMode=document.getElementById('bulk').checked;selected.clear();document.getElementById('bulk-actions').style.display=bulkMode?'flex':'none';renderGrid()}
function toggleSel(id,on){if(on)selected.add(id);else selected.delete(id);document.getElementById('sel-count').textContent=selected.size+' selected'}
async function bulkWatched(on){await Promise.all([...selected].map(id=>api('/api/watched/'+id,{method:'POST'})));selected.clear();await loadLibrary()}
async function bulkFlag(flag){
// Set the chosen flag on every selected video in parallel, then refresh.
// Flag stays on (toggle-off in bulk is rarely what users want — opt for
// explicit setting only, matching the desktop behaviour).
const ids=[...selected];
if(!ids.length){setStatus('Nothing selected');return}
try{
await Promise.all(ids.map(id=>api(
`/api/videos/${encodeURIComponent(id)}/flags/${encodeURIComponent(flag)}`,
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:true})}
)));
setStatus(`${ids.length} marked ${flag}`);
selected.clear();
await loadLibrary();
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Download preview ───────────────────────────────────────────── */
async function previewDownload(){
const url=document.getElementById('dl-url').value.trim();
if(!url)return;
setStatus('Fetching preview…');
const bg=document.createElement('div');bg.className='modal-bg';
bg.innerHTML=`<div class="modal" style="max-width:420px">
<div class="modal-hdr"><h2>Fetching info…</h2></div>
<div style="color:var(--muted);font-size:13px">Contacting YouTube, please wait…</div>
<button onclick="this.closest('.modal-bg').remove();setStatus('')">Cancel</button>
</div>`;
document.body.appendChild(bg);
try{
const r=await fetch('/api/preview?url='+encodeURIComponent(url));
if(!r.ok)throw new Error(await r.text());
const d=await r.json();
const info=[d.type,d.entry_count?d.entry_count+' videos':null,d.duration?fmtDur(d.duration):null,d.view_count?d.view_count.toLocaleString()+' views':null].filter(Boolean).join(' · ');
bg.innerHTML=`<div class="modal" style="max-width:480px">
<div class="modal-hdr"><h2>Confirm download</h2></div>
<div class="modal-body" style="align-items:flex-start;gap:12px">
${d.thumbnail?`<img class="preview-thumb" src="${esc(safeUrl(d.thumbnail))}" onerror="this.remove()">`:''}
<div>
<div style="font-weight:600;margin-bottom:6px">${esc(d.title||'Unknown')}</div>
${d.channel?`<div style="font-size:12px;color:var(--muted);margin-bottom:4px">${esc(d.channel)}</div>`:''}
<div style="font-size:12px;color:var(--muted)">${esc(info)}</div>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="this.closest('.modal-bg').remove();setStatus('')">Cancel</button>
<button class="primary" onclick="confirmDownload('${esc(url)}',this)">⬇ Download</button>
</div>
</div>`;
setStatus('');
}catch(e){
bg.innerHTML=`<div class="modal" style="max-width:400px">
<div class="modal-hdr"><h2>Preview failed</h2></div>
<div style="color:#f87171;font-size:13px;margin-bottom:8px">${esc(e.message)}</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="confirmDownload('${esc(url)}',this)">Download anyway</button>
</div>
</div>`;
setStatus('');
}
}
function fullScan(){return !(document.getElementById('dl-full-scan')?.checked||false)}
function dlLive(){return document.getElementById('dl-live')?.checked||false}
function dlTwitchClips(){return document.getElementById('dl-twitch-clips')?.checked||false}
// Show the Twitch "Clips only" toggle only when the URL currently in the
// download field looks like a Twitch channel (not a specific VOD/clip).
function refreshTwitchClipsVisibility(){
const url=(document.getElementById('dl-url')?.value||'').toLowerCase();
const isTwitchChannel=url.includes('twitch.tv/')
&& !url.includes('/videos/')
&& !url.includes('/clip/');
const lbl=document.getElementById('twitch-clips-label');
if(lbl)lbl.style.display=isTwitchChannel?'flex':'none';
}
function dlQuality(){return document.getElementById('dl-quality')?.value||'best'}
function updateDlMode(){
const isMusic=dlQuality()==='music';
document.getElementById('fast-mode-label').style.display=isMusic?'none':'flex';
// Live recording makes no sense for music-only mode.
document.getElementById('live-mode-label').style.display=isMusic?'none':'flex';
}
async function confirmDownload(url,btn){
if(btn)btn.closest('.modal-bg').remove();
try{
const quality=dlQuality();
// Twitch clips-only: rewrite a bare twitch.tv/<user> URL to
// twitch.tv/<user>/clips so yt-dlp's TwitchClips extractor handles it.
// Skip the rewrite when the URL already targets a specific video/clip.
let finalUrl=url;
if(dlTwitchClips()
&& /twitch\.tv\//i.test(url)
&& !/\/(?:videos|clip)\//i.test(url)
&& !/\/clips(?:\/?$|\?)/i.test(url)){
finalUrl=url.replace(/\/+$/,'')+'/clips';
}
const body={url:finalUrl,full_scan:fullScan(),quality,live:dlLive()};
await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
document.getElementById('dl-url').value='';
refreshTwitchClipsVisibility();
setStatus('Download queued…')
}catch(e){setStatus('Error: '+e.message)}
}
async function downloadChannel(url){try{await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,full_scan:true,quality:'best'})});setStatus('Checking for new videos…')}catch(e){setStatus('Error: '+e.message)}}
function rebuildChannelUrl(ch){
// Prefer the stored source URL (works for every platform). Fall back to
// the legacy YouTube heuristic only when the channel pre-dates the
// multi-platform changes and has no .source-url sidecar.
if(ch.source_url)return ch.source_url;
return(/^UC.{22}$/.test(ch.name))
?'https://www.youtube.com/channel/'+ch.name
:'https://www.youtube.com/@'+ch.name;
}
async function downloadChannelByIdx(i){await downloadChannel(rebuildChannelUrl(library[i]))}
/* ── Per-channel download options ──────────────────────────────── */
async function openChannelOptions(idx){
const ch=library[idx]; if(!ch)return;
const platform=ch.platform, handle=ch.name;
let opts={};
try{
opts=await(await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`)).json();
}catch(e){setStatus('Could not load options: '+e.message);return}
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const q=opts.quality||'';
const qOpt=(val,label)=>`<option value="${val}"${q===val?' selected':''}>${label}</option>`;
const langs=(opts.subtitle_langs||[]).join(', ');
const extras=(opts.extra_args||[]).join('\n');
bg.innerHTML=`<div class="modal" style="max-width:480px;width:100%;overflow-y:auto;max-height:90vh">
<div class="modal-hdr"><h2>⚙ ${esc(ch.platform_label||ch.platform)} · ${esc(handle)}</h2>
<button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div class="settings-hint" style="margin-bottom:10px">Overrides apply during scheduled re-checks and "Check for new videos". Explicit downloads from the URL bar still use the dialog's own picker.</div>
<div class="settings-row"><label>Quality cap</label>
<select id="opt-quality" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 6px;border-radius:4px">
${qOpt('','— Global default —')}
${qOpt('Best','Best')}
${qOpt('Res1080','1080p')}
${qOpt('Res720','720p')}
${qOpt('Res480','480p')}
${qOpt('Res360','360p')}
</select></div>
<div class="settings-row"><label>Audio-only</label>
<input type="checkbox" id="opt-audio" ${opts.audio_only?'checked':''}></div>
<div class="settings-hint" style="margin-bottom:4px">Useful for music channels — saves disk + skips video re-encode.</div>
<div class="settings-row"><label>Fetch comments</label>
<input type="checkbox" id="opt-comments" ${opts.fetch_comments?'checked':''}></div>
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--write-comments</code>. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player.</div>
<div class="settings-row"><label>Bandwidth cap (KB/s)</label>
<input type="number" id="opt-rate" value="${opts.limit_rate_kb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Min size (MB)</label>
<input type="number" id="opt-minsz" value="${opts.min_filesize_mb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Max size (MB)</label>
<input type="number" id="opt-maxsz" value="${opts.max_filesize_mb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Only videos after (YYYYMMDD)</label>
<input type="text" id="opt-date" value="${esc(opts.date_after||'')}" placeholder="e.g. 20240101" pattern="[0-9]{8}" style="width:120px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Match filter (yt-dlp --match-filter)</label>
<input type="text" id="opt-filter" value="${esc(opts.match_filter||'')}" placeholder="e.g. duration > 60 & view_count > 100" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-family:monospace;font-size:12px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Subtitle languages (comma separated, blank = all)</label>
<input type="text" id="opt-subs" value="${esc(langs)}" placeholder="en, ja" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:4px">
<label>Extra yt-dlp args (one per line)</label>
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
--ignore-config" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:12px">${esc(extras)}</textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button onclick="clearChannelOptions('${esc(platform)}','${esc(handle)}',this)">Clear all</button>
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveChannelOptions('${esc(platform)}','${esc(handle)}',this)">Save</button>
</div>
</div>`;
document.body.appendChild(bg);
}
function readChannelOptionsForm(){
const intOrNull=id=>{const v=parseInt(document.getElementById(id)?.value,10);return Number.isFinite(v)&&v>0?v:null};
const strOrNull=id=>{const v=(document.getElementById(id)?.value||'').trim();return v?v:null};
const subs=(document.getElementById('opt-subs')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);
const extra=(document.getElementById('opt-extra')?.value||'').split('\n').map(s=>s.trim()).filter(Boolean);
const q=document.getElementById('opt-quality')?.value||'';
return {
quality:q?q:null,
audio_only:document.getElementById('opt-audio')?.checked||false,
fetch_comments:document.getElementById('opt-comments')?.checked||false,
limit_rate_kb:intOrNull('opt-rate'),
min_filesize_mb:intOrNull('opt-minsz'),
max_filesize_mb:intOrNull('opt-maxsz'),
date_after:strOrNull('opt-date'),
match_filter:strOrNull('opt-filter'),
subtitle_langs:subs,
extra_args:extra,
};
}
async function saveChannelOptions(platform,handle,btn){
btn.disabled=true;
try{
const body=readChannelOptionsForm();
const r=await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`,{
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(!r.ok)throw new Error(await r.text());
btn.closest('.modal-bg').remove();
setStatus('Channel options saved');
await loadLibrary();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
async function clearChannelOptions(platform,handle,btn){
if(!confirm('Clear all channel option overrides? The channel will fall back to global defaults.'))return;
btn.disabled=true;
try{
await api(`/api/channels/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/options`,{method:'DELETE'});
btn.closest('.modal-bg').remove();
setStatus('Channel options cleared');
await loadLibrary();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
/* ── Folder management ─────────────────────────────────────────── */
async function openFolderManager(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const folders=librarySnapshotFolders||[];
const rows=folders.map(f=>{
const memberCount=library.filter(c=>c.folder_id===f.id).length;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="flex:1">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
${memberCount>0?`<button onclick="checkFolder(${f.id},'${esc(f.name)}',this)" title="Check every channel in this folder for new videos">⬇ Check</button>`:''}
<button onclick="renameFolderPrompt(${f.id},'${esc(f.name)}')">Rename</button>
<button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button>
</div>`;
}).join('');
bg.innerHTML=`<div class="modal" style="max-width:480px;width:100%">
<div class="modal-hdr"><h2>📁 Manage folders</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div style="margin-bottom:12px">
${rows||'<div style="color:var(--muted);font-size:12px;padding:4px 0">No folders yet — create one below to organize your channels.</div>'}
</div>
<div style="display:flex;gap:6px">
<input type="text" id="new-folder-name" placeholder="New folder name" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px">
<button class="primary" onclick="createFolder(this)">Create</button>
</div>
</div>`;
document.body.appendChild(bg);
}
async function createFolder(btn){
const name=(document.getElementById('new-folder-name')?.value||'').trim();
if(!name){setStatus('Folder name required');return}
btn.disabled=true;
try{
const r=await api('/api/folders',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder created');
btn.closest('.modal-bg').remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
async function renameFolderPrompt(id,oldName){
const name=window.prompt('Rename folder',oldName);
if(!name||name===oldName)return;
try{
const r=await api(`/api/folders/${id}/rename`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder renamed');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
async function checkFolder(id,name,btn){
btn.disabled=true;
try{
const r=await fetch(`/api/folders/${id}/check`,{method:'POST'});
const t=await r.text();
if(!r.ok&&r.status!==409)throw new Error(t);
setStatus(r.status===409?`${name}: downloads already running`:`${name}: ${t}`);
}catch(e){setStatus('Error: '+e.message)}
finally{btn.disabled=false}
}
async function deleteFolderConfirm(id,name,memberCount){
const msg=memberCount>0
? `Delete folder "${name}"? Its ${memberCount} channel${memberCount===1?'':'s'} will revert to "Unfiled".`
: `Delete folder "${name}"?`;
if(!confirm(msg))return;
try{
const r=await api(`/api/folders/${id}`,{method:'DELETE'});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder deleted');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Move channel to folder ────────────────────────────────────── */
async function openMoveToFolder(channelIdx){
const ch=library[channelIdx]; if(!ch)return;
const folders=librarySnapshotFolders||[];
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const options=[
`<button onclick="moveChannelToFolder(${channelIdx},null,this)" style="text-align:left;padding:8px 12px;width:100%;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--text);cursor:pointer${ch.folder_id===null?';background:var(--card)':''}">— Unfiled —</button>`,
...folders.map(f=>`<button onclick="moveChannelToFolder(${channelIdx},${f.id},this)" style="text-align:left;padding:8px 12px;width:100%;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--text);cursor:pointer${ch.folder_id===f.id?';background:var(--card)':''}">📁 ${esc(f.name)}</button>`),
].join('');
bg.innerHTML=`<div class="modal" style="max-width:360px;width:100%">
<div class="modal-hdr"><h2>Move "${esc(ch.name)}"</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div style="display:flex;flex-direction:column;gap:6px">${options}</div>
<div class="settings-hint" style="margin-top:10px">Channels in a folder appear under that folder instead of their platform section.</div>
</div>`;
document.body.appendChild(bg);
}
async function moveChannelToFolder(channelIdx,folderId,btn){
const ch=library[channelIdx]; if(!ch)return;
btn.disabled=true;
try{
const r=await api(`/api/channels/${encodeURIComponent(ch.platform)}/${encodeURIComponent(ch.name)}/folder`,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({folder_id:folderId}),
});
if(!r.ok)throw new Error(await r.text());
btn.closest('.modal-bg').remove();
setStatus('Channel moved');
await loadLibrary();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
/* ── Rescan ─────────────────────────────────────────────────────── */
async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
/* ── Find video ─────────────────────────────────────────────────── */
function findVideo(id){for(const ch of library)for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])if(v.id===id)return v;return null}
/* ── Player ─────────────────────────────────────────────────────── */
// Pick a random unwatched, downloaded video from the whole library and
// open it in the player. Falls back to "any unwatched" if nothing's
// downloaded yet, and finally to "any video" if everything's watched.
function shufflePlay(){
const all=library.flatMap(ch=>[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]);
const pickFrom=arr=>arr[Math.floor(Math.random()*arr.length)];
const pool=all.filter(v=>!v.watched&&v.has_video&&v.video_url);
let pick;
if(pool.length){pick=pickFrom(pool)}
else{
const downloaded=all.filter(v=>v.has_video&&v.video_url);
if(downloaded.length){pick=pickFrom(downloaded);setStatus('Everything is watched — playing a random one anyway')}
else{setStatus('No downloaded videos to shuffle from');return}
}
playVideo(pick.id);
}
function playVideo(id){
const v=findVideo(id);if(!v||!v.video_url)return;
currentPlayingId=id;
const bg=document.createElement('div');bg.className='modal-bg';
bg.onclick=e=>{if(e.target===bg)closeModal(bg)};
const tracks=(v.subtitles||[]).map((s,i)=>`<track kind="subtitles" src="${esc(safeUrl(s.url))}" srclang="${esc(s.lang)}" label="${esc(s.label)}"${i===0?' default':''}>`).join('');
const chapPane=v.has_chapters?`<div class="chapters-pane" id="chapters-pane"><h4>Chapters</h4><div id="chapters-list"><em style="padding:10px;display:block;color:var(--muted)">Loading…</em></div></div>`:'';
// Show upload date + duration in the modal header so the viewer has
// basic context without opening the metadata pane.
const headerMeta=[
v.upload_date?fmtDate(v.upload_date):null,
v.duration_secs!=null?fmtDur(v.duration_secs):null,
].filter(Boolean).join(' · ');
const metaLine=headerMeta?`<span style="font-size:11px;color:var(--muted);margin-left:8px">${esc(headerMeta)}</span>`:'';
bg.innerHTML=`<div class="modal">
<div class="modal-hdr">
<h2>${esc(v.title)}${metaLine}</h2>
<button onclick="openComments('${esc(id)}')" title="Comments">💬</button>
<button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button>
</div>
<div class="modal-body"><video id="player-video" src="${esc(safeUrl(v.video_url))}" controls autoplay crossorigin="anonymous">${tracks}</video>${chapPane}</div>
</div>`;
document.body.appendChild(bg);
const vid=bg.querySelector('#player-video');
if(v.resume_pos&&v.resume_pos>5)vid.addEventListener('loadedmetadata',()=>{vid.currentTime=v.resume_pos},{once:true});
if(saveTimer)clearInterval(saveTimer);
saveTimer=setInterval(()=>savePosition(vid),5000);
vid.addEventListener('pause',()=>savePosition(vid));
if(v.has_chapters)loadChapters(id);
}
async function savePosition(vid){
if(!vid||vid.readyState<1||!currentPlayingId)return;
const pos=vid.currentTime,dur=vid.duration;
if(!pos||!dur)return;
const nearEnd=dur>0&&pos>dur*0.95;
try{
await fetch('/api/resume/'+encodeURIComponent(currentPlayingId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({position:nearEnd?0:pos})});
if(nearEnd){const v=findVideo(currentPlayingId);if(v&&!v.watched)await api('/api/watched/'+encodeURIComponent(currentPlayingId),{method:'POST'})}
}catch{}
}
function closeModal(el){
if(saveTimer){clearInterval(saveTimer);saveTimer=null}
const vid=el.querySelector('video');
if(vid){savePosition(vid);vid.pause();vid.src=''}
currentPlayingId=null;
el.remove();
loadLibrary();
}
async function loadChapters(id){
try{
const chapters=((await(await fetch('/api/chapters/'+encodeURIComponent(id))).json()).chapters)||[];
const list=document.getElementById('chapters-list');if(!list)return;
if(!chapters.length){list.innerHTML='<em style="padding:10px;display:block;color:var(--muted)">No chapters</em>';return}
list.innerHTML=chapters.map(c=>`<div class="chapter" onclick="jumpToChapter(${c.start})"><div class="ch-time">${fmtDur(c.start)}</div><div class="ch-title">${esc(c.title)}</div></div>`).join('');
const vid=document.getElementById('player-video');
if(vid)vid.addEventListener('timeupdate',()=>{
const t=vid.currentTime;let ai=-1;
for(let i=0;i<chapters.length;i++){if(t>=chapters[i].start)ai=i;else break}
list.querySelectorAll('.chapter').forEach((el,i)=>{el.classList.toggle('active',i===ai)});
const a=list.querySelector('.chapter.active');if(a)a.scrollIntoView({block:'nearest'});
});
}catch{}
}
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
/* ── Comments viewer ───────────────────────────────────────────── */
async function openComments(videoId){
const bg=document.createElement('div');bg.className='modal-bg';
bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:640px;width:100%">
<div class="modal-hdr"><h2>💬 Comments</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div id="comments-body" style="overflow-y:auto;max-height:75vh"><em style="color:var(--muted)">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/comments/'+encodeURIComponent(videoId))).json();
renderComments(r.comments||[]);
}catch(e){
document.getElementById('comments-body').innerHTML=`<div style="color:#f87171">Failed to load comments: ${esc(e.message)}</div>`;
}
}
function renderComments(list){
const body=document.getElementById('comments-body');if(!body)return;
if(!list.length){
body.innerHTML='<div style="color:var(--muted);padding:12px">No comments captured for this video. Enable "Fetch comments" in this channel\'s options + re-download.</div>';
return;
}
// Build a tree from parent ids. Top-level = parent missing / not in set.
const byId=new Map(list.map(c=>[c.id,{...c,children:[]}]));
const roots=[];
for(const c of byId.values()){
if(c.parent && byId.has(c.parent)) byId.get(c.parent).children.push(c);
else roots.push(c);
}
const render=(c,depth)=>{
const indent=depth*16;
const author=c.author?`<strong>${esc(c.author)}</strong>`:'<em>unknown</em>';
const meta=[c.time,c.likes!=null?`${c.likes} likes`:null].filter(Boolean).join(' · ');
const replies=c.children.length?c.children.map(r=>render(r,depth+1)).join(''):'';
return `<div style="padding:6px 10px 6px ${10+indent}px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4">
<div style="margin-bottom:2px">${author}${meta?` <span style="color:var(--muted);font-size:11px">· ${esc(meta)}</span>`:''}</div>
<div style="white-space:pre-wrap;word-wrap:break-word">${esc(c.text)}</div>
</div>${replies}`;
};
body.innerHTML=`<div style="font-size:11px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border)">${list.length} comment${list.length===1?'':'s'}</div>`+roots.map(c=>render(c,0)).join('');
}
/* ── Select / Details ───────────────────────────────────────────── */
function selectVideo(id){selectedId=selectedId===id?null:id;renderGrid();renderDetails()}
function closeDetails(){selectedId=null;renderGrid();renderDetails()}
function renderDetails(){
const el=document.getElementById('details');
if(!selectedId){el.style.display='none';return}
const v=findVideo(selectedId);if(!v){el.style.display='none';return}
el.style.display='block';
const meta=[
v.duration_secs!=null?fmtDur(v.duration_secs):null,
v.file_size!=null?fmtSize(v.file_size):null,
v.resume_pos&&v.duration_secs?'Resume at '+fmtDur(v.resume_pos):null,
v.watched?'watched':'unwatched',
v.has_live_chat?'💬 live chat':null,
!v.has_video?'no file':null,
].filter(Boolean);
const playBtn=v.has_video&&v.video_url?`<button class="primary" onclick="playVideo('${v.id}')">▶ Play</button>`:'';
el.innerHTML=`<div class="det-head">
<h3>${esc(v.title)}</h3>
<div class="det-actions">
${playBtn}
<button onclick="toggleWatched('${v.id}')">${v.watched?'✓ Watched':'○ Unwatched'}</button>
<button onclick="showMetadata('${v.id}')">📋 Metadata</button>
<button onclick="closeDetails()">✕</button>
</div>
</div>
<div class="det-meta">${meta.map(esc).join('<span style="color:var(--border)"> | </span>')}</div>
<div class="det-desc" id="det-desc"><em style="color:var(--muted)">Loading…</em></div>`;
fetchDescription(selectedId);
}
async function fetchDescription(id){
try{const t=await(await fetch('/api/description/'+encodeURIComponent(id))).text();const el=document.getElementById('det-desc');if(el)el.textContent=t.trim()||'(no description)'}
catch{const el=document.getElementById('det-desc');if(el)el.textContent='(could not load description)'}
}
/* ── Metadata viewer ────────────────────────────────────────────── */
async function showMetadata(id){
let data;try{const r=await fetch('/api/metadata/'+encodeURIComponent(id));if(!r.ok)throw new Error(await r.text());data=await r.json()}catch(e){setStatus('Error: '+e.message);return}
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const fields=[
['Title',data.title],['ID',data.id],['Channel',data.channel||data.uploader],
['Channel URL',data.channel_url?`<a href="${esc(data.channel_url)}" target="_blank">${esc(data.channel_url)}</a>`:null],
['Original URL',data.webpage_url?`<a href="${esc(data.webpage_url)}" target="_blank">${esc(data.webpage_url)}</a>`:null],
['Upload date',data.upload_date?`${data.upload_date.slice(0,4)}-${data.upload_date.slice(4,6)}-${data.upload_date.slice(6,8)}`:null],
['Duration',data.duration!=null?fmtDur(data.duration):null],
['Views',data.view_count!=null?data.view_count.toLocaleString():null],
['Likes',data.like_count!=null?data.like_count.toLocaleString():null],
['Comments',data.comment_count!=null?data.comment_count.toLocaleString():null],
['Format',data.format],['Resolution',data.resolution||(data.width&&data.height?`${data.width}x${data.height}`:null)],
['FPS',data.fps],['Video codec',data.vcodec],['Audio codec',data.acodec],
['Filesize',data.filesize_approx?fmtSize(data.filesize_approx):null],
['Categories',Array.isArray(data.categories)?data.categories.join(', '):null],
['Tags',Array.isArray(data.tags)?data.tags.slice(0,30).join(', '):null],
['Age limit',data.age_limit],['Live status',data.live_status],['Availability',data.availability],
].filter(([,v])=>v!=null&&v!=='');
const sum=`<dl class="meta-grid">${fields.map(([k,v])=>`<dt>${esc(k)}</dt><dd>${typeof v==='string'&&v.startsWith('<a')?v:esc(v)}</dd>`).join('')}</dl>`;
const raw=`<pre class="meta-raw">${esc(JSON.stringify(data,null,2))}</pre>`;
bg.innerHTML=`<div class="modal" style="max-width:700px">
<div class="modal-hdr"><h2>Metadata</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div class="meta-tabs">
<div class="meta-tab active" onclick="switchMetaTab(this,'summary')">Summary</div>
<div class="meta-tab" onclick="switchMetaTab(this,'raw')">Raw JSON</div>
</div>
<div id="meta-summary" style="overflow:auto;max-height:65vh">${sum}</div>
<div id="meta-raw" style="display:none">${raw}</div>
</div>`;
document.body.appendChild(bg);
}
function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab').forEach(t=>t.classList.remove('active'));tab.classList.add('active');document.getElementById('meta-summary').style.display=which==='summary'?'':'none';document.getElementById('meta-raw').style.display=which==='raw'?'':'none'}
/* ── Settings ───────────────────────────────────────────────────── */
const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']];
function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)}
async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}}
async function openSettings(){
let cur={transcode:false,source_url:null,current_bind:null,available_binds:[],download_password_required:false};try{cur=await(await api('/api/settings')).json()}catch{}
const savedTheme=localStorage.getItem('theme')||'dark';
const srcRow=`<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Source code (AGPL §13)</div>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:6px">
<input type="url" id="cf-source-url" value="${esc(cur.source_url||'')}" placeholder="https://codeberg.org/you/your-fork"
style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-size:12px">
<div class="settings-hint">Shown as the &quot;Source&quot; link in the footer. AGPL-3.0 requires every network user be offered a way to obtain the running source code. Leave empty to hide the link.</div>
</div>`;
const bindRows=cur.available_binds?.length?`<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Binding</label>
<div style="font-size:12px;color:var(--muted);margin-bottom:4px">Current: <code style="background:var(--bg);padding:2px 4px;border-radius:2px">${esc(cur.current_bind||'unknown')}</code></div>
<select id="cf-bind" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px;width:100%">
${cur.available_binds.map(b=>`<option value="${esc(b.id)}">${esc(b.label)}</option>`).join('')}
</select>
<div class="settings-hint">Change requires restart. Access from: tailscale, LAN, or all interfaces.</div>
</div>`:''
const logoutBtn=cur.download_password_required?`<button onclick="logout()">Log out</button>`:'';
let ck={exists:false,cookies:0};try{ck=await(await api('/api/cookies')).json()}catch{}
const cookiesStatus=ck.exists?`${ck.cookies} cookie(s) loaded`:'no cookies.txt';
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:420px;overflow-y:auto">
<div class="modal-hdr" style="position:sticky;top:0;background:var(--panel);z-index:1;padding-bottom:6px"><h2>Settings</h2></div>
<div class="settings-row">
<label for="cf-transcode">Transcode videos (mp4/H.264)</label>
<input type="checkbox" id="cf-transcode" ${cur.transcode?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:10px">Requires ffmpeg. Lets Chrome play MKV files. Seeking disabled while transcoding.</div>
<div class="settings-row">
<label>Theme</label>
<select id="cf-theme" onchange="applyTheme(this.value)" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${THEMES.map(([id,label])=>`<option value="${id}"${savedTheme===id?' selected':''}>${label}</option>`).join('')}
</select>
</div>
<div class="settings-row">
<label for="cf-download-pwd">Require password to access UI</label>
<input type="checkbox" id="cf-download-pwd" ${cur.download_password_required?'checked':''} onchange="document.getElementById('cf-pwd-input').style.display=this.checked?'flex':'none'">
</div>
<div id="cf-pwd-input" style="display:${cur.download_password_required?'flex':'none'};flex-direction:column;gap:4px;margin-bottom:10px">
<input type="password" id="cf-download-password" placeholder="New password (leave empty to disable)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);padding:6px 8px;border-radius:4px">
<div class="settings-hint">Gates the whole UI and all API access. Leave empty to disable on save; changing it logs out other sessions.</div>
</div>
${bindRows}
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Cookies (cookies.txt)</label>
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
<input type="file" id="cf-cookies-file" accept=".txt,text/plain" onchange="loadCookieFile(this)" style="font-size:11px;color:var(--muted)">
<textarea id="cf-cookies" placeholder="…or paste Netscape-format cookies.txt here" style="width:100%;height:70px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:11px;resize:vertical"></textarea>
<div style="display:flex;gap:6px">
<button onclick="saveCookies(this)">Update cookies</button>
<button onclick="clearCookies(this)" style="color:var(--muted)">Clear</button>
</div>
<div class="settings-hint">Choose a file or paste (e.g. from "Get cookies.txt LOCALLY"). Refresh when downloads start hitting captchas.</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Scheduler</div>
<div class="settings-row">
<label for="cf-sched-enabled">Auto-check all channels</label>
<input type="checkbox" id="cf-sched-enabled" ${cur.scheduler_enabled?'checked':''} onchange="document.getElementById('cf-sched-interval-row').style.display=this.checked?'flex':'none'">
</div>
<div id="cf-sched-interval-row" class="settings-row" style="display:${cur.scheduler_enabled?'flex':'none'}">
<label for="cf-sched-interval">Every</label>
<select id="cf-sched-interval" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${[1,2,6,12,24,48,72,168].map(h=>`<option value="${h}"${(cur.scheduler_interval_hours||24)===h?' selected':''}>${h===1?'1 hour':h===168?'1 week':h<24?h+' hours':h===24?'1 day':(h/24)+' days'}</option>`).join('')}
</select>
</div>
<div class="settings-hint" style="margin-bottom:6px">${cur.scheduler_next_check_secs!=null?'Next check in '+fmtCountdown(cur.scheduler_next_check_secs)+'.':''}</div>
<div style="display:flex;gap:6px;margin-bottom:4px">
<button onclick="runScheduler(this)">▶ Check all channels now</button>
<span id="sched-status" style="font-size:11px;color:var(--muted);align-self:center"></span>
</div>
<div class="settings-row" style="margin-top:6px">
<label for="cf-max-concurrent">Max concurrent downloads</label>
<input type="number" id="cf-max-concurrent" value="${cur.max_concurrent||3}" min="1" max="10" style="width:56px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-size:13px">
</div>
<div class="settings-hint" style="margin-bottom:4px">Extra downloads queue and start automatically when a slot opens.</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">yt-dlp binary</div>
<div class="settings-row">
<label for="cf-ytdlp-bundled">Use bundled yt-dlp + deno</label>
<input type="checkbox" id="cf-ytdlp-bundled" ${cur.use_bundled_ytdlp?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:6px">Bundled binaries live in ~/.local/share/yt-offline/bin/ and include a JS runtime (deno) needed for YouTube signature deciphering. When off, the yt-dlp on your PATH is used.</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<button onclick="updateYtdlp(this)">${cur.bundled_ytdlp_installed?'⟳ Update bundled':'⤓ Install bundled'}</button>
<span style="font-size:11px;color:var(--muted)">${cur.bundled_ytdlp_installed?'✓ installed':'not installed'}</span>
<span id="ytdlp-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Plex</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Library path</label>
<input type="text" id="cf-plex-path" value="${esc(cur.plex_library_path||'')}" placeholder="/media/plex/YouTube" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);padding:6px 8px;border-radius:4px;font-family:monospace;font-size:12px">
<div class="settings-hint">Symlink tree of channels as TV shows. Point a Plex "TV Shows" library here.</div>
<div style="display:flex;gap:6px;align-items:center">
<button onclick="generatePlex(this)">⟳ Generate Plex library</button>
<span id="plex-status" style="font-size:11px;color:var(--muted)"></span>
</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Backup</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<div class="settings-hint">Saves the SQLite file holding your watched / favourites / bookmarks / waiting / channel-options / folders. Restore by replacing <code>yt-offline.db</code> in the channels directory.</div>
<button onclick="downloadBackupDb()">⬇ Download library snapshot</button>
</div>
${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
${logoutBtn}
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveSettings(this)">Save</button>
</div>
</div>`;
document.body.appendChild(bg);
}
function loadCookieFile(input){
const f=input.files&&input.files[0];if(!f)return;
const r=new FileReader();
r.onload=()=>{document.getElementById('cf-cookies').value=r.result;setStatus('Loaded '+f.name+' — click Update cookies to save')};
r.onerror=()=>setStatus('Could not read file');
r.readAsText(f);
}
async function saveCookies(btn){
const t=document.getElementById('cf-cookies').value;
if(!t.trim()){setStatus('Paste cookies first');return}
btn.disabled=true;
try{
const r=await fetch('/api/cookies',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cookies:t})});
if(!r.ok)throw new Error(await r.text());
const d=await r.json();
document.getElementById('cf-cookies').value='';
document.getElementById('cookies-status').textContent=d.cookies+' cookie(s) loaded';
setStatus('Cookies updated ('+d.cookies+' entries)');
}catch(e){setStatus('Cookies error: '+e.message)}finally{btn.disabled=false}
}
async function clearCookies(btn){
if(!confirm('Remove cookies.txt? Downloads requiring login will fail until you add new cookies.'))return;
btn.disabled=true;
try{
await api('/api/cookies',{method:'DELETE'});
document.getElementById('cookies-status').textContent='no cookies.txt';
setStatus('Cookies cleared');
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false}
}
function downloadBackupDb(){
// Open in a new tab so the auth cookie rides along; the server sets
// Content-Disposition so the browser triggers a download instead of
// rendering the SQLite bytes inline.
window.open('/api/backup/db','_blank');
}
async function generatePlex(btn){
const path=document.getElementById('cf-plex-path')?.value.trim();
if(!path){document.getElementById('plex-status').textContent='Set a path first';return}
btn.disabled=true;
document.getElementById('plex-status').textContent='Generating…';
try{
await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({transcode:document.getElementById('cf-transcode').checked,plex_library_path:path})});
const r=await api('/api/plex/generate',{method:'POST'});
const d=await r.json();
const errs=d.errors?.length?' ('+d.errors.length+' error(s))':'';
document.getElementById('plex-status').textContent=d.links_created+' link(s) created'+errs;
}catch(e){document.getElementById('plex-status').textContent='Error: '+e.message}
finally{btn.disabled=false}
}
function fmtCountdown(secs){
if(secs<=0)return'now';
const h=Math.floor(secs/3600),m=Math.floor((secs%3600)/60);
if(h>0)return h+'h '+(m>0?m+'m ':'');
return m>0?m+'m':'< 1m';
}
async function updateYtdlp(btn){
btn.disabled=true;
const st=document.getElementById('ytdlp-status');
st.textContent='Started — click ⬇ in the header to view progress';
try{
const r=await fetch('/api/ytdlp/update',{method:'POST'});
const t=await r.text();
if(!r.ok)throw new Error(t);
setStatus('yt-dlp update job started');
}catch(e){st.textContent='Error: '+e.message}
finally{btn.disabled=false}
}
async function runScheduler(btn){
btn.disabled=true;
const st=document.getElementById('sched-status');
st.textContent='Starting…';
try{
const r=await fetch('/api/scheduler/run',{method:'POST'});
const t=await r.text();
if(!r.ok&&r.status!==409)throw new Error(t);
st.textContent=r.status===409?'Already running.':t;
setStatus('Scheduled check started');
}catch(e){st.textContent='Error: '+e.message}
finally{btn.disabled=false}
}
async function saveSettings(btn){
const transcode=document.getElementById('cf-transcode').checked;
const bindMode=document.getElementById('cf-bind')?.value;
const pwdCheckbox=document.getElementById('cf-download-pwd');
const pwdInput=document.getElementById('cf-download-password');
const plexPath=document.getElementById('cf-plex-path')?.value;
const schedEnabled=document.getElementById('cf-sched-enabled')?.checked||false;
const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10);
const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10);
const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false;
const sourceUrl=document.getElementById('cf-source-url')?.value;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp};
if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
if(pwdCheckbox&&pwdCheckbox.checked){
payload.new_download_password=pwdInput?.value||'';
}
const settingPwd=pwdCheckbox&&pwdCheckbox.checked&&pwdInput?.value;
try{
await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
btn.closest('.modal-bg').remove();
if(settingPwd){setStatus('Password set — signing in again…');location.reload();return}
let msg='Saved.';
if(bindMode)msg='Settings saved. Restart required for binding change.';
if(pwdCheckbox&&pwdCheckbox.checked&&!pwdInput?.value)msg='Password disabled. '+msg;
setStatus(msg);await loadLibrary();
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Statistics ─────────────────────────────────────────────────── */
async function openStats(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:760px;width:100%">
<div class="modal-hdr"><h2>📊 Library statistics</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div id="stats-body" style="overflow:auto;max-height:75vh"><em style="color:var(--muted)">Computing…</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/stats')).json();
renderStats(r);
}catch(e){document.getElementById('stats-body').innerHTML=`<div style="color:#f87171">Stats failed: ${esc(e.message)}</div>`}
}
function fmtHours(secs){
if(!secs||secs<60)return'0h';
const h=Math.floor(secs/3600),m=Math.floor((secs%3600)/60);
return h>0?`${h}h ${m}m`:`${m}m`;
}
function renderStats(r){
const body=document.getElementById('stats-body');if(!body)return;
const tot=fmtHours(r.total_duration_secs);
const wat=fmtHours(r.watched_duration_secs);
const tile=(label,value)=>`<div style="background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px 12px;flex:1;min-width:120px"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">${esc(label)}</div><div style="font-size:18px;font-weight:600;margin-top:2px">${esc(value)}</div></div>`;
let h=`<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">
${tile('Channels',r.total_channels.toLocaleString())}
${tile('Videos',r.total_videos.toLocaleString())}
${tile('Playlists',r.total_playlists.toLocaleString())}
${tile('Disk used',fmtSize(r.total_size_bytes))}
${tile('Total runtime',tot)}
${tile('Watched',`${r.watched_count.toLocaleString()} · ${wat}`)}
${tile('Continue watching',r.continue_watching_count.toLocaleString())}
</div>`;
// Downloads per week — horizontal bar chart.
const weeks=r.downloads_per_week||[];
const maxWeek=Math.max(1,...weeks.map(w=>w.count));
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Downloads — last ${weeks.length} weeks</h3>`;
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:80px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
for(const w of weeks){
const pct=(w.count/maxWeek)*100;
const d=new Date(w.week_start_unix*1000);
const lbl=`${d.getMonth()+1}/${d.getDate()}`;
const tip=`${lbl}: ${w.count} videos, ${fmtSize(w.size_bytes)}`;
h+=`<div title="${esc(tip)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
<div style="width:100%;height:${pct}%;background:var(--accent);border-radius:2px 2px 0 0;min-height:${w.count>0?'2px':'0'}"></div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(lbl)}</div>
</div>`;
}
h+=`</div>`;
// Uploads per year.
const years=r.videos_per_year||[];
if(years.length){
const maxYear=Math.max(1,...years.map(y=>y.count));
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Videos by upload year</h3>`;
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:60px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
for(const y of years){
const pct=(y.count/maxYear)*100;
h+=`<div title="${esc(y.year+': '+y.count)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
<div style="width:100%;height:${pct}%;background:var(--accent);opacity:.7;border-radius:2px 2px 0 0;min-height:${y.count>0?'2px':'0'}"></div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(y.year)}</div>
</div>`;
}
h+=`</div>`;
}
// Two side-by-side top-N tables.
const topTable=(title,rows)=>{
if(!rows||!rows.length)return'';
let t=`<h3 style="font-size:13px;margin:4px 0 6px">${esc(title)}</h3>
<table style="width:100%;font-size:12px;border-collapse:collapse;margin-bottom:14px"><tbody>`;
for(const row of rows){
t+=`<tr style="border-bottom:1px solid var(--border)">
<td style="padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px">${esc(row.name)}</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${row.count} videos</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtSize(row.size_bytes)}</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtHours(row.duration_secs)}</td>
</tr>`;
}
t+=`</tbody></table>`;
return t;
};
h+=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>${topTable('Top channels by size',r.top_channels_by_size)}</div>
<div>${topTable('Top channels by count',r.top_channels_by_count)}</div>
</div>`;
body.innerHTML=h;
}
/* ── Maintenance (library health) ───────────────────────────────── */
async function openMaintenance(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:760px;width:100%">
<div class="modal-hdr"><h2>Library health</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div id="maint-body" style="overflow:auto;max-height:75vh"><em style="color:var(--muted)">Scanning…</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/maintenance/scan')).json();
renderMaintenance(r);
}catch(e){document.getElementById('maint-body').innerHTML=`<div style="color:#f87171">Scan failed: ${esc(e.message)}</div>`}
}
function renderMaintenance(r){
const body=document.getElementById('maint-body');if(!body)return;
const dups=r.duplicates||[],miss=r.missing||[];
let h='';
h+=`<h3 style="font-size:13px;margin:4px 0 8px">Duplicates (${dups.length})</h3>`;
if(!dups.length){h+='<div style="color:var(--muted);font-size:12px;margin-bottom:12px">No duplicate video IDs found.</div>'}
else{
for(const g of dups){
h+=`<div style="border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:8px">
<div style="font-weight:600;font-size:12px;margin-bottom:6px">${esc(g.title)} <span style="color:var(--muted)">[${esc(g.id)}]</span></div>`;
g.copies.forEach((c,i)=>{
const tag=c.recommended_keep?'<span style="color:#4ade80">keep</span>':'<span style="color:#f87171">remove</span>';
const size=c.file_size?fmtSize(c.file_size):'no video';
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
<input type="checkbox" class="dup-chk" data-files='${esc(JSON.stringify(c.files))}' ${c.recommended_keep?'':'checked'}>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.location||'(unknown)')} · ${size} · ${c.files.length} files</span>
${tag}
</label>`;
});
h+=`</div>`;
}
h+=`<button class="primary" onclick="removeDuplicates(this)">🗑 Remove checked copies</button>`;
}
h+=`<h3 style="font-size:13px;margin:16px 0 8px">Missing assets (${miss.length})</h3>`;
if(!miss.length){h+='<div style="color:var(--muted);font-size:12px">Every video has its thumbnail, metadata, and description.</div>'}
else{
if(miss.length>1)h+=`<button onclick="repairAll(this)" style="margin-bottom:8px">⬇ Fetch all missing (${miss.length})</button>`;
for(const m of miss){
const need=[m.missing_thumbnail?'thumbnail':null,m.missing_info?'metadata':null,m.missing_description?'description':null].filter(Boolean).join(', ');
h+=`<div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--border)">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.title)} <span style="color:var(--muted)">— missing ${need}</span></span>
<button onclick="repairVideo('${esc(m.id)}',this)">⬇ Fetch</button>
</div>`;
}
}
body.innerHTML=h;
}
async function removeDuplicates(btn){
const chks=[...document.querySelectorAll('.dup-chk:checked')];
let paths=[];
for(const c of chks){try{paths=paths.concat(JSON.parse(c.dataset.files))}catch{}}
if(!paths.length){setStatus('Nothing selected.');return}
if(!confirm(`Delete ${paths.length} file(s)? This cannot be undone.`))return;
btn.disabled=true;
try{
const r=await(await api('/api/maintenance/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths})})).json();
setStatus(`Removed ${r.removed} file(s)`+(r.errors&&r.errors.length?`, ${r.errors.length} error(s)`:''));
await loadLibrary();
const fresh=await(await api('/api/maintenance/scan')).json();renderMaintenance(fresh);
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false}
}
async function repairVideo(id,btn){
if(btn){btn.disabled=true;btn.textContent='⏳ Queued'}
try{await api('/api/maintenance/repair/'+encodeURIComponent(id),{method:'POST'});setStatus('Repair queued — see Downloads')}
catch(e){setStatus('Error: '+e.message);if(btn){btn.disabled=false;btn.textContent='⬇ Fetch'}}
}
async function repairAll(btn){
btn.disabled=true;
const buttons=[...document.querySelectorAll('#maint-body button')].filter(b=>b.textContent.includes('Fetch')&&b!==btn);
for(const b of buttons){const id=b.getAttribute('onclick')?.match(/repairVideo\('([^']+)'/)?.[1];if(id)await repairVideo(id,b)}
setStatus('All repairs queued — see Downloads');
}
/* ── Jobs ───────────────────────────────────────────────────────── */
// Jobs now live behind the ⬇ Downloads modal instead of a fixed bottom
// bar. This function:
// 1. Updates the header badge (active + queued count) on every snapshot
// 2. If the modal is open, repaints its body so progress bars stay live
// The modal is identified by its data-downloads-modal attribute so we can
// reach it without scanning every .modal-bg in the DOM.
function renderJobs(jobs,queued,maxConcurrent){
jobs=jobs||[];queued=queued||[];
// Badge: active jobs (running) + queued. Finished/failed don't bump the
// badge — they're informational, not in-progress.
const activeCount=jobs.filter(j=>j.state==='running').length+queued.length;
const btn=document.getElementById('dl-btn');
const badge=document.getElementById('dl-badge');
if(btn&&badge){
badge.textContent=String(activeCount);
btn.classList.toggle('has-active',activeCount>0);
}
// If the modal isn't open we're done.
const body=document.getElementById('downloads-body');
if(!body)return;
if(!jobs.length&&!queued.length){
body.innerHTML=`<div class="empty">No active or recent downloads.</div>`;
return;
}
const fin=jobs.some(j=>j.state!=='running');
const hdr=fin?`<div style="padding:4px 14px;display:flex;justify-content:flex-end;border-bottom:1px solid var(--border)"><button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button></div>`:'';
const queuedHtml=queued.map(q=>`<div class="job">
<span class="badge" style="color:var(--muted)">queued</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)">${esc(q.label)}${esc(q.url)}</span>
</div>`).join('');
const limitNote=maxConcurrent>0?`<div style="font-size:11px;color:var(--muted);padding:6px 14px">max ${maxConcurrent} concurrent</div>`:'';
body.innerHTML=hdr+jobs.map((j,i)=>{
const dismiss=j.state!=='running'?`<button onclick="removeJob(${i})" style="font-size:11px;padding:1px 6px">✕</button>`:'';
// Failed-job hint: when the classifier matched a known pattern, show
// the suggested action below the row in a subtle box. Unclassified
// failures (error_class === undefined) fall back to just the raw
// last_line we already render in the badge row.
const errBadge=j.state==='failed'&&j.error_class
? `<span class="err-class" title="${esc(j.error_hint||'')}">${esc(j.error_class)}</span>` : '';
const errHint=j.state==='failed'&&j.error_hint
? `<div class="job-hint">${esc(j.error_hint)}</div>` : '';
return `<div class="job-row">
<div class="job">
<span class="badge ${j.state}">${j.state}</span>
${errBadge}
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)}${esc(j.url)}</span>
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
<span class="last-line" style="font-size:11px;color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>
${dismiss}
</div>
${errHint}
</div>`;
}).join('')+queuedHtml+limitNote;
}
// Open the Downloads modal. If it's already open, do nothing (clicking the
// ⬇ button again would otherwise stack duplicate modals).
async function openDownloads(){
if(document.querySelector('[data-downloads-modal]'))return;
const bg=document.createElement('div');
bg.className='modal-bg';
bg.setAttribute('data-downloads-modal','1');
bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:720px;width:100%">
<div class="modal-hdr"><h2>⬇ Downloads</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div id="downloads-body" style="overflow:auto;max-height:80vh;min-height:120px"><em style="color:var(--muted);padding:14px;display:block">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
// Repaint immediately from the latest snapshot if we have one — saves a
// round trip. Falls through to /api/progress otherwise.
try{
const d=await(await api('/api/progress')).json();
renderJobs(d.jobs,d.queued,d.max_concurrent);
}catch(e){
document.getElementById('downloads-body').innerHTML=
`<div style="color:#f87171;padding:14px">Failed to load: ${esc(e.message)}</div>`;
}
}
async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
// Progress updates come over a WebSocket (`/ws/progress`) when available
// and fall back to polling `/api/progress` if the upgrade fails (mobile
// carriers / reverse proxies that strip WS sometimes do this). The two
// paths share `applyProgressSnapshot` so the rendering logic is single-
// sourced.
let wasRunning=false;
let progressWs=null;
let pollTimer=null;
function applyProgressSnapshot(d){
if(!d)return;
renderJobs(d.jobs,d.queued,d.max_concurrent);
const run=d.jobs.some(j=>j.state==='running')||(d.queued&&d.queued.length>0);
if(wasRunning&&!run)loadLibrary();
wasRunning=run;
}
async function pollProgress(){
try{
const d=await(await api('/api/progress')).json();
applyProgressSnapshot(d);
schedulePoll(wasRunning?600:5000);
}catch{schedulePoll(2000)}
}
function schedulePoll(ms){
if(pollTimer)clearTimeout(pollTimer);
pollTimer=setTimeout(pollProgress,ms);
}
function startProgressFeed(){
// Build the ws URL relative to the current page so the same code works
// for http/https + custom port + reverse-proxy mounts.
const proto=location.protocol==='https:'?'wss:':'ws:';
const url=`${proto}//${location.host}/ws/progress`;
try{
progressWs=new WebSocket(url);
}catch{
schedulePoll(600);
return;
}
progressWs.onopen=()=>{
// Stop the polling timer once the socket's live — pushes are
// authoritative until the connection drops.
if(pollTimer){clearTimeout(pollTimer);pollTimer=null}
};
progressWs.onmessage=(ev)=>{
try{applyProgressSnapshot(JSON.parse(ev.data))}catch{}
};
const onClose=()=>{
progressWs=null;
// Reconnect attempt after a short backoff; HTTP polling fills in
// the gap so the UI never goes blank.
schedulePoll(600);
setTimeout(()=>{if(!progressWs)startProgressFeed()},5000);
};
progressWs.onclose=onClose;
progressWs.onerror=onClose;
}
startProgressFeed();
/* ── Keyboard shortcuts ─────────────────────────────────────────── */
// Listed in the ? help dialog below. Skip when the user is typing in a
// text input — otherwise pressing `r` while typing a filename would
// trigger a rescan.
document.addEventListener('keydown',(e)=>{
const tag=(document.activeElement?.tagName||'').toLowerCase();
const inField=tag==='input'||tag==='textarea'||document.activeElement?.isContentEditable;
// Esc always closes the topmost modal — even from within an input.
if(e.key==='Escape'){
const modals=document.querySelectorAll('.modal-bg');
if(modals.length){modals[modals.length-1].remove();e.preventDefault();return}
}
if(inField)return;
switch(e.key){
case '/':
document.getElementById('search')?.focus();
e.preventDefault();
break;
case 'r':
rescan();
e.preventDefault();
break;
case 'd':
openDownloads();
e.preventDefault();
break;
case '?':
showShortcutsHelp();
e.preventDefault();
break;
}
});
function showShortcutsHelp(){
if(document.getElementById('shortcuts-help'))return;
const bg=document.createElement('div');bg.className='modal-bg';bg.id='shortcuts-help';
bg.onclick=e=>{if(e.target===bg)bg.remove()};
const row=(k,d)=>`<tr><td style="padding:4px 12px 4px 0"><kbd style="background:var(--card);border:1px solid var(--border);border-radius:3px;padding:1px 6px;font-family:monospace;font-size:11px">${k}</kbd></td><td style="padding:4px 0;font-size:13px">${d}</td></tr>`;
bg.innerHTML=`<div class="modal" style="max-width:380px">
<div class="modal-hdr"><h2>Keyboard shortcuts</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<table><tbody>
${row('/','Focus search')}
${row('r','Rescan library')}
${row('d','Open downloads')}
${row('Esc','Close modal / cancel')}
${row('?','This help')}
</tbody></table>
<div class="settings-hint" style="margin-top:10px">Shortcuts are ignored while typing in text inputs.</div>
</div>`;
document.body.appendChild(bg);
}
/* ── Utilities ──────────────────────────────────────────────────── */
function fmtDur(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`}
function fmtSize(b){if(b>=1073741824)return(b/1073741824).toFixed(1)+' GB';if(b>=1048576)return Math.round(b/1048576)+' MB';return Math.round(b/1024)+' KB'}
function fmtDate(d){if(!d||d.length<8)return d||'';return d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8)}
// Title + id + channel-name match for the filter input. Lowercase once
// per call so callers don't have to thread the lowercased value around.
function matchesSearch(v,chName,q){
if(!q)return true;
const t=(v.title||'').toLowerCase();
const id=(v.id||'').toLowerCase();
const ch=(chName||'').toLowerCase();
return t.includes(q)||id.includes(q)||ch.includes(q);
}
function p(n){return String(n).padStart(2,'0')}
function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
/* Sanitize a URL before inserting into an href/src attribute. Allows
relative paths and http(s)/data:image/blob:; replaces javascript:, vbscript:,
etc. with a safe blank to defang interpolated server-side data. */
function safeUrl(u){
if(!u)return'';
const s=String(u).trim();
if(/^(?:javascript|vbscript|data:text|data:application):/i.test(s))return'about:blank';
return s;
}
/* ── AGPL §13 source notice ─────────────────────────────────────── */
// On load, fetch the source URL from the server and render it in the footer.
// This satisfies the AGPL §13 requirement that network users are offered
// access to the Corresponding Source of the software they interact with.
async function loadSourceNotice(){
try{
const s=await(await fetch('/api/settings')).json();
const el=document.getElementById('agpl-notice');
if(!el)return;
if(s.source_url){
el.innerHTML=`Licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPL-3.0</a> &mdash; <a href="${esc(s.source_url)}" target="_blank">Source code</a>`;
} else {
el.textContent='Licensed under AGPL-3.0 — set web.source_url in config.toml to link source code';
}
}catch{}
}
/* ── Init ───────────────────────────────────────────────────────── */
applyTheme(localStorage.getItem('theme')||'dark');
// Library load is async, but the sort + search inputs can be restored
// immediately. View selectors need the library array to be populated
// before we can resolve `channel:<name>` back to an index — do that
// after loadLibrary() resolves.
(async()=>{
await loadLibrary();
restoreUiState();
renderSidebar();
renderGrid();
})();
loadSourceNotice();
</script>
</body>
</html>