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:
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']) {
|
||||
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 || [])],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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=<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) => {
|
||||
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||
|
||||
Reference in New Issue
Block a user