diff --git a/frontend/app.js b/frontend/app.js index 21a82a0..51f2ff0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -3268,7 +3268,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform const Notes = (() => { const LC = window.LyricsCore; const panel = $('notesPanel'); - const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {}, chapterAt() { return null; } }; + const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {}, chapterAt() { return null; }, jumpChapter() {} }; if (!panel || !LC) return noop; const CACHE_KEY = 'ytpNotesCache'; @@ -3283,7 +3283,11 @@ const Notes = (() => { const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || ''); const profileName = () => (data.profile && data.profile.name) || ''; const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []); - const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || []; + // Shared chapters win; with none, YouTube's own chapters (description + // timestamps) stand in, read-only (auto: true). + const ownChapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || []; + const chapters = () => { const own = ownChapters(); return own.length ? own : (st.ytChapters || []); }; + const autoChapters = () => !ownChapters().length && (st.ytChapters || []).length > 0; const bookmarks = () => { if (!st.videoId || !data.bookmarks) return []; return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : []; @@ -3347,6 +3351,8 @@ const Notes = (() => { const cached = readCache()[id]; st.lyrics = cached ? cached.lyrics || null : null; st.chapters = cached ? cached.chapters || null : null; + st.ytChapters = cached && Array.isArray(cached.ytChapters) ? cached.ytChapters : []; + loadYtChapters(id, seq); panel.classList.remove('hidden'); setStatus(cached ? '' : 'Loading…'); renderAll(); @@ -3441,6 +3447,19 @@ const Notes = (() => { return out.sort((a, b) => a.t - b.t); } + // YouTube's chapters arrive on their own (never blocks the notes load). + async function loadYtChapters(id, seq) { + if (isUploadId(id) || String(id).startsWith('edit_')) return; + try { + const j = await (await fetch(`/api/chapters?v=${encodeURIComponent(id)}`)).json(); + if (seq !== st.loadSeq || st.videoId !== id || !j || !j.ok) return; + st.ytChapters = (j.chapters || []).map((c) => ({ t: c.t, title: c.title, auto: true })); + const entry = readCache()[id] || {}; + writeCache(id, { lyrics: entry.lyrics || st.lyrics, chapters: entry.chapters || st.chapters, ytChapters: st.ytChapters }); + if (!st.sync) renderAll(); + } catch { /* offline: keep the cached copy */ } + } + function renderMarkers() { const chs = chapters(); const bms = bookmarks(); @@ -3463,11 +3482,12 @@ const Notes = (() => { ${m.note ? `
${escapeHtml(m.note)}
` : ''} - - + ${m.auto ? '' : ` + `} `; $('chapterList').innerHTML = chs.length - ? chs.map((c, i) => row(c, 'chapter', i)).join('') + ? (autoChapters() ? '
Chapters from YouTube · + Chapter starts your own list from these
' : '') + + chs.map((c, i) => row(c, 'chapter', i)).join('') : '
No chapters yet — tap + Chapter while the song plays.
'; $('bookmarkList').innerHTML = bms.length ? bms.map((b, i) => row(b, 'bookmark', i)).join('') @@ -3863,10 +3883,30 @@ const Notes = (() => { const baseRev = st.chapters ? st.chapters.rev : 0; const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' }); if (!v) return; - await saveShared('chapters', { items: [...chapters(), v] }, baseRev); + // Starting from YouTube's chapters copies them in as ordinary ones. + await saveShared('chapters', { items: [...chapters().map((c) => ({ t: c.t, title: c.title })), v] }, baseRev); + } + + // [ / ] — previous / next chapter. "Previous" restarts the current chapter + // first when more than 3 s into it, like a CD player. + function jumpChapter(dir) { + const cs = chapters().slice().sort((a, b) => a.t - b.t); + if (!cs.length || !Player.master) { toast('No chapters for this video'); return; } + const cur = Player.master.currentTime || 0; + let target = null; + if (dir > 0) target = cs.find((c) => c.t > cur + 0.5); + else { + const before = cs.filter((c) => c.t <= cur + 0.5); + const here = before[before.length - 1]; + target = here && cur - here.t > 3 ? here : before[before.length - 2] || cs[0]; + } + if (!target) { toast('Last chapter'); return; } + Player.seek(target.t); + toast(`${cs.indexOf(target) + 1}/${cs.length} · ${target.title}`, { duration: 1500 }); } async function editMarker(kind, i) { + if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be edited — + Chapter starts your own list'); return; } const list = kind === 'chapter' ? chapters() : bookmarks(); const item = list[i]; if (!item) return; @@ -3882,6 +3922,7 @@ const Notes = (() => { } async function deleteMarker(kind, i) { + if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be deleted — + Chapter starts your own list'); return; } if (kind === 'chapter') { if (!requireProfile()) return; const next = chapters().filter((_, j) => j !== i); @@ -4396,6 +4437,7 @@ const Notes = (() => { return { load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); }, stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn }, + jumpChapter, chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; }, }; })(); @@ -10777,6 +10819,8 @@ document.querySelectorAll('.chip').forEach((c) => { else if (e.key === 'p') { if (current) Notes.addBookmark(); } else if (e.key === 'n') { if (current) Notes.addNote(); } else if (e.key === 'i') PiP.toggle(); + else if (e.key === '[') { if (current) Notes.jumpChapter(-1); } + else if (e.key === ']') { if (current) Notes.jumpChapter(1); } else if (e.key === 'e') EQ.open(); else if (e.key === '?') toggleShortcutHelp(); else if (e.key === 'Escape') { diff --git a/frontend/index.html b/frontend/index.html index e8fc909..cbbc42a 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -621,6 +621,7 @@
ASet A-B loop start point
BSet A-B loop end point
PPin a bookmark at the current time
+
[]Previous / next chapter
NWrite a note at this moment
IPicture-in-picture
EEqualizer
diff --git a/server/db.js b/server/db.js index 106e331..d466e5c 100644 --- a/server/db.js +++ b/server/db.js @@ -217,6 +217,13 @@ export async function initDb() { for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) { try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ } } + // Chapters YouTube publishes for a video (from yt-dlp's `chapters`), cached so + // a server-cached copy can show them without another yt-dlp call. + await db.execute(`CREATE TABLE IF NOT EXISTS yt_chapters ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, -- JSON [{ t, end, title }] + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + )`); // Optional profile protection: a password or generated key, stored only as a // slow salted hash (Bun.password / argon2id). NULL = the name alone opens it. for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) { @@ -800,3 +807,19 @@ export async function deleteUpload(id) { export async function touchUpload(id) { db.execute({ sql: 'UPDATE uploads SET plays = plays + 1 WHERE id = ?', args: [id] }).catch(() => {}); } + +// ---- YouTube chapters cache ------------------------------------------------- +export async function getYtChapters(id) { + const r = await db.execute({ sql: 'SELECT data, updated_at FROM yt_chapters WHERE id = ?', args: [id] }); + if (!r.rows[0]) return null; + let items = []; + try { items = JSON.parse(r.rows[0].data) || []; } catch { /* corrupt */ } + return { items, updatedAt: Number(r.rows[0].updated_at) }; +} +export async function setYtChapters(id, items) { + await db.execute({ + sql: `INSERT INTO yt_chapters (id, data, updated_at) VALUES (?, ?, unixepoch()) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, + args: [id, JSON.stringify(items || [])], + }); +} diff --git a/server/server.js b/server/server.js index d3f8e8e..a78f3a9 100644 --- a/server/server.js +++ b/server/server.js @@ -40,7 +40,7 @@ import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; -import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, +import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, getYtChapters, setYtChapters, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js'; import * as notesDb from './db.js'; @@ -630,6 +630,17 @@ function parseExpiry(url) { // Resolve (and briefly cache) a video's playable formats via yt-dlp -J. The // cache spares a fresh ~2-3s yt-dlp run on every Range request the media // element fires; its TTL is bounded a minute inside the URLs' own expiry. +// YouTube's own chapters (description timestamps / creator chapters) as the +// app's chapter shape: [{ t, end, title }] sorted by start. +function chaptersFromInfo(info) { + const ch = Array.isArray(info && info.chapters) ? info.chapters : []; + return ch + .map((c) => ({ t: Math.max(0, Number(c.start_time) || 0), end: Number(c.end_time) || null, title: String(c.title || '').trim().slice(0, 200) })) + .filter((c) => Number.isFinite(c.t) && c.title) + .sort((a, b) => a.t - b.t) + .slice(0, 500); +} + async function resolveStreamsUncached(videoId) { const now = Date.now(); const cached = streamCache.get(videoId); @@ -637,6 +648,7 @@ async function resolveStreamsUncached(videoId) { const out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`], { pooled: true }); const info = JSON.parse(out); + setYtChapters(videoId, chaptersFromInfo(info)).catch(() => {}); const raw = Array.isArray(info.formats) ? info.formats : []; const formats = []; let soonest = Infinity; @@ -746,6 +758,26 @@ function countView(c, videoId) { } // GET /api/streams?v= — meta + proxied audio/quality URLs. +// GET /api/chapters?v= — YouTube's chapters for a video ([] when it has +// none). Served from the cache table; otherwise resolved once (the stream +// resolver's own cache usually makes that free) and remembered for 30 days. +// Never fails the player: errors answer an empty list. +app.get('/api/chapters', async (c) => { + const id = (c.req.query('v') || '').trim(); + const nc = { 'Cache-Control': 'no-store' }; + if (!id || isUpload(id) || !media.isMediaId(id)) return c.json({ ok: true, chapters: [] }, 200, nc); + try { + const row = await getYtChapters(id); + if (row && Date.now() / 1000 - row.updatedAt < 30 * 86400) return c.json({ ok: true, chapters: row.items }, 200, nc); + const { info } = await resolveStreams(id); + const items = chaptersFromInfo(info); + await setYtChapters(id, items); + return c.json({ ok: true, chapters: items }, 200, nc); + } catch { + return c.json({ ok: true, chapters: [], unavailable: true }, 200, nc); + } +}); + app.get('/api/streams', async (c) => { const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim(); if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);