Browse Source

feat: support timestamps for youtube video emebds (#9737)

David Luzar 1 month ago
parent
commit
cde46793f8
2 changed files with 185 additions and 2 deletions
  1. 32 2
      packages/element/src/embeddable.ts
  2. 153 0
      packages/element/tests/embeddable.test.ts

+ 32 - 2
packages/element/src/embeddable.ts

@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
 const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
 
 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 =
   /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
 
+const parseYouTubeTimestamp = (url: string): number => {
+  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]) {

+ 153 - 0
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");
+    }
+  });
+});