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