YouTube chapters: extracted automatically, ticks on the seek bar and Glass scrubber, [ / ] to jump
- Server: chapters come from yt-dlp's `chapters` (creator chapters and description timestamps) whenever a video is resolved, kept in a yt_chapters table; GET /api/chapters?v= serves them (resolving once if needed, 30-day cache) so server-cached copies get them too. Never fails the player. - Player: a video's shared chapters win; with none, YouTube's stand in — listed as "Chapters from YouTube", read-only (no edit/delete; + Chapter copies them into an editable list). They drive the existing seek-bar ticks (classic and Glass Stage), the chapter badge and the chapter list, and are cached offline with the video's notes. - Keys: [ previous chapter (restarts the current one when >3 s in), ] next.
This commit is contained in:
@@ -3268,7 +3268,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
|
|||||||
const Notes = (() => {
|
const Notes = (() => {
|
||||||
const LC = window.LyricsCore;
|
const LC = window.LyricsCore;
|
||||||
const panel = $('notesPanel');
|
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;
|
if (!panel || !LC) return noop;
|
||||||
|
|
||||||
const CACHE_KEY = 'ytpNotesCache';
|
const CACHE_KEY = 'ytpNotesCache';
|
||||||
@@ -3283,7 +3283,11 @@ const Notes = (() => {
|
|||||||
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
|
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
|
||||||
const profileName = () => (data.profile && data.profile.name) || '';
|
const profileName = () => (data.profile && data.profile.name) || '';
|
||||||
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
|
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 = () => {
|
const bookmarks = () => {
|
||||||
if (!st.videoId || !data.bookmarks) return [];
|
if (!st.videoId || !data.bookmarks) return [];
|
||||||
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
|
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
|
||||||
@@ -3347,6 +3351,8 @@ const Notes = (() => {
|
|||||||
const cached = readCache()[id];
|
const cached = readCache()[id];
|
||||||
st.lyrics = cached ? cached.lyrics || null : null;
|
st.lyrics = cached ? cached.lyrics || null : null;
|
||||||
st.chapters = cached ? cached.chapters || 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');
|
panel.classList.remove('hidden');
|
||||||
setStatus(cached ? '' : 'Loading…');
|
setStatus(cached ? '' : 'Loading…');
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -3441,6 +3447,19 @@ const Notes = (() => {
|
|||||||
return out.sort((a, b) => a.t - b.t);
|
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() {
|
function renderMarkers() {
|
||||||
const chs = chapters();
|
const chs = chapters();
|
||||||
const bms = bookmarks();
|
const bms = bookmarks();
|
||||||
@@ -3463,11 +3482,12 @@ const Notes = (() => {
|
|||||||
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
|
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
|
||||||
<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
||||||
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>
|
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
$('chapterList').innerHTML = chs.length
|
$('chapterList').innerHTML = chs.length
|
||||||
? chs.map((c, i) => row(c, 'chapter', i)).join('')
|
? (autoChapters() ? '<div class="notes-empty small">Chapters from YouTube · + Chapter starts your own list from these</div>' : '') +
|
||||||
|
chs.map((c, i) => row(c, 'chapter', i)).join('')
|
||||||
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
|
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
|
||||||
$('bookmarkList').innerHTML = bms.length
|
$('bookmarkList').innerHTML = bms.length
|
||||||
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
|
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
|
||||||
@@ -3863,10 +3883,30 @@ const Notes = (() => {
|
|||||||
const baseRev = st.chapters ? st.chapters.rev : 0;
|
const baseRev = st.chapters ? st.chapters.rev : 0;
|
||||||
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
|
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
|
||||||
if (!v) return;
|
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) {
|
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 list = kind === 'chapter' ? chapters() : bookmarks();
|
||||||
const item = list[i];
|
const item = list[i];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@@ -3882,6 +3922,7 @@ const Notes = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteMarker(kind, i) {
|
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 (kind === 'chapter') {
|
||||||
if (!requireProfile()) return;
|
if (!requireProfile()) return;
|
||||||
const next = chapters().filter((_, j) => j !== i);
|
const next = chapters().filter((_, j) => j !== i);
|
||||||
@@ -4396,6 +4437,7 @@ const Notes = (() => {
|
|||||||
return {
|
return {
|
||||||
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
|
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
|
||||||
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
|
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 || '' }; },
|
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 === 'p') { if (current) Notes.addBookmark(); }
|
||||||
else if (e.key === 'n') { if (current) Notes.addNote(); }
|
else if (e.key === 'n') { if (current) Notes.addNote(); }
|
||||||
else if (e.key === 'i') PiP.toggle();
|
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 === 'e') EQ.open();
|
||||||
else if (e.key === '?') toggleShortcutHelp();
|
else if (e.key === '?') toggleShortcutHelp();
|
||||||
else if (e.key === 'Escape') {
|
else if (e.key === 'Escape') {
|
||||||
|
|||||||
@@ -621,6 +621,7 @@
|
|||||||
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
|
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
|
||||||
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
|
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
|
||||||
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
|
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>[</kbd><kbd>]</kbd><span>Previous / next chapter</span></div>
|
||||||
<div class="shortcut-row"><kbd>N</kbd><span>Write a note at this moment</span></div>
|
<div class="shortcut-row"><kbd>N</kbd><span>Write a note at this moment</span></div>
|
||||||
<div class="shortcut-row"><kbd>I</kbd><span>Picture-in-picture</span></div>
|
<div class="shortcut-row"><kbd>I</kbd><span>Picture-in-picture</span></div>
|
||||||
<div class="shortcut-row"><kbd>E</kbd><span>Equalizer</span></div>
|
<div class="shortcut-row"><kbd>E</kbd><span>Equalizer</span></div>
|
||||||
|
|||||||
23
server/db.js
23
server/db.js
@@ -217,6 +217,13 @@ export async function initDb() {
|
|||||||
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
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 */ }
|
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
|
// Optional profile protection: a password or generated key, stored only as a
|
||||||
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
|
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
|
||||||
for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) {
|
for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) {
|
||||||
@@ -800,3 +807,19 @@ export async function deleteUpload(id) {
|
|||||||
export async function touchUpload(id) {
|
export async function touchUpload(id) {
|
||||||
db.execute({ sql: 'UPDATE uploads SET plays = plays + 1 WHERE id = ?', args: [id] }).catch(() => {});
|
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 || [])],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { Readable } from 'node:stream';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
|
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';
|
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
|
||||||
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
|
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
|
||||||
import * as notesDb from './db.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
|
// 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
|
// 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.
|
// 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) {
|
async function resolveStreamsUncached(videoId) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = streamCache.get(videoId);
|
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 out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`], { pooled: true });
|
||||||
const info = JSON.parse(out);
|
const info = JSON.parse(out);
|
||||||
|
setYtChapters(videoId, chaptersFromInfo(info)).catch(() => {});
|
||||||
const raw = Array.isArray(info.formats) ? info.formats : [];
|
const raw = Array.isArray(info.formats) ? info.formats : [];
|
||||||
const formats = [];
|
const formats = [];
|
||||||
let soonest = Infinity;
|
let soonest = Infinity;
|
||||||
@@ -746,6 +758,26 @@ function countView(c, videoId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/streams?v=<videoId> — meta + proxied audio/quality URLs.
|
// GET /api/streams?v=<videoId> — meta + proxied audio/quality URLs.
|
||||||
|
// GET /api/chapters?v=<id> — 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) => {
|
app.get('/api/streams', async (c) => {
|
||||||
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||||
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||||
|
|||||||
Reference in New Issue
Block a user