v3: youtube embeds now honour the timestamp in the source url, with a migration backfilling existing dumps
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
|
||||||
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||||
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
||||||
|
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +28,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
||||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||||
{ name: "0009_chat_reply", up: up0009ChatReply },
|
{ name: "0009_chat_reply", up: up0009ChatReply },
|
||||||
|
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
76
api/db/migrations/0010_youtube_embed_start.ts
Normal file
76
api/db/migrations/0010_youtube_embed_start.ts
Normal file
@@ -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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
/** Matches /channel/UC…, /@handle, /c/name, /user/name */
|
||||||
function extractChannelPath(url: string): string | null {
|
function extractChannelPath(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +158,8 @@ export const youtubeProvider: RichContentProvider = {
|
|||||||
|
|
||||||
const embedParams = new URLSearchParams({ rel: "0" });
|
const embedParams = new URLSearchParams({ rel: "0" });
|
||||||
if (listId) embedParams.set("list", listId);
|
if (listId) embedParams.set("list", listId);
|
||||||
|
const start = extractStartSeconds(url);
|
||||||
|
if (start) embedParams.set("start", String(start));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "youtube",
|
type: "youtube",
|
||||||
|
|||||||
Reference in New Issue
Block a user