mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 22:40:32 +01:00
Update commit checkpoints, add API route for stream viewers, and synchronize viewer count to database
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"last_commit": {
|
||||
"timestamp": "2025-12-21T23:12:34.993938+00:00",
|
||||
"timestamp": "2025-12-21T23:15:16.584895+00:00",
|
||||
"session_id": "60ef8f57-a5e4-4edb-a61d-0a4778a6de32",
|
||||
"last_entry_timestamp": "2025-12-21T23:12:32.638Z"
|
||||
"last_entry_timestamp": "2025-12-21T23:15:15.232Z"
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function useStreamViewers(username: string): UseStreamViewersResult {
|
||||
const perSession = presenceFeed.perSession
|
||||
if (perSession) {
|
||||
for (const sessionId of Object.keys(perSession)) {
|
||||
const entry = (perSession as Record<string, { value?: { lastActive: number } }>)[sessionId]
|
||||
const entry = (presenceFeed.perSession as Record<string, { value?: { lastActive: number } }>)[sessionId]
|
||||
if (entry?.value) {
|
||||
const age = now - entry.value.lastActive
|
||||
if (age < PRESENCE_STALE_MS) {
|
||||
@@ -139,6 +139,24 @@ export function useStreamViewers(username: string): UseStreamViewersResult {
|
||||
return () => clearInterval(interval)
|
||||
}, [presenceFeed?.$isLoaded, presenceFeed])
|
||||
|
||||
// Sync viewer count to database for external access
|
||||
useEffect(() => {
|
||||
if (viewerCount === 0) return
|
||||
|
||||
// Debounce the API call
|
||||
const timeout = setTimeout(() => {
|
||||
fetch(`/api/streams/${username}/viewers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ viewerCount }),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to sync viewer count:", err)
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [viewerCount, username])
|
||||
|
||||
return {
|
||||
viewerCount,
|
||||
isConnected: me.$isLoaded,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.i
|
||||
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
|
||||
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
|
||||
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
||||
import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers'
|
||||
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
|
||||
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
|
||||
|
||||
@@ -295,6 +296,12 @@ const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
||||
path: '/demo/start/ssr/data-only',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStreamsUsernameViewersRoute =
|
||||
ApiStreamsUsernameViewersRouteImport.update({
|
||||
id: '/viewers',
|
||||
path: '/viewers',
|
||||
getParentRoute: () => ApiStreamsUsernameRoute,
|
||||
} as any)
|
||||
const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({
|
||||
id: '/$imageId',
|
||||
path: '/$imageId',
|
||||
@@ -343,7 +350,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
@@ -352,6 +359,7 @@ export interface FileRoutesByFullPath {
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
@@ -393,7 +401,7 @@ export interface FileRoutesByTo {
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
@@ -402,6 +410,7 @@ export interface FileRoutesByTo {
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
@@ -445,7 +454,7 @@ export interface FileRoutesById {
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
@@ -454,6 +463,7 @@ export interface FileRoutesById {
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
@@ -507,6 +517,7 @@ export interface FileRouteTypes {
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/api/streams/$username/viewers'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
@@ -557,6 +568,7 @@ export interface FileRouteTypes {
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/api/streams/$username/viewers'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
@@ -608,6 +620,7 @@ export interface FileRouteTypes {
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/api/streams/$username/viewers'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
@@ -644,7 +657,7 @@ export interface RootRouteChildren {
|
||||
ApiChatGuestRoute: typeof ApiChatGuestRoute
|
||||
ApiChatMutationsRoute: typeof ApiChatMutationsRoute
|
||||
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
|
||||
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRoute
|
||||
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren
|
||||
ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute
|
||||
ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute
|
||||
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
||||
@@ -987,6 +1000,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/streams/$username/viewers': {
|
||||
id: '/api/streams/$username/viewers'
|
||||
path: '/viewers'
|
||||
fullPath: '/api/streams/$username/viewers'
|
||||
preLoaderRoute: typeof ApiStreamsUsernameViewersRouteImport
|
||||
parentRoute: typeof ApiStreamsUsernameRoute
|
||||
}
|
||||
'/api/canvas/images/$imageId': {
|
||||
id: '/api/canvas/images/$imageId'
|
||||
path: '/$imageId'
|
||||
@@ -1115,6 +1135,17 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren(
|
||||
ApiUsersRouteChildren,
|
||||
)
|
||||
|
||||
interface ApiStreamsUsernameRouteChildren {
|
||||
ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute
|
||||
}
|
||||
|
||||
const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = {
|
||||
ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute,
|
||||
}
|
||||
|
||||
const ApiStreamsUsernameRouteWithChildren =
|
||||
ApiStreamsUsernameRoute._addFileChildren(ApiStreamsUsernameRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
UsernameRoute: UsernameRoute,
|
||||
@@ -1144,7 +1175,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiChatGuestRoute: ApiChatGuestRoute,
|
||||
ApiChatMutationsRoute: ApiChatMutationsRoute,
|
||||
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
|
||||
ApiStreamsUsernameRoute: ApiStreamsUsernameRoute,
|
||||
ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren,
|
||||
ApiStripeCheckoutRoute: ApiStripeCheckoutRoute,
|
||||
ApiStripeWebhooksRoute: ApiStripeWebhooksRoute,
|
||||
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||
|
||||
105
packages/web/src/routes/api/streams.$username.viewers.ts
Normal file
105
packages/web/src/routes/api/streams.$username.viewers.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { streams, users } from "@/db/schema"
|
||||
|
||||
const resolveDatabaseUrl = () => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => {
|
||||
cloudflare?: { env?: Record<string, string> }
|
||||
} | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) return url
|
||||
} catch {
|
||||
// Not in server context
|
||||
}
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||
throw new Error("DATABASE_URL not configured")
|
||||
}
|
||||
|
||||
// GET - fetch current viewer count from database
|
||||
const getViewerCount = async ({
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { username: string }
|
||||
}) => {
|
||||
const { username } = params
|
||||
|
||||
try {
|
||||
const db = getDb(resolveDatabaseUrl())
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ viewerCount: 0, username })
|
||||
}
|
||||
|
||||
const stream = await db.query.streams.findFirst({
|
||||
where: eq(streams.user_id, user.id),
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
viewerCount: stream?.viewer_count ?? 0,
|
||||
username,
|
||||
isLive: stream?.is_live ?? false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get viewer count:", error)
|
||||
return Response.json({ viewerCount: 0, username })
|
||||
}
|
||||
}
|
||||
|
||||
// POST - update viewer count (called by Jazz client)
|
||||
const updateViewerCount = async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { username: string }
|
||||
}) => {
|
||||
const { username } = params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { viewerCount } = body as { viewerCount: number }
|
||||
|
||||
if (typeof viewerCount !== "number" || viewerCount < 0) {
|
||||
return Response.json({ error: "Invalid viewer count" }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb(resolveDatabaseUrl())
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(streams)
|
||||
.set({ viewer_count: viewerCount })
|
||||
.where(eq(streams.user_id, user.id))
|
||||
|
||||
return Response.json({ success: true, viewerCount, username })
|
||||
} catch (error) {
|
||||
console.error("Failed to update viewer count:", error)
|
||||
return Response.json({ error: "Failed to update" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/streams/$username/viewers")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: getViewerCount,
|
||||
POST: updateViewerCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user