catacomb/src/web_ui/index.html
Luna f30e32707e Perceptual-hash dedup: web UI + background job (3.7, part 2)
Wires the fingerprint engine into the web Maintenance view.

- web.rs: a DedupState job (one at a time) on its own thread. POST
  /api/maintenance/dedup/scan snapshots the library, mtime-gates against
  stored fingerprints, fingerprints the new/changed videos in parallel
  (progress counter), prunes vanished entries, groups by visual
  similarity, and builds review rows (title/channel/size/files + a
  recommended-keep = largest copy). GET /api/maintenance/dedup/status
  polls progress + results. Deletion reuses /api/maintenance/remove.
- index.html: a "Similar content (perceptual)" section in the Maintenance
  modal — Scan button, live progress bar, grouped results with
  checkboxes (recommended-keep pre-unchecked), and a re-scan that drops
  deleted copies. Poller self-cancels when the modal closes.

Integration test (ffmpeg-gated): generates orig + CRF-38 downscaled
re-encode + an unrelated clip, runs the real scan end-to-end, and asserts
the first two group while the third stays out. 118 tests pass.

Desktop Maintenance UI next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 06:17:58 -07:00

2570 lines
156 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}
.transcript-pane{width:300px;background:var(--bg);border:1px solid var(--border);border-radius:4px;display:flex;flex-direction:column;flex-shrink:0;min-height:0}
.transcript-pane h4{font-size:11px;padding:7px 10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
.t-search{margin:6px;padding:4px 8px;background:var(--panel);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px}
.t-list{overflow-y:auto;flex:1;min-height:0}
.t-cue{padding:5px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border);line-height:1.35}
.t-cue:hover{background:var(--card)}
.t-cue.active{background:var(--card);border-left:3px solid var(--accent)}
.t-cue .t-time{color:var(--muted);font-size:10px;font-family:monospace;margin-right:6px}
.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:6px 12px;display:flex;gap:8px;align-items:center;flex-shrink:0;justify-content:center;min-height:24px}
/* "New download" form inside the Downloads modal. Replaces the old
bottom-of-page footer; sits between the modal header and the
active-jobs list. */
.dl-new{padding:10px 14px;display:flex;flex-direction:column;gap:8px}
.dl-new-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.dl-new-row input[type="url"]{flex:1;min-width:200px;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 9px;border-radius:4px;font-size:13px}
.dl-new-row select{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:5px 6px;font-size:12px}
.dl-new-flags{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
.dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
.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}
.transcript-pane{display:none !important}
section#content{padding:8px}
/* Modal "new download" form stacks vertically on small screens so
the URL + button + quality picker don't squeeze each other. */
.dl-new-row{flex-direction:column;align-items:stretch}
.dl-new-row input[type="url"]{width:100%}
/* 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="openSearch()" title="Search titles + descriptions (f)">🔍</button>
<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>
<span id="agpl-notice" style="font-size:10px;color:var(--muted);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=[];
// Notes map: "video:<id>" or "channel:<platform>/<handle>" → body text.
// Fetched once on load via /api/notes; updated locally on edit. Drives
// the 📝 indicator on cards/channels and feeds the global search.
let notes={};
async function loadNotes(){
try{ notes=await(await api('/api/notes')).json(); }
catch{ notes={}; }
}
// Convenience accessors keyed the same way the server stores them.
function videoNote(id){ return notes['video:'+id]||''; }
function channelNote(platform,handle){ return notes['channel:'+platform+'/'+handle]||''; }
// 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.
// `pad` is the left padding in px for nesting under a folder; defaults
// to the flat 10px used by top-level (Unfiled / platform) channels.
const renderChannel=(i,pad)=>{
const ch=library[i];
const active=activeChannelIdx===i&&activePlaylistIdx===null&&!showContinue;
const size=ch.size_bytes>0?' · '+fmtSize(ch.size_bytes):'';
const padStyle=pad!=null?`padding-left:${pad}px;`:'';
let s=`<div class="ch-item${active?' active':''}" style="${padStyle}" 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>`;
const hasNote=!!channelNote(ch.platform,ch.name);
s+=`<div class="ch-sub" onclick="editChannelNote(${i})"${hasNote?' style="color:var(--accent)"':''}>📝 ${hasNote?'Edit note…':'Add note…'}</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:[];
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">${folders.length?'manage':'new'}</button>
</div>`;
if(folders.length){
// Recursively render the folder tree. parent_id===null (or a missing
// parent) marks a top-level folder; children indent one step further.
// A `seen` set guards against a malformed cycle in the data turning
// this into infinite recursion.
const childrenOf=(pid)=>folders.filter(f=>(f.parent_id??null)===pid);
// Count members including all descendant folders, so a parent folder's
// tally reflects everything nested under it.
const directMembers=(fid)=>{
const out=[];
for(let i=0;i<library.length;i++)if(library[i].folder_id===fid)out.push(i);
return out;
};
const subtreeCount=(fid,seen)=>{
if(seen.has(fid))return 0; seen.add(fid);
let n=directMembers(fid).length;
for(const c of childrenOf(fid))n+=subtreeCount(c.id,seen);
return n;
};
const renderFolderTree=(f,depth,seen)=>{
if(seen.has(f.id))return; seen.add(f.id);
const pad=8+depth*12;
const total=subtreeCount(f.id,new Set());
h+=`<div class="sidebar-label" style="padding-left:${pad}px;font-size:11px;color:var(--muted)">${'└ '.repeat(0)}${esc(f.name)} (${total})</div>`;
// Direct member channels of this folder, indented under it.
for(const i of directMembers(f.id))h+=renderChannel(i,pad+6);
// Then nested child folders.
for(const c of childrenOf(f.id))renderFolderTree(c,depth+1,seen);
};
const seen=new Set();
for(const f of childrenOf(null))renderFolderTree(f,0,seen);
// Safety net: render any orphaned folder whose parent_id points at a
// now-deleted row (shouldn't happen with the FK cascade, but cheap).
for(const f of folders)if(!seen.has(f.id))renderFolderTree(f,0,seen);
}
// 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=!filtersAreDefault(filter);
const clear=anyActive?`<button class="chip-clear" onclick="clearFilters()">✕ Clear filters</button>`:'';
const save=anyActive?`<button class="chip-clear" onclick="saveCurrentFilterPreset()" title="Save these filters as a named preset">★ Save preset</button>`:'';
// Saved presets: click name to apply, ✕ to delete. Active preset (one
// whose filters match the current set) is highlighted.
const presets=filterPresets.length
? `<div class="filter-grp"><span class="filter-grp-label">★ Presets</span>`
+ filterPresets.map((p,i)=>
`<button class="chip${presetMatchesCurrent(p)?' active':''}" onclick="applyFilterPreset(${i})" title="Apply preset">`
+ `${esc(p.name)}<span onclick="event.stopPropagation();deleteFilterPreset(${i})" style="margin-left:6px;opacity:.55;font-size:10px" title="Delete preset">✕</span>`
+ `</button>`).join('')
+ `</div>`
: '';
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')+
presets+save+clear;
}
function setFilter(key,val){filter[key]=val;saveUiState();renderFilters();renderGrid()}
function toggleFilter(key){filter[key]=!filter[key];saveUiState();renderFilters();renderGrid()}
const DEFAULT_FILTER={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};
function clearFilters(){filter={...DEFAULT_FILTER};saveUiState();renderFilters();renderGrid()}
/* ── Named filter presets ───────────────────────────────────────── */
// Saved chip-filter sets, persisted under their own localStorage key so
// they outlive a clearFilters() and aren't tangled with view state.
let filterPresets=[];
function loadFilterPresets(){
try{filterPresets=JSON.parse(localStorage.getItem('filter-presets')||'[]')}catch{filterPresets=[]}
if(!Array.isArray(filterPresets))filterPresets=[];
}
function persistFilterPresets(){
try{localStorage.setItem('filter-presets',JSON.stringify(filterPresets))}catch{}
}
function filtersAreDefault(f){
return f.watch==='all'&&f.date==='all'&&f.size==='all'&&!f.hasSubs&&!f.hasChapters;
}
function presetMatchesCurrent(p){
const f=p.filter||{};
return f.watch===filter.watch&&f.date===filter.date&&f.size===filter.size
&&!!f.hasSubs===!!filter.hasSubs&&!!f.hasChapters===!!filter.hasChapters;
}
function saveCurrentFilterPreset(){
if(filtersAreDefault(filter)){setStatus('No active filters to save');return}
const name=(prompt('Name this filter preset:')||'').trim();
if(!name)return;
// Overwrite a same-named preset rather than duplicating.
filterPresets=filterPresets.filter(p=>p.name!==name);
filterPresets.push({name,filter:{...filter}});
persistFilterPresets();
renderFilters();
setStatus('Saved preset “'+name+'”');
}
// Index-based so preset names with quotes/apostrophes can't break the
// inline onclick handlers; the array is re-rendered after every change so
// indices always match what's on screen.
function applyFilterPreset(i){
const p=filterPresets[i]; if(!p)return;
filter={...DEFAULT_FILTER,...p.filter};
saveUiState();renderFilters();renderGrid();
}
function deleteFilterPreset(i){
filterPresets.splice(i,1);
persistFilterPresets();
renderFilters();
}
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>
<button onclick="editNote('video','${v.id}',${JSON.stringify(v.title).replace(/"/g,'&quot;')})" title="${videoNote(v.id)?'Edit note':'Add note'}" style="${videoNote(v.id)?'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);
}
}
/* ── Notes ──────────────────────────────────────────────────────── */
// Open a small modal to edit a free-text note on a video or channel.
// `kind` is 'video' or 'channel'; `id` is the video ID or
// '<platform>/<handle>'. `title` is shown in the header for context.
function editNote(kind,id,title){
const key=kind+':'+id;
const current=notes[key]||'';
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:480px;width:100%">
<div class="modal-hdr"><h2>📝 Note — ${esc(title||id)}</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<textarea id="note-text" style="width:100%;min-height:160px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;resize:vertical" placeholder="Anything you want to remember about this…">${esc(current)}</textarea>
<div class="settings-hint">Notes are searchable from the filter box. Leave empty to delete.</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveNote('${esc(kind)}','${esc(id)}',this)">Save</button>
</div>
</div>`;
document.body.appendChild(bg);
setTimeout(()=>document.getElementById('note-text')?.focus(),0);
}
// Channel note: keyed by "<platform>/<handle>". Convenience wrapper so
// the sidebar sub-action doesn't have to build the composite id inline.
function editChannelNote(idx){
const ch=library[idx]; if(!ch)return;
editNote('channel',ch.platform+'/'+ch.name,ch.name);
}
async function saveNote(kind,id,btn){
const body=document.getElementById('note-text')?.value||'';
btn.disabled=true;
try{
const r=await api(`/api/notes/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({body}),
});
if(!r.ok)throw new Error(await r.text());
// Update the local map so indicators + search reflect immediately.
const key=kind+':'+id;
if(body.trim()) notes[key]=body; else delete notes[key];
btn.closest('.modal-bg').remove();
setStatus('Note saved');
renderGrid();
if(activeChannelIdx!==null||true)renderSidebar();
}catch(e){
setStatus('Note error: '+e.message);
btn.disabled=false;
}
}
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';
// The labels only exist while the Downloads modal is open; nothing to
// do when this is called from a stale event handler outside it.
const fast=document.getElementById('fast-mode-label');
const live=document.getElementById('live-mode-label');
if(fast)fast.style.display=isMusic?'none':'flex';
// Live recording makes no sense for music-only mode.
if(live)live.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 ──────────────────────────────── */
// Tri-state select for an Option<bool> override. `v` is true / false /
// null|undefined (= use global default). Returns the <select> markup.
function triSelect(id,v){
const sel=(want)=>v===want?' selected':'';
// null/undefined → Default; true → On; false → Off.
const dflt=(v===true||v===false)?'':' selected';
return `<select id="${id}" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:3px 6px;font-size:12px">`
+`<option value=""${dflt}>Default</option>`
+`<option value="on"${sel(true)}>On</option>`
+`<option value="off"${sel(false)}>Off</option>`
+`</select>`;
}
// Read a tri-state select back into an Option<bool>: "" → null, "on" →
// true, "off" → false.
function triValue(id){
const v=document.getElementById(id)?.value;
return v==='on'?true:v==='off'?false:null;
}
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>Skip auth check</label>
<input type="checkbox" id="opt-skipauth" ${opts.skip_auth_check?'checked':''}></div>
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--extractor-args youtubetab:skip=authcheck</code>. Safe for <strong>public</strong> channels — silences the "playlists that require authentication may not extract correctly" warning without changing which videos are found. Leave off for members-only/private channels, where that warning means your cookies may not be working.</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-hint" style="margin:2px 0 4px"><strong>Subtitle overrides</strong> — "Default" uses the global setting; the others force the behavior for this channel.</div>
<div class="settings-row"><label>Download subtitles</label>
${triSelect('opt-subs-enabled',opts.subtitles_enabled)}</div>
<div class="settings-row"><label>Auto-generated captions</label>
${triSelect('opt-subs-auto',opts.subtitles_auto)}</div>
<div class="settings-row"><label>Embed into video</label>
${triSelect('opt-subs-embed',opts.subtitles_embed)}</div>
<div class="settings-row"><label>Convert format (blank = global)</label>
<input type="text" id="opt-subs-format" value="${esc(opts.subtitle_format||'')}" placeholder="srt/vtt/ass" style="width:100px;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>YouTube player clients (blank = global)</label>
<input type="text" id="opt-player-clients" value="${esc(opts.youtube_player_clients||'')}" placeholder="e.g. tv,mweb" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-hint" style="margin-bottom:4px">Per-channel <code>--extractor-args youtube:player_client</code> override. If this channel keeps hitting captchas, try <code>tv,mweb</code> (least bot-checked).</div>
<div class="settings-row"><label>SponsorBlock (blank = global)</label>
<select id="opt-sponsorblock" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
${[['','Default (global)'],['off','Off'],['mark','Mark chapters'],['remove','Remove segments']].map(([v,l])=>`<option value="${v}"${(opts.sponsorblock_mode||'')===v?' selected':''}>${l}</option>`).join('')}
</select></div>
<div class="settings-row"><label>Convert (blank = global)</label>
<select id="opt-convert" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
${[['','Default (global)'],['off','Off'],['remux-mp4','Remux → mp4'],['h264-mp4','H.264 mp4'],['audio','Audio']].map(([v,l])=>`<option value="${v}"${(opts.convert_mode||'')===v?' selected':''}>${l}</option>`).join('')}
</select></div>
<div class="settings-hint" style="margin-bottom:4px">Post-download ffmpeg conversion for this channel. CRF / preset / audio-format come from the global Format-conversion setting.</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,
skip_auth_check:document.getElementById('opt-skipauth')?.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,
subtitles_enabled:triValue('opt-subs-enabled'),
subtitles_auto:triValue('opt-subs-auto'),
subtitles_embed:triValue('opt-subs-embed'),
subtitle_format:strOrNull('opt-subs-format'),
youtube_player_clients:strOrNull('opt-player-clients'),
sponsorblock_mode:strOrNull('opt-sponsorblock'),
convert_mode:strOrNull('opt-convert'),
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||[];
// Build the set of descendant ids for each folder so the parent
// dropdown can grey out choices that would create a cycle (you can't
// nest a folder inside itself or any of its own descendants).
const childrenOf=(pid)=>folders.filter(f=>(f.parent_id??null)===pid);
const descendantsOf=(fid)=>{
const out=new Set(); const stack=[fid];
while(stack.length){const cur=stack.pop();
for(const c of childrenOf(cur)){if(!out.has(c.id)){out.add(c.id);stack.push(c.id);}}}
return out;
};
const rows=folders.map(f=>{
const memberCount=library.filter(c=>c.folder_id===f.id).length;
const banned=descendantsOf(f.id); banned.add(f.id);
const parentOpts=[`<option value=""${(f.parent_id==null)?' selected':''}>— top level —</option>`]
.concat(folders.filter(o=>!banned.has(o.id)).map(o=>
`<option value="${o.id}"${f.parent_id===o.id?' selected':''}>${esc(o.name)}</option>`)).join('');
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);flex-wrap:wrap">
<span style="flex:1;min-width:120px">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
<select onchange="setFolderParent(${f.id},this.value)" title="Nest this folder under another" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:3px 5px;font-size:11px;max-width:140px">${parentOpts}</select>
${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 setFolderParent(id,parentVal){
// Empty string from the dropdown means "top level" → null parent.
const parent_id=parentVal===''?null:parseInt(parentVal,10);
try{
const r=await api(`/api/folders/${id}/parent`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({parent_id})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder moved');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message);await loadLibrary();}
}
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;
transcript={cues:[],loaded:false}; // reset for the new video
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>`:'';
const hasSubs=v.subtitles&&v.subtitles.length;
const transcriptPane=hasSubs?`<div class="transcript-pane" id="transcript-pane" style="display:none">
<h4>Transcript</h4>
<input class="t-search" id="t-search" placeholder="Search transcript…" oninput="filterTranscript()">
<div class="t-list" id="t-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>
${hasSubs?`<button onclick="toggleTranscript('${esc(id)}')" title="Transcript (searchable, click to seek)">📄</button>`:''}
<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}${transcriptPane}</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()}}
/* ── Transcript viewer (parses the served .vtt client-side) ──────── */
let transcript={cues:[],loaded:false};
let tSearching=false;
function toggleTranscript(id){
const pane=document.getElementById('transcript-pane');if(!pane)return;
const showing=pane.style.display!=='none';
pane.style.display=showing?'none':'flex';
if(!showing&&!transcript.loaded){
const v=findVideo(id);const sub=((v&&v.subtitles)||[])[0];
if(sub&&sub.url)loadTranscript(sub.url); else {const l=document.getElementById('t-list');if(l)l.innerHTML='<em style="padding:10px;display:block;color:var(--muted)">No subtitle track.</em>'}
}
}
async function loadTranscript(url){
const list=document.getElementById('t-list');
try{
const text=await(await fetch(safeUrl(url))).text();
transcript.cues=parseVtt(text);
transcript.loaded=true;
renderCues(transcript.cues.map((c,i)=>({c,i})),'');
const vid=document.getElementById('player-video');
if(vid)vid.addEventListener('timeupdate',syncTranscript);
}catch(e){if(list)list.innerHTML=`<div style="color:#f87171;padding:10px">Couldn't load transcript: ${esc(e.message)}</div>`}
}
// Minimal WebVTT/SRT cue parser: pull out each "start --> end" line and the
// text lines under it, stripping inline tags. Tolerant of SRT comma decimals.
function parseVtt(text){
const cues=[],lines=text.split(/\r?\n/);
const tc=/-->/;
for(let i=0;i<lines.length;i++){
if(tc.test(lines[i])){
const start=parseVttTime(lines[i].split('-->')[0].trim());
if(isNaN(start)){continue}
const buf=[];i++;
while(i<lines.length&&lines[i].trim()!==''){buf.push(lines[i]);i++}
const txt=buf.join(' ').replace(/<[^>]+>/g,'').replace(/\s+/g,' ').trim();
if(txt)cues.push({start,text:txt});
}
}
// Collapse consecutive duplicate lines (common in auto-captions).
return cues.filter((c,i)=>i===0||c.text!==cues[i-1].text);
}
function parseVttTime(s){
s=(s||'').replace(',','.').replace(/[^\d:.]/g,'');
const p=s.split(':').map(Number);
if(p.some(isNaN)||!p.length)return NaN;
return p.reduce((acc,x)=>acc*60+x,0);
}
function renderCues(items,q){
const list=document.getElementById('t-list');if(!list)return;
if(!items.length){list.innerHTML='<em style="padding:10px;display:block;color:var(--muted)">'+(q?'No lines match.':'No transcript text.')+'</em>';return}
list.innerHTML=items.map(({c,i})=>`<div class="t-cue" data-i="${i}" onclick="seekTo(${c.start})"><span class="t-time">${fmtDur(c.start)}</span>${tHl(c.text,q)}</div>`).join('');
}
function filterTranscript(){
const q=(document.getElementById('t-search')?.value||'').toLowerCase().trim();
tSearching=!!q;
const items=transcript.cues.map((c,i)=>({c,i}));
renderCues(q?items.filter(({c})=>c.text.toLowerCase().includes(q)):items,q);
}
function seekTo(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
function syncTranscript(){
if(tSearching)return; // don't fight the user's filtered view
const v=document.getElementById('player-video'),list=document.getElementById('t-list');
if(!v||!list)return;
const t=v.currentTime,cues=transcript.cues;let ai=-1;
for(let i=0;i<cues.length;i++){if(t>=cues[i].start)ai=i;else break}
list.querySelectorAll('.t-cue.active').forEach(el=>el.classList.remove('active'));
if(ai>=0){const el=list.querySelector('.t-cue[data-i="'+ai+'"]');if(el){el.classList.add('active');el.scrollIntoView({block:'nearest'})}}
}
function tHl(s,q){const e=esc(s);if(!q)return e;try{return e.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','ig'),'<mark>$1</mark>')}catch{return e}}
/* ── Comments viewer ───────────────────────────────────────────── */
let cmt={vid:null,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
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:680px;width:100%">
<div class="modal-hdr"><h2>💬 Comments</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div style="display:flex;gap:6px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);flex-wrap:wrap">
<input id="cmt-search" placeholder="Search comments…" oninput="cmt.q=this.value;renderComments()"
style="flex:1;min-width:120px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px">
<select id="cmt-sort" onchange="cmt.sort=this.value;renderComments()"
style="padding:4px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px">
<option value="threaded">Threaded</option>
<option value="top">Top</option>
<option value="new">Newest</option>
<option value="old">Oldest</option>
</select>
<button onclick="cmtCollapseAll(true)" title="Collapse all replies">⊟</button>
<button onclick="cmtCollapseAll(false)" title="Expand all replies">⊞</button>
</div>
<div id="cmt-status" style="font-size:11px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border)"></div>
<div id="comments-body" style="overflow-y:auto;max-height:68vh"><em style="color:var(--muted)">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
cmt={vid:videoId,list:[],q:'',sort:'threaded',collapsed:new Set(),prevVisit:0};
try{
const r=await(await api('/api/comments/'+encodeURIComponent(videoId))).json();
cmt.list=r.comments||[];
// "New since last visit": remember when this video's comments were last
// opened (unix secs in localStorage) and flag comments posted after that.
const key='cmts:'+videoId;
cmt.prevVisit=parseInt(localStorage.getItem(key)||'0',10)||0;
localStorage.setItem(key,String(Math.floor(Date.now()/1000)));
renderComments();
}catch(e){
const b=document.getElementById('comments-body');
if(b)b.innerHTML=`<div style="color:#f87171;padding:10px">Failed to load comments: ${esc(e.message)}</div>`;
}
}
function cmtCollapseAll(collapse){
cmt.collapsed=new Set();
if(collapse)cmt.list.filter(c=>c.parent).forEach(c=>cmt.collapsed.add(c.parent));
renderComments();
}
function cmtToggle(id){
if(cmt.collapsed.has(id))cmt.collapsed.delete(id);else cmt.collapsed.add(id);
renderComments();
}
function cmtHl(s){
const e=esc(s||'');const q=cmt.q.trim();
if(!q)return e;
try{return e.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','ig'),'<mark>$1</mark>')}catch{return e}
}
function renderComments(){
const body=document.getElementById('comments-body');if(!body)return;
const status=document.getElementById('cmt-status');
const list=cmt.list;
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>';
if(status)status.textContent='';
return;
}
// Build the reply tree (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 q=cmt.q.trim().toLowerCase();
const matches=c=>(c.text||'').toLowerCase().includes(q)||(c.author||'').toLowerCase().includes(q);
// While searching: keep matching comments plus their ancestor chain so a
// reply still shows in context. visit() returns true if the subtree matches.
const keep=new Set();
if(q){
const visit=c=>{let any=matches(c);for(const ch of c.children)any=visit(ch)||any;if(any)keep.add(c.id);return any};
roots.forEach(visit);
}
const cmp={top:(a,b)=>(b.likes||0)-(a.likes||0),
new:(a,b)=>(b.timestamp||0)-(a.timestamp||0),
old:(a,b)=>(a.timestamp||0)-(b.timestamp||0)}[cmt.sort]||null;
const render=(c,depth)=>{
if(q&&!keep.has(c.id))return'';
const indent=Math.min(depth,8)*14;
const isNew=cmt.prevVisit&&c.timestamp&&c.timestamp>cmt.prevVisit;
const collapsed=cmt.collapsed.has(c.id)&&!q;
const toggle=c.children.length?`<span onclick="cmtToggle('${c.id}')" style="cursor:pointer;user-select:none;color:var(--muted)">${collapsed?'▸':'▾'}${c.children.length}</span> `:'';
const author=c.author?`<strong>${cmtHl(c.author)}</strong>`:'<em>unknown</em>';
const op=c.is_uploader?' <span style="font-size:10px;background:#3b82f6;color:#fff;padding:0 4px;border-radius:3px">OP</span>':'';
const meta=[c.time,c.likes!=null?`${c.likes} likes`:null].filter(Boolean).join(' · ');
const newBadge=isNew?' <span style="font-size:10px;color:#34d399">● new</span>':'';
const childHtml=collapsed?'':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;${isNew?'border-left:2px solid #34d399':''}">
<div style="margin-bottom:2px">${toggle}${author}${op}${meta?` <span style="color:var(--muted);font-size:11px">· ${esc(meta)}</span>`:''}${newBadge}</div>
<div style="white-space:pre-wrap;word-wrap:break-word">${cmtHl(c.text)}</div>
</div>${childHtml}`;
};
const ordered=cmp?roots.slice().sort(cmp):roots;
body.innerHTML=ordered.map(c=>render(c,0)).join('')||'<div style="color:var(--muted);padding:12px">No comments match your search.</div>';
if(status){
const newCount=cmt.prevVisit?list.filter(c=>c.timestamp&&c.timestamp>cmt.prevVisit).length:0;
const bits=[`${list.length} comment${list.length===1?'':'s'}`];
if(newCount)bits.push(`<span style="color:#34d399">${newCount} new since last visit</span>`);
if(q)bits.push('filtered');
status.innerHTML=bits.join(' · ');
}
}
/* ── Full-text library search ───────────────────────────────────── */
let ftsSeq=0;
function openSearch(){
if(document.getElementById('fts-modal'))return;
const bg=document.createElement('div');bg.className='modal-bg';bg.id='fts-modal';
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>🔍 Search library</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<input id="fts-input" type="search" placeholder="Search titles, channels, descriptions…" autocomplete="off"
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:14px">
<div id="fts-status" style="font-size:11px;color:var(--muted);padding:6px 2px"></div>
<div id="fts-results" style="overflow-y:auto;max-height:64vh"></div>
</div>`;
document.body.appendChild(bg);
const input=document.getElementById('fts-input');
input.focus();
let timer=null;
input.oninput=()=>{clearTimeout(timer);timer=setTimeout(()=>runFtsSearch(input.value.trim()),180)};
}
async function runFtsSearch(q){
const my=++ftsSeq;
const status=document.getElementById('fts-status');
const box=document.getElementById('fts-results');
if(!box)return;
if(!q){box.innerHTML='';if(status)status.textContent='';return}
if(status)status.textContent='Searching…';
try{
const r=await(await api('/api/search?limit=100&q='+encodeURIComponent(q))).json();
if(my!==ftsSeq)return; // a newer keystroke superseded this response
const res=r.results||[];
if(!res.length){box.innerHTML='<div style="color:var(--muted);padding:12px">No matches.</div>';if(status)status.textContent='0 results';return}
box.innerHTML=res.map(h=>`<div onclick="ftsOpen('${esc(h.video_id)}')" style="padding:8px 10px;border-bottom:1px solid var(--border);cursor:pointer">
<div style="font-size:13px">${esc(h.title)}</div>
<div style="font-size:11px;color:var(--muted)">${esc(h.channel)} · ${esc(h.platform)}</div>
${h.snippet?`<div style="font-size:12px;color:var(--muted);margin-top:2px">${ftsSnippet(h.snippet)}</div>`:''}
</div>`).join('');
if(status)status.textContent=res.length+' result'+(res.length===1?'':'s');
}catch(e){box.innerHTML=`<div style="color:#f87171;padding:12px">Search failed: ${esc(e.message)}</div>`}
}
// FTS5 wraps matched terms in STX/ETX control chars; escape first, then mark.
function ftsSnippet(s){return esc(s).replace(/\x02/g,'<mark>').replace(/\x03/g,'</mark>')}
function ftsOpen(id){
document.getElementById('fts-modal')?.remove();
if(!findVideo(id)){setStatus('Video not in the loaded library — try Rescan');return}
selectVideo(id);
document.getElementById('details')?.scrollIntoView({behavior:'smooth',block:'nearest'});
}
/* ── 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';
// Freshness nudge: expired/soon-to-expire auth cookies make YouTube's
// bot-detection worse, so flag them prominently.
let cookieWarn='';
if(ck.exists&&ck.no_auth_cookies){
cookieWarn=`<div class="settings-hint" style="color:#f87171;font-weight:600">⚠ These cookies are anonymous — they have no YouTube login session (no SID/SAPISID/LOGIN_INFO). YouTube captchas anonymous requests most aggressively. Export a fresh cookies.txt while signed in to YouTube.</div>`;
}else if(ck.exists&&ck.expired){
cookieWarn=`<div class="settings-hint" style="color:#f87171;font-weight:600">⚠ Your login cookies have expired (${Math.abs(ck.days_left||0)}d ago). Refresh them below — stale cookies make YouTube's bot-detection worse.</div>`;
}else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){
cookieWarn=`<div class="settings-hint" style="color:#facc15">⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.</div>`;
}
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>
${cookieWarn}
<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>
<div class="settings-row" style="margin-top:8px">
<label for="cf-pot-provider">Use POT token provider</label>
<input type="checkbox" id="cf-pot-provider" ${cur.use_pot_provider?'checked':''} ${cur.use_bundled_ytdlp?'':'disabled'}>
</div>
<div class="settings-hint" style="margin-bottom:6px">Spawns the bgutil-pot HTTP server (loopback only) and points yt-dlp at it. YouTube increasingly requires a Proof-of-Origin token bound to each video ID — without one, format URLs come back empty. Requires the bundled yt-dlp; the matching Python plugin is pip-installed into the bundled venv on Install.</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<button onclick="updatePotProvider(this)" ${cur.use_bundled_ytdlp?'':'disabled'}>${cur.pot_provider_installed?'⟳ Update POT':'⤓ Install POT'}</button>
<span style="font-size:11px;color:var(--muted)">${cur.pot_provider_installed?'✓ installed':'not installed'}</span>
<span id="pot-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<div class="settings-row" style="margin-top:8px">
<label for="cf-player-clients">YouTube player clients</label>
<input type="text" id="cf-player-clients" value="${esc(cur.youtube_player_clients||'')}" placeholder="default" style="width:140px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div class="settings-hint" style="margin-bottom:6px">Comma-separated <code>--extractor-args youtube:player_client</code>. Blank = let yt-dlp pick (recommended). YouTube's bot-detection targets different clients over time; if you keep hitting captchas, try <code>tv,mweb</code> — currently the least-checked. Per-channel overrides live in each channel's options.</div>
<div class="settings-row" style="margin-top:8px">
<label for="cf-sponsorblock">SponsorBlock</label>
<select id="cf-sponsorblock" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
${[['off','Off'],['mark','Mark chapters'],['remove','Remove segments']].map(([v,l])=>`<option value="${v}"${(cur.sponsorblock_mode||'mark')===v?' selected':''}>${l}</option>`).join('')}
</select>
</div>
<div class="settings-hint" style="margin-bottom:6px">Uses the community SponsorBlock database for sponsor / intro / self-promo segments. <b>Mark</b> adds skippable chapter markers; <b>Remove</b> cuts the segments out of the saved file. Per-channel overrides live in each channel's options.</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
<div class="settings-row">
<label for="cf-subs-enabled">Download subtitles</label>
<input type="checkbox" id="cf-subs-enabled" ${cur.subtitles_enabled?'checked':''} onchange="document.getElementById('cf-subs-detail').style.display=this.checked?'block':'none'">
</div>
<div id="cf-subs-detail" style="display:${cur.subtitles_enabled?'block':'none'};padding-left:8px;border-left:2px solid var(--border)">
<div class="settings-row">
<label for="cf-subs-auto">Include auto-generated (machine) captions</label>
<input type="checkbox" id="cf-subs-auto" ${cur.subtitles_auto?'checked':''}>
</div>
<div class="settings-row">
<label for="cf-subs-embed">Embed subtitles into the video file</label>
<input type="checkbox" id="cf-subs-embed" ${cur.subtitles_embed?'checked':''}>
</div>
<div class="settings-hint" style="margin-bottom:4px">--embed-subs: soft subs toggleable in the player, in addition to sidecar files.</div>
<div class="settings-row">
<label for="cf-subs-langs">Languages (comma sep, blank = all)</label>
<input type="text" id="cf-subs-langs" value="${esc(cur.subtitle_langs||'')}" placeholder="en, ja" 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">
<label for="cf-subs-format">Convert to format (blank = native)</label>
<input type="text" id="cf-subs-format" value="${esc(cur.subtitle_format||'')}" placeholder="srt" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div class="settings-hint" style="margin-bottom:4px">--convert-subs. srt is the most player/Plex-compatible.</div>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Format conversion (global defaults)</div>
<div class="settings-hint" style="margin-bottom:6px">Runs ffmpeg after each download. Individual channels can override the mode in their options. Requires ffmpeg on the server.</div>
<div class="settings-row">
<label for="cf-convert-mode">Mode</label>
<select id="cf-convert-mode" onchange="updateConvertRows()" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
<option value=""${!cur.convert_mode?' selected':''}>Off</option>
<option value="remux-mp4"${cur.convert_mode==='remux-mp4'?' selected':''}>Remux → mp4 (no re-encode)</option>
<option value="h264-mp4"${cur.convert_mode==='h264-mp4'?' selected':''}>Re-encode → H.264 mp4</option>
<option value="audio"${cur.convert_mode==='audio'?' selected':''}>Extract audio</option>
</select>
</div>
<div id="cf-convert-crf-row" class="settings-row" style="display:${cur.convert_mode==='h264-mp4'?'flex':'none'}">
<label for="cf-convert-crf">CRF (051, lower=better)</label>
<input type="number" id="cf-convert-crf" value="${cur.convert_crf||23}" min="0" max="51" style="width:70px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px">
</div>
<div id="cf-convert-preset-row" class="settings-row" style="display:${cur.convert_mode==='h264-mp4'?'flex':'none'}">
<label for="cf-convert-preset">x264 preset</label>
<select id="cf-convert-preset" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower','veryslow'].map(p=>`<option value="${p}"${(cur.convert_preset||'medium')===p?' selected':''}>${p}</option>`).join('')}
</select>
</div>
<div id="cf-convert-audio-row" class="settings-row" style="display:${cur.convert_mode==='audio'?'flex':'none'}">
<label for="cf-convert-audio">Audio format</label>
<select id="cf-convert-audio" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px">
${['mp3','m4a','opus','flac'].map(f=>`<option value="${f}"${(cur.convert_audio_format||'mp3')===f?' selected':''}>${f}</option>`).join('')}
</select>
</div>
<div id="cf-convert-keep-row" class="settings-row" style="display:${cur.convert_mode?'flex':'none'}">
<label for="cf-convert-keep">Keep original alongside converted</label>
<input type="checkbox" id="cf-convert-keep" ${cur.convert_keep_original?'checked':''}>
</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">Snapshots your watched / favourites / bookmarks / waiting / channel-options / folders. Import merges another snapshot in without touching the channel files on disk — re-running the same import is safe.</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button onclick="downloadBackupDb()">⬇ Download library snapshot</button>
<button onclick="document.getElementById('restore-input').click()">📂 Import library snapshot…</button>
<input type="file" id="restore-input" accept=".db,application/x-sqlite3" style="display:none" onchange="uploadRestoreDb(this)">
</div>
<div id="restore-status" class="settings-hint" style="display:none"></div>
</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 uploadRestoreDb(input){
// Stream the file's raw bytes to /api/restore/db. The server enforces a
// 100 MB cap; we mirror it client-side to give an instant error rather
// than blowing through the upload before getting a 413.
const f=input.files&&input.files[0];
if(!f){return}
const MAX=100*1024*1024;
const status=document.getElementById('restore-status');
status.style.display='';
if(f.size>MAX){status.textContent='File too large (max 100 MB)';input.value='';return}
if(!confirm(`Merge ${f.name} (${fmtSize(f.size)}) into the live database? Existing rows stay; conflicts resolve by the later timestamp.`)){input.value='';status.style.display='none';return}
status.textContent='Uploading…';
try{
const r=await fetch('/api/restore/db',{method:'POST',headers:{'Content-Type':'application/x-sqlite3'},body:f});
if(!r.ok){throw new Error(await r.text())}
const s=await r.json();
status.textContent=`Imported: ${s.watched_added}W · ${s.positions_added}P · ${s.options_added}opts · ${s.flags_added}flags · ${s.folders_added}folders · ${s.assignments_added}assigns · ${s.notes_added||0}notes`;
// Pull in any imported notes so indicators + search reflect them.
loadNotes();
setStatus('Library snapshot imported');
// Refresh the sidebar / grid so the new flags / folders show up.
loadLibrary();
}catch(e){
status.textContent='Import failed: '+e.message;
setStatus('Import failed: '+e.message);
}finally{
input.value='';
}
}
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 updatePotProvider(btn){
btn.disabled=true;
const st=document.getElementById('pot-status');
st.textContent='Started — click ⬇ in the header to view progress';
try{
const r=await fetch('/api/pot/update',{method:'POST'});
const t=await r.text();
if(!r.ok)throw new Error(t);
setStatus('POT provider install 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}
}
// Show only the convert sub-rows relevant to the selected mode.
function updateConvertRows(){
const mode=document.getElementById('cf-convert-mode')?.value||'';
const show=(id,on)=>{const el=document.getElementById(id);if(el)el.style.display=on?'flex':'none'};
show('cf-convert-crf-row',mode==='h264-mp4');
show('cf-convert-preset-row',mode==='h264-mp4');
show('cf-convert-audio-row',mode==='audio');
show('cf-convert-keep-row',!!mode);
}
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 usePotProvider=document.getElementById('cf-pot-provider')?.checked||false;
const sourceUrl=document.getElementById('cf-source-url')?.value;
const subsEnabled=document.getElementById('cf-subs-enabled')?.checked||false;
const subsAuto=document.getElementById('cf-subs-auto')?.checked||false;
const subsEmbed=document.getElementById('cf-subs-embed')?.checked||false;
const subsLangs=document.getElementById('cf-subs-langs')?.value||'';
const subsFormat=document.getElementById('cf-subs-format')?.value||'';
const playerClients=document.getElementById('cf-player-clients')?.value||'';
const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
const convMode=document.getElementById('cf-convert-mode')?.value||'';
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
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>`;
}
}
h+=`<h3 style="font-size:13px;margin:16px 0 8px">Similar content (perceptual)</h3>
<div class="settings-hint" style="margin-bottom:8px">Finds the same video re-uploaded under a different ID — mirrors, re-encodes, resolution changes — by comparing sampled frames. The first scan fingerprints your library (a few minutes); after that it's cached, so re-scans are quick.</div>
<div id="dedup-area"></div>`;
body.innerHTML=h;
initDedupArea();
}
/* ── Perceptual dedup (similar content) ─────────────────────────── */
let dedupTimer=null;
async function initDedupArea(){
if(dedupTimer){clearInterval(dedupTimer);dedupTimer=null}
try{
const s=await(await api('/api/maintenance/dedup/status')).json();
if(s.running){renderDedupProgress(s);startDedupPoll();}
else if(s.groups){renderSimilarGroups(s.groups);}
else dedupIdle(s.error);
}catch(e){const a=document.getElementById('dedup-area');if(a)a.innerHTML=`<div style="color:#f87171;font-size:12px">${esc(e.message)}</div>`}
}
function dedupIdle(err){
const a=document.getElementById('dedup-area');if(!a)return;
a.innerHTML=`${err?`<div style="color:#f87171;font-size:12px;margin-bottom:6px">Last scan error: ${esc(err)}</div>`:''}
<button class="primary" onclick="startDedup(this)">🔍 Scan for similar content</button>`;
}
async function startDedup(btn){
if(btn)btn.disabled=true;
try{await api('/api/maintenance/dedup/scan',{method:'POST'});startDedupPoll();}
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
}
function startDedupPoll(){
if(dedupTimer)clearInterval(dedupTimer);
dedupTimer=setInterval(async()=>{
if(!document.getElementById('dedup-area')){clearInterval(dedupTimer);dedupTimer=null;return} // modal closed
let s;try{s=await(await api('/api/maintenance/dedup/status')).json()}catch{return}
if(s.running){renderDedupProgress(s);return}
clearInterval(dedupTimer);dedupTimer=null;
if(s.error)dedupIdle(s.error); else renderSimilarGroups(s.groups||[]);
},800);
}
function renderDedupProgress(s){
const a=document.getElementById('dedup-area');if(!a)return;
const pct=s.total?Math.round(100*s.done/s.total):0;
a.innerHTML=`<div style="font-size:12px;color:var(--muted);margin-bottom:6px">Fingerprinting ${s.done} / ${s.total||'…'} new videos…</div>
<div style="height:6px;background:var(--bg);border-radius:3px;overflow:hidden"><div style="height:100%;width:${pct}%;background:var(--accent);transition:width .3s"></div></div>`;
}
function renderSimilarGroups(groups){
const a=document.getElementById('dedup-area');if(!a)return;
let h=`<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<span style="font-size:12px;color:var(--muted)">${groups.length} similar group${groups.length===1?'':'s'}</span>
<button onclick="startDedup(this)">⟳ Re-scan</button></div>`;
if(!groups.length){h+='<div style="color:var(--muted);font-size:12px">No visually-similar duplicates found across different IDs. 🎉</div>';a.innerHTML=h;return}
groups.forEach((g,gi)=>{
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">Group ${gi+1}${g.videos.length} copies</div>`;
g.videos.forEach(v=>{
const tag=v.recommended_keep?'<span style="color:#4ade80">keep</span>':'<span style="color:#f87171">remove</span>';
const size=v.file_size?fmtSize(v.file_size):'no video';
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
<input type="checkbox" class="sim-chk" data-files='${esc(JSON.stringify(v.files))}' ${v.recommended_keep?'':'checked'}>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.location)}">${esc(v.title)} <span style="color:var(--muted)">· ${esc(v.channel)} · ${size} · [${esc(v.video_id)}]</span></span>
${tag}
</label>`;
});
h+=`</div>`;
});
h+=`<button class="primary" onclick="removeSimilar(this)">🗑 Remove checked copies</button>`;
a.innerHTML=h;
}
async function removeSimilar(btn){
const chks=[...document.querySelectorAll('.sim-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) across the checked copies? 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();
startDedup(); // re-scan (fingerprints cached) so deleted copies drop out
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
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).
//
// Layout:
// ┌──────────────────────────────────────────────────────┐
// │ ⬇ Downloads [✕] │
// ├──────────────────────────────────────────────────────┤
// │ New download │
// │ [URL input………………] [quality▾] [⬇ Download] │
// │ [☐ Fast] [☐ 🔴 Live] [☐ Clips] │
// ├──────────────────────────────────────────────────────┤
// │ Active + recent jobs… │
// └──────────────────────────────────────────────────────┘
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()};
// Inline the URL/quality/flags form inside the modal. The element IDs
// (dl-url, dl-quality, dl-full-scan, dl-live, dl-twitch-clips) match
// the names global helpers (previewDownload, fullScan, dlLive,
// refreshTwitchClipsVisibility…) already look up by getElementById,
// so those helpers keep working unchanged.
bg.innerHTML=`<div class="modal" style="max-width:760px;width:100%">
<div class="modal-hdr"><h2>⬇ Downloads</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
<div class="dl-new">
<div class="dl-new-row">
<input type="url" id="dl-url" placeholder="Paste a YouTube / TikTok / Twitch / … URL…" oninput="refreshTwitchClipsVisibility()" onkeydown="if(event.key==='Enter')previewDownload()">
<select id="dl-quality" title="Download quality" 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>
<button class="primary" onclick="previewDownload()">⬇ Download</button>
</div>
<div class="dl-new-flags">
<label id="fast-mode-label" 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" 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" title="Twitch only: pull the channel's Clips listing instead of its VODs."><input type="checkbox" id="dl-twitch-clips"> Clips only</label>
</div>
</div>
<hr style="border-color:var(--border);margin:0">
<div id="downloads-body" style="overflow:auto;max-height:60vh;min-height:120px"><em style="color:var(--muted);padding:14px;display:block">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
// Focus the URL input so users can paste + hit Enter immediately. Slight
// delay so the focus survives the DOM-attach + any pending click event.
setTimeout(()=>document.getElementById('dl-url')?.focus(),0);
// Hide the Clips-only checkbox unless the URL is a Twitch channel.
refreshTwitchClipsVisibility();
// 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 'f':
openSearch();
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 filter box')}
${row('f','Search titles + descriptions')}
${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();
// Search also hits the per-video note body so a user can find a clip
// by something they wrote about it, not just its title.
const note=videoNote(v.id).toLowerCase();
return t.includes(q)||id.includes(q)||ch.includes(q)||note.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.
loadFilterPresets();
(async()=>{
await Promise.all([loadLibrary(), loadNotes()]);
restoreUiState();
renderSidebar();
renderGrid();
})();
loadSourceNotice();
</script>
</body>
</html>