diff --git a/api/db/migrate.ts b/api/db/migrate.ts index e5459bb..87d0971 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -8,6 +8,7 @@ import { up as up0006Categories } from "./migrations/0006_categories.ts"; import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts"; import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts"; import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts"; +import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts"; interface Migration { name: string; @@ -27,6 +28,7 @@ const MIGRATIONS: Migration[] = [ { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, { name: "0008_chat_messages", up: up0008ChatMessages }, { name: "0009_chat_reply", up: up0009ChatReply }, + { name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0010_youtube_embed_start.ts b/api/db/migrations/0010_youtube_embed_start.ts new file mode 100644 index 0000000..661956e --- /dev/null +++ b/api/db/migrations/0010_youtube_embed_start.ts @@ -0,0 +1,76 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Backfills the `start` parameter on stored YouTube embed URLs. +// +// `rich_content.embedUrl` is computed once, at dump creation, and previously +// dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so +// those videos always opened at 0:00 in the global player. The provider now +// translates `t`/`start` into the `start` param the iframe player honours; this +// migration applies the same translation to rows written before that fix. +// +// Purely local — it re-derives the param from the already-stored source URL and +// makes no network calls. Self-contained (no imports from api/services) so it +// stays reproducible even if the provider's parsing evolves later. +// +// Idempotent: rows whose embed URL already carries a `start` are skipped, so a +// fresh database built from schema.sql is simply a no-op. + +/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */ +function extractStartSeconds(url: string): number | null { + let raw: string | null; + try { + const u = new URL(url); + raw = u.searchParams.get("t") ?? u.searchParams.get("start"); + } catch { + return null; + } + if (!raw) return null; + + if (/^\d+$/.test(raw)) return Number(raw); + + const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw); + if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null; + return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 + + Number(hms[3] ?? 0); +} + +export function up(db: DatabaseSync): void { + const rows = db.prepare( + `SELECT id, url, rich_content FROM dumps + WHERE kind = 'url' AND url IS NOT NULL AND rich_content IS NOT NULL;`, + ).all() as { id: string; url: string; rich_content: string }[]; + + const update = db.prepare( + `UPDATE dumps SET rich_content = ? WHERE id = ?;`, + ); + + let patched = 0; + for (const row of rows) { + let rich: { type?: string; embedUrl?: string }; + try { + rich = JSON.parse(row.rich_content); + } catch { + continue; // malformed payload — leave it untouched + } + if (rich.type !== "youtube" || !rich.embedUrl) continue; + + const start = extractStartSeconds(row.url); + if (!start) continue; + + let embed: URL; + try { + embed = new URL(rich.embedUrl); + } catch { + continue; + } + if (embed.searchParams.has("start")) continue; + + embed.searchParams.set("start", String(start)); + update.run(JSON.stringify({ ...rich, embedUrl: embed.toString() }), row.id); + patched++; + } + + if (patched > 0) { + console.log(`[migrate] 0010: added start offset to ${patched} embed(s)`); + } +} diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 404e918..e1254ef 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -32,6 +32,30 @@ function extractPlaylistId(url: string): string | null { } } +/** + * Extract the playback offset in seconds from a YouTube URL. + * Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start` + * (plain seconds). Returns null when absent or unparseable. + */ +function extractStartSeconds(url: string): number | null { + let raw: string | null; + try { + const u = new URL(url); + raw = u.searchParams.get("t") ?? u.searchParams.get("start"); + } catch { + return null; + } + if (!raw) return null; + + const plain = /^\d+$/.exec(raw); + if (plain) return Number(raw); + + const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw); + if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null; + return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 + + Number(hms[3] ?? 0); +} + /** Matches /channel/UC…, /@handle, /c/name, /user/name */ function extractChannelPath(url: string): string | null { try { @@ -134,6 +158,8 @@ export const youtubeProvider: RichContentProvider = { const embedParams = new URLSearchParams({ rel: "0" }); if (listId) embedParams.set("list", listId); + const start = extractStartSeconds(url); + if (start) embedParams.set("start", String(start)); return { type: "youtube",