mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add YouTube video and playlist support in document collection with enhanced URL handling
This commit is contained in:
parent
e481415655
commit
c6fc4edbc2
10 changed files with 445 additions and 100 deletions
|
|
@ -42,6 +42,7 @@ from .search_spaces_routes import router as search_spaces_router
|
|||
from .slack_add_connector_route import router as slack_add_connector_router
|
||||
from .surfsense_docs_routes import router as surfsense_docs_router
|
||||
from .teams_add_connector_route import router as teams_add_connector_router
|
||||
from .youtube_routes import router as youtube_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -79,3 +80,4 @@ router.include_router(notifications_router) # Notifications with Electric SQL s
|
|||
router.include_router(composio_router) # Composio OAuth and toolkit management
|
||||
router.include_router(public_chat_router) # Public chat sharing and cloning
|
||||
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
|
||||
router.include_router(youtube_router) # YouTube playlist resolution
|
||||
|
|
|
|||
205
surfsense_backend/app/routes/youtube_routes.py
Normal file
205
surfsense_backend/app/routes/youtube_routes.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""YouTube utility routes (playlist resolution)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
from fake_useragent import UserAgent
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.db import User
|
||||
from app.users import current_active_user
|
||||
from app.utils.proxy_config import get_requests_proxies
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLAYLIST_ID_RE = re.compile(r"[?&]list=([\w-]+)")
|
||||
|
||||
_INNERTUBE_API_URL = "https://www.youtube.com/youtubei/v1/browse"
|
||||
_INNERTUBE_CLIENT = {
|
||||
"clientName": "WEB",
|
||||
"clientVersion": "2.20240313.05.00",
|
||||
"hl": "en",
|
||||
"gl": "US",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/youtube/playlist-videos")
|
||||
async def get_playlist_videos(
|
||||
url: str = Query(..., description="YouTube playlist URL"),
|
||||
_user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Resolve a YouTube playlist URL into individual video URLs."""
|
||||
match = _PLAYLIST_ID_RE.search(url)
|
||||
if not match:
|
||||
raise HTTPException(status_code=400, detail="Invalid YouTube playlist URL")
|
||||
|
||||
playlist_id = match.group(1)
|
||||
|
||||
try:
|
||||
video_ids = await _fetch_playlist_via_innertube(playlist_id)
|
||||
|
||||
if not video_ids:
|
||||
video_ids = await _fetch_playlist_via_html(playlist_id)
|
||||
|
||||
if not video_ids:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No videos found in the playlist. It may be private or empty.",
|
||||
)
|
||||
|
||||
video_urls = [
|
||||
f"https://www.youtube.com/watch?v={vid}" for vid in video_ids
|
||||
]
|
||||
return {"video_urls": video_urls, "count": len(video_urls)}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error resolving playlist %s: %s", url, e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resolve playlist: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
async def _fetch_playlist_via_innertube(playlist_id: str) -> list[str]:
|
||||
"""Fetch playlist videos using YouTube's innertube API (no cookies needed)."""
|
||||
payload = {
|
||||
"context": {"client": _INNERTUBE_CLIENT},
|
||||
"browseId": f"VL{playlist_id}",
|
||||
}
|
||||
proxies = get_requests_proxies()
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.post(
|
||||
_INNERTUBE_API_URL,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
proxy=proxies["http"] if proxies else None,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
"Innertube API returned %d for playlist %s",
|
||||
response.status,
|
||||
playlist_id,
|
||||
)
|
||||
return []
|
||||
data = await response.json()
|
||||
|
||||
return _extract_playlist_video_ids(data)
|
||||
except Exception as e:
|
||||
logger.warning("Innertube API failed for playlist %s: %s", playlist_id, e)
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_playlist_via_html(playlist_id: str) -> list[str]:
|
||||
"""Fallback: scrape playlist page HTML with consent cookies set."""
|
||||
ua = UserAgent()
|
||||
headers = {
|
||||
"User-Agent": ua.random,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
cookies = {
|
||||
"CONSENT": "PENDING+999",
|
||||
"SOCS": "CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjMwODI5LjA3X3AxGgJlbiADGgYIgOa_pgY",
|
||||
}
|
||||
proxies = get_requests_proxies()
|
||||
playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(cookies=cookies) as session,
|
||||
session.get(
|
||||
playlist_url,
|
||||
headers=headers,
|
||||
proxy=proxies["http"] if proxies else None,
|
||||
) as response,
|
||||
):
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
"HTML fallback returned %d for playlist %s",
|
||||
response.status,
|
||||
playlist_id,
|
||||
)
|
||||
return []
|
||||
html = await response.text()
|
||||
|
||||
yt_data = _extract_yt_initial_data(html)
|
||||
if not yt_data:
|
||||
logger.warning(
|
||||
"Could not find ytInitialData in HTML for playlist %s",
|
||||
playlist_id,
|
||||
)
|
||||
return []
|
||||
|
||||
return _extract_playlist_video_ids(yt_data)
|
||||
except Exception as e:
|
||||
logger.warning("HTML fallback failed for playlist %s: %s", playlist_id, e)
|
||||
return []
|
||||
|
||||
|
||||
def _extract_yt_initial_data(html: str) -> dict | None:
|
||||
"""Extract the ytInitialData JSON object embedded in a YouTube page."""
|
||||
patterns = [
|
||||
re.compile(r"var\s+ytInitialData\s*=\s*"),
|
||||
re.compile(r'window\["ytInitialData"\]\s*=\s*'),
|
||||
]
|
||||
|
||||
start = -1
|
||||
for pattern in patterns:
|
||||
match = pattern.search(html)
|
||||
if match:
|
||||
start = match.end()
|
||||
break
|
||||
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(html):
|
||||
ch = html[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
elif ch == '"':
|
||||
i += 1
|
||||
while i < len(html) and html[i] != '"':
|
||||
if html[i] == "\\":
|
||||
i += 1
|
||||
i += 1
|
||||
i += 1
|
||||
|
||||
try:
|
||||
return json.loads(html[start : i + 1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_playlist_video_ids(data: dict) -> list[str]:
|
||||
"""Walk the data tree and collect videoIds from playlistVideoRenderer nodes."""
|
||||
video_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _walk(obj: object) -> None:
|
||||
if isinstance(obj, dict):
|
||||
if "playlistVideoRenderer" in obj:
|
||||
vid = obj["playlistVideoRenderer"].get("videoId")
|
||||
if vid and vid not in seen:
|
||||
seen.add(vid)
|
||||
video_ids.append(vid)
|
||||
else:
|
||||
for v in obj.values():
|
||||
_walk(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk(item)
|
||||
|
||||
_walk(data)
|
||||
return video_ids
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Info } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,9 +12,29 @@ import { Label } from "@/components/ui/label";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
|
||||
const youtubeRegex =
|
||||
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
const YOUTUBE_VIDEO_URL_RE =
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?[^\s]*v=[\w-]{11}|youtu\.be\/[\w-]{11})[^\s]*/;
|
||||
|
||||
const YOUTUBE_PLAYLIST_URL_RE =
|
||||
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/[^\s]*[?&]list=[\w-]+[^\s]*/;
|
||||
|
||||
const YOUTUBE_ANY_URL_RE =
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch[^\s]*|playlist[^\s]*)|youtu\.be\/[\w-]+[^\s]*)/gi;
|
||||
|
||||
function isYoutubeVideoUrl(url: string): boolean {
|
||||
return YOUTUBE_VIDEO_URL_RE.test(url.trim());
|
||||
}
|
||||
|
||||
function isYoutubePlaylistUrl(url: string): boolean {
|
||||
return YOUTUBE_PLAYLIST_URL_RE.test(url.trim());
|
||||
}
|
||||
|
||||
function extractYoutubeUrls(text: string): string[] {
|
||||
const matches = text.match(YOUTUBE_ANY_URL_RE);
|
||||
return matches ? [...new Set(matches)] : [];
|
||||
}
|
||||
|
||||
interface YouTubeCrawlerViewProps {
|
||||
searchSpaceId: string;
|
||||
|
|
@ -26,27 +46,107 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFetchingPlaylist, setIsFetchingPlaylist] = useState(false);
|
||||
|
||||
// Use the createDocumentMutationAtom
|
||||
const [createDocumentMutation] = useAtom(createDocumentMutationAtom);
|
||||
const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation;
|
||||
|
||||
const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
const match = url.match(/(?:[?&]v=|youtu\.be\/)([\w-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const resolvePlaylist = useCallback(
|
||||
async (url: string) => {
|
||||
setIsFetchingPlaylist(true);
|
||||
toast(t("resolving_playlist_toast"), {
|
||||
description: t("resolving_playlist_toast_desc"),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = (await baseApiService.get(
|
||||
`/api/v1/youtube/playlist-videos?url=${encodeURIComponent(url)}`
|
||||
)) as { video_urls: string[]; count: number };
|
||||
|
||||
const resolvedUrls: string[] = response.video_urls ?? [];
|
||||
|
||||
setVideoTags((prev) => {
|
||||
const existingTexts = new Set(prev.map((tag) => tag.text));
|
||||
const newTags = resolvedUrls
|
||||
.filter((vUrl) => !existingTexts.has(vUrl))
|
||||
.map((vUrl) => ({
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
text: vUrl,
|
||||
}));
|
||||
return newTags.length > 0 ? [...prev, ...newTags] : prev;
|
||||
});
|
||||
|
||||
toast(t("playlist_resolved_toast"), {
|
||||
description: t("playlist_resolved_toast_desc", { count: resolvedUrls.length }),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("error_generic");
|
||||
toast(t("playlist_error_toast"), { description: message });
|
||||
} finally {
|
||||
setIsFetchingPlaylist(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
const urls = extractYoutubeUrls(text);
|
||||
if (urls.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const playlistUrls: string[] = [];
|
||||
const videoUrls: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
if (isYoutubePlaylistUrl(url)) {
|
||||
playlistUrls.push(url);
|
||||
} else if (isYoutubeVideoUrl(url)) {
|
||||
videoUrls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (videoUrls.length > 0) {
|
||||
setVideoTags((prev) => {
|
||||
const existingTexts = new Set(prev.map((tag) => tag.text));
|
||||
const newTags = videoUrls
|
||||
.filter((url) => !existingTexts.has(url.trim()))
|
||||
.map((url) => ({
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
text: url.trim(),
|
||||
}));
|
||||
if (newTags.length === 0) {
|
||||
toast(t("duplicate_url_toast"), {
|
||||
description: t("duplicate_url_toast_desc"),
|
||||
});
|
||||
}
|
||||
return newTags.length > 0 ? [...prev, ...newTags] : prev;
|
||||
});
|
||||
}
|
||||
|
||||
for (const url of playlistUrls) {
|
||||
await resolvePlaylist(url);
|
||||
}
|
||||
},
|
||||
[resolvePlaylist, t]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (videoTags.length === 0) {
|
||||
setError(t("error_no_video"));
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
|
||||
const invalidUrls = videoTags.filter((tag) => !isYoutubeVideoUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(t("error_invalid_urls", { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
|
||||
return;
|
||||
|
|
@ -60,7 +160,6 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
|
||||
const videoUrls = videoTags.map((tag) => tag.text);
|
||||
|
||||
// Use the mutation to create YouTube documents
|
||||
createYouTubeDocument(
|
||||
{
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
|
|
@ -86,7 +185,12 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
};
|
||||
|
||||
const handleAddTag = (text: string) => {
|
||||
if (!isValidYoutubeUrl(text)) {
|
||||
if (isYoutubePlaylistUrl(text)) {
|
||||
resolvePlaylist(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isYoutubeVideoUrl(text)) {
|
||||
toast(t("invalid_url_toast"), {
|
||||
description: t("invalid_url_toast_desc"),
|
||||
});
|
||||
|
|
@ -111,7 +215,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
|
@ -139,31 +243,48 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||
{t("label")}
|
||||
</Label>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder={t("placeholder")}
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border border-slate-400/20 rounded-lg bg-muted/50 shadow-sm shadow-black/5 transition-shadow focus-within:border-slate-400/40 focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input:
|
||||
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground/90 placeholder:text-muted-foreground bg-transparent",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
{/* Wrapper intercepts paste events for auto-detection of YouTube URLs */}
|
||||
<div onPasteCapture={handlePaste}>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder={t("placeholder")}
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border border-slate-400/20 rounded-lg bg-muted/50 shadow-sm shadow-black/5 transition-shadow focus-within:border-slate-400/40 focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input:
|
||||
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground/90 placeholder:text-muted-foreground bg-transparent",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("hint")}</p>
|
||||
</div>
|
||||
|
||||
{isFetchingPlaylist && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>{t("resolving_playlist")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
|
||||
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("chat_tip")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">{t("tips_title")}</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
|
|
@ -171,14 +292,15 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
<li>{t("tip_2")}</li>
|
||||
<li>{t("tip_3")}</li>
|
||||
<li>{t("tip_4")}</li>
|
||||
<li>{t("tip_5")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{videoTags.length > 0 && (
|
||||
{videoTags.length > 0 && videoTags.length <= 3 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="font-medium">{t("preview")}:</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{videoTags.map((tag, _index) => {
|
||||
{videoTags.map((tag) => {
|
||||
const videoId = extractVideoId(tag.text);
|
||||
return videoId ? (
|
||||
<div
|
||||
|
|
@ -203,18 +325,18 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
|
||||
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isFetchingPlaylist}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || videoTags.length === 0}
|
||||
disabled={isSubmitting || isFetchingPlaylist || videoTags.length === 0}
|
||||
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { useFeatureFlagVariantKey } from "@posthog/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
|
|
@ -47,8 +46,6 @@ function useIsDesktop(breakpoint = 1024) {
|
|||
export function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
|
||||
const isNotebookLMVariant = heroVariant === "superpowers";
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
return (
|
||||
|
|
@ -99,19 +96,11 @@ export function HeroSection() {
|
|||
)}
|
||||
|
||||
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
||||
{isNotebookLMVariant ? (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<Balancer>NotebookLM with Superpowers</Balancer>
|
||||
</div>
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<Balancer>NotebookLM for Teams</Balancer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<Balancer>NotebookLM for Teams</Balancer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
|
||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
|
||||
|
|
@ -187,20 +176,6 @@ function GetStartedButton() {
|
|||
);
|
||||
}
|
||||
|
||||
function ContactSalesButton() {
|
||||
return (
|
||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/contact"
|
||||
//target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
|
||||
>
|
||||
Contact Sales
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const BackgroundGrids = () => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ export const createDocumentRequest = document
|
|||
});
|
||||
|
||||
export const createDocumentResponse = z.object({
|
||||
message: z.literal("Documents created successfully"),
|
||||
message: z.literal("Documents queued for background processing"),
|
||||
status: z.literal("queued"),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -433,15 +433,17 @@
|
|||
},
|
||||
"add_youtube": {
|
||||
"title": "Add YouTube Videos",
|
||||
"subtitle": "Enter YouTube video URLs to add to your document collection",
|
||||
"label": "Enter YouTube Video URLs",
|
||||
"placeholder": "Enter a YouTube URL and press Enter",
|
||||
"hint": "Add multiple YouTube URLs by pressing Enter after each one",
|
||||
"subtitle": "Add YouTube videos or playlists to your knowledge base",
|
||||
"label": "YouTube Video or Playlist URLs",
|
||||
"placeholder": "Paste YouTube video or playlist URLs here",
|
||||
"hint": "Just paste YouTube URLs and they're added automatically. You can also type a URL and press Enter.",
|
||||
"tips_title": "Tips for adding YouTube videos:",
|
||||
"tip_1": "Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)",
|
||||
"tip_2": "Make sure videos are publicly accessible",
|
||||
"tip_3": "Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID",
|
||||
"tip_4": "Processing may take some time depending on video length",
|
||||
"tip_5": "Paste a playlist URL (youtube.com/playlist?list=...) to add all its videos at once",
|
||||
"chat_tip": "Want a quick summary without saving to your knowledge base? Just paste YouTube links directly into the chat instead.",
|
||||
"preview": "Preview",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add",
|
||||
|
|
@ -456,9 +458,15 @@
|
|||
"error_toast_desc": "Error processing YouTube videos",
|
||||
"error_generic": "An error occurred while processing YouTube videos",
|
||||
"invalid_url_toast": "Invalid YouTube URL",
|
||||
"invalid_url_toast_desc": "Please enter a valid YouTube video URL",
|
||||
"invalid_url_toast_desc": "Please enter a valid YouTube video or playlist URL",
|
||||
"duplicate_url_toast": "Duplicate URL",
|
||||
"duplicate_url_toast_desc": "This YouTube video has already been added"
|
||||
"duplicate_url_toast_desc": "This YouTube video has already been added",
|
||||
"resolving_playlist": "Resolving playlist videos...",
|
||||
"resolving_playlist_toast": "Resolving Playlist",
|
||||
"resolving_playlist_toast_desc": "Fetching video list from the playlist...",
|
||||
"playlist_resolved_toast": "Playlist Resolved",
|
||||
"playlist_resolved_toast_desc": "Added {count} videos from the playlist",
|
||||
"playlist_error_toast": "Playlist Error"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
|
|
|||
|
|
@ -433,15 +433,17 @@
|
|||
},
|
||||
"add_youtube": {
|
||||
"title": "Agregar videos de YouTube",
|
||||
"subtitle": "Ingresa URLs de videos de YouTube para agregar a tu colección de documentos",
|
||||
"label": "Ingresa URLs de videos de YouTube",
|
||||
"placeholder": "Ingresa una URL de YouTube y presiona Enter",
|
||||
"hint": "Agrega múltiples URLs de YouTube presionando Enter después de cada una",
|
||||
"subtitle": "Agrega videos o listas de reproducción de YouTube a tu base de conocimiento",
|
||||
"label": "URLs de videos o listas de reproducción de YouTube",
|
||||
"placeholder": "Pega URLs de videos o listas de reproducción de YouTube aquí",
|
||||
"hint": "Solo pega URLs de YouTube y se agregan automáticamente. También puedes escribir una URL y presionar Enter.",
|
||||
"tips_title": "Consejos para agregar videos de YouTube:",
|
||||
"tip_1": "Usa URLs estándar de YouTube (youtube.com/watch?v= o youtu.be/)",
|
||||
"tip_2": "Asegúrate de que los videos sean accesibles públicamente",
|
||||
"tip_3": "Formatos soportados: youtube.com/watch?v=VIDEO_ID o youtu.be/VIDEO_ID",
|
||||
"tip_4": "El procesamiento puede tomar un tiempo dependiendo de la duración del video",
|
||||
"tip_5": "Pega una URL de lista de reproducción (youtube.com/playlist?list=...) para agregar todos sus videos a la vez",
|
||||
"chat_tip": "¿Quieres un resumen rápido sin guardarlo en tu base de conocimiento? Solo pega los enlaces de YouTube directamente en el chat.",
|
||||
"preview": "Vista previa",
|
||||
"cancel": "Cancelar",
|
||||
"submit": "Agregar",
|
||||
|
|
@ -456,9 +458,15 @@
|
|||
"error_toast_desc": "Error al procesar videos de YouTube",
|
||||
"error_generic": "Ocurrió un error al procesar videos de YouTube",
|
||||
"invalid_url_toast": "URL de YouTube inválida",
|
||||
"invalid_url_toast_desc": "Por favor ingresa una URL válida de video de YouTube",
|
||||
"invalid_url_toast_desc": "Por favor ingresa una URL válida de video o lista de reproducción de YouTube",
|
||||
"duplicate_url_toast": "URL duplicada",
|
||||
"duplicate_url_toast_desc": "Este video de YouTube ya ha sido agregado"
|
||||
"duplicate_url_toast_desc": "Este video de YouTube ya ha sido agregado",
|
||||
"resolving_playlist": "Cargando videos de la lista de reproducción...",
|
||||
"resolving_playlist_toast": "Cargando lista de reproducción",
|
||||
"resolving_playlist_toast_desc": "Obteniendo la lista de videos de la lista de reproducción...",
|
||||
"playlist_resolved_toast": "Lista de reproducción cargada",
|
||||
"playlist_resolved_toast_desc": "Se agregaron {count} videos de la lista de reproducción",
|
||||
"playlist_error_toast": "Error de lista de reproducción"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
|
|
|
|||
|
|
@ -433,15 +433,17 @@
|
|||
},
|
||||
"add_youtube": {
|
||||
"title": "YouTube वीडियो जोड़ें",
|
||||
"subtitle": "अपने दस्तावेज़ संग्रह में जोड़ने के लिए YouTube वीडियो URL दर्ज करें",
|
||||
"label": "YouTube वीडियो URL दर्ज करें",
|
||||
"placeholder": "YouTube URL दर्ज करें और Enter दबाएं",
|
||||
"hint": "प्रत्येक के बाद Enter दबाकर कई YouTube URL जोड़ें",
|
||||
"subtitle": "अपने ज्ञान आधार में YouTube वीडियो या प्लेलिस्ट जोड़ें",
|
||||
"label": "YouTube वीडियो या प्लेलिस्ट URL",
|
||||
"placeholder": "YouTube वीडियो या प्लेलिस्ट URL यहां पेस्ट करें",
|
||||
"hint": "बस YouTube URL पेस्ट करें और वे स्वचालित रूप से जुड़ जाएंगे। आप URL टाइप करके Enter भी दबा सकते हैं।",
|
||||
"tips_title": "YouTube वीडियो जोड़ने के लिए सुझाव:",
|
||||
"tip_1": "मानक YouTube URL का उपयोग करें (youtube.com/watch?v= या youtu.be/)",
|
||||
"tip_2": "सुनिश्चित करें कि वीडियो सार्वजनिक रूप से सुलभ हैं",
|
||||
"tip_3": "समर्थित प्रारूप: youtube.com/watch?v=VIDEO_ID या youtu.be/VIDEO_ID",
|
||||
"tip_4": "वीडियो की अवधि के आधार पर प्रोसेसिंग में कुछ समय लग सकता है",
|
||||
"tip_5": "सभी वीडियो एक साथ जोड़ने के लिए प्लेलिस्ट URL (youtube.com/playlist?list=...) पेस्ट करें",
|
||||
"chat_tip": "ज्ञान आधार में सहेजे बिना त्वरित सारांश चाहिए? इसके बजाय YouTube लिंक सीधे चैट में पेस्ट करें।",
|
||||
"preview": "पूर्वावलोकन",
|
||||
"cancel": "रद्द करें",
|
||||
"submit": "जोड़ें",
|
||||
|
|
@ -456,9 +458,15 @@
|
|||
"error_toast_desc": "YouTube वीडियो प्रोसेस करने में त्रुटि",
|
||||
"error_generic": "YouTube वीडियो प्रोसेस करते समय त्रुटि हुई",
|
||||
"invalid_url_toast": "अमान्य YouTube URL",
|
||||
"invalid_url_toast_desc": "कृपया एक मान्य YouTube वीडियो URL दर्ज करें",
|
||||
"invalid_url_toast_desc": "कृपया एक मान्य YouTube वीडियो या प्लेलिस्ट URL दर्ज करें",
|
||||
"duplicate_url_toast": "डुप्लिकेट URL",
|
||||
"duplicate_url_toast_desc": "यह YouTube वीडियो पहले से जोड़ा जा चुका है"
|
||||
"duplicate_url_toast_desc": "यह YouTube वीडियो पहले से जोड़ा जा चुका है",
|
||||
"resolving_playlist": "प्लेलिस्ट वीडियो लोड हो रहे हैं...",
|
||||
"resolving_playlist_toast": "प्लेलिस्ट लोड हो रही है",
|
||||
"resolving_playlist_toast_desc": "प्लेलिस्ट से वीडियो सूची प्राप्त हो रही है...",
|
||||
"playlist_resolved_toast": "प्लेलिस्ट लोड हो गई",
|
||||
"playlist_resolved_toast_desc": "प्लेलिस्ट से {count} वीडियो जोड़े गए",
|
||||
"playlist_error_toast": "प्लेलिस्ट त्रुटि"
|
||||
},
|
||||
"settings": {
|
||||
"title": "सेटिंग्स",
|
||||
|
|
|
|||
|
|
@ -433,15 +433,17 @@
|
|||
},
|
||||
"add_youtube": {
|
||||
"title": "Adicionar vídeos do YouTube",
|
||||
"subtitle": "Insira URLs de vídeos do YouTube para adicionar à sua coleção de documentos",
|
||||
"label": "Insira URLs de vídeos do YouTube",
|
||||
"placeholder": "Insira uma URL do YouTube e pressione Enter",
|
||||
"hint": "Adicione múltiplas URLs do YouTube pressionando Enter após cada uma",
|
||||
"subtitle": "Adicione vídeos ou playlists do YouTube à sua base de conhecimento",
|
||||
"label": "URLs de vídeos ou playlists do YouTube",
|
||||
"placeholder": "Cole URLs de vídeos ou playlists do YouTube aqui",
|
||||
"hint": "Basta colar URLs do YouTube e elas são adicionadas automaticamente. Você também pode digitar uma URL e pressionar Enter.",
|
||||
"tips_title": "Dicas para adicionar vídeos do YouTube:",
|
||||
"tip_1": "Use URLs padrão do YouTube (youtube.com/watch?v= ou youtu.be/)",
|
||||
"tip_2": "Certifique-se de que os vídeos sejam acessíveis publicamente",
|
||||
"tip_3": "Formatos suportados: youtube.com/watch?v=VIDEO_ID ou youtu.be/VIDEO_ID",
|
||||
"tip_4": "O processamento pode levar algum tempo dependendo da duração do vídeo",
|
||||
"tip_5": "Cole uma URL de playlist (youtube.com/playlist?list=...) para adicionar todos os vídeos de uma vez",
|
||||
"chat_tip": "Quer um resumo rápido sem salvar na sua base de conhecimento? Basta colar os links do YouTube diretamente no chat.",
|
||||
"preview": "Visualizar",
|
||||
"cancel": "Cancelar",
|
||||
"submit": "Adicionar",
|
||||
|
|
@ -456,9 +458,15 @@
|
|||
"error_toast_desc": "Erro ao processar vídeos do YouTube",
|
||||
"error_generic": "Ocorreu um erro ao processar vídeos do YouTube",
|
||||
"invalid_url_toast": "URL do YouTube inválida",
|
||||
"invalid_url_toast_desc": "Por favor, insira uma URL válida de vídeo do YouTube",
|
||||
"invalid_url_toast_desc": "Por favor, insira uma URL válida de vídeo ou playlist do YouTube",
|
||||
"duplicate_url_toast": "URL duplicada",
|
||||
"duplicate_url_toast_desc": "Este vídeo do YouTube já foi adicionado"
|
||||
"duplicate_url_toast_desc": "Este vídeo do YouTube já foi adicionado",
|
||||
"resolving_playlist": "Carregando vídeos da playlist...",
|
||||
"resolving_playlist_toast": "Carregando playlist",
|
||||
"resolving_playlist_toast_desc": "Obtendo a lista de vídeos da playlist...",
|
||||
"playlist_resolved_toast": "Playlist carregada",
|
||||
"playlist_resolved_toast_desc": "Adicionados {count} vídeos da playlist",
|
||||
"playlist_error_toast": "Erro na playlist"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
|
|
|
|||
|
|
@ -417,18 +417,20 @@
|
|||
},
|
||||
"add_youtube": {
|
||||
"title": "添加 YouTube 视频",
|
||||
"subtitle": "输入 YouTube 视频 URL 以添加到您的文档集合",
|
||||
"label": "输入 YouTube 视频 URL",
|
||||
"placeholder": "输入 YouTube URL 并按 Enter",
|
||||
"hint": "按 Enter 键添加多个 YouTube URL",
|
||||
"subtitle": "将 YouTube 视频或播放列表添加到您的知识库",
|
||||
"label": "YouTube 视频或播放列表 URL",
|
||||
"placeholder": "在此粘贴 YouTube 视频或播放列表 URL",
|
||||
"hint": "直接粘贴 YouTube URL 即可自动添加。您也可以输入 URL 后按 Enter。",
|
||||
"tips_title": "添加 YouTube 视频的提示:",
|
||||
"tip_1": "使用标准 YouTube URL(youtube.com/watch?v= 或 youtu.be/)",
|
||||
"tip_2": "确保视频可公开访问",
|
||||
"tip_3": "支持的格式:youtube.com/watch?v=VIDEO_ID 或 youtu.be/VIDEO_ID",
|
||||
"tip_4": "处理时间可能会根据视频长度而有所不同",
|
||||
"tip_5": "粘贴播放列表 URL(youtube.com/playlist?list=...)可一次添加所有视频",
|
||||
"chat_tip": "想要快速摘要而不保存到知识库?直接在聊天中粘贴 YouTube 链接即可。",
|
||||
"preview": "预览",
|
||||
"cancel": "取消",
|
||||
"submit": "提交 YouTube 视频",
|
||||
"submit": "添加",
|
||||
"processing": "处理中...",
|
||||
"error_no_video": "请至少添加一个 YouTube 视频 URL",
|
||||
"error_invalid_urls": "检测到无效的 YouTube URL:{urls}",
|
||||
|
|
@ -440,9 +442,15 @@
|
|||
"error_toast_desc": "处理 YouTube 视频时出错",
|
||||
"error_generic": "处理 YouTube 视频时发生错误",
|
||||
"invalid_url_toast": "无效的 YouTube URL",
|
||||
"invalid_url_toast_desc": "请输入有效的 YouTube 视频 URL",
|
||||
"invalid_url_toast_desc": "请输入有效的 YouTube 视频或播放列表 URL",
|
||||
"duplicate_url_toast": "重复的 URL",
|
||||
"duplicate_url_toast_desc": "此 YouTube 视频已添加"
|
||||
"duplicate_url_toast_desc": "此 YouTube 视频已添加",
|
||||
"resolving_playlist": "正在加载播放列表视频...",
|
||||
"resolving_playlist_toast": "正在加载播放列表",
|
||||
"resolving_playlist_toast_desc": "正在从播放列表获取视频列表...",
|
||||
"playlist_resolved_toast": "播放列表已加载",
|
||||
"playlist_resolved_toast_desc": "已从播放列表添加 {count} 个视频",
|
||||
"playlist_error_toast": "播放列表错误"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue