diff --git a/apps/web/components/document-icon.tsx b/apps/web/components/document-icon.tsx
index 00e363414..8088b44eb 100644
--- a/apps/web/components/document-icon.tsx
+++ b/apps/web/components/document-icon.tsx
@@ -17,6 +17,7 @@ import {
} from "@ui/assets/icons"
import { Globe, FileText, FileCode, Image } from "lucide-react"
import { cn } from "@lib/utils"
+import { isYouTubeUrl } from "@/lib/url-helpers"
function MCPIcon({ className }: { className?: string }) {
return (
@@ -206,7 +207,7 @@ export function DocumentIcon({
return
}
- if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
+ if (isYouTubeUrl(url)) {
return
}
diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx
index 8e1c9a11e..2232d159c 100644
--- a/apps/web/components/document-modal/content/index.tsx
+++ b/apps/web/components/document-modal/content/index.tsx
@@ -3,7 +3,7 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import dynamic from "next/dynamic"
-import { isTwitterUrl } from "@/lib/url-helpers"
+import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers"
import { ImagePreview } from "./image-preview"
import { TweetContent } from "./tweet"
import { NotionDoc } from "./notion-doc"
@@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
if (document.type === "google_doc") return "google_doc"
if (document.type === "google_sheet") return "google_sheet"
if (document.type === "google_slide") return "google_slide"
- if (document.url?.includes("youtube.com")) return "youtube"
+ if (isYouTubeUrl(document.url)) return "youtube"
if (document.type === "webpage") return "webpage"
return null
diff --git a/apps/web/components/timeline-view.tsx b/apps/web/components/timeline-view.tsx
index 18437d774..c0975c892 100644
--- a/apps/web/components/timeline-view.tsx
+++ b/apps/web/components/timeline-view.tsx
@@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
+import { isYouTubeUrl } from "@/lib/url-helpers"
import { SyncLogoIcon } from "@ui/assets/icons"
import { DocumentIcon } from "@/components/document-icon"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
@@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
if (doc.source === "mcp")
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
- if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
+ if (isYouTubeUrl(doc.url))
return {
label: "YouTube Videos",
singularLabel: "YouTube Video",
diff --git a/apps/web/components/utils.ts b/apps/web/components/utils.ts
index cd0cbc39a..3da320183 100644
--- a/apps/web/components/utils.ts
+++ b/apps/web/components/utils.ts
@@ -1,15 +1,9 @@
"use client"
import { useQuery } from "@tanstack/react-query"
+import { isYouTubeUrl } from "@/lib/url-helpers"
-export function isYouTubeUrl(url: string | undefined | null): boolean {
- if (!url) return false
- return (
- url.includes("youtube.com") ||
- url.includes("youtu.be") ||
- url.includes("m.youtube.com")
- )
-}
+export { isYouTubeUrl }
export function extractYouTubeVideoId(
url: string | undefined | null,
diff --git a/apps/web/lib/url-helpers.test.ts b/apps/web/lib/url-helpers.test.ts
new file mode 100644
index 000000000..bc34c9c55
--- /dev/null
+++ b/apps/web/lib/url-helpers.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "bun:test"
+import { isYouTubeUrl } from "./url-helpers"
+
+describe("isYouTubeUrl", () => {
+ it("matches canonical youtube.com watch URLs", () => {
+ expect(isYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
+ true,
+ )
+ })
+
+ it("matches real youtube subdomains", () => {
+ expect(isYouTubeUrl("https://m.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
+ true,
+ )
+ })
+
+ it("matches youtu.be short links", () => {
+ expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true)
+ })
+
+ it("matches embed and shorts paths", () => {
+ expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(
+ true,
+ )
+ })
+
+ it("is case-insensitive for scheme and host", () => {
+ expect(isYouTubeUrl("HTTPS://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("https://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ")).toBe(
+ true,
+ )
+ })
+
+ it("matches scheme-less URLs", () => {
+ expect(isYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+ expect(isYouTubeUrl("www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+ })
+
+ it("rejects lookalike domains", () => {
+ expect(isYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBe(
+ false,
+ )
+ expect(isYouTubeUrl("https://myyoutu.be/dQw4w9WgXcQ")).toBe(false)
+ })
+
+ it("rejects hosts that merely start with youtube.com", () => {
+ expect(
+ isYouTubeUrl("https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ"),
+ ).toBe(false)
+ expect(isYouTubeUrl("https://youtu.be.evil.example/dQw4w9WgXcQ")).toBe(
+ false,
+ )
+ })
+
+ it("rejects URLs that only contain youtube.com in the path", () => {
+ expect(
+ isYouTubeUrl("https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ"),
+ ).toBe(false)
+ expect(isYouTubeUrl("https://evil.example/redirect?to=youtu.be/x")).toBe(
+ false,
+ )
+ })
+
+ it("rejects empty and nullish input", () => {
+ expect(isYouTubeUrl("")).toBe(false)
+ expect(isYouTubeUrl(null)).toBe(false)
+ expect(isYouTubeUrl(undefined)).toBe(false)
+ })
+})
diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts
index ea5bdec21..cf8f1cf88 100644
--- a/apps/web/lib/url-helpers.ts
+++ b/apps/web/lib/url-helpers.ts
@@ -157,6 +157,22 @@ export const isTwitterUrl = (url: string): boolean => {
)
}
+/**
+ * Checks if a URL is a YouTube URL by matching the hostname against
+ * youtube.com / youtu.be (and their subdomains, e.g. www / m).
+ * Lookalike hosts (`notyoutube.com`, `youtube.com.evil.example`) and URLs
+ * that only contain "youtube.com" in the path do not match.
+ */
+export const isYouTubeUrl = (url: string | undefined | null): boolean => {
+ if (!url) return false
+ const parsed = parseWebUrl(url)
+ if (!parsed) return false
+ return (
+ hostnameMatches(parsed.hostname, "youtube.com") ||
+ hostnameMatches(parsed.hostname, "youtu.be")
+ )
+}
+
/**
* Checks if a URL is a LinkedIn profile URL (not a company page).
*/