From eb8cfd296c6a80cdbd6f0d930f59d3765876ec0c Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 21 Mar 2026 23:29:23 -0700 Subject: [PATCH] feat: add public routes for video presentations and audio streaming --- .../app/routes/public_chat_routes.py | 114 ++++++++++++++++++ .../app/services/public_chat_service.py | 64 ++++++++++ .../generate-video-presentation.tsx | 63 +++++++--- 3 files changed, 222 insertions(+), 19 deletions(-) diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index 9afcbc188..e206bfd11 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -21,6 +21,7 @@ from app.services.public_chat_service import ( get_public_chat, get_snapshot_podcast, get_snapshot_report, + get_snapshot_video_presentation, ) from app.users import current_active_user @@ -117,6 +118,119 @@ async def stream_public_podcast( ) +@router.get("/{share_token}/video-presentations/{video_presentation_id}") +async def get_public_video_presentation( + share_token: str, + video_presentation_id: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Get video presentation details from a public chat snapshot. + + No authentication required - the share_token provides access. + Returns slide data (with public audio URLs) and scene codes. + """ + vp_info = await get_snapshot_video_presentation( + session, share_token, video_presentation_id + ) + + if not vp_info: + raise HTTPException(status_code=404, detail="Video presentation not found") + + slides = vp_info.get("slides") or [] + public_slides = _replace_audio_paths_with_public_urls( + share_token, video_presentation_id, slides + ) + + return { + "id": vp_info.get("original_id"), + "title": vp_info.get("title"), + "status": "ready", + "slides": public_slides, + "scene_codes": vp_info.get("scene_codes"), + "slide_count": len(slides) if slides else None, + } + + +@router.get( + "/{share_token}/video-presentations/{video_presentation_id}/slides/{slide_number}/audio" +) +async def stream_public_slide_audio( + share_token: str, + video_presentation_id: int, + slide_number: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Stream a slide's audio from a public chat snapshot. + + No authentication required - the share_token provides access. + """ + from pathlib import Path + + vp_info = await get_snapshot_video_presentation( + session, share_token, video_presentation_id + ) + + if not vp_info: + raise HTTPException(status_code=404, detail="Video presentation not found") + + slides = vp_info.get("slides") or [] + slide_data = None + for s in slides: + if s.get("slide_number") == slide_number: + slide_data = s + break + + if not slide_data: + raise HTTPException(status_code=404, detail=f"Slide {slide_number} not found") + + file_path = slide_data.get("audio_file") + if not file_path or not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Slide audio file not found") + + ext = Path(file_path).suffix.lower() + media_type = "audio/wav" if ext == ".wav" else "audio/mpeg" + + def iterfile(): + with open(file_path, mode="rb") as file_like: + yield from file_like + + return StreamingResponse( + iterfile(), + media_type=media_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Disposition": f"inline; filename={Path(file_path).name}", + }, + ) + + +def _replace_audio_paths_with_public_urls( + share_token: str, + video_presentation_id: int, + slides: list[dict], +) -> list[dict]: + """Replace server-local audio_file paths with public streaming API URLs.""" + result = [] + for slide in slides: + slide_copy = dict(slide) + slide_number = slide_copy.get("slide_number") + audio_file = slide_copy.pop("audio_file", None) + + if audio_file and slide_number is not None: + slide_copy["audio_url"] = ( + f"/api/v1/public/{share_token}" + f"/video-presentations/{video_presentation_id}" + f"/slides/{slide_number}/audio" + ) + else: + slide_copy["audio_url"] = None + + result.append(slide_copy) + return result + + @router.get("/{share_token}/reports/{report_id}/content") async def get_public_report_content( share_token: str, diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index a74d45991..a75eb73f8 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -32,6 +32,8 @@ from app.db import ( Report, SearchSpaceMembership, User, + VideoPresentation, + VideoPresentationStatus, ) from app.utils.rbac import check_permission @@ -40,6 +42,7 @@ UI_TOOLS = { "link_preview", "generate_podcast", "generate_report", + "generate_video_presentation", "scrape_webpage", "multi_link_preview", } @@ -199,6 +202,8 @@ async def create_snapshot( podcast_ids_seen: set[int] = set() reports_data = [] report_ids_seen: set[int] = set() + video_presentations_data = [] + video_presentation_ids_seen: set[int] = set() for msg in sorted(thread.messages, key=lambda m: m.created_at): author = await get_author_display(session, msg.author_id, user_cache) @@ -225,6 +230,18 @@ async def create_snapshot( # Update status to "ready" so frontend renders PodcastPlayer part["result"] = {**result_data, "status": "ready"} + elif tool_name == "generate_video_presentation": + result_data = part.get("result", {}) + vp_id = result_data.get("video_presentation_id") + if vp_id and vp_id not in video_presentation_ids_seen: + vp_info = await _get_video_presentation_for_snapshot( + session, vp_id + ) + if vp_info: + video_presentations_data.append(vp_info) + video_presentation_ids_seen.add(vp_id) + part["result"] = {**result_data, "status": "ready"} + elif tool_name == "generate_report": result_data = part.get("result", {}) report_id = result_data.get("report_id") @@ -283,6 +300,7 @@ async def create_snapshot( "messages": messages_data, "podcasts": podcasts_data, "reports": reports_data, + "video_presentations": video_presentations_data, } # Create new snapshot @@ -326,6 +344,27 @@ async def _get_podcast_for_snapshot( } +async def _get_video_presentation_for_snapshot( + session: AsyncSession, + video_presentation_id: int, +) -> dict | None: + """Get video presentation info for embedding in snapshot_data.""" + result = await session.execute( + select(VideoPresentation).filter(VideoPresentation.id == video_presentation_id) + ) + vp = result.scalars().first() + + if not vp or vp.status != VideoPresentationStatus.READY: + return None + + return { + "original_id": vp.id, + "title": vp.title, + "slides": vp.slides, + "scene_codes": vp.scene_codes, + } + + async def _get_report_for_snapshot( session: AsyncSession, report_id: int, @@ -769,6 +808,31 @@ async def get_snapshot_podcast( return None +async def get_snapshot_video_presentation( + session: AsyncSession, + share_token: str, + video_presentation_id: int, +) -> dict | None: + """ + Get video presentation info from a snapshot by original video presentation ID. + + Used for rendering video presentation in public view. + Looks up the presentation by its original_id in the snapshot's video_presentations array. + """ + snapshot = await get_snapshot_by_token(session, share_token) + + if not snapshot: + return None + + video_presentations = snapshot.snapshot_data.get("video_presentations", []) + + for vp in video_presentations: + if vp.get("original_id") == video_presentation_id: + return vp + + return None + + async def get_snapshot_report( session: AsyncSession, share_token: str, diff --git a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx index 71d04b0f6..34e443f5d 100644 --- a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx @@ -10,6 +10,7 @@ import { Presentation, X, } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; import { z } from "zod"; import { Spinner } from "@/components/ui/spinner"; import { baseApiService } from "@/lib/apis/base-api.service"; @@ -157,9 +158,11 @@ function CompilationLoadingState({ title }: { title: string }) { function VideoPresentationPlayer({ presentationId, title, + shareToken, }: { presentationId: number; title: string; + shareToken?: string | null; }) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -181,9 +184,11 @@ function VideoPresentationPlayer({ setIsLoading(true); setError(null); try { - const raw = await baseApiService.get( - `/api/v1/video-presentations/${presentationId}`, - ); + const apiPath = shareToken + ? `/api/v1/public/${shareToken}/video-presentations/${presentationId}` + : `/api/v1/video-presentations/${presentationId}`; + + const raw = await baseApiService.get(apiPath); const data = parseStatusResponse(raw); if (!data) throw new Error("Invalid response"); if (data.status !== "ready") throw new Error(`Unexpected status: ${data.status}`); @@ -224,23 +229,30 @@ function VideoPresentationPlayer({ throw new Error("No slides compiled successfully"); } - // Pre-fetch all audio files with auth headers and convert to blob URLs. - // Remotion's