From 205c38d0ee468fc0ee820b730e3a530b7d05c7de Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 00:41:00 -0800 Subject: [PATCH] Add database schema updates for user profile fields and streams, seed initial data, and extend components with streaming settings and profile fields. --- flow.toml | 223 ++++++++++ packages/web/scripts/seed.ts | 72 ++- .../web/src/components/ProfileSidebar.tsx | 1 - .../web/src/components/Settings-panel.tsx | 4 +- packages/web/src/db/schema.ts | 9 +- packages/web/src/lib/stream/db.ts | 4 + packages/web/src/routeTree.gen.ts | 63 ++- packages/web/src/routes/$username.tsx | 418 ++++-------------- packages/web/src/routes/api/check-hls.ts | 66 ++- .../web/src/routes/api/stream.settings.ts | 164 +++++++ .../routes/api/streams.$username.check-hls.ts | 139 ++++++ .../web/src/routes/api/streams.$username.ts | 4 + packages/web/src/routes/settings.tsx | 215 ++++++++- 13 files changed, 1030 insertions(+), 352 deletions(-) create mode 100644 packages/web/src/routes/api/stream.settings.ts create mode 100644 packages/web/src/routes/api/streams.$username.check-hls.ts diff --git a/flow.toml b/flow.toml index cf3a3662..5be58579 100644 --- a/flow.toml +++ b/flow.toml @@ -1935,3 +1935,226 @@ pnpm exec wrangler secret list 2>&1 | grep '"name"' | sed 's/.*"name": "\([^"]*\ ''' dependencies = ["pnpm"] shortcuts = ["sec"] + +# ============================================================================= +# Stream & Profile Management +# ============================================================================= + +[[tasks]] +name = "update-profile" +interactive = true +description = "Update nikiv's profile in production database" +command = ''' +set -euo pipefail + +cd packages/web + +# Load env +if [ -f .env ]; then + export $(grep -E "^PROD_DATABASE_URL=" .env | xargs) +fi + +if [ -z "${PROD_DATABASE_URL:-}" ]; then + echo "❌ PROD_DATABASE_URL not set in packages/web/.env" + exit 1 +fi + +echo "=== Update Profile ===" +echo "" +read -p "Enter username to update [nikiv]: " USERNAME +USERNAME="${USERNAME:-nikiv}" + +read -p "Enter bio: " BIO +read -p "Enter website (e.g., nikiv.dev): " WEBSITE +read -p "Enter location (optional): " LOCATION + +DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e " +const { neon } = require('@neondatabase/serverless'); +const sql = neon(process.env.DATABASE_URL); + +async function run() { + const bio = process.argv[2] || null; + const website = process.argv[3] || null; + const location = process.argv[4] || null; + const username = process.argv[5]; + + const result = await sql\` + UPDATE users + SET bio = \${bio}, website = \${website}, location = \${location}, \"updatedAt\" = NOW() + WHERE username = \${username} + RETURNING id, name, username, bio, website, location + \`; + + if (result.length === 0) { + console.log('User not found:', username); + return; + } + + console.log('✓ Profile updated:'); + console.log(JSON.stringify(result[0], null, 2)); +} + +run().catch(console.error); +" "$BIO" "$WEBSITE" "$LOCATION" "$USERNAME" +''' +dependencies = ["node", "pnpm"] +shortcuts = ["profile"] + +[[tasks]] +name = "deploy-all" +description = "Push schema + deploy worker + deploy web" +command = ''' +set -euo pipefail + +echo "=== Full Deploy ===" +echo "" + +cd packages/web + +# Load env +if [ -f .env ]; then + export $(grep -E "^PROD_DATABASE_URL=" .env | xargs) +fi + +# 1. Push schema +if [ -n "${PROD_DATABASE_URL:-}" ]; then + echo "1/3 Pushing schema..." + DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5 + echo "✓ Schema pushed" +else + echo "1/3 Skipping schema push (PROD_DATABASE_URL not set)" +fi + +# 2. Deploy worker +echo "" +echo "2/3 Deploying worker..." +cd ../worker +pnpm deploy 2>&1 | tail -5 +echo "✓ Worker deployed" + +# 3. Deploy web +echo "" +echo "3/3 Deploying web..." +cd ../web +pnpm deploy 2>&1 | tail -10 +echo "✓ Web deployed" + +echo "" +echo "=== Deploy Complete ===" +''' +dependencies = ["node", "pnpm"] +shortcuts = ["da", "full"] + +[[tasks]] +name = "deploy-web" +description = "Deploy web to Cloudflare" +command = ''' +cd packages/web +pnpm deploy +''' +dependencies = ["pnpm"] +shortcuts = ["dw"] + +[[tasks]] +name = "deploy-worker" +description = "Deploy worker to Cloudflare" +command = ''' +cd packages/worker +pnpm deploy +''' +dependencies = ["pnpm"] +shortcuts = ["dwk"] + +[[tasks]] +name = "schema-push" +description = "Push Drizzle schema to production (quick)" +command = ''' +set -euo pipefail + +cd packages/web + +if [ -f .env ]; then + export $(grep -E "^PROD_DATABASE_URL=" .env | xargs) +fi + +if [ -z "${PROD_DATABASE_URL:-}" ]; then + echo "❌ PROD_DATABASE_URL not set" + exit 1 +fi + +echo "Pushing schema to production..." +DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5 +echo "✓ Done" +''' +dependencies = ["node", "pnpm"] +shortcuts = ["sp", "schema"] + +[[tasks]] +name = "stream-secret" +interactive = true +description = "Set Cloudflare stream secret (CLOUDFLARE_LIVE_INPUT_UID)" +command = ''' +set -euo pipefail + +cd packages/web + +echo "=== Set Stream Secret ===" +echo "" +echo "Get your Live Input UID from Cloudflare Stream dashboard:" +echo "https://dash.cloudflare.com/?to=/:account/stream/inputs" +echo "" +read -p "Enter CLOUDFLARE_LIVE_INPUT_UID: " LIVE_INPUT_UID + +if [ -n "$LIVE_INPUT_UID" ]; then + echo "$LIVE_INPUT_UID" | pnpm exec wrangler secret put CLOUDFLARE_LIVE_INPUT_UID + echo "✓ CLOUDFLARE_LIVE_INPUT_UID set" +else + echo "Skipped" +fi +''' +dependencies = ["pnpm"] +shortcuts = ["stream"] + +[[tasks]] +name = "show-user" +description = "Show user profile from production database" +command = ''' +set -euo pipefail + +cd packages/web + +if [ -f .env ]; then + export $(grep -E "^PROD_DATABASE_URL=" .env | xargs) +fi + +if [ -z "${PROD_DATABASE_URL:-}" ]; then + echo "❌ PROD_DATABASE_URL not set" + exit 1 +fi + +USERNAME="${1:-nikiv}" + +DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e " +const { neon } = require('@neondatabase/serverless'); +const sql = neon(process.env.DATABASE_URL); + +async function run() { + const username = process.argv[2] || 'nikiv'; + const result = await sql\` + SELECT id, name, email, username, image, bio, website, location, tier, \"createdAt\" + FROM users WHERE username = \${username} + \`; + + if (result.length === 0) { + console.log('User not found:', username); + return; + } + + console.log(JSON.stringify(result[0], null, 2)); +} + +run().catch(console.error); +" "$USERNAME" +''' +dependencies = ["node", "pnpm"] +shortcuts = ["user"] diff --git a/packages/web/scripts/seed.ts b/packages/web/scripts/seed.ts index 6c236f23..3dd0e333 100644 --- a/packages/web/scripts/seed.ts +++ b/packages/web/scripts/seed.ts @@ -7,6 +7,7 @@ import { chat_messages, chat_threads, sessions, + streams, users, verifications, } from "../src/db/schema" @@ -116,7 +117,12 @@ async function ensureTables() { ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false, ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(), - ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now() + ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now(), + ADD COLUMN IF NOT EXISTS "username" text UNIQUE, + ADD COLUMN IF NOT EXISTS "bio" text, + ADD COLUMN IF NOT EXISTS "website" text, + ADD COLUMN IF NOT EXISTS "location" text, + ADD COLUMN IF NOT EXISTS "tier" varchar(32) DEFAULT 'free' `) await authDb.execute( sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`, @@ -264,6 +270,68 @@ async function ensureTables() { async function seed() { await ensureTables() + // Create streams table if it doesn't exist + await appDb.execute(sql` + CREATE TABLE IF NOT EXISTS "streams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "title" text NOT NULL DEFAULT 'Live Stream', + "description" text, + "is_live" boolean NOT NULL DEFAULT false, + "viewer_count" integer NOT NULL DEFAULT 0, + "stream_key" text NOT NULL UNIQUE, + "cloudflare_live_input_uid" text, + "cloudflare_customer_code" text, + "hls_url" text, + "webrtc_url" text, + "thumbnail_url" text, + "started_at" timestamptz, + "ended_at" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ); + `) + + // Add cloudflare columns if they don't exist (for existing tables) + await appDb.execute(sql` + ALTER TABLE "streams" + ADD COLUMN IF NOT EXISTS "cloudflare_live_input_uid" text, + ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text + `) + + // ========== Seed nikiv user ========== + const nikivUserId = "nikiv" + const nikivEmail = "nikita.voloboev@gmail.com" + + await authDb + .insert(users) + .values({ + id: nikivUserId, + name: "Nikita Voloboev", + email: nikivEmail, + username: "nikiv", + emailVerified: true, + image: "https://nikiv.dev/nikiv.jpg", + bio: "Building in public. Making tools I want to exist.", + website: "nikiv.dev", + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing({ target: users.id }) + + // Create stream for nikiv (HLS URL will come from env variable fallback) + const nikivStreamKey = crypto.randomUUID() + await appDb + .insert(streams) + .values({ + user_id: nikivUserId, + title: "Live Coding", + description: "Building in public", + stream_key: nikivStreamKey, + }) + .onConflictDoNothing() + + // ========== Seed demo user ========== const demoUserId = "demo-user" const demoEmail = "demo@ai.chat" @@ -342,7 +410,7 @@ async function seed() { seed() .then(() => { - console.log("Seed complete: demo user and chat thread ready.") + console.log("Seed complete: nikiv user, stream, demo user, and chat thread ready.") }) .catch((err) => { console.error(err) diff --git a/packages/web/src/components/ProfileSidebar.tsx b/packages/web/src/components/ProfileSidebar.tsx index 3ff7e2ec..f03d6d1c 100644 --- a/packages/web/src/components/ProfileSidebar.tsx +++ b/packages/web/src/components/ProfileSidebar.tsx @@ -1,4 +1,3 @@ -import { Link } from "@tanstack/react-router" import { ExternalLink, MapPin, Calendar, Users } from "lucide-react" interface ProfileSidebarProps { diff --git a/packages/web/src/components/Settings-panel.tsx b/packages/web/src/components/Settings-panel.tsx index 33a94792..223c7de4 100644 --- a/packages/web/src/components/Settings-panel.tsx +++ b/packages/web/src/components/Settings-panel.tsx @@ -5,9 +5,10 @@ import { UserRound, type LucideIcon, CreditCard, + Video, } from "lucide-react" -type SettingsSection = "preferences" | "profile" | "billing" +type SettingsSection = "preferences" | "profile" | "streaming" | "billing" interface UserProfile { name?: string | null @@ -30,6 +31,7 @@ type NavItem = { const navItems: NavItem[] = [ { id: "preferences", label: "Preferences", icon: SlidersHorizontal }, { id: "profile", label: "Profile", icon: UserRound }, + { id: "streaming", label: "Streaming", icon: Video }, { id: "billing", label: "Manage Billing", icon: CreditCard }, ] diff --git a/packages/web/src/db/schema.ts b/packages/web/src/db/schema.ts index 9b2bc392..2ff0b334 100644 --- a/packages/web/src/db/schema.ts +++ b/packages/web/src/db/schema.ts @@ -25,6 +25,10 @@ export const users = pgTable("users", { .$defaultFn(() => false) .notNull(), image: text("image"), + // Profile fields + bio: text("bio"), + website: text("website"), + location: text("location"), // Access tiers: 'free' | 'creator' | 'dev' - determines feature access tier: varchar("tier", { length: 32 }).notNull().default("free"), createdAt: timestamp("createdAt") @@ -247,7 +251,10 @@ export const streams = pgTable("streams", { is_live: boolean("is_live").notNull().default(false), viewer_count: integer("viewer_count").notNull().default(0), stream_key: text("stream_key").notNull().unique(), // secret key for streaming - // Stream endpoints (set by Linux server) + // Cloudflare Stream integration + cloudflare_live_input_uid: text("cloudflare_live_input_uid"), // Cloudflare Live Input UID for automatic stream detection + cloudflare_customer_code: text("cloudflare_customer_code"), // Customer subdomain (optional, defaults to linsa's) + // Stream endpoints (can be auto-generated from cloudflare_live_input_uid or set manually) hls_url: text("hls_url"), // HLS playback URL webrtc_url: text("webrtc_url"), // WebRTC playback URL thumbnail_url: text("thumbnail_url"), diff --git a/packages/web/src/lib/stream/db.ts b/packages/web/src/lib/stream/db.ts index 37246dba..1429962c 100644 --- a/packages/web/src/lib/stream/db.ts +++ b/packages/web/src/lib/stream/db.ts @@ -6,6 +6,10 @@ export type StreamPageData = { name: string username: string | null image: string | null + bio?: string | null + website?: string | null + location?: string | null + joinedAt?: string | null } stream: { id: string diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 9b3141d2..f6c22c2e 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -50,6 +50,7 @@ import { Route as ApiStripePortalRouteImport } from './routes/api/stripe/portal' import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout' import { Route as ApiStripeBillingRouteImport } from './routes/api/stripe/billing' import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username' +import { Route as ApiStreamSettingsRouteImport } from './routes/api/stream.settings' import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId' import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing' import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$' @@ -69,6 +70,7 @@ import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.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 ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays' +import { Route as ApiStreamsUsernameCheckHlsRouteImport } from './routes/api/streams.$username.check-hls' import { Route as ApiCreatorUsernameAccessRouteImport } from './routes/api/creator/$username.access' import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId' import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate' @@ -278,6 +280,11 @@ const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({ path: '/api/streams/$username', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamSettingsRoute = ApiStreamSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ApiStreamRoute, +} as any) const ApiStreamReplaysReplayIdRoute = ApiStreamReplaysReplayIdRouteImport.update({ id: '/$replayId', @@ -377,6 +384,12 @@ const ApiStreamsUsernameReplaysRoute = path: '/replays', getParentRoute: () => ApiStreamsUsernameRoute, } as any) +const ApiStreamsUsernameCheckHlsRoute = + ApiStreamsUsernameCheckHlsRouteImport.update({ + id: '/check-hls', + path: '/check-hls', + getParentRoute: () => ApiStreamsUsernameRoute, + } as any) const ApiCreatorUsernameAccessRoute = ApiCreatorUsernameAccessRouteImport.update({ id: '/api/creator/$username/access', @@ -417,7 +430,7 @@ export interface FileRoutesByFullPath { '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute - '/api/stream': typeof ApiStreamRoute + '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-status': typeof ApiStreamStatusRoute @@ -440,6 +453,7 @@ export interface FileRoutesByFullPath { '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute + '/api/stream/settings': typeof ApiStreamSettingsRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute @@ -452,6 +466,7 @@ export interface FileRoutesByFullPath { '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute + '/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -481,7 +496,7 @@ export interface FileRoutesByTo { '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute - '/api/stream': typeof ApiStreamRoute + '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-status': typeof ApiStreamStatusRoute @@ -504,6 +519,7 @@ export interface FileRoutesByTo { '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute + '/api/stream/settings': typeof ApiStreamSettingsRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute @@ -516,6 +532,7 @@ export interface FileRoutesByTo { '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute + '/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -547,7 +564,7 @@ export interface FileRoutesById { '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute - '/api/stream': typeof ApiStreamRoute + '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-status': typeof ApiStreamStatusRoute @@ -570,6 +587,7 @@ export interface FileRoutesById { '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute + '/api/stream/settings': typeof ApiStreamSettingsRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute @@ -582,6 +600,7 @@ export interface FileRoutesById { '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren '/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute + '/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute @@ -637,6 +656,7 @@ export interface FileRouteTypes { | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' + | '/api/stream/settings' | '/api/streams/$username' | '/api/stripe/billing' | '/api/stripe/checkout' @@ -649,6 +669,7 @@ export interface FileRouteTypes { | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' | '/api/creator/$username/access' + | '/api/streams/$username/check-hls' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -701,6 +722,7 @@ export interface FileRouteTypes { | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' + | '/api/stream/settings' | '/api/streams/$username' | '/api/stripe/billing' | '/api/stripe/checkout' @@ -713,6 +735,7 @@ export interface FileRouteTypes { | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' | '/api/creator/$username/access' + | '/api/streams/$username/check-hls' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -766,6 +789,7 @@ export interface FileRouteTypes { | '/api/flowglad/$' | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' + | '/api/stream/settings' | '/api/streams/$username' | '/api/stripe/billing' | '/api/stripe/checkout' @@ -778,6 +802,7 @@ export interface FileRouteTypes { | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' | '/api/creator/$username/access' + | '/api/streams/$username/check-hls' | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' @@ -809,7 +834,7 @@ export interface RootRouteChildren { ApiCheckHlsRoute: typeof ApiCheckHlsRoute ApiContextItemsRoute: typeof ApiContextItemsRoute ApiProfileRoute: typeof ApiProfileRoute - ApiStreamRoute: typeof ApiStreamRoute + ApiStreamRoute: typeof ApiStreamRouteWithChildren ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren ApiStreamStatusRoute: typeof ApiStreamStatusRoute @@ -1128,6 +1153,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamsUsernameRouteImport parentRoute: typeof rootRouteImport } + '/api/stream/settings': { + id: '/api/stream/settings' + path: '/settings' + fullPath: '/api/stream/settings' + preLoaderRoute: typeof ApiStreamSettingsRouteImport + parentRoute: typeof ApiStreamRoute + } '/api/stream-replays/$replayId': { id: '/api/stream-replays/$replayId' path: '/$replayId' @@ -1261,6 +1293,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport parentRoute: typeof ApiStreamsUsernameRoute } + '/api/streams/$username/check-hls': { + id: '/api/streams/$username/check-hls' + path: '/check-hls' + fullPath: '/api/streams/$username/check-hls' + preLoaderRoute: typeof ApiStreamsUsernameCheckHlsRouteImport + parentRoute: typeof ApiStreamsUsernameRoute + } '/api/creator/$username/access': { id: '/api/creator/$username/access' path: '/api/creator/$username/access' @@ -1372,6 +1411,18 @@ const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren( ApiCanvasRouteChildren, ) +interface ApiStreamRouteChildren { + ApiStreamSettingsRoute: typeof ApiStreamSettingsRoute +} + +const ApiStreamRouteChildren: ApiStreamRouteChildren = { + ApiStreamSettingsRoute: ApiStreamSettingsRoute, +} + +const ApiStreamRouteWithChildren = ApiStreamRoute._addFileChildren( + ApiStreamRouteChildren, +) + interface ApiStreamReplaysRouteChildren { ApiStreamReplaysReplayIdRoute: typeof ApiStreamReplaysReplayIdRoute } @@ -1408,11 +1459,13 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( ) interface ApiStreamsUsernameRouteChildren { + ApiStreamsUsernameCheckHlsRoute: typeof ApiStreamsUsernameCheckHlsRoute ApiStreamsUsernameReplaysRoute: typeof ApiStreamsUsernameReplaysRoute ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute } const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = { + ApiStreamsUsernameCheckHlsRoute: ApiStreamsUsernameCheckHlsRoute, ApiStreamsUsernameReplaysRoute: ApiStreamsUsernameReplaysRoute, ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute, } @@ -1442,7 +1495,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCheckHlsRoute: ApiCheckHlsRoute, ApiContextItemsRoute: ApiContextItemsRoute, ApiProfileRoute: ApiProfileRoute, - ApiStreamRoute: ApiStreamRoute, + ApiStreamRoute: ApiStreamRouteWithChildren, ApiStreamCommentsRoute: ApiStreamCommentsRoute, ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren, ApiStreamStatusRoute: ApiStreamStatusRoute, diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 110df05c..d2a7524d 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -2,14 +2,9 @@ import { useEffect, useRef, useState } from "react" import { createFileRoute, Link } from "@tanstack/react-router" import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { VideoPlayer } from "@/components/VideoPlayer" -import { resolveStreamPlayback } from "@/lib/stream/playback" import { JazzProvider } from "@/lib/jazz/provider" import { CommentBox } from "@/components/CommentBox" import { ProfileSidebar } from "@/components/ProfileSidebar" -import { - getSpotifyNowPlaying, - type SpotifyNowPlayingResponse, -} from "@/lib/spotify/now-playing" import { authClient } from "@/lib/auth-client" import { MessageCircle, LogIn, X, User } from "lucide-react" @@ -20,34 +15,6 @@ export const Route = createFileRoute("/$username")({ const READY_PULSE_MS = 1200 -// Hardcoded user for nikiv (hls_url will be updated from API) -function makeNikivData(hlsUrl: string): StreamPageData { - return { - user: { - id: "nikiv", - name: "Nikita Voloboev", - username: "nikiv", - image: "https://nikiv.dev/nikiv.jpg", - bio: "Building in public. Making tools I want to exist.", - website: "nikiv.dev", - location: null, - joinedAt: "2024-01-01", - }, - stream: { - id: "nikiv-stream", - title: "Live Coding", - description: "Building in public", - is_live: false, - viewer_count: 0, - hls_url: hlsUrl, - webrtc_url: null, - playback: resolveStreamPlayback({ hlsUrl, webrtcUrl: null }), - thumbnail_url: null, - started_at: null, - }, - } -} - function StreamPage() { const { username } = Route.useParams() const { data: session } = authClient.useSession() @@ -58,32 +25,20 @@ function StreamPage() { const [hlsLive, setHlsLive] = useState(null) const [hlsUrl, setHlsUrl] = useState(null) const [isConnecting, setIsConnecting] = useState(false) - const [nowPlaying, setNowPlaying] = useState( - null, - ) - const [nowPlayingLoading, setNowPlayingLoading] = useState(false) - const [nowPlayingError, setNowPlayingError] = useState(false) - const [streamLive, setStreamLive] = useState(false) const [showReadyPulse, setShowReadyPulse] = useState(false) const readyPulseTimeoutRef = useRef | null>(null) const hasConnectedOnce = useRef(false) - // Mobile chat overlay + // Mobile overlays const [showMobileChat, setShowMobileChat] = useState(false) + const [showMobileProfile, setShowMobileProfile] = useState(false) const isAuthenticated = !!session?.user + // Fetch user and stream data from API useEffect(() => { let isActive = true - // Special handling for nikiv - URL comes from API (secret) - // Data and loading state are handled by the HLS check effect - if (username === "nikiv") { - return () => { - isActive = false - } - } - const loadData = async () => { if (!isActive) return setLoading(true) @@ -92,6 +47,9 @@ function StreamPage() { const result = await getStreamByUsername(username) if (isActive) { setData(result) + if (result?.stream?.hls_url) { + setHlsUrl(result.stream.hls_url) + } } } catch (err) { if (isActive) { @@ -111,34 +69,7 @@ function StreamPage() { } }, [username]) - // Poll stream status for nikiv from nikiv.dev/api/stream-status - useEffect(() => { - if (username !== "nikiv") { - return - } - - let isActive = true - - const fetchStatus = async () => { - const status = await getStreamStatus() - console.log("[Stream Status] nikiv.dev/api/stream-status:", status) - if (isActive) { - setStreamLive(status.isLive) - } - } - - // Fetch immediately - fetchStatus() - - // Poll every 10 seconds - const interval = setInterval(fetchStatus, 10000) - - return () => { - isActive = false - clearInterval(interval) - } - }, [username]) - + // Ready pulse animation useEffect(() => { if (readyPulseTimeoutRef.current) { clearTimeout(readyPulseTimeoutRef.current) @@ -165,218 +96,64 @@ function StreamPage() { }, [playerReady]) const stream = data?.stream ?? null - // For nikiv, always use HLS directly (no WebRTC) - URL comes from API - const activePlayback = username === "nikiv" && hlsUrl + const activePlayback = hlsUrl ? { type: "hls" as const, url: hlsUrl } : stream?.playback ?? null - const isHlsPlaylistLive = (manifest: string) => { - const upper = manifest.toUpperCase() - const hasEndlist = upper.includes("#EXT-X-ENDLIST") - const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD") - const hasSegments = - upper.includes("#EXTINF") || upper.includes("#EXT-X-PART") - // Also check for #EXTM3U which is the start of any valid HLS manifest - const isValidManifest = upper.includes("#EXTM3U") - return isValidManifest && !hasEndlist && !isVod && hasSegments - } - - // For nikiv, use server-side API to check HLS (avoids CORS, gets URL from secret) + // Poll HLS status via server-side API (avoids CORS issues) useEffect(() => { - if (username !== "nikiv") return + if (!data?.user) return let isActive = true - let isFirstCheck = true - const checkHlsViaApi = async () => { + const checkHls = async () => { try { - const res = await fetch("/api/check-hls", { cache: "no-store" }) + const res = await fetch(`/api/streams/${username}/check-hls`, { cache: "no-store" }) if (!isActive) return const apiData = await res.json() - // Update HLS URL from API (comes from server secret) + // Update HLS URL if returned from API if (apiData.hlsUrl && apiData.hlsUrl !== hlsUrl) { setHlsUrl(apiData.hlsUrl) - setData(makeNikivData(apiData.hlsUrl)) } if (apiData.isLive) { - // Stream is live - set connecting state if first time if (!hasConnectedOnce.current) { setIsConnecting(true) } setHlsLive(true) } else { - // Only set offline if we haven't connected yet - // This prevents flickering when HLS check temporarily fails if (!hasConnectedOnce.current) { setHlsLive(false) } } } catch { - // Silently ignore errors - don't change state on network issues - } finally { - // Mark loading as done after first check completes - if (isActive && isFirstCheck) { - isFirstCheck = false - setLoading(false) - } + // Silently ignore errors } } - // Initial check setHlsLive(null) - checkHlsViaApi() + checkHls() - // Poll every 5 seconds to detect when stream goes live - const interval = setInterval(checkHlsViaApi, 5000) + // Poll every 5 seconds + const interval = setInterval(checkHls, 5000) return () => { isActive = false clearInterval(interval) } - }, [username, hlsUrl]) + }, [data?.user, username, hlsUrl]) - // For non-nikiv users, use direct HLS check - useEffect(() => { - if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") { - return - } - - let isActive = true - - const checkHlsLive = async () => { - try { - const res = await fetch(activePlayback.url, { - cache: "no-store", - mode: "cors", - }) - - if (!isActive) return - - if (!res.ok) { - if (!hasConnectedOnce.current) { - setHlsLive(false) - } - return - } - - const manifest = await res.text() - if (!isActive) return - - const live = isHlsPlaylistLive(manifest) - if (live) { - if (!hasConnectedOnce.current) { - setIsConnecting(true) - } - setHlsLive(true) - } else if (!hasConnectedOnce.current) { - setHlsLive(false) - } - } catch { - // Silently ignore fetch errors - } - } - - setHlsLive(null) - checkHlsLive() - - const interval = setInterval(checkHlsLive, 5000) - - return () => { - isActive = false - clearInterval(interval) - } - }, [ - username, - activePlayback?.type, - activePlayback?.type === "hls" ? activePlayback.url : null, - ]) - - useEffect(() => { - let isActive = true - if (!stream?.hls_url || activePlayback?.type === "hls") { - return () => { - isActive = false - } - } - - setHlsLive(null) - fetch(stream.hls_url) - .then(async (res) => { - if (!isActive) return - if (!res.ok) { - setHlsLive(false) - return - } - const manifest = await res.text() - if (!isActive) return - setHlsLive(isHlsPlaylistLive(manifest)) - }) - .catch(() => { - if (isActive) { - setHlsLive(false) - } - }) - - return () => { - isActive = false - } - }, [activePlayback?.type, stream?.hls_url]) - - // For nikiv, use HLS live check from our API - // For other users, use stream?.is_live from the database - const isActuallyLive = username === "nikiv" - ? hlsLive === true - : Boolean(stream?.is_live) - - // Only show Spotify when we know stream is offline (not during initial check) - const shouldFetchSpotify = username === "nikiv" && !isActuallyLive && hlsLive === false - - useEffect(() => { - if (!shouldFetchSpotify) { - setNowPlaying(null) - setNowPlayingLoading(false) - setNowPlayingError(false) - return - } - - let isActive = true - - const fetchNowPlaying = async (showLoading: boolean) => { - if (showLoading) { - setNowPlayingLoading(true) - } - try { - const response = await getSpotifyNowPlaying() - if (!isActive) return - setNowPlaying(response) - setNowPlayingError(false) - } catch { - if (!isActive) return - // Silently handle Spotify errors - it's not critical - setNowPlayingError(true) - } finally { - if (isActive && showLoading) { - setNowPlayingLoading(false) - } - } - } - - fetchNowPlaying(true) - const interval = setInterval(() => fetchNowPlaying(false), 30000) - - return () => { - isActive = false - clearInterval(interval) - } - }, [shouldFetchSpotify]) + // Determine if stream is actually live + const isActuallyLive = hlsLive === true || Boolean(stream?.is_live) if (loading) { return (
-
Loading...
+
+
+
) } @@ -405,41 +182,31 @@ function StreamPage() { ) } - const nowPlayingTrack = nowPlaying?.track ?? null - const nowPlayingArtists = nowPlayingTrack?.artists.length - ? nowPlayingTrack.artists.join(", ") - : null - const nowPlayingText = nowPlayingTrack - ? nowPlayingArtists - ? `${nowPlayingArtists} — ${nowPlayingTrack.title}` - : nowPlayingTrack.title - : null - - // Callback when player is ready const handlePlayerReady = () => { hasConnectedOnce.current = true setIsConnecting(false) setPlayerReady(true) } - // Show loading state during initial check const isChecking = hlsLive === null + // Build profile user object + const profileUser = { + id: data.user.id, + name: data.user.name, + username: data.user.username ?? username, + image: data.user.image, + bio: data.user.bio ?? null, + website: data.user.website ?? null, + location: data.user.location ?? null, + joinedAt: data.user.joinedAt ?? null, + } + return ( -
- {/* Main content area */} + {/* Main content area - Stream */}
- - {/* Viewer count overlay - hidden on mobile */} - {isActuallyLive && ( -
- -
- )} - - {/* Loading state - checking if stream is live */} {isChecking ? (
@@ -448,14 +215,12 @@ function StreamPage() {

Checking stream status...

) : isActuallyLive && activePlayback ? ( - /* Stream is live - show the player */
- {/* Loading overlay while connecting */} {(isConnecting || !playerReady) && (
@@ -464,7 +229,6 @@ function StreamPage() {

Connecting to stream...

)} - {/* Ready pulse */} {showReadyPulse && (
🔴
@@ -472,76 +236,43 @@ function StreamPage() { )}
) : ( - /* Stream is offline */
- {shouldFetchSpotify ? ( -
-
- - Offline -
-

- Not live right now -

-
- {nowPlayingLoading ? ( - Checking Spotify... - ) : nowPlaying?.isPlaying && nowPlayingTrack ? ( - - Currently playing{" "} - {nowPlayingTrack.url ? ( - - {nowPlayingText ?? "Spotify"} - - ) : ( - - {nowPlayingText ?? "Spotify"} - - )} - - ) : null} -
- +
+
+ + Offline +
+

+ Not live right now +

+ {profileUser.website && ( - nikiv.dev + {profileUser.website.replace(/^https?:\/\//, "")} -
- ) : ( - - )} + )} +
)}
- {/* Desktop Chat sidebar */} -
- + {/* Desktop Profile Sidebar with Chat */} +
+ + +
{/* Mobile bottom bar */} -
+
{!isAuthenticated && ( Chat +
{/* Mobile chat overlay */} @@ -579,6 +318,29 @@ function StreamPage() {
)} + + {/* Mobile profile overlay */} + {showMobileProfile && ( +
+
+ Profile + +
+
+ +
+
+ )}
) diff --git a/packages/web/src/routes/api/check-hls.ts b/packages/web/src/routes/api/check-hls.ts index 14082ab7..3f75b38d 100644 --- a/packages/web/src/routes/api/check-hls.ts +++ b/packages/web/src/routes/api/check-hls.ts @@ -1,4 +1,7 @@ import { createFileRoute } from "@tanstack/react-router" +import { eq } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { users } from "@/db/schema" const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { @@ -9,21 +12,52 @@ const json = (data: unknown, status = 200) => // Cloudflare customer subdomain const CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc" -function getHlsUrl(): string { +function getEnvFromContext(): { hlsUrl: string; databaseUrl: string | null } { try { const { getServerContext } = require("@tanstack/react-start/server") as { getServerContext: () => { cloudflare?: { env?: Record } } | null } const ctx = getServerContext() const liveInputUid = ctx?.cloudflare?.env?.CLOUDFLARE_LIVE_INPUT_UID + const databaseUrl = ctx?.cloudflare?.env?.DATABASE_URL ?? process.env.DATABASE_URL ?? null + if (liveInputUid) { - return `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8` + return { + hlsUrl: `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`, + databaseUrl, + } } } catch {} - // Fallback - should not happen in production throw new Error("CLOUDFLARE_LIVE_INPUT_UID not configured") } +async function getNikivProfile(databaseUrl: string | null) { + if (!databaseUrl) return null + + try { + const database = getDb(databaseUrl) + const user = await database.query.users.findFirst({ + where: eq(users.username, "nikiv"), + }) + + if (!user) return null + + return { + id: user.id, + name: user.name, + username: user.username, + image: user.image, + bio: user.bio ?? null, + website: user.website ?? null, + location: user.location ?? null, + joinedAt: user.createdAt?.toISOString() ?? null, + } + } catch (err) { + console.error("[check-hls] Failed to fetch nikiv profile:", err) + return null + } +} + function isHlsPlaylistLive(manifest: string): boolean { const upper = manifest.toUpperCase() const hasEndlist = upper.includes("#EXT-X-ENDLIST") @@ -44,23 +78,27 @@ export const Route = createFileRoute("/api/check-hls")({ handlers: { GET: async () => { try { - const hlsUrl = getHlsUrl() - const res = await fetch(hlsUrl, { - cache: "no-store", - }) + const { hlsUrl, databaseUrl } = getEnvFromContext() - console.log("[check-hls] Response status:", res.status) + // Fetch profile and HLS status in parallel + const [profile, hlsRes] = await Promise.all([ + getNikivProfile(databaseUrl), + fetch(hlsUrl, { cache: "no-store" }), + ]) - if (!res.ok) { + console.log("[check-hls] Response status:", hlsRes.status) + + if (!hlsRes.ok) { return json({ isLive: false, hlsUrl, - status: res.status, + profile, + status: hlsRes.status, error: "HLS not available", }) } - const manifest = await res.text() + const manifest = await hlsRes.text() const isLive = isHlsPlaylistLive(manifest) console.log("[check-hls] Manifest check:", { @@ -72,7 +110,8 @@ export const Route = createFileRoute("/api/check-hls")({ return json({ isLive, hlsUrl, - status: res.status, + profile, + status: hlsRes.status, manifestLength: manifest.length, }) } catch (err) { @@ -80,7 +119,8 @@ export const Route = createFileRoute("/api/check-hls")({ console.error("[check-hls] Error:", error.message) return json({ isLive: false, - hlsUrl: getHlsUrl(), + hlsUrl: null, + profile: null, error: error.message, }) } diff --git a/packages/web/src/routes/api/stream.settings.ts b/packages/web/src/routes/api/stream.settings.ts new file mode 100644 index 00000000..d6e5d6f5 --- /dev/null +++ b/packages/web/src/routes/api/stream.settings.ts @@ -0,0 +1,164 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { users, streams } from "@/db/schema" +import crypto from "node:crypto" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +const getServerContext = () => { + try { + const { getServerContext: gsc } = require("@tanstack/react-start/server") as { + getServerContext: () => { cloudflare?: { env?: Record } } | null + } + return gsc() + } catch {} + return null +} + +const resolveDatabaseUrl = () => { + const ctx = getServerContext() + const env = ctx?.cloudflare?.env as Record | undefined + if (env?.DATABASE_URL) return env.DATABASE_URL + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + throw new Error("DATABASE_URL is not configured") +} + +// Get user from session cookie +async function getSessionUser(request: Request) { + const cookie = request.headers.get("cookie") + if (!cookie) return null + + // Parse session token from cookie + const sessionMatch = cookie.match(/better-auth\.session_token=([^;]+)/) + if (!sessionMatch) return null + + const sessionToken = decodeURIComponent(sessionMatch[1]) + const database = getDb(resolveDatabaseUrl()) + + // Look up session and get user + const result = await database.execute<{ userId: string }>( + `SELECT "userId" FROM sessions WHERE token = $1 AND "expiresAt" > NOW()`, + [sessionToken] + ) + + if (!result.rows.length) return null + + const user = await database.query.users.findFirst({ + where: eq(users.id, result.rows[0].userId), + }) + + return user +} + +export const Route = createFileRoute("/api/stream/settings")({ + server: { + handlers: { + // GET - Fetch current user's stream settings + GET: async ({ request }) => { + const user = await getSessionUser(request) + if (!user) { + return json({ error: "Unauthorized" }, 401) + } + + const database = getDb(resolveDatabaseUrl()) + + let stream = await database.query.streams.findFirst({ + where: eq(streams.user_id, user.id), + }) + + // If no stream exists, create one + if (!stream) { + const streamKey = crypto.randomUUID() + const [newStream] = await database + .insert(streams) + .values({ + user_id: user.id, + stream_key: streamKey, + title: "Live Stream", + }) + .returning() + stream = newStream + } + + return json({ + id: stream.id, + title: stream.title, + description: stream.description, + cloudflare_live_input_uid: stream.cloudflare_live_input_uid, + cloudflare_customer_code: stream.cloudflare_customer_code, + hls_url: stream.hls_url, + stream_key: stream.stream_key, + }) + }, + + // PUT - Update stream settings + PUT: async ({ request }) => { + const user = await getSessionUser(request) + if (!user) { + return json({ error: "Unauthorized" }, 401) + } + + const body = await request.json() + const { + title, + description, + cloudflare_live_input_uid, + cloudflare_customer_code, + } = body + + const database = getDb(resolveDatabaseUrl()) + + // Get or create stream + let stream = await database.query.streams.findFirst({ + where: eq(streams.user_id, user.id), + }) + + if (!stream) { + const streamKey = crypto.randomUUID() + const [newStream] = await database + .insert(streams) + .values({ + user_id: user.id, + stream_key: streamKey, + title: title || "Live Stream", + description, + cloudflare_live_input_uid, + cloudflare_customer_code, + }) + .returning() + stream = newStream + } else { + // Update existing stream + const [updatedStream] = await database + .update(streams) + .set({ + title: title ?? stream.title, + description: description ?? stream.description, + cloudflare_live_input_uid: cloudflare_live_input_uid ?? stream.cloudflare_live_input_uid, + cloudflare_customer_code: cloudflare_customer_code ?? stream.cloudflare_customer_code, + updated_at: new Date(), + }) + .where(eq(streams.id, stream.id)) + .returning() + stream = updatedStream + } + + return json({ + success: true, + stream: { + id: stream.id, + title: stream.title, + description: stream.description, + cloudflare_live_input_uid: stream.cloudflare_live_input_uid, + cloudflare_customer_code: stream.cloudflare_customer_code, + }, + }) + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/streams.$username.check-hls.ts b/packages/web/src/routes/api/streams.$username.check-hls.ts new file mode 100644 index 00000000..5659fd54 --- /dev/null +++ b/packages/web/src/routes/api/streams.$username.check-hls.ts @@ -0,0 +1,139 @@ +import { createFileRoute } from "@tanstack/react-router" +import { eq } from "drizzle-orm" +import { getDb } from "@/db/connection" +import { users, streams } from "@/db/schema" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +// Default Cloudflare customer subdomain (linsa's account) +const DEFAULT_CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc" + +const getServerEnv = () => { + try { + const { getServerContext } = require("@tanstack/react-start/server") as { + getServerContext: () => { cloudflare?: { env?: Record } } | null + } + return getServerContext()?.cloudflare?.env ?? {} + } catch {} + return {} +} + +const resolveDatabaseUrl = () => { + const env = getServerEnv() + if (env.DATABASE_URL) return env.DATABASE_URL + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + throw new Error("DATABASE_URL is not configured") +} + +// Construct Cloudflare Stream HLS URL from live input UID +const buildCloudflareHlsUrl = (liveInputUid: string, customerCode?: string | null): string => { + const code = customerCode || DEFAULT_CLOUDFLARE_CUSTOMER_CODE + return `https://customer-${code}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8` +} + +// Fallback to env variable for backwards compatibility +const resolveEnvCloudflareHlsUrl = (): string | null => { + const env = getServerEnv() + const liveInputUid = env.CLOUDFLARE_LIVE_INPUT_UID + if (liveInputUid) { + return buildCloudflareHlsUrl(liveInputUid) + } + return null +} + +function isHlsPlaylistLive(manifest: string): boolean { + const upper = manifest.toUpperCase() + const hasEndlist = upper.includes("#EXT-X-ENDLIST") + const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD") + const hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART") + const isValidManifest = upper.includes("#EXTM3U") + const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF") + return isValidManifest && (isMasterPlaylist || (!hasEndlist && !isVod && hasSegments)) +} + +export const Route = createFileRoute("/api/streams/$username/check-hls")({ + server: { + handlers: { + GET: async ({ params }) => { + const { username } = params + + if (!username) { + return json({ error: "Username required" }, 400) + } + + try { + const database = getDb(resolveDatabaseUrl()) + + const user = await database.query.users.findFirst({ + where: eq(users.username, username), + }) + + if (!user) { + return json({ error: "User not found", isLive: false }, 404) + } + + const stream = await database.query.streams.findFirst({ + where: eq(streams.user_id, user.id), + }) + + // Priority for HLS URL: + // 1. Stream's cloudflare_live_input_uid (per-user Cloudflare Stream) + // 2. Stream's hls_url (manually configured) + // 3. Environment variable (backwards compatibility) + let hlsUrl: string | null = null + + if (stream?.cloudflare_live_input_uid) { + hlsUrl = buildCloudflareHlsUrl( + stream.cloudflare_live_input_uid, + stream.cloudflare_customer_code + ) + } else if (stream?.hls_url) { + hlsUrl = stream.hls_url + } else { + hlsUrl = resolveEnvCloudflareHlsUrl() + } + + if (!hlsUrl) { + return json({ + isLive: false, + hlsUrl: null, + error: "No stream configured. Add your Cloudflare Live Input UID in settings.", + }) + } + + const res = await fetch(hlsUrl, { cache: "no-store" }) + + if (!res.ok) { + return json({ + isLive: false, + hlsUrl, + status: res.status, + error: "HLS not available", + }) + } + + const manifest = await res.text() + const isLive = isHlsPlaylistLive(manifest) + + return json({ + isLive, + hlsUrl, + status: res.status, + manifestLength: manifest.length, + }) + } catch (err) { + const error = err as Error + console.error("[check-hls] Error:", error.message) + return json({ + isLive: false, + error: error.message, + }) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/streams.$username.ts b/packages/web/src/routes/api/streams.$username.ts index 93342d9e..b394df8a 100644 --- a/packages/web/src/routes/api/streams.$username.ts +++ b/packages/web/src/routes/api/streams.$username.ts @@ -82,6 +82,10 @@ const serve = async ({ name: user.name, username: user.username, image: user.image, + bio: user.bio ?? null, + website: user.website ?? null, + location: user.location ?? null, + joinedAt: user.createdAt?.toISOString() ?? null, }, stream: stream ? { diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index d2812037..22e63885 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -11,9 +11,12 @@ import { Lock, MessageCircle, HelpCircle, + Video, + Copy, + ExternalLink, } from "lucide-react" -type SectionId = "preferences" | "profile" | "billing" +type SectionId = "preferences" | "profile" | "streaming" | "billing" const PLAN_CARD_NOISE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E" @@ -462,6 +465,214 @@ function ProfileSection({ ) } +function StreamingSection({ username }: { username: string | null | undefined }) { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + + // Stream settings + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [liveInputUid, setLiveInputUid] = useState("") + const [customerCode, setCustomerCode] = useState("") + const [streamKey, setStreamKey] = useState("") + + useEffect(() => { + const fetchSettings = async () => { + try { + const res = await fetch("/api/stream/settings", { credentials: "include" }) + if (res.ok) { + const data = await res.json() + setTitle(data.title || "") + setDescription(data.description || "") + setLiveInputUid(data.cloudflare_live_input_uid || "") + setCustomerCode(data.cloudflare_customer_code || "") + setStreamKey(data.stream_key || "") + } + } catch { + // Ignore errors + } finally { + setLoading(false) + } + } + fetchSettings() + }, []) + + const handleSave = async () => { + setSaving(true) + setError(null) + setSaved(false) + try { + const res = await fetch("/api/stream/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title, + description, + cloudflare_live_input_uid: liveInputUid || null, + cloudflare_customer_code: customerCode || null, + }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || "Failed to save") + } else { + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + } catch { + setError("Network error") + } finally { + setSaving(false) + } + } + + const copyStreamKey = () => { + navigator.clipboard.writeText(streamKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const streamUrl = username ? `https://linsa.io/${username}` : null + + return ( +
+ +
+ {loading ? ( +
+ ) : ( + <> + +
+
+ + setTitle(e.target.value)} + placeholder="My Live Stream" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+
+ +