diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml new file mode 100644 index 0000000000..e0bc01f0d2 --- /dev/null +++ b/.github/workflows/tinybird-cd.yml @@ -0,0 +1,65 @@ +name: Tinybird CD + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'tinybird/**' + +concurrency: ${{ github.workflow }}-${{ github.event.ref }} + +permissions: + contents: read + +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + # URL must point at a pinned Tinybird CLI installer or binary published by Tinybird + # (e.g., a GitHub Release asset). The checksum must be computed from that asset and + # stored in secrets so we can detect tampering before executing the installer. + TINYBIRD_INSTALLER_URL: ${{ vars.TINYBIRD_INSTALLER_URL }} + TINYBIRD_INSTALLER_SHA256: ${{ secrets.TINYBIRD_INSTALLER_SHA256 }} + +jobs: + cd: + name: Deploy + runs-on: ubuntu-latest + defaults: + run: + working-directory: 'tinybird' + steps: + - uses: actions/checkout@v3 + + - name: Install Tinybird CLI + env: + INSTALLER_URL: ${{ env.TINYBIRD_INSTALLER_URL }} + INSTALLER_SHA256: ${{ env.TINYBIRD_INSTALLER_SHA256 }} + run: | + set -euo pipefail + + if [[ -z "${INSTALLER_URL:-}" ]]; then + echo "TINYBIRD_INSTALLER_URL repo variable is not set. Point it to the" >&2 + echo "pinned Tinybird CLI release asset (e.g., https://github.com/tinybirdco/... )." >&2 + exit 1 + fi + + if [[ -z "${INSTALLER_SHA256:-}" ]]; then + echo "TINYBIRD_INSTALLER_SHA256 secret is not defined." >&2 + echo "Store the SHA-256 computed from the trusted release asset so tampering" >&2 + echo "causes the workflow to fail before execution." >&2 + exit 1 + fi + + installer_path="/tmp/tinybird-cli-installer" + curl -fSL "$INSTALLER_URL" -o "$installer_path" + + echo "${INSTALLER_SHA256} ${installer_path}" | sha256sum -c - + + chmod +x "$installer_path" + "$installer_path" + + - name: Deploy project + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml new file mode 100644 index 0000000000..2739cc27f2 --- /dev/null +++ b/.github/workflows/tinybird-ci.yml @@ -0,0 +1,99 @@ +name: Tinybird CI + +on: + workflow_dispatch: + pull_request: + branches: + - main + - master + paths: + - 'tinybird/**' + types: [opened, reopened, labeled, unlabeled, synchronize] + +concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} + +permissions: + contents: read + +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + TINYBIRD_INSTALLER_URL: ${{ vars.TINYBIRD_INSTALLER_URL }} + TINYBIRD_INSTALLER_SHA256: ${{ secrets.TINYBIRD_INSTALLER_SHA256 }} + +jobs: + ci: + name: Validate + runs-on: ubuntu-latest + defaults: + run: + working-directory: 'tinybird' + services: + tinybird: + image: tinybirdco/tinybird-local:latest + ports: + - 7181:7181 + steps: + - uses: actions/checkout@v3 + + - name: Install Tinybird CLI + env: + INSTALLER_URL: ${{ env.TINYBIRD_INSTALLER_URL }} + INSTALLER_SHA256: ${{ env.TINYBIRD_INSTALLER_SHA256 }} + run: | + set -euo pipefail + + if [[ -z "${INSTALLER_URL:-}" ]]; then + echo "TINYBIRD_INSTALLER_URL repo variable is not set. Point it to the" >&2 + echo "pinned Tinybird CLI release asset (e.g., https://github.com/tinybirdco/... )." >&2 + exit 1 + fi + + if [[ -z "${INSTALLER_SHA256:-}" ]]; then + echo "TINYBIRD_INSTALLER_SHA256 secret is not defined." >&2 + echo "Store the SHA-256 computed from the trusted release asset so tampering" >&2 + echo "causes the workflow to fail before execution." >&2 + exit 1 + fi + + installer_path="/tmp/tinybird-cli-installer" + curl -fSL "$INSTALLER_URL" -o "$installer_path" + + echo "${INSTALLER_SHA256} ${installer_path}" | sha256sum -c - + + chmod +x "$installer_path" + "$installer_path" + + - name: Build project + run: tb build + + - name: Wait for Tinybird Local + run: | + set -euo pipefail + + endpoints=("http://localhost:7181/_health" "http://localhost:7181/health") + max_attempts=20 + base_delay=3 + + for ((attempt = 1; attempt <= max_attempts; attempt++)); do + for endpoint in "${endpoints[@]}"; do + status=$(curl -fsS -o /dev/null -w "%{http_code}" "$endpoint" || true) + if [[ "$status" == "200" ]]; then + echo "Tinybird local is healthy via $endpoint" + exit 0 + fi + done + + sleep_seconds=$((base_delay * attempt)) + echo "Tinybird local not ready (attempt ${attempt}/${max_attempts}). Retrying in ${sleep_seconds}s..." + sleep "$sleep_seconds" + done + + echo "Tinybird local failed to become healthy after waiting ~$((base_delay * max_attempts * (max_attempts + 1) / 2))s" >&2 + exit 1 + + - name: Test project + run: tb test run + + - name: Deployment check + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check diff --git a/.gitignore b/.gitignore index b88fcb3f4d..f788d7a461 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ tauri.windows.conf.json # Cursor .cursor .env*.local + +# Tinybird +.tinyb diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts deleted file mode 100644 index 7792c16d46..0000000000 --- a/apps/web/actions/videos/get-analytics.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import { dub } from "@cap/utils"; - -export async function getVideoAnalytics(videoId: string) { - if (!videoId) { - throw new Error("Video ID is required"); - } - - try { - const response = await dub().analytics.retrieve({ - domain: "cap.link", - key: videoId, - }); - const { clicks } = response as { clicks: number }; - - if (typeof clicks !== "number" || clicks === null) { - return { count: 0 }; - } - - return { count: clicks }; - } catch (error: any) { - if (error.code === "not_found") { - return { count: 0 }; - } - return { count: 0 }; - } -} diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 2974ec8e72..f77a1247c3 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; +import { usePublicEnv } from "@/utils/public-env"; import { useDashboardContext } from "../Contexts"; import { NewFolderDialog, @@ -55,18 +56,17 @@ export type VideoData = { export const Caps = ({ data, count, - dubApiKeyEnabled, folders, }: { data: VideoData; count: number; folders: FolderDataType[]; - dubApiKeyEnabled: boolean; }) => { const router = useRouter(); const params = useSearchParams(); const page = Number(params.get("page")) || 1; const { user } = useDashboardContext(); + const publicEnv = usePublicEnv(); const limit = 15; const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); const totalPages = Math.ceil(count / limit); @@ -78,7 +78,7 @@ export const Caps = ({ const analyticsQuery = useVideosAnalyticsQuery( data.map((video) => video.id), - dubApiKeyEnabled, + publicEnv.analyticsAvailable, ); const analytics = analyticsQuery.data || {}; diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index c274c8b66d..515b0bf353 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -262,11 +262,6 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { ).pipe(runPromise); return ( - + ); } diff --git a/apps/web/app/api/analytics/route.ts b/apps/web/app/api/analytics/route.ts deleted file mode 100644 index 7a3fd4a061..0000000000 --- a/apps/web/app/api/analytics/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextRequest } from "next/server"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; - -export async function GET(request: NextRequest) { - const url = new URL(request.url); - const videoId = url.searchParams.get("videoId"); - - if (!videoId) { - return Response.json({ error: "Video ID is required" }, { status: 400 }); - } - - try { - const result = await getVideoAnalytics(videoId); - return Response.json({ count: result.count }, { status: 200 }); - } catch (error) { - console.error("Error fetching video analytics:", error); - return Response.json( - { error: "Failed to fetch analytics" }, - { status: 500 }, - ); - } -} diff --git a/apps/web/app/api/video/analytics/route.ts b/apps/web/app/api/video/analytics/route.ts new file mode 100644 index 0000000000..c49bd674c0 --- /dev/null +++ b/apps/web/app/api/video/analytics/route.ts @@ -0,0 +1,150 @@ +import { VideosAnalytics } from "@cap/web-backend"; +import { VideoAnalytics } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, +} from "@effect/platform"; +import { geolocation } from "@vercel/functions"; +import { Effect, Layer } from "effect"; +import { apiToHandler } from "@/lib/server"; + +const normalizeHeaderValue = (value?: string | null) => { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const LOCALHOST_GEO_DATA = { + city: normalizeHeaderValue(process.env.LOCAL_GEO_CITY) ?? "San Francisco", + country: normalizeHeaderValue(process.env.LOCAL_GEO_COUNTRY) ?? "US", +} as const; + +const isRunningOnVercel = process.env.VERCEL === "1"; + +class Api extends HttpApi.make("VideoAnalyticsCaptureApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.post("captureAnalytics")`/api/video/analytics` + .setPayload(VideoAnalytics.VideoCaptureEvent) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.InternalServerError), + ), +) {} + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + Effect.gen(function* () { + const videosAnalytics = yield* VideosAnalytics; + + return handlers.handle("captureAnalytics", ({ payload }) => + videosAnalytics.captureEvent(payload).pipe( + Effect.catchTags({ + HttpBodyError: () => new HttpApiError.BadRequest(), + RequestError: () => new HttpApiError.InternalServerError(), + ResponseError: () => new HttpApiError.InternalServerError(), + }), + ), + ); + }), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +const CITY_HEADER_KEYS = [ + "x-vercel-ip-city", + "cf-ipcity", + "x-nf-geo-city", + "x-geo-city", + "x-appengine-city", +] as const; + +const COUNTRY_HEADER_KEYS = [ + "x-vercel-ip-country", + "cf-ipcountry", + "x-nf-geo-country", + "x-geo-country", + "x-appengine-country", + "x-country-code", +] as const; + +const pickHeaderValue = (request: Request, keys: readonly string[]) => { + for (const key of keys) { + const value = normalizeHeaderValue(request.headers.get(key)); + if (value) return value; + } + + return undefined; +}; + +const getGeoFromRequest = (request: Request) => { + if (!isRunningOnVercel) return { city: undefined, country: undefined }; + + try { + const details = geolocation(request); + return { + city: normalizeHeaderValue(details.city), + country: normalizeHeaderValue(details.country), + }; + } catch { + return { city: undefined, country: undefined }; + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const POST = async (request: Request) => { + const { city: geoCity, country: geoCountry } = getGeoFromRequest(request); + const headerCity = pickHeaderValue(request, CITY_HEADER_KEYS); + const headerCountry = pickHeaderValue(request, COUNTRY_HEADER_KEYS); + + const fallbackCity = !isRunningOnVercel ? LOCALHOST_GEO_DATA.city : undefined; + const fallbackCountry = !isRunningOnVercel ? LOCALHOST_GEO_DATA.country : undefined; + + const derivedCity = geoCity ?? headerCity ?? fallbackCity; + const derivedCountry = geoCountry ?? headerCountry ?? fallbackCountry; + + if (!derivedCity && !derivedCountry) return handler(request); + + let parsedBody: unknown; + try { + parsedBody = await request.clone().json(); + } catch { + return handler(request); + } + + if (!isRecord(parsedBody)) return handler(request); + + const existingCity = normalizeHeaderValue( + typeof parsedBody.city === "string" ? parsedBody.city : undefined, + ); + const existingCountry = normalizeHeaderValue( + typeof parsedBody.country === "string" ? parsedBody.country : undefined, + ); + + const cityToApply = !existingCity ? derivedCity : undefined; + const countryToApply = !existingCountry ? derivedCountry : undefined; + + if (!cityToApply && !countryToApply) return handler(request); + + const enhancedPayload = { + ...parsedBody, + ...(cityToApply ? { city: cityToApply } : {}), + ...(countryToApply ? { country: countryToApply } : {}), + }; + + const headers = new Headers(request.headers); + headers.delete("content-length"); + + const enhancedRequest = new Request(request, { + headers, + body: JSON.stringify(enhancedPayload), + }); + + return handler(enhancedRequest); +}; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 7130597f6e..6d63da528a 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -131,6 +131,10 @@ export default ({ children }: PropsWithChildren) => webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, workosAuthAvailable: !!serverEnv().WORKOS_CLIENT_ID, googleAuthAvailable: !!serverEnv().GOOGLE_CLIENT_ID, + analyticsAvailable: + !!serverEnv().DUB_API_KEY || + (!!serverEnv().TINYBIRD_HOST && + !!serverEnv().TINYBIRD_TOKEN), }} > diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 347c5bd5a0..bc70dd72cb 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -17,11 +17,13 @@ import { type VideoStatusResult, } from "@/actions/videos/get-status"; import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; +import { usePublicEnv } from "@/utils/public-env"; import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; import SummaryChapters from "./_components/SummaryChapters"; import { Toolbar } from "./_components/Toolbar"; -import type { VideoData } from "./types"; +import { useShareAnalytics } from "./useShareAnalytics"; +import type { ShareAnalyticsContext, VideoData } from "./types"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; @@ -49,6 +51,7 @@ interface ShareProps { processing?: boolean; } | null; aiGenerationEnabled: boolean; + analyticsContext: ShareAnalyticsContext; } const useVideoStatus = ( @@ -134,13 +137,20 @@ export const Share = ({ initialAiData, aiGenerationEnabled, videoSettings, + analyticsContext, }: ShareProps) => { const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) : data.createdAt; + const publicEnv = usePublicEnv(); const playerRef = useRef(null); + const [videoElement, setVideoElement] = useState(null); const activityRef = useRef<{ scrollToBottom: () => void }>(null); + const handlePlayerRef = useCallback((node: HTMLVideoElement | null) => { + playerRef.current = node; + setVideoElement(node); + }, []); const initialComments: CommentType[] = comments instanceof Promise ? use(comments) : comments; const [commentsData, setCommentsData] = @@ -272,6 +282,13 @@ export const Share = ({ isDisabled("disableSummary") && isDisabled("disableTranscript"); + useShareAnalytics({ + videoId: data.id, + analyticsContext, + videoElement, + enabled: publicEnv.analyticsAvailable, + }); + return (
@@ -287,7 +304,7 @@ export const Share = ({ areReactionStampsDisabled={areReactionStampsDisabled} chapters={aiData?.chapters || []} aiProcessing={aiData?.processing || false} - ref={playerRef} + ref={handlePlayerRef} />
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 8c5db1f5d5..df37bd4661 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -1,5 +1,4 @@ -import type { userSelectProps } from "@cap/database/auth/session"; -import type { comments as commentsSchema, videos } from "@cap/database/schema"; +import type { comments as commentsSchema } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; import { Logo } from "@cap/ui"; import type { ImageUpload } from "@cap/web-domain"; @@ -11,245 +10,240 @@ import { useRef, useState, } from "react"; -import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { UpgradeModal } from "@/components/UpgradeModal"; import type { VideoData } from "../types"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; import { - formatChaptersAsVTT, - formatTranscriptAsVTT, - parseVTT, - type TranscriptEntry, + formatChaptersAsVTT, + formatTranscriptAsVTT, + parseVTT, + type TranscriptEntry, } from "./utils/transcript-utils"; -declare global { - interface Window { - MSStream: any; - } -} - type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; authorImage: ImageUpload.ImageUrl | null; }; export const ShareVideo = forwardRef< - HTMLVideoElement, + HTMLVideoElement, { data: VideoData & { hasActiveUpload?: boolean; }; comments: MaybePromise; - chapters?: { title: string; start: number }[]; - areChaptersDisabled?: boolean; - areCaptionsDisabled?: boolean; - areCommentStampsDisabled?: boolean; - areReactionStampsDisabled?: boolean; + chapters?: { title: string; start: number }[]; + areChaptersDisabled?: boolean; + areCaptionsDisabled?: boolean; + areCommentStampsDisabled?: boolean; + areReactionStampsDisabled?: boolean; aiProcessing?: boolean; } >( ( - { - data, - comments, - chapters = [], - areCaptionsDisabled, - areChaptersDisabled, - areCommentStampsDisabled, - areReactionStampsDisabled, - }, - ref, - ) => { - const videoRef = useRef(null); - useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); - - const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); - const [transcriptData, setTranscriptData] = useState([]); - const [subtitleUrl, setSubtitleUrl] = useState(null); - const [chaptersUrl, setChaptersUrl] = useState(null); - const [commentsData, setCommentsData] = useState([]); - - const { data: transcriptContent, error: transcriptError } = useTranscript( - data.id, - data.transcriptionStatus, - ); - - // Handle comments data - useEffect(() => { - if (comments) { - if (Array.isArray(comments)) { - setCommentsData(comments); - } else { - comments.then(setCommentsData); - } - } - }, [comments]); - - // Handle seek functionality - const handleSeek = (time: number) => { - if (videoRef.current) { - videoRef.current.currentTime = time; - } - }; - - useEffect(() => { - if (transcriptContent) { - const parsed = parseVTT(transcriptContent); - setTranscriptData(parsed); - } else if (transcriptError) { - console.error( - "[Transcript] Transcript error from React Query:", - transcriptError.message, - ); - } - }, [transcriptContent, transcriptError]); - - // Handle subtitle URL creation - useEffect(() => { - if ( - data.transcriptionStatus === "COMPLETE" && - transcriptData && - transcriptData.length > 0 - ) { - const vttContent = formatTranscriptAsVTT(transcriptData); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); - - // Clean up previous URL - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); - } - - setSubtitleUrl(newUrl); - - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); - setSubtitleUrl(null); - } - } - }, [data.transcriptionStatus, transcriptData]); - - // Handle chapters URL creation - useEffect(() => { - if (chapters?.length > 0) { - const vttContent = formatChaptersAsVTT(chapters); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); - - // Clean up previous URL - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); - } - - setChaptersUrl(newUrl); - - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); - setChaptersUrl(null); - } - } - }, [chapters]); - - const isMp4Source = - data.source.type === "desktopMP4" || data.source.type === "webMP4"; - let videoSrc: string; - let enableCrossOrigin = false; - - if (isMp4Source) { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=mp4`; - // Start with CORS enabled for MP4 sources, CapVideoPlayer will disable if needed - enableCrossOrigin = true; - } else if ( - NODE_ENV === "development" || - ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && - data.source.type === "MediaConvert") - ) { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=master`; - } else if (data.source.type === "MediaConvert") { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; - } else { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; - } - - return ( - <> -
- {isMp4Source ? ( - ({ - id: comment.id, - type: comment.type, - timestamp: comment.timestamp, - content: comment.content, - authorName: comment.authorName, - authorImage: comment.authorImage ?? undefined, - }))} - onSeek={handleSeek} - /> - ) : ( - - )} -
- - {!data.owner.isPro && ( -
-
{ - e.stopPropagation(); - setUpgradeModalOpen(true); - }} - > -
-
- -
- -
-

- Remove watermark -

-
-
-
-
- )} - - - ); + { + data, + comments, + chapters = [], + areCaptionsDisabled, + areChaptersDisabled, + areCommentStampsDisabled, + areReactionStampsDisabled, }, + ref +) => { + const videoRef = useRef(null); + useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); + + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [transcriptData, setTranscriptData] = useState([]); + const [subtitleUrl, setSubtitleUrl] = useState(null); + const [chaptersUrl, setChaptersUrl] = useState(null); + const [commentsData, setCommentsData] = useState([]); + + const { data: transcriptContent, error: transcriptError } = useTranscript( + data.id, + data.transcriptionStatus + ); + + // Handle comments data + useEffect(() => { + if (comments) { + if (Array.isArray(comments)) { + setCommentsData(comments); + } else { + comments.then(setCommentsData); + } + } + }, [comments]); + + // Handle seek functionality + const handleSeek = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + } + }; + + useEffect(() => { + if (transcriptContent) { + const parsed = parseVTT(transcriptContent); + setTranscriptData(parsed); + } else if (transcriptError) { + console.error( + "[Transcript] Transcript error from React Query:", + transcriptError.message + ); + } + }, [transcriptContent, transcriptError]); + + // Handle subtitle URL creation + useEffect(() => { + if ( + data.transcriptionStatus === "COMPLETE" && + transcriptData && + transcriptData.length > 0 + ) { + const vttContent = formatTranscriptAsVTT(transcriptData); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); + + // Clean up previous URL + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + } + + setSubtitleUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + setSubtitleUrl(null); + } + } + }, [data.transcriptionStatus, transcriptData, subtitleUrl]); + + // Handle chapters URL creation + useEffect(() => { + if (chapters?.length > 0) { + const vttContent = formatChaptersAsVTT(chapters); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); + + // Clean up previous URL + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + } + + setChaptersUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + setChaptersUrl(null); + } + } + }, [chapters, chaptersUrl]); + + const isMp4Source = + data.source.type === "desktopMP4" || data.source.type === "webMP4"; + let videoSrc: string; + let enableCrossOrigin = false; + + if (isMp4Source) { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=mp4`; + // Start with CORS enabled for MP4 sources, CapVideoPlayer will disable if needed + enableCrossOrigin = true; + } else if ( + NODE_ENV === "development" || + ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && + data.source.type === "MediaConvert") + ) { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=master`; + } else if (data.source.type === "MediaConvert") { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; + } else { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; + } + + return ( + <> +
+ {isMp4Source ? ( + ({ + id: comment.id, + type: comment.type, + timestamp: comment.timestamp, + content: comment.content, + authorName: comment.authorName, + authorImage: comment.authorImage ?? undefined, + }))} + onSeek={handleSeek} + /> + ) : ( + + )} +
+ + {!data.owner.isPro && ( +
+ +
+ )} + + + ); + } ); diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx index 436714a48b..08d1b53a11 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx @@ -1,7 +1,4 @@ -"use client"; - -import { use, useEffect, useMemo, useState } from "react"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; +import { use, useMemo } from "react"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; @@ -9,25 +6,9 @@ const Analytics = (props: { videoId: string; views: MaybePromise; comments: CommentType[]; - isLoadingAnalytics: boolean; }) => { - const [views, setViews] = useState( - props.views instanceof Promise ? use(props.views) : props.views, - ); - - useEffect(() => { - const fetchAnalytics = async () => { - try { - const result = await getVideoAnalytics(props.videoId); - - setViews(result.count); - } catch (error) { - console.error("Error fetching analytics:", error); - } - }; - - fetchAnalytics(); - }, [props.videoId]); + const views = + typeof props.views === "number" ? props.views : use(props.views); const totalComments = useMemo( () => props.comments.filter((c) => c.type === "text").length, @@ -41,7 +22,7 @@ const Analytics = (props: { return ( } diff --git a/apps/web/app/s/[videoId]/_components/video/media-player.tsx b/apps/web/app/s/[videoId]/_components/video/media-player.tsx index 3d576b71c9..3caeb2ffc0 100644 --- a/apps/web/app/s/[videoId]/_components/video/media-player.tsx +++ b/apps/web/app/s/[videoId]/_components/video/media-player.tsx @@ -1488,9 +1488,20 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const mediaCurrentTime = useMediaSelector( (state) => state.mediaCurrentTime ?? 0, ); - const [seekableStart = 0, seekableEnd = 0] = useMediaSelector( + const [seekableStartRaw = 0, seekableEndRaw = 0] = useMediaSelector( (state) => state.mediaSeekable ?? [0, 0], ); + const seekableStart = Number.isFinite(seekableStartRaw) + ? seekableStartRaw + : 0; + const seekableEnd = + Number.isFinite(seekableEndRaw) && seekableEndRaw >= seekableStart + ? seekableEndRaw + : seekableStart; + const isSeekRangeValid = + Number.isFinite(seekableEnd) && + Number.isFinite(seekableStart) && + seekableEnd > seekableStart; const mediaBuffered = useMediaSelector((state) => state.mediaBuffered ?? []); const mediaEnded = useMediaSelector((state) => state.mediaEnded ?? false); @@ -1537,7 +1548,29 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const timeCache = React.useRef>(new Map()); - const displayValue = seekState.pendingSeekTime ?? mediaCurrentTime; + const onInteractionStart = React.useCallback(() => { + if (!store.getState().dragging) { + store.setState("dragging", true); + } + }, [store.getState, store.setState]); + + const onInteractionEnd = React.useCallback(() => { + if (store.getState().dragging) { + store.setState("dragging", false); + } + }, [store.getState, store.setState]); + + const displayValueCandidate = + seekState.pendingSeekTime ?? mediaCurrentTime ?? seekableStart; + const displayValue = Math.min( + Math.max( + Number.isFinite(displayValueCandidate) + ? displayValueCandidate + : seekableStart, + seekableStart, + ), + seekableEnd, + ); const isDisabled = disabled || context.disabled; const tooltipDisabled = @@ -1709,7 +1742,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { ); const onHoverProgressUpdate = React.useCallback(() => { - if (!seekRef.current || seekableEnd <= 0) return; + if (!seekRef.current || !isSeekRangeValid) return; const hoverPercent = Math.min( 100, @@ -1719,7 +1752,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { SEEK_HOVER_PERCENT, `${hoverPercent.toFixed(4)}%`, ); - }, [seekableEnd]); + }, [isSeekRangeValid, seekableEnd]); React.useEffect(() => { if (seekState.pendingSeekTime !== null) { @@ -1752,7 +1785,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }, [dispatch, seekState.isHovering, tooltipDisabled]); const bufferedProgress = React.useMemo(() => { - if (mediaBuffered.length === 0 || seekableEnd <= 0) return 0; + if (mediaBuffered.length === 0 || !isSeekRangeValid) return 0; if (mediaEnded) return 1; @@ -1765,7 +1798,14 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { } return Math.min(1, seekableStart / seekableEnd); - }, [mediaBuffered, mediaCurrentTime, seekableEnd, mediaEnded, seekableStart]); + }, [ + isSeekRangeValid, + mediaBuffered, + mediaCurrentTime, + seekableEnd, + mediaEnded, + seekableStart, + ]); const onPointerEnter = React.useCallback(() => { if (seekRef.current) { @@ -1777,7 +1817,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { horizontalMovementRef.current = 0; verticalMovementRef.current = 0; - if (seekableEnd > 0) { + if (isSeekRangeValid) { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } @@ -1792,7 +1832,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { } } } - }, [seekableEnd, onTooltipPositionUpdate, tooltipDisabled]); + }, [isSeekRangeValid, onTooltipPositionUpdate, tooltipDisabled]); const onPointerLeave = React.useCallback(() => { if (hoverTimeoutRef.current) { @@ -1835,7 +1875,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const onPointerMove = React.useCallback( (event: React.PointerEvent) => { - if (seekableEnd <= 0) return; + if (!isSeekRangeValid) return; if (!seekRectRef.current && seekRef.current) { seekRectRef.current = seekRef.current.getBoundingClientRect(); @@ -1926,6 +1966,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }); }, [ + isSeekRangeValid, onPreviewUpdate, onTooltipPositionUpdate, onHoverProgressUpdate, @@ -1939,11 +1980,9 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { (value: number[]) => { const time = value[0] ?? 0; - setSeekState((prev) => ({ ...prev, pendingSeekTime: time })); + if (!store.getState().dragging) return; - if (!store.getState().dragging) { - store.setState("dragging", true); - } + setSeekState((prev) => ({ ...prev, pendingSeekTime: time })); if (seekThrottleRef.current) { cancelAnimationFrame(seekThrottleRef.current); @@ -1957,7 +1996,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { seekThrottleRef.current = null; }); }, - [dispatch, store.getState, store.setState], + [dispatch, store.getState], ); const onSeekCommit = React.useCallback( @@ -1998,9 +2037,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { horizontalMovementRef.current = 0; verticalMovementRef.current = 0; - if (store.getState().dragging) { - store.setState("dragging", false); - } + onInteractionEnd(); dispatch({ type: MediaActionTypes.MEDIA_SEEK_REQUEST, @@ -2012,7 +2049,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { detail: undefined, }); }, - [dispatch, store.getState, store.setState], + [dispatch, onInteractionEnd], ); React.useEffect(() => { @@ -2037,7 +2074,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const hoverTime = getCachedTime(hoverTimeRef.current, seekableEnd); const chapterSeparators = React.useMemo(() => { - if (withoutChapter || chapterCues.length <= 1 || seekableEnd <= 0) { + if (withoutChapter || chapterCues.length <= 1 || !isSeekRangeValid) { return null; } @@ -2059,7 +2096,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { /> ); }); - }, [chapterCues, seekableEnd, withoutChapter]); + }, [chapterCues, isSeekRangeValid, seekableEnd, withoutChapter]); const spriteStyle = React.useMemo(() => { if (!thumbnail?.coords || !thumbnail?.src) { @@ -2088,7 +2125,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }; }, [thumbnail?.coords, thumbnail?.src]); - const SeekSlider = ( + const SeekSlider = isSeekRangeValid ? (
- {seekState.isHovering && seekableEnd > 0 && ( + {seekState.isHovering && (
- {!withoutTooltip && - !context.withoutTooltip && - seekState.isHovering && - seekableEnd > 0 && ( - + {!withoutTooltip && !context.withoutTooltip && seekState.isHovering && ( + +
+ {thumbnail?.src && ( +
+
+
+ )} + {currentChapterCue && ( +
+ {currentChapterCue.text} +
+ )}
- {thumbnail?.src && ( -
- {thumbnail.coords ? ( -
- ) : ( - {`Preview - )} -
- )} - {currentChapterCue && ( -
- {currentChapterCue.text} -
- )} -
- {tooltipTimeVariant === "progress" - ? `${hoverTime} / ${duration}` - : hoverTime} -
+ {tooltipTimeVariant === "progress" + ? `${hoverTime} / ${duration}` + : hoverTime}
- - )} +
+ + )} +
+ ) : ( +
+
); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 38329a3c4d..c39961054f 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -11,7 +11,6 @@ import { videos, videoUploads, } from "@cap/database/schema"; -import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; @@ -22,13 +21,7 @@ import { Videos, } from "@cap/web-backend"; import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy"; -import { - Comment, - type ImageUpload, - type Organisation, - Policy, - type Video, -} from "@cap/web-domain"; +import { Comment, type Organisation, Policy, Video } from "@cap/web-domain"; import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import type { Metadata } from "next"; @@ -36,7 +29,6 @@ import { headers } from "next/headers"; import Link from "next/link"; import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; import { getDashboardData, type OrganizationSettings, @@ -50,6 +42,7 @@ import { isAiGenerationEnabled } from "@/utils/flags"; import { PasswordOverlay } from "./_components/PasswordOverlay"; import { ShareHeader } from "./_components/ShareHeader"; import { Share } from "./Share"; +import type { ShareAnalyticsContext } from "./types"; // Helper function to fetch shared spaces data for a video async function getSharedSpacesForVideo(videoId: Video.VideoId) { @@ -134,7 +127,7 @@ export async function generateMetadata( Option.match({ onNone: () => notFound(), onSome: ([video]) => ({ - title: video.name + " | Cap Recording", + title: `${video.name} | Cap Recording`, description: "Watch this video on Cap", openGraph: { images: [ @@ -161,7 +154,7 @@ export async function generateMetadata( }, twitter: { card: "player", - title: video.name + " | Cap Recording", + title: `${video.name} | Cap Recording`, description: "Watch this video on Cap", images: [ new URL( @@ -254,7 +247,7 @@ export async function generateMetadata( export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { const params = await props.params; const searchParams = await props.searchParams; - const videoId = params.videoId as Video.VideoId; + const videoId = Video.VideoId.make(params.videoId); return Effect.gen(function* () { const videosPolicy = yield* VideosPolicy; @@ -321,6 +314,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
)), Effect.catchTags({ + // biome-ignore lint/correctness/noNestedComponentDefinitions: inline Effect handler returning JSX is fine here PolicyDenied: () => Effect.succeed(
) {

, ), + // biome-ignore lint/correctness/noNestedComponentDefinitions: inline Effect handler returning JSX is fine here NoSuchElementException: () => Effect.sync(() => notFound()), }), provideOptionalAuth, @@ -363,6 +358,40 @@ async function AuthorizedContent({ // will have already been fetched if auth is required const user = await getCurrentUser(); const videoId = video.id; + const headerList = await headers(); + + const getSearchParam = (key: string) => { + const value = searchParams?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; + }; + + const referrerUrl = + headerList.get("x-referrer") ?? headerList.get("referer") ?? null; + const userAgent = headerList.get("x-user-agent") ?? headerList.get("user-agent"); + const userAgentDetails = deriveUserAgentDetails(userAgent); + const analyticsContext: ShareAnalyticsContext = { + city: headerList.get("x-vercel-ip-city"), + country: headerList.get("x-vercel-ip-country"), + referrerUrl, + referrer: (() => { + if (!referrerUrl) return null; + try { + return new URL(referrerUrl).hostname; + } catch { + return null; + } + })(), + utmSource: getSearchParam("utm_source"), + utmMedium: getSearchParam("utm_medium"), + utmCampaign: getSearchParam("utm_campaign"), + utmTerm: getSearchParam("utm_term"), + utmContent: getSearchParam("utm_content"), + userAgent, + device: userAgentDetails.device ?? null, + browser: userAgentDetails.browser ?? null, + os: userAgentDetails.os ?? null, + }; if (user && video && user.id !== video.owner.id) { try { @@ -494,7 +523,7 @@ async function AuthorizedContent({ } } - const currentMetadata = (video.metadata as VideoMetadata) || {}; + const currentMetadata = video.metadata || {}; const metadata = currentMetadata; let initialAiData = null; @@ -558,8 +587,7 @@ async function AuthorizedContent({ const org = orgArr[0]; if ( - org && - org.customDomain && + org?.customDomain && org.domainVerified !== null && user.id === video.owner.id ) { @@ -681,7 +709,12 @@ async function AuthorizedContent({ ); }).pipe(EffectRuntime.runPromise); - const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); + const viewsPromise = Effect.flatMap(Videos, (videos) => + videos.getAnalytics(videoId), + ).pipe( + Effect.map((v) => v.count), + EffectRuntime.runPromise, + ); const [ membersList, @@ -750,6 +783,7 @@ async function AuthorizedContent({ userOrganizations={userOrganizations} initialAiData={initialAiData} aiGenerationEnabled={aiGenerationEnabled} + analyticsContext={analyticsContext} />
@@ -765,3 +799,52 @@ async function AuthorizedContent({ ); } + +type UserAgentDetails = { + device?: string; + browser?: string; + os?: string; +}; + +const deriveUserAgentDetails = (ua?: string | null): UserAgentDetails => { + if (!ua) return {}; + const value = ua.toLowerCase(); + const details: UserAgentDetails = {}; + + if ( + /ipad|tablet/.test(value) || + (/android/.test(value) && !/mobile/.test(value)) + ) { + details.device = "tablet"; + } else if (/mobi|iphone|ipod|android/.test(value)) { + details.device = "mobile"; + } else if (/mac|win|linux|cros|x11/.test(value)) { + details.device = "desktop"; + } + + if (/chrome|crios/.test(value) && !/edge|edg\//.test(value)) { + details.browser = "chrome"; + } else if (/safari/.test(value) && !/chrome|crios/.test(value)) { + details.browser = "safari"; + } else if (/firefox/.test(value)) { + details.browser = "firefox"; + } else if (/edg\//.test(value)) { + details.browser = "edge"; + } else if (/msie|trident/.test(value)) { + details.browser = "ie"; + } + + if (/windows nt/.test(value)) { + details.os = "windows"; + } else if (/mac os x/.test(value)) { + details.os = "mac"; + } else if (/android/.test(value)) { + details.os = "android"; + } else if (/iphone|ipad|ipod/.test(value)) { + details.os = "ios"; + } else if (/linux/.test(value)) { + details.os = "linux"; + } + + return details; +}; diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index 6bd2536f80..81c5ab0860 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -17,3 +17,19 @@ export type VideoOwner = { name?: string | null; image?: ImageUpload.ImageUrl | null; }; + +export type ShareAnalyticsContext = { + city?: string | null; + country?: string | null; + referrer?: string | null; + referrerUrl?: string | null; + utmSource?: string | null; + utmMedium?: string | null; + utmCampaign?: string | null; + utmTerm?: string | null; + utmContent?: string | null; + userAgent?: string | null; + device?: string | null; + browser?: string | null; + os?: string | null; +}; diff --git a/apps/web/app/s/[videoId]/useShareAnalytics.ts b/apps/web/app/s/[videoId]/useShareAnalytics.ts new file mode 100644 index 0000000000..bcb1acf271 --- /dev/null +++ b/apps/web/app/s/[videoId]/useShareAnalytics.ts @@ -0,0 +1,273 @@ +"use client"; + +import type * as WebDomain from "@cap/web-domain"; +import type { Schema } from "effect/Schema"; +import { useCallback, useEffect, useRef } from "react"; +import type { ShareAnalyticsContext } from "./types"; + +type CapturePayload = Schema.Type< + typeof WebDomain.VideoAnalytics.VideoCaptureEvent +>; + + +type ClientAnalyticsExtras = { + locale?: string; + language?: string; + timezone?: string; + pathname?: string; + href?: string; + referrer?: string; + userAgent?: string; +}; + +type Options = { + videoId: WebDomain.Video.VideoId; + analyticsContext: ShareAnalyticsContext; + videoElement: HTMLVideoElement | null; + enabled: boolean; +}; + +const ANALYTICS_ENDPOINT = "/api/video/analytics"; +const SESSION_STORAGE_KEY = "cap.analytics.session"; +const SESSION_TTL_MS = 30 * 60 * 1000; + +const randomId = () => + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + +export function useShareAnalytics({ + videoId, + analyticsContext, + videoElement, + enabled, +}: Options) { + const analyticsRef = useRef(analyticsContext); + const videoIdRef = useRef(videoId); + const fallbackSessionRef = useRef(null); + const sessionIdRef = useRef(null); + const clientDataRef = useRef(null); + const sentRef = useRef(false); + const watchStartRef = useRef(null); + const watchedMsRef = useRef(0); + + analyticsRef.current = analyticsContext; + videoIdRef.current = videoId; + + const accumulateWatchTime = useCallback(() => { + if (watchStartRef.current === null) return; + watchedMsRef.current += performance.now() - watchStartRef.current; + watchStartRef.current = null; + }, []); + + const ensureSessionId = useCallback(() => { + if (typeof window === "undefined") { + if (!fallbackSessionRef.current) + fallbackSessionRef.current = randomId(); + return fallbackSessionRef.current; + } + + try { + const storedValue = window.localStorage.getItem(SESSION_STORAGE_KEY); + const now = Date.now(); + if (storedValue) { + const parsed = JSON.parse(storedValue) as { value?: string; expiry?: number }; + if ( + parsed?.value && + typeof parsed.value === "string" && + typeof parsed.expiry === "number" && + parsed.expiry > now + ) { + const refreshed = JSON.stringify({ + value: parsed.value, + expiry: now + SESSION_TTL_MS, + }); + window.localStorage.setItem(SESSION_STORAGE_KEY, refreshed); + return parsed.value; + } + } + + const nextSessionId = + window.crypto?.randomUUID?.() ?? randomId(); + const serialized = JSON.stringify({ + value: nextSessionId, + expiry: now + SESSION_TTL_MS, + }); + window.localStorage.setItem(SESSION_STORAGE_KEY, serialized); + return nextSessionId; + } catch { + if (!fallbackSessionRef.current) fallbackSessionRef.current = randomId(); + return fallbackSessionRef.current; + } + }, []); + + const getClientAnalytics = useCallback((): ClientAnalyticsExtras => { + if (clientDataRef.current) return clientDataRef.current; + if (typeof window === "undefined") return {}; + + const data: ClientAnalyticsExtras = { + locale: + (window.navigator.languages?.[0]) || + window.navigator.language, + language: window.navigator.language, + timezone: (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return undefined; + } + })(), + pathname: window.location.pathname, + href: window.location.href, + referrer: document.referrer || undefined, + userAgent: window.navigator.userAgent, + }; + + clientDataRef.current = data; + return data; + }, []); + + const buildPayload = useCallback( + (watchTimeSeconds: number): CapturePayload => { + const analytics = analyticsRef.current; + const client = getClientAnalytics(); + const normalizedWatch = Math.max(0, Math.trunc(watchTimeSeconds)); + const sessionId = sessionIdRef.current ?? ensureSessionId(); + const normalize = (value?: string | null) => + value && value.trim().length > 0 ? value : undefined; + + const city = normalize(analytics.city); + const country = normalize(analytics.country); + const device = normalize(analytics.device); + const browser = normalize(analytics.browser); + const os = normalize(analytics.os); + + const clientReferrer = normalize(client.referrer); + const referrer = normalize(analytics.referrer) ?? clientReferrer; + const referrerUrl = normalize(analytics.referrerUrl) ?? clientReferrer; + const utmSource = normalize(analytics.utmSource); + const utmMedium = normalize(analytics.utmMedium); + const utmCampaign = normalize(analytics.utmCampaign); + const utmTerm = normalize(analytics.utmTerm); + const utmContent = normalize(analytics.utmContent); + const userAgent = normalize(analytics.userAgent) ?? normalize(client.userAgent); + const locale = normalize(client.locale); + const language = normalize(client.language); + const timezone = normalize(client.timezone); + const pathname = normalize(client.pathname); + const href = normalize(client.href); + + return { + video: videoIdRef.current, + watchTimeSeconds: normalizedWatch > 0 ? normalizedWatch : 0, + ...(sessionId ? { sessionId } : {}), + ...(city ? { city } : {}), + ...(country ? { country } : {}), + ...(device ? { device } : {}), + ...(browser ? { browser } : {}), + ...(os ? { os } : {}), + ...(referrer ? { referrer } : {}), + ...(referrerUrl ? { referrerUrl } : {}), + ...(utmSource ? { utmSource } : {}), + ...(utmMedium ? { utmMedium } : {}), + ...(utmCampaign ? { utmCampaign } : {}), + ...(utmTerm ? { utmTerm } : {}), + ...(utmContent ? { utmContent } : {}), + ...(userAgent ? { userAgent } : {}), + ...(locale ? { locale } : {}), + ...(language ? { language } : {}), + ...(timezone ? { timezone } : {}), + ...(pathname ? { pathname } : {}), + ...(href ? { href } : {}), + } satisfies CapturePayload; + }, + [getClientAnalytics, ensureSessionId], + ); + + const sendPayload = useCallback( + (reason: string) => { + if (!enabled || sentRef.current) return; + + accumulateWatchTime(); + const watchSeconds = watchedMsRef.current / 1000; + const payload = buildPayload(watchSeconds); + sentRef.current = true; + const body = JSON.stringify(payload); + + const sendWithBeacon = () => { + if (typeof navigator === "undefined" || !navigator.sendBeacon) return false; + try { + const blob = new Blob([body], { type: "application/json" }); + return navigator.sendBeacon(ANALYTICS_ENDPOINT, blob); + } catch { + return false; + } + }; + + const fallbackFetch = () => + fetch(ANALYTICS_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: reason !== "manual", + }).catch(() => undefined); + + if (!sendWithBeacon()) void fallbackFetch(); + }, [accumulateWatchTime, buildPayload, enabled]); + + useEffect(() => { + if (!enabled) return; + sessionIdRef.current = ensureSessionId(); + }, [enabled, ensureSessionId]); + + useEffect(() => { + if (!enabled || !videoElement) return; + + const handlePlay = () => { + if (document.hidden) return; + if (watchStartRef.current === null) watchStartRef.current = performance.now(); + }; + const handlePauseLike = () => accumulateWatchTime(); + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + accumulateWatchTime(); + } else if (!videoElement.paused) { + handlePlay(); + } + }; + + videoElement.addEventListener("playing", handlePlay); + videoElement.addEventListener("pause", handlePauseLike); + videoElement.addEventListener("ended", handlePauseLike); + videoElement.addEventListener("waiting", handlePauseLike); + videoElement.addEventListener("seeking", handlePauseLike); + document.addEventListener("visibilitychange", handleVisibility); + + if (!videoElement.paused && !document.hidden) handlePlay(); + + return () => { + accumulateWatchTime(); + videoElement.removeEventListener("playing", handlePlay); + videoElement.removeEventListener("pause", handlePauseLike); + videoElement.removeEventListener("ended", handlePauseLike); + videoElement.removeEventListener("waiting", handlePauseLike); + videoElement.removeEventListener("seeking", handlePauseLike); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [accumulateWatchTime, enabled, videoElement]); + + useEffect(() => { + if (!enabled) return; + + const handlePageHide = () => sendPayload("pagehide"); + + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("beforeunload", handlePageHide); + + return () => { + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("beforeunload", handlePageHide); + sendPayload("manual"); + }; + }, [enabled, sendPayload]); +} diff --git a/apps/web/lib/Requests/AnalyticsRequest.ts b/apps/web/lib/Requests/AnalyticsRequest.ts index da475c5bb2..331cd5e765 100644 --- a/apps/web/lib/Requests/AnalyticsRequest.ts +++ b/apps/web/lib/Requests/AnalyticsRequest.ts @@ -19,7 +19,7 @@ export namespace AnalyticsRequest { const requestResolver = RequestResolver.makeBatched( (requests: NonEmptyArray) => - rpc.VideosGetAnalytics(requests.map((r) => r.videoId)).pipe( + rpc.VideosGetViewCount(requests.map((r) => r.videoId)).pipe( Effect.flatMap( // biome-ignore lint/suspicious/useIterableCallbackReturn: effect Effect.forEach((result, index) => diff --git a/apps/web/lib/effect-react-query.ts b/apps/web/lib/effect-react-query.ts index f9d0d651fd..bbf79f44d5 100644 --- a/apps/web/lib/effect-react-query.ts +++ b/apps/web/lib/effect-react-query.ts @@ -265,7 +265,6 @@ export function makeUseEffectMutation( // }) // ); const result = await runtime.runPromiseExit(effectToRun); - console.log({ result }); if (Exit.isFailure(result)) { // we always throw the cause throw result.cause; diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index 3c93c45b7f..ca206f90b5 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -15,6 +15,7 @@ import { SpacesPolicy, Users, Videos, + VideosAnalytics, VideosPolicy, VideosRepo, Workflows, @@ -113,6 +114,7 @@ export const Dependencies = Layer.mergeAll( Videos.Default, VideosPolicy.Default, VideosRepo.Default, + VideosAnalytics.Default, Folders.Default, SpacesPolicy.Default, OrganisationsPolicy.Default, diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx index 9499992390..0ec2cf4a4e 100644 --- a/apps/web/utils/public-env.tsx +++ b/apps/web/utils/public-env.tsx @@ -6,6 +6,7 @@ type PublicEnvContext = { webUrl: string; googleAuthAvailable: boolean; workosAuthAvailable: boolean; + analyticsAvailable: boolean; }; const Context = createContext(null); diff --git a/biome.json b/biome.json index 621aba7608..e231fd0063 100644 --- a/biome.json +++ b/biome.json @@ -23,6 +23,9 @@ "recommended": true, "suspicious": { "noShadowRestrictedNames": "off" + }, + "correctness": { + "useYield": "off" } } }, diff --git a/packages/env/server.ts b/packages/env/server.ts index 8adcc34922..7afb2a1f42 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -114,6 +114,8 @@ function createServerEnv() { NODE_ENV: z.string(), WORKFLOWS_RPC_URL: z.string().optional(), WORKFLOWS_RPC_SECRET: z.string().optional(), + TINYBIRD_HOST: z.string().optional(), + TINYBIRD_TOKEN: z.string().optional(), }, experimental__runtimeEnv: { S3_PUBLIC_ENDPOINT: process.env.CAP_AWS_ENDPOINT, diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json index 7da887abb1..3fc1e954ad 100644 --- a/packages/web-backend/package.json +++ b/packages/web-backend/package.json @@ -20,6 +20,7 @@ "@cap/database": "workspace:*", "@cap/utils": "workspace:*", "@cap/web-domain": "workspace:*", + "@cap/env": "workspace:*", "@effect/cluster": "^0.50.4", "@effect/platform": "^0.92.1", "@effect/rpc": "^0.71.0", diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts index 0b4fb06328..ce0ce86ea8 100644 --- a/packages/web-backend/src/Rpcs.ts +++ b/packages/web-backend/src/Rpcs.ts @@ -11,9 +11,11 @@ import { FolderRpcsLive } from "./Folders/FoldersRpcs.ts"; import { OrganisationsRpcsLive } from "./Organisations/OrganisationsRpcs.ts"; import { UsersRpcsLive } from "./Users/UsersRpcs.ts"; import { VideosRpcsLive } from "./Videos/VideosRpcs.ts"; +import { VideosAnalyticsRpcsLive } from "./VideosAnalytics/VideosAnalyticsRpcs.ts"; export const RpcsLive = Layer.mergeAll( VideosRpcsLive, + VideosAnalyticsRpcsLive, FolderRpcsLive, UsersRpcsLive, OrganisationsRpcsLive, diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index dc4aa4de8d..7e090cd4de 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -73,9 +73,9 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), - VideosGetThumbnails: (videoIds) => + VideosGetThumbnails: (videoIds: ReadonlyArray) => Effect.all( - videoIds.map((id) => + videoIds.map((id: Video.VideoId) => videos.getThumbnailURL(id).pipe( Effect.catchTag( "DatabaseError", @@ -98,49 +98,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( { concurrency: 10 }, ).pipe( provideOptionalAuth, - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), - ), - - VideosGetAnalytics: (videoIds) => - Effect.all( - videoIds.map((id) => - videos.getAnalytics(id).pipe( - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), - Effect.matchEffect({ - onSuccess: (v) => Effect.succeed(Exit.succeed(v)), - onFailure: (e) => - Schema.is(InternalError)(e) - ? Effect.fail(e) - : Effect.succeed(Exit.fail(e)), - }), - Effect.map((v) => Unify.unify(v)), - ), - ), - { concurrency: 10 }, - ).pipe( - provideOptionalAuth, - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), ), }; }), diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 49f7587705..7ecd0d8df7 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,9 +1,10 @@ import * as Db from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; -import { CurrentUser, type Folder, Policy, Video } from "@cap/web-domain"; +import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain"; +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; import * as Dz from "drizzle-orm"; -import { Array, Context, Effect, Option, pipe } from "effect"; +import { Array, Effect, Option, pipe } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; @@ -30,6 +31,7 @@ export class Videos extends Effect.Service()("Videos", { const repo = yield* VideosRepo; const policy = yield* VideosPolicy; const s3Buckets = yield* S3Buckets; + const client = yield* HttpClient.HttpClient; const getByIdForViewing = (id: Video.VideoId) => repo @@ -359,6 +361,23 @@ export class Videos extends Effect.Service()("Videos", { return yield* Effect.fail(new Video.NotFoundError()); const [video] = maybeVideo.value; + console.log("HERE GET"); + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (token && host) { + const response2 = yield* client.get( + `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`, + ); + if (response2.status !== 200) { + // TODO + } + console.log("ANALYTICS", response2.status, yield* response2.text); + + // TODO: Effect schema + const result = JSON.parse(yield* response2.text); + return { count: result.data[0].count }; + } + const response = yield* Effect.tryPromise(() => dub().analytics.retrieve({ domain: "cap.link", @@ -371,6 +390,39 @@ export class Videos extends Effect.Service()("Videos", { return { count: clicks }; }), + + captureAnalytics: Effect.fn("Videos.captureAnalytics")(function* ( + videoId: Video.VideoId, + ) { + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (!token || !host) return; + + console.log("TINYBIRD EVENT"); // TODO + const response = yield* client.post( + `${host}/v0/events?name=analytics_views`, + { + body: HttpBody.unsafeJson({ + timestamp: new Date().toISOString(), + version: "1", + session_id: "todo", // TODO + video_id: videoId, + payload: JSON.stringify({ + hello: "world", // TODO + }), + }), + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + // const response = yield* HttpClientResponse.filterStatusOk(response); + if (response.status !== 200) { + // TODO + } + + console.log(response.status, yield* response.text); + }), }; }), dependencies: [ @@ -378,5 +430,6 @@ export class Videos extends Effect.Service()("Videos", { VideosRepo.Default, Database.Default, S3Buckets.Default, + FetchHttpClient.layer, ], }) {} diff --git a/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts new file mode 100644 index 0000000000..491c6c15a8 --- /dev/null +++ b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts @@ -0,0 +1,67 @@ +import { InternalError, VideoAnalytics } from "@cap/web-domain"; +import { Effect, Exit, Schema, Unify } from "effect"; + +import { provideOptionalAuth } from "../Auth.ts"; +import { VideosAnalytics } from "./index.ts"; + +export const VideosAnalyticsRpcsLive = + VideoAnalytics.VideoAnalyticsRpcs.toLayer( + Effect.gen(function* () { + const videosAnalytics = yield* VideosAnalytics; + + return { + VideosGetViewCount: (videoIds) => + Effect.all( + videoIds.map((id) => + videosAnalytics.getViewCount(id).pipe( + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + RequestError: () => + new InternalError({ type: "httpRequest" }), + ResponseError: () => + new InternalError({ type: "httpResponse" }), + UnknownException: () => + new InternalError({ type: "unknown" }), + }), + Effect.matchEffect({ + onSuccess: (v) => Effect.succeed(Exit.succeed(v)), + onFailure: (e) => + Schema.is(InternalError)(e) + ? Effect.fail(e) + : Effect.succeed(Exit.fail(e)), + }), + Effect.map((v) => Unify.unify(v)), + ), + ), + { concurrency: 10 }, + ).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + + VideosGetAnalytics: (videoId) => + videosAnalytics.getAnalytics(videoId).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + + VideosCaptureEvent: (event) => + videosAnalytics.captureEvent(event).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + HttpBodyError: () => new InternalError({ type: "httpRequest" }), + RequestError: () => new InternalError({ type: "httpRequest" }), + ResponseError: () => new InternalError({ type: "httpResponse" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + }; + }), + ); diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts new file mode 100644 index 0000000000..a07a9394ec --- /dev/null +++ b/packages/web-backend/src/VideosAnalytics/index.ts @@ -0,0 +1,162 @@ +import { serverEnv } from "@cap/env"; +import { dub } from "@cap/utils"; +import { Policy, Video, VideoAnalytics } from "@cap/web-domain"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +import { VideosPolicy } from "../Videos/VideosPolicy"; +import { VideosRepo } from "../Videos/VideosRepo"; + +export class VideosAnalytics extends Effect.Service()( + "VideosAnalytics", + { + effect: Effect.gen(function* () { + const repo = yield* VideosRepo; + const policy = yield* VideosPolicy; + const client = yield* HttpClient.HttpClient; + + const getByIdForViewing = (id: Video.VideoId) => + repo + .getById(id) + .pipe( + Policy.withPublicPolicy(policy.canView(id)), + Effect.withSpan("VideosAnalytics.getById"), + ); + + return { + getViewCount: Effect.fn("VideosAnalytics.getViewCount")(function* ( + videoId: Video.VideoId, + ) { + const [video] = yield* getByIdForViewing(videoId).pipe( + Effect.flatten, + Effect.catchTag( + "NoSuchElementException", + () => new Video.NotFoundError(), + ), + ); + + console.log("HERE GET"); + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (token && host) { + const response2 = yield* client.get( + `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`, + ); + if (response2.status !== 200) { + // TODO + } + console.log("ANALYTICS", response2.status, yield* response2.text); + + // TODO: Effect schema + const result = JSON.parse(yield* response2.text); + return { count: result.data[0].count }; + } + + const response = yield* Effect.tryPromise(() => + dub().analytics.retrieve({ + domain: "cap.link", + key: video.id, + }), + ); + const { clicks } = response as { clicks: unknown }; + + if (typeof clicks !== "number" || clicks === null) + return { count: 0 }; + + return { count: clicks }; + }), + + getAnalytics: Effect.fn("VideosAnalytics.getAnalytics")(function* ( + _videoId: Video.VideoId, + ) { + // TODO: Implement this + + return VideoAnalytics.VideoAnalytics.make({ + views: 0, + }); + }), + + captureEvent: Effect.fn("VideosAnalytics.captureEvent")(function* ( + event: VideoAnalytics.VideoCaptureEvent, + ) { + const videoId = event.video; + + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (!token || !host) return; + + const toNullableString = (value?: string | null) => + value && value.trim().length > 0 ? value : null; + const toNullableNumber = (value?: number | null) => + typeof value === "number" && Number.isFinite(value) ? value : null; + const toNullableInt = (value?: number | null) => + typeof value === "number" && Number.isFinite(value) + ? Math.trunc(value) + : null; + + const watchTime = toNullableNumber(event.watchTimeSeconds) ?? 0; + + const serializedPayload = JSON.stringify({ + city: event.city ?? null, + country: event.country ?? null, + device: event.device ?? null, + browser: event.browser ?? null, + os: event.os ?? null, + referrer: toNullableString(event.referrer), + referrerUrl: toNullableString(event.referrerUrl), + utmSource: toNullableString(event.utmSource), + utmMedium: toNullableString(event.utmMedium), + utmCampaign: toNullableString(event.utmCampaign), + utmTerm: toNullableString(event.utmTerm), + utmContent: toNullableString(event.utmContent), + locale: toNullableString(event.locale), + language: toNullableString(event.language), + timezone: toNullableString(event.timezone), + pathname: toNullableString(event.pathname), + href: toNullableString(event.href), + userAgent: toNullableString(event.userAgent), + watchTimeSeconds: watchTime, + }); + + const payload = { + timestamp: new Date().toISOString(), + version: "1", + session_id: toNullableString(event.sessionId), + video_id: videoId, + watch_time_seconds: watchTime, + city: event.city ?? null, + country: event.country ?? null, + device: event.device ?? null, + browser: event.browser ?? null, + os: event.os ?? null, + referrer: toNullableString(event.referrer), + referrer_url: toNullableString(event.referrerUrl), + utm_source: toNullableString(event.utmSource), + utm_medium: toNullableString(event.utmMedium), + utm_campaign: toNullableString(event.utmCampaign), + utm_term: toNullableString(event.utmTerm), + utm_content: toNullableString(event.utmContent), + payload: serializedPayload, + }; + + yield* client + .post(`${host}/v0/events?name=analytics_views`, { + body: yield* HttpBody.json(payload), + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }), + }; + }), + dependencies: [ + VideosPolicy.Default, + VideosRepo.Default, + FetchHttpClient.layer, + ], + }, +) {} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index b3b4382dcf..dfbb2e227c 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -15,4 +15,5 @@ export { Users } from "./Users/index.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; export { VideosRepo } from "./Videos/VideosRepo.ts"; +export { VideosAnalytics } from "./VideosAnalytics/index.ts"; export * as Workflows from "./Workflows.ts"; diff --git a/packages/web-domain/src/Errors.ts b/packages/web-domain/src/Errors.ts index 597d8c3d90..d077b445c7 100644 --- a/packages/web-domain/src/Errors.ts +++ b/packages/web-domain/src/Errors.ts @@ -2,5 +2,13 @@ import { Schema } from "effect"; export class InternalError extends Schema.TaggedError()( "InternalError", - { type: Schema.Literal("database", "s3", "unknown") }, + { + type: Schema.Literal( + "database", + "s3", + "httpRequest", + "httpResponse", + "unknown", + ), + }, ) {} diff --git a/packages/web-domain/src/Rpcs.ts b/packages/web-domain/src/Rpcs.ts index f4106430c1..a4366695a6 100644 --- a/packages/web-domain/src/Rpcs.ts +++ b/packages/web-domain/src/Rpcs.ts @@ -4,9 +4,11 @@ import { FolderRpcs } from "./Folder.ts"; import { OrganisationRpcs } from "./Organisation.ts"; import { UserRpcs } from "./User.ts"; import { VideoRpcs } from "./Video.ts"; +import { VideoAnalyticsRpcs } from "./VideoAnalytics.ts"; export const Rpcs = RpcGroup.make().merge( VideoRpcs, + VideoAnalyticsRpcs, FolderRpcs, UserRpcs, OrganisationRpcs, diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 53112f6c61..9f49fb8582 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -226,21 +226,4 @@ export class VideoRpcs extends RpcGroup.make( ), error: InternalError, }), - Rpc.make("VideosGetAnalytics", { - payload: Schema.Array(VideoId).pipe( - Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"), - ), - success: Schema.Array( - Schema.Exit({ - success: Schema.Struct({ count: Schema.Int }), - failure: Schema.Union( - NotFoundError, - PolicyDeniedError, - VerifyVideoPasswordError, - ), - defect: Schema.Unknown, - }), - ), - error: InternalError, - }), ) {} diff --git a/packages/web-domain/src/VideoAnalytics.ts b/packages/web-domain/src/VideoAnalytics.ts new file mode 100644 index 0000000000..5f56f88044 --- /dev/null +++ b/packages/web-domain/src/VideoAnalytics.ts @@ -0,0 +1,69 @@ +import { Rpc, RpcGroup } from "@effect/rpc"; +import { Schema } from "effect"; +import { InternalError } from "./Errors.ts"; +import { PolicyDeniedError } from "./Policy.ts"; +import { NotFoundError, VerifyVideoPasswordError, VideoId } from "./Video.ts"; + +export class VideoAnalytics extends Schema.Class( + "VideoAnalytics", +)({ + views: Schema.Int, +}) {} + +// TODO: Break into enum for page vs watch event +export class VideoCaptureEvent extends Schema.Class( + "VideoCaptureEvent", +)({ + video: VideoId, + sessionId: Schema.optional(Schema.String), + city: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + device: Schema.optional(Schema.String), + browser: Schema.optional(Schema.String), + os: Schema.optional(Schema.String), + referrer: Schema.optional(Schema.String), + referrerUrl: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + utmTerm: Schema.optional(Schema.String), + utmContent: Schema.optional(Schema.String), + watchTimeSeconds: Schema.optional(Schema.Int), + locale: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + timezone: Schema.optional(Schema.String), + pathname: Schema.optional(Schema.String), + href: Schema.optional(Schema.String), + userAgent: Schema.optional(Schema.String), +}) {} + +export class VideoAnalyticsRpcs extends RpcGroup.make( + Rpc.make("VideosGetViewCount", { + payload: Schema.Array(VideoId).pipe( + Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"), + ), + success: Schema.Array( + Schema.Exit({ + success: Schema.Struct({ count: Schema.Int }), + failure: Schema.Union( + NotFoundError, + PolicyDeniedError, + VerifyVideoPasswordError, // TODO: Is this correct? + ), + defect: Schema.Unknown, + }), + ), + error: InternalError, + }), + + Rpc.make("VideosGetAnalytics", { + payload: VideoId, + success: VideoAnalytics, + error: InternalError, + }), + + Rpc.make("VideosCaptureEvent", { + payload: VideoCaptureEvent, + error: InternalError, + }), +) {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 76c7755801..93abfc9ca9 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -18,3 +18,4 @@ export { SpaceMemberId, SpaceMemberRole } from "./Space.ts"; export * as User from "./User.ts"; export { UserId } from "./User.ts"; export * as Video from "./Video.ts"; +export * as VideoAnalytics from "./VideoAnalytics.ts"; diff --git a/tinybird/datasources/analytics_sessions.datasource b/tinybird/datasources/analytics_sessions.datasource new file mode 100644 index 0000000000..5c04350d88 --- /dev/null +++ b/tinybird/datasources/analytics_sessions.datasource @@ -0,0 +1,18 @@ +DESCRIPTION > + Sessions + +SCHEMA > + `date` Date, + `session_id` String, + `tenant_id` String, + `domain` String, + `device` SimpleAggregateFunction(any, String), + `browser` SimpleAggregateFunction(any, String), + `location` SimpleAggregateFunction(any, String), + `first_hit` SimpleAggregateFunction(min, DateTime), + `latest_hit` SimpleAggregateFunction(max, DateTime), + `hits` AggregateFunction(count) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY tenant_id, domain, date, session_id diff --git a/tinybird/datasources/analytics_views.datasource b/tinybird/datasources/analytics_views.datasource new file mode 100644 index 0000000000..8ea9ddfeef --- /dev/null +++ b/tinybird/datasources/analytics_views.datasource @@ -0,0 +1,32 @@ +DESCRIPTION > + Analytics events for when the share page is opened. + +TOKEN "dashboard" APPEND + +SCHEMA > + `timestamp` DateTime `json:$.timestamp`, + `version` LowCardinality(String) `json:$.version`, + + `session_id` Nullable(String) `json:$.session_id`, + `video_id` String `json:$.video_id`, + + `watch_time_seconds` Float32 `json:$.watch_time_seconds`, + + `city` LowCardinality(String) `json:$.city`, + `country` LowCardinality(String) `json:$.country`, + `device` LowCardinality(String) `json:$.device`, + `browser` LowCardinality(String) `json:$.browser`, + `os` LowCardinality(String) `json:$.os`, + `referrer` LowCardinality(String) `json:$.referrer`, + `referrer_url` String `json:$.referrer_url`, + `utm_source` LowCardinality(String) `json:$.utm_source`, + `utm_medium` LowCardinality(String) `json:$.utm_medium`, + `utm_campaign` LowCardinality(String) `json:$.utm_campaign`, + `utm_term` LowCardinality(String) `json:$.utm_term`, + `utm_content` LowCardinality(String) `json:$.utm_content`, + + `payload` String `json:$.payload` + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(timestamp) +ENGINE_SORTING_KEY video_id, timestamp diff --git a/tinybird/datasources/analytics_watch.datasource b/tinybird/datasources/analytics_watch.datasource new file mode 100644 index 0000000000..3af4baa8dd --- /dev/null +++ b/tinybird/datasources/analytics_watch.datasource @@ -0,0 +1,19 @@ +DESCRIPTION > + Analytics events for when a view is being viewed in the player. + +TOKEN "dashboard" APPEND + +SCHEMA > + `timestamp` DateTime `json:$.timestamp`, + `version` LowCardinality(String) `json:$.version`, + + `session_id` Nullable(String) `json:$.session_id`, + `video_id` String `json:$.video_id`, + + `action` LowCardinality(String) `json:$.action`, + + `ts` DateTime64(3) `json:$.ts`, + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(timestamp) +ENGINE_SORTING_KEY video_id, action, timestamp diff --git a/tinybird/endpoints/video_views.pipe b/tinybird/endpoints/video_views.pipe new file mode 100644 index 0000000000..db25008418 --- /dev/null +++ b/tinybird/endpoints/video_views.pipe @@ -0,0 +1,9 @@ +TOKEN dashboard READ +DESCRIPTION endpoint to get views for video + +NODE result +SQL > + % + SELECT count(*) as count FROM analytics_views + WHERE video_id = {{String(video_id)}} +TYPE ENDPOINT