From cde46793f8db57187616b9c869027c41dfdc9862 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:19:10 +0200 Subject: [PATCH] feat: support timestamps for youtube video emebds (#9737) --- packages/element/src/embeddable.ts | 34 ++++- packages/element/tests/embeddable.test.ts | 153 ++++++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 packages/element/tests/embeddable.test.ts diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 78dc26fe2f..71c75cc23a 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired; const embeddedLinkCache = new Map(); const RE_YOUTUBE = - /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; + /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/; const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; @@ -56,6 +56,35 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { + let timeParam: string | null | undefined; + + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + timeParam = + urlObj.searchParams.get("t") || urlObj.searchParams.get("start"); + } catch (error) { + const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/); + timeParam = timeMatch?.[1]; + } + + if (!timeParam) { + return 0; + } + + if (/^\d+$/.test(timeParam)) { + return parseInt(timeParam, 10); + } + + const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); + if (!timeMatch) { + return 0; + } + + const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch; + return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", @@ -113,7 +142,8 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const time = ytLink[3] ? `&start=${ytLink[3]}` : ``; + const startTime = parseYouTubeTimestamp(originalLink); + const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; switch (ytLink[1]) { diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts new file mode 100644 index 0000000000..7f585e866f --- /dev/null +++ b/packages/element/tests/embeddable.test.ts @@ -0,0 +1,153 @@ +import { getEmbedLink } from "../src/embeddable"; + +describe("YouTube timestamp parsing", () => { + it("should parse YouTube URLs with timestamp in seconds", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90", + expectedStart: 90, + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=120", + expectedStart: 120, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150", + expectedStart: 150, + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should parse YouTube URLs with timestamp in time format", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s", + expectedStart: 90, // 1*60 + 30 + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s", + expectedStart: 165, // 2*60 + 45 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s", + expectedStart: 3723, // 1*3600 + 2*60 + 3 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s", + expectedStart: 45, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m", + expectedStart: 300, // 5*60 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h", + expectedStart: 7200, // 2*3600 + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should handle YouTube URLs without timestamps", () => { + const testCases = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + ]; + + testCases.forEach((url) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).not.toContain("start="); + } + }); + }); + + it("should handle YouTube shorts URLs with timestamps", () => { + const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=30"); + } + // Shorts should have portrait aspect ratio + expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 }); + }); + + it("should handle playlist URLs with timestamps", () => { + const url = + "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=60"); + expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ"); + } + }); + + it("should handle malformed or edge case timestamps", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc", + expectedStart: 0, // Invalid timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=", + expectedStart: 0, // Empty timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0", + expectedStart: 0, // Zero timestamp should be handled + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + if (expectedStart === 0) { + expect(result.link).not.toContain("start="); + } else { + expect(result.link).toContain(`start=${expectedStart}`); + } + } + }); + }); + + it("should preserve other URL parameters", () => { + const url = + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=90"); + expect(result.link).toContain("enablejsapi=1"); + } + }); +});