Add "download date" sorting (file mtime, distinct from upload date) plus a broader set of options. Web select is now grouped via <optgroup>; desktop gains SortMode::DownloadDesc/DownloadAsc/ChannelAsc with matching arms and toolbar entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2913 lines
177 KiB
HTML
2913 lines
177 KiB
HTML
<!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}
|
||
/* Remote (federated) browsing is read-only: hide the download bar and every
|
||
per-card mutating action, leaving just Play. */
|
||
.remote-mode .dl-new{display:none}
|
||
.remote-mode .card-foot button:not(.play){display:none}
|
||
.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}
|
||
/* Custom player (used for non-seekable transcode streams): the live ffmpeg
|
||
pipe has no byte ranges, so we drive a scrubber off the known duration and
|
||
re-request the stream at an offset on seek. */
|
||
.vid-wrap{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
|
||
.vid-wrap video{flex:1;min-height:0;max-height:72vh;width:100%;background:#000}
|
||
.vctrl{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 8px;flex-wrap:wrap}
|
||
.vctrl .vbtn{background:transparent;border:none;color:var(--text);cursor:pointer;font-size:15px;line-height:1;padding:4px 7px;border-radius:4px}
|
||
.vctrl .vbtn:hover{background:var(--card)}
|
||
.vctrl .vseek{flex:1;min-width:120px;accent-color:var(--accent);cursor:pointer;height:5px}
|
||
.vctrl .vvol{width:70px;accent-color:var(--accent);cursor:pointer}
|
||
.vctrl .vtime{font-size:12px;color:var(--muted);font-family:monospace;white-space:nowrap}
|
||
.vid-wrap:fullscreen{background:#000;padding:0;gap:0}
|
||
.vid-wrap:fullscreen video{max-height:none}
|
||
.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}.badge.cancelled{color:var(--muted)}.badge.queued{color:var(--muted)}
|
||
.job-meta{font-size:11px;color:var(--muted);flex-shrink:0;font-variant-numeric:tabular-nums}
|
||
.job-act{font-size:11px;padding:1px 7px;flex-shrink:0}
|
||
.job-log{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin:0 14px 8px;padding:8px;max-height:220px;overflow:auto;color:var(--muted)}
|
||
/* 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{grid-column:1/-1;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()">
|
||
<optgroup label="Download date">
|
||
<option value="dl-desc">Recently downloaded</option>
|
||
<option value="dl-asc">Oldest download</option>
|
||
</optgroup>
|
||
<optgroup label="Upload date">
|
||
<option value="date-desc">Newest</option>
|
||
<option value="date-asc">Oldest</option>
|
||
</optgroup>
|
||
<optgroup label="Title">
|
||
<option value="title">Title (A→Z)</option>
|
||
<option value="title-desc">Title (Z→A)</option>
|
||
</optgroup>
|
||
<optgroup label="Channel">
|
||
<option value="channel">Channel (A→Z)</option>
|
||
</optgroup>
|
||
<optgroup label="Duration">
|
||
<option value="dur-asc">Shortest</option>
|
||
<option value="dur-desc">Longest</option>
|
||
</optgroup>
|
||
<optgroup label="Size">
|
||
<option value="size-asc">Smallest</option>
|
||
<option value="size-desc">Largest</option>
|
||
</optgroup>
|
||
</select>
|
||
<span id="hdr-stats"></span>
|
||
<button onclick="openSearch()" title="Search titles, descriptions & transcripts (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;
|
||
let remotes=[], remoteMode=null; // remoteMode = peer id when browsing a remote, else null
|
||
async function loadRemotes(){
|
||
try{remotes=await(await api('/api/remotes')).json();}catch(e){remotes=[];}
|
||
renderSidebar();
|
||
}
|
||
async function enterRemote(id){
|
||
const r=remotes.find(x=>x.id===id);if(!r)return;
|
||
setStatus('Connecting to '+r.name+'…');
|
||
try{
|
||
const data=await(await api('/api/remotes/'+id+'/library')).json();
|
||
remoteMode=id;
|
||
library=data.channels||[];
|
||
librarySnapshotFolders=data.folders||[];
|
||
channelUrls=library.map(ch=>ch.channel_url||null);
|
||
document.body.classList.add('remote-mode');
|
||
resetViewSelectors();selected.clear();closeSidebar();
|
||
renderSidebar();renderGrid();
|
||
setStatus('Viewing '+r.name+' (read-only)');
|
||
}catch(e){setStatus('Remote error: '+e.message);}
|
||
}
|
||
function exitRemote(){
|
||
remoteMode=null;
|
||
document.body.classList.remove('remote-mode');
|
||
libraryEtag=null; // force a fresh local fetch
|
||
resetViewSelectors();selected.clear();closeSidebar();
|
||
loadLibrary();
|
||
setStatus('Back to your library');
|
||
}
|
||
async function loadLibrary(){
|
||
if(remoteMode!==null)return; // a remote is in view; don't clobber it with local polling
|
||
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='';
|
||
// Federation: peers to browse read-only, plus an exit row when in one.
|
||
if(remotes.length){
|
||
h+=`<div class="sidebar-label" style="display:flex;align-items:center;gap:6px">🌐 Remotes</div>`;
|
||
if(remoteMode!==null)h+=`<div class="ch-item" onclick="exitRemote()">← Back to my library</div>`;
|
||
remotes.forEach(r=>{h+=`<div class="ch-item${remoteMode===r.id?' active':''}" onclick="enterRemote(${r.id})" title="${esc(r.url)}">🌐 ${esc(r.name)}</div>`;});
|
||
}
|
||
h+=`<div class="sidebar-label">${remoteMode!==null?'Remote library (read-only)':'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>`;
|
||
}
|
||
// Mutating sub-actions are local-only; hide them when browsing a remote.
|
||
if(remoteMode===null){
|
||
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==='dl-desc')vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0));
|
||
if(sort==='dl-asc')vids.sort((a,b)=>(a.mtime_unix||0)-(b.mtime_unix||0));
|
||
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));
|
||
if(sort==='title-desc')vids.sort((a,b)=>b.title.localeCompare(a.title));
|
||
if(sort==='channel')vids.sort((a,b)=>(a.channel||'').localeCompare(b.channel||'')||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||' '}</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,'"')})" 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="grid-column:1/-1;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="grid-column:1/-1;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 (blank = global)</label>
|
||
${triSelect('opt-comments',opts.fetch_comments)}</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. "Default" uses the global setting.</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="copyChannelFeed('${esc(platform)}','${esc(handle)}',this)" title="Copy this channel's podcast feed URL" style="margin-right:auto">📡 Feed URL</button>
|
||
<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:triValue('opt-comments'),
|
||
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);
|
||
}
|
||
// Player state for the custom transcode controls. For direct /files/ videos
|
||
// `isTranscode` is false and native <video controls> handle everything.
|
||
let player={isTranscode:false,base:'',seekBase:0,duration:0};
|
||
function transcodeUrlAt(t){const u=player.base;const sep=u.includes('?')?'&':'?';return safeUrl(u+sep+'start='+Math.max(0,t).toFixed(2));}
|
||
// Effective playback position: a transcode element's currentTime restarts at 0
|
||
// each seek, so add the offset we started the stream at.
|
||
function effTime(){const v=document.getElementById('player-video');if(!v)return 0;return player.isTranscode?player.seekBase+(v.currentTime||0):(v.currentTime||0);}
|
||
// The one seek path everything routes through (scrubber, ±10s, chapters,
|
||
// transcript, keyboard, resume). Transcode reloads the stream at the offset;
|
||
// direct video just sets currentTime.
|
||
function playerSeek(t){
|
||
const v=document.getElementById('player-video');if(!v)return;
|
||
t=Math.max(0,player.duration?Math.min(t,player.duration):t);
|
||
if(player.isTranscode){player.seekBase=t;v.src=transcodeUrlAt(t);v.load();v.play().catch(()=>{});}
|
||
else{v.currentTime=t;v.play().catch(()=>{});}
|
||
updateVctrl();
|
||
}
|
||
function vTogglePlay(){const v=document.getElementById('player-video');if(!v)return;if(v.paused)v.play().catch(()=>{});else v.pause();}
|
||
function vToggleMute(){const v=document.getElementById('player-video');if(!v)return;v.muted=!v.muted;updateVctrl();}
|
||
function vFullscreen(){const w=document.querySelector('.vid-wrap');if(w){if(document.fullscreenElement)document.exitFullscreen();else w.requestFullscreen&&w.requestFullscreen();}}
|
||
function updateVctrl(){
|
||
const v=document.getElementById('player-video');if(!v||!player.isTranscode)return;
|
||
const t=effTime(),d=player.duration||0;
|
||
const tl=document.getElementById('v-time');if(tl)tl.textContent=fmtDur(t)+' / '+fmtDur(d);
|
||
const sk=document.getElementById('v-seek');if(sk&&!sk._drag)sk.value=Math.floor(t);
|
||
const pb=document.getElementById('v-play');if(pb)pb.textContent=v.paused?'⏵':'⏸';
|
||
const mb=document.getElementById('v-mute');if(mb)mb.textContent=(v.muted||v.volume===0)?'🔇':'🔊';
|
||
}
|
||
function playerKey(e){
|
||
if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))return;
|
||
const v=document.getElementById('player-video');if(!v)return;
|
||
if(e.key===' '||e.key==='k'){e.preventDefault();vTogglePlay();}
|
||
else if(e.key==='f'){e.preventDefault();vFullscreen();}
|
||
else if(player.isTranscode&&e.key==='ArrowLeft'){e.preventDefault();playerSeek(effTime()-5);}
|
||
else if(player.isTranscode&&e.key==='ArrowRight'){e.preventDefault();playerSeek(effTime()+5);}
|
||
else if(player.isTranscode&&e.key==='j'){e.preventDefault();playerSeek(effTime()-10);}
|
||
else if(player.isTranscode&&e.key==='l'){e.preventDefault();playerSeek(effTime()+10);}
|
||
}
|
||
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>`:'';
|
||
const isT=(v.video_url||'').includes('/api/transcode/');
|
||
const resume=(v.resume_pos&&v.resume_pos>5)?v.resume_pos:0;
|
||
player={isTranscode:isT,base:v.video_url,seekBase:isT?resume:0,duration:v.duration_secs||0};
|
||
const initSrc=isT?transcodeUrlAt(player.seekBase):esc(safeUrl(v.video_url));
|
||
const dmax=Math.max(1,Math.floor(player.duration||0));
|
||
const bar=isT?`<div class="vctrl" id="vctrl">
|
||
<button class="vbtn" id="v-play" onclick="vTogglePlay()" title="Play / Pause (space)">⏸</button>
|
||
<button class="vbtn" onclick="playerSeek(effTime()-10)" title="Back 10s (j)">« 10</button>
|
||
<button class="vbtn" onclick="playerSeek(effTime()+10)" title="Forward 10s (l)">10 »</button>
|
||
<span class="vtime" id="v-time">0:00 / ${fmtDur(player.duration)}</span>
|
||
<input type="range" class="vseek" id="v-seek" min="0" max="${dmax}" step="1" value="${Math.floor(player.seekBase)}" title="Seek">
|
||
<button class="vbtn" id="v-mute" onclick="vToggleMute()" title="Mute">🔊</button>
|
||
<input type="range" class="vvol" id="v-vol" min="0" max="1" step="0.05" value="1" title="Volume">
|
||
<button class="vbtn" onclick="vFullscreen()" title="Fullscreen (f)">⛶</button>
|
||
</div>`:'';
|
||
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"><div class="vid-wrap"><video id="player-video" src="${initSrc}" ${isT?'':'controls'} autoplay crossorigin="anonymous">${tracks}</video>${bar}</div>${chapPane}${transcriptPane}</div>
|
||
</div>`;
|
||
document.body.appendChild(bg);
|
||
const vid=bg.querySelector('#player-video');
|
||
// Direct (seekable) videos resume via currentTime; a transcode stream was
|
||
// already started at the resume offset above.
|
||
if(!isT&&resume)vid.addEventListener('loadedmetadata',()=>{vid.currentTime=resume},{once:true});
|
||
if(isT){
|
||
const sk=bg.querySelector('#v-seek');
|
||
sk.addEventListener('input',()=>{sk._drag=true;const tl=document.getElementById('v-time');if(tl)tl.textContent=fmtDur(+sk.value)+' / '+fmtDur(player.duration);});
|
||
sk.addEventListener('change',()=>{sk._drag=false;playerSeek(+sk.value);});
|
||
bg.querySelector('#v-vol').addEventListener('input',function(){vid.volume=+this.value;vid.muted=(+this.value===0);updateVctrl();});
|
||
['timeupdate','play','pause','volumechange'].forEach(ev=>vid.addEventListener(ev,updateVctrl));
|
||
updateVctrl();
|
||
}
|
||
bg.tabIndex=-1;bg.addEventListener('keydown',playerKey);bg.focus();
|
||
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||!currentPlayingId)return;
|
||
const pos=player.isTranscode?effTime():vid.currentTime;
|
||
const dur=player.isTranscode?player.duration: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=effTime();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){playerSeek(s);}
|
||
|
||
/* ── 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){playerSeek(s);}
|
||
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=effTime(),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(' · ');
|
||
}
|
||
}
|
||
|
||
/* ── Podcast feed URLs ──────────────────────────────────────────── */
|
||
function copyFeedUrl(id,btn){
|
||
const el=document.getElementById(id);if(!el||!el.value)return;
|
||
el.select();
|
||
const done=()=>{if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}};
|
||
if(navigator.clipboard)navigator.clipboard.writeText(el.value).then(done).catch(()=>{document.execCommand('copy');done()});
|
||
else{document.execCommand('copy');done()}
|
||
}
|
||
async function copyChannelFeed(platform,handle,btn){
|
||
try{
|
||
const {token}=await(await api('/api/feed-info')).json();
|
||
const url=`${location.origin}/feed/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}?token=${token}`;
|
||
if(navigator.clipboard)await navigator.clipboard.writeText(url);
|
||
if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}
|
||
setStatus('Channel feed URL copied to clipboard');
|
||
}catch(e){setStatus('Error: '+e.message)}
|
||
}
|
||
|
||
/* ── 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, transcripts…" 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 "Source" 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>`;
|
||
}
|
||
let feed={token:''};try{feed=await(await api('/api/feed-info')).json()}catch{}
|
||
const feedUrl=feed.token?`${location.origin}/feed.xml?token=${feed.token}`:'';
|
||
const feedRow=`<hr style="border-color:var(--border);margin:12px 0">
|
||
<div style="font-weight:700;margin-bottom:8px">📡 Podcast feed</div>
|
||
<div class="settings-hint" style="margin-bottom:6px">Subscribe to your whole library in any podcast / media app. The token in the URL grants read-only access to the feed and its media — so it works even with a password set. Treat it like a share link.</div>
|
||
<div class="settings-row" style="gap:6px">
|
||
<input type="text" id="cf-feed-url" readonly value="${esc(feedUrl)}" onclick="this.select()" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-size:12px">
|
||
<button onclick="copyFeedUrl('cf-feed-url',this)">Copy</button>
|
||
</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}
|
||
${feedRow}
|
||
<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>
|
||
<div class="settings-row" style="margin-top:8px">
|
||
<label for="cf-comments">Fetch comments</label>
|
||
<input type="checkbox" id="cf-comments" ${cur.fetch_comments?'checked':''}>
|
||
</div>
|
||
<div class="settings-hint" style="margin-bottom:6px">Adds <code>--write-comments</code> to every download — fetches each video's comment tree into its info.json so the player's Comments tab works. Slow on popular videos. Per-channel overrides live in each channel's options.</div>
|
||
<div class="settings-row" style="margin-top:8px">
|
||
<label for="cf-dedup">Similar-content scan</label>
|
||
<input type="checkbox" id="cf-dedup" ${cur.dedup_enabled!==false?'checked':''}>
|
||
</div>
|
||
<div class="settings-hint" style="margin-bottom:6px">Enables the Maintenance “Scan for similar content” tool, which fingerprints your videos (an ffmpeg keyframe pass) to find visual duplicates. Turn off on low-powered machines to skip that work.</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 (0–51, 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 fetchComments=document.getElementById('cf-comments')?.checked||false;
|
||
const dedupEnabled=document.getElementById('cf-dedup')?.checked!==false;
|
||
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,fetch_comments:fetchComments,dedup_enabled:dedupEnabled,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>`;
|
||
h+=`<h3 style="font-size:13px;margin:16px 0 8px">🏷 Auto-tag suggestions</h3>
|
||
<div class="settings-hint" style="margin-bottom:8px">Groups your <b>unfiled</b> channels by source platform and typical video length. Review a group and apply it to drop those channels into a folder — nothing moves until you click Apply.</div>
|
||
<div id="autotag-area"><em style="color:var(--muted);font-size:12px">Analyzing…</em></div>`;
|
||
body.innerHTML=h;
|
||
initDedupArea();
|
||
initAutotagArea();
|
||
}
|
||
|
||
/* ── Smart auto-tagging (suggested folder groups) ───────────────── */
|
||
async function initAutotagArea(){
|
||
const area=document.getElementById('autotag-area');if(!area)return;
|
||
let groups;
|
||
try{groups=await(await api('/api/autotag/suggest')).json();}
|
||
catch(e){area.innerHTML=`<div style="color:#f87171;font-size:12px">Could not analyze: ${esc(e.message)}</div>`;return}
|
||
renderAutotag(groups);
|
||
}
|
||
function renderAutotag(groups){
|
||
const area=document.getElementById('autotag-area');if(!area)return;
|
||
if(!groups||!groups.length){area.innerHTML='<div style="color:var(--muted);font-size:12px">No grouping suggestions — every channel is already filed or too ambiguous to call.</div>';return}
|
||
// Confidence → dot colour: strong (green) ≥ .85, medium (amber) ≥ .7, else grey.
|
||
const dot=c=>{const col=c>=0.85?'#4ade80':c>=0.7?'#facc15':'#9ca3af';return `<span title="confidence ${(c*100).toFixed(0)}%" style="flex-shrink:0;width:8px;height:8px;border-radius:50%;background:${col};display:inline-block"></span>`};
|
||
let h='';
|
||
groups.forEach((g,gi)=>{
|
||
h+=`<div style="border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:8px" data-grp="${gi}">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<span style="font-weight:600;font-size:12px;flex:1">📁 ${esc(g.group)} <span style="color:var(--muted)">(${g.channels.length})</span></span>
|
||
<button class="primary" onclick="applyAutotag(${gi},this)">Apply → ${esc(g.group)}</button>
|
||
</div>`;
|
||
g.channels.forEach((c,ci)=>{
|
||
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
|
||
<input type="checkbox" class="at-chk" data-grp="${gi}" data-ci="${ci}" checked>
|
||
${dot(c.confidence)}
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.display_name)} <span style="color:var(--muted)">· ${esc(c.platform_label)} · ${esc(c.reason)}</span></span>
|
||
</label>`;
|
||
});
|
||
h+=`</div>`;
|
||
});
|
||
area.innerHTML=h;
|
||
// Stash the data so applyAutotag can read the channel keys back.
|
||
area._groups=groups;
|
||
}
|
||
async function applyAutotag(gi,btn){
|
||
const area=document.getElementById('autotag-area');
|
||
const groups=area&&area._groups;if(!groups||!groups[gi])return;
|
||
const g=groups[gi];
|
||
const picked=[...area.querySelectorAll(`.at-chk[data-grp="${gi}"]`)]
|
||
.filter(cb=>cb.checked)
|
||
.map(cb=>g.channels[+cb.dataset.ci])
|
||
.map(c=>({platform:c.platform,handle:c.handle}));
|
||
if(!picked.length){setStatus('No channels selected');return}
|
||
btn.disabled=true;btn.textContent='Applying…';
|
||
try{
|
||
const r=await(await api('/api/autotag/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({group:g.group,channels:picked})})).json();
|
||
setStatus(`Moved ${r.applied} channel${r.applied===1?'':'s'} → ${g.group}`);
|
||
await loadLibrary();renderSidebar();
|
||
initAutotagArea(); // re-analyze: applied channels drop out (now filed)
|
||
}catch(e){setStatus('Apply failed: '+e.message);btn.disabled=false;btn.textContent='Apply → '+g.group;}
|
||
}
|
||
|
||
/* ── 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{
|
||
const r=await(await api('/api/maintenance/dedup/scan',{method:'POST'})).json();
|
||
if(r&&r.disabled){
|
||
const a=document.getElementById('dedup-area');
|
||
if(a)a.innerHTML=`<div style="color:var(--muted);font-size:12px">Similar-content scan is disabled in Settings.</div>`;
|
||
return;
|
||
}
|
||
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>`;
|
||
jobsSig='';
|
||
return;
|
||
}
|
||
// Diff-aware repaint: a full innerHTML rebuild on every progress tick
|
||
// (~0.6s while downloading) flickers and collapses any open per-job log.
|
||
// Only rebuild when the *structure* changes (jobs added/removed/state or
|
||
// retryability flipped, or the queue length changed); otherwise just patch
|
||
// the live bits (progress bar, speed/ETA) of the running rows in place.
|
||
const sig=JSON.stringify([jobs.map(j=>[j.state,j.can_retry]),queued.length]);
|
||
if(sig===jobsSig&&body.querySelector('.job-row')){
|
||
jobs.forEach((j,i)=>{
|
||
if(j.state!=='running')return;
|
||
const row=body.querySelector(`.job-row[data-job="${i}"]`);
|
||
if(!row)return;
|
||
const pr=row.querySelector('progress'); if(pr)pr.value=j.progress||0;
|
||
const mt=row.querySelector('.job-meta');
|
||
if(mt)mt.textContent=`${jobMeta(j.last_line)} ${Math.round((j.progress||0)*100)}%`;
|
||
});
|
||
return;
|
||
}
|
||
jobsSig=sig;
|
||
const fin=jobs.some(j=>j.state!=='running');
|
||
const anyRetry=jobs.some(j=>j.state!=='running'&&j.can_retry);
|
||
const hdrBtns=`${anyRetry?`<button onclick="retryAllFailed(this)" style="font-size:11px;padding:2px 8px">↻ Retry all failed</button>`:''}<button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button>`;
|
||
const hdr=fin?`<div style="padding:4px 14px;display:flex;gap:6px;justify-content:flex-end;border-bottom:1px solid var(--border)">${hdrBtns}</div>`:'';
|
||
// Queued jobs map 1:1 to the server's pending-queue index, so the cancel
|
||
// button can DELETE /api/queued/<i>.
|
||
const queuedHtml=queued.map((q,i)=>`<div class="job">
|
||
<span class="badge queued">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>
|
||
<button class="job-act" onclick="cancelQueued(${i})" title="Remove from queue">✕</button>
|
||
</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 running=j.state==='running';
|
||
// 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>` : '';
|
||
// Live speed/ETA parsed from the yt-dlp progress line (running only);
|
||
// otherwise show the raw last log line.
|
||
const meta=running?jobMeta(j.last_line):'';
|
||
const pct=running?` ${Math.round((j.progress||0)*100)}%`:'';
|
||
// Per-row actions: cancel (running), retry (finished + retryable),
|
||
// log toggle (always), dismiss (finished).
|
||
const acts=[];
|
||
if(running)acts.push(`<button class="job-act" onclick="cancelJob(${i},this)" title="Stop this download">⛔ Cancel</button>`);
|
||
if(!running&&j.can_retry)acts.push(`<button class="job-act" onclick="retryJob(${i},this)" title="Re-run this download">↻ Retry</button>`);
|
||
acts.push(`<button class="job-act" onclick="toggleJobLog(${i},this)" title="Show full log">📄 Log</button>`);
|
||
if(!running)acts.push(`<button class="job-act" onclick="removeJob(${i})" title="Dismiss">✕</button>`);
|
||
return `<div class="job-row" data-job="${i}">
|
||
<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>
|
||
${running?`<progress value="${j.progress}" max="1"></progress><span class="job-meta">${esc(meta)}${pct}</span>`:`<span class="last-line" style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>`}
|
||
${acts.join('')}
|
||
</div>
|
||
${errHint}
|
||
</div>`;
|
||
}).join('')+queuedHtml+limitNote;
|
||
}
|
||
// Parse a yt-dlp "[download] 42.7% of 100MiB at 5MiB/s ETA 00:10" line into a
|
||
// compact "5MiB/s · ETA 00:10" meta string. Empty when the line isn't a
|
||
// recognizable download-progress line (post-processing, fragments, etc.).
|
||
function jobMeta(line){
|
||
if(!line)return '';
|
||
const sp=line.match(/at\s+([\d.]+\s*[KMGT]?i?B\/s)/i);
|
||
const eta=line.match(/ETA\s+([\d:]+)/i);
|
||
const parts=[];
|
||
if(sp)parts.push(sp[1].replace(/\s+/g,''));
|
||
if(eta)parts.push('ETA '+eta[1]);
|
||
return parts.join(' · ');
|
||
}
|
||
async function cancelJob(idx,btn){
|
||
if(btn)btn.disabled=true;
|
||
try{await api('/api/jobs/'+idx+'/cancel',{method:'POST'});await pollProgress();}
|
||
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||
}
|
||
async function retryJob(idx,btn){
|
||
if(btn)btn.disabled=true;
|
||
try{await api('/api/jobs/'+idx+'/retry',{method:'POST'});await pollProgress();}
|
||
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||
}
|
||
async function cancelQueued(idx){
|
||
try{await api('/api/queued/'+idx,{method:'DELETE'});await pollProgress();}
|
||
catch(e){setStatus('Error: '+e.message)}
|
||
}
|
||
// Retry every retryable failed job. Each retry removes the old row and shifts
|
||
// indices, so we re-snapshot and retry the first match until none remain
|
||
// (capped so a server hiccup can't spin forever).
|
||
async function retryAllFailed(btn){
|
||
if(btn)btn.disabled=true;
|
||
try{
|
||
for(let guard=0;guard<200;guard++){
|
||
const d=await(await api('/api/progress')).json();
|
||
const idx=(d.jobs||[]).findIndex(j=>j.state!=='running'&&j.can_retry);
|
||
if(idx<0)break;
|
||
await api('/api/jobs/'+idx+'/retry',{method:'POST'});
|
||
}
|
||
await pollProgress();
|
||
}catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||
}
|
||
// Expand/collapse the full server-side log for one job, fetched on demand.
|
||
async function toggleJobLog(idx,btn){
|
||
const row=document.querySelector(`.job-row[data-job="${idx}"]`);
|
||
if(!row)return;
|
||
const existing=row.querySelector('.job-log');
|
||
if(existing){existing.remove();if(btn)btn.classList.remove('primary');return}
|
||
if(btn)btn.classList.add('primary');
|
||
const pre=document.createElement('div');
|
||
pre.className='job-log';pre.textContent='Loading…';
|
||
row.appendChild(pre);
|
||
try{
|
||
const d=await(await api('/api/jobs/'+idx+'/log')).json();
|
||
pre.textContent=(d.lines||[]).join('\n')||'(no log output)';
|
||
pre.scrollTop=pre.scrollHeight;
|
||
}catch(e){pre.textContent='Failed to load log: '+e.message}
|
||
}
|
||
// 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;
|
||
// Structural signature of the last full jobs repaint; lets renderJobs patch
|
||
// progress in place instead of rebuilding (and collapsing open logs) each tick.
|
||
let jobsSig='';
|
||
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 & transcripts')}
|
||
${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||
/* 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> — <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();
|
||
})();
|
||
loadRemotes();
|
||
loadSourceNotice();
|
||
</script>
|
||
</body>
|
||
</html>
|