From 7e480305c451521796ee73af720fb7ffbfdfba2e Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 24 Dec 2025 18:52:46 -0800 Subject: [PATCH] Add API route to check HLS stream status and integrate server-side HLS validation in StreamPage component --- packages/web/src/routeTree.gen.ts | 21 ++++ packages/web/src/routes/$username.tsx | 115 +++++++++++++----- packages/web/src/routes/api/check-hls.ts | 65 ++++++++++ .../web/src/routes/api/spotify.now-playing.ts | 19 +-- packages/web/src/routes/settings.tsx | 31 +++++ 5 files changed, 205 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/routes/api/check-hls.ts diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 4fdb61c2..9b3141d2 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comme import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiProfileRouteImport } from './routes/api/profile' import { Route as ApiContextItemsRouteImport } from './routes/api/context-items' +import { Route as ApiCheckHlsRouteImport } from './routes/api/check-hls' import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads' import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages' import { Route as ApiCanvasRouteImport } from './routes/api/canvas' @@ -197,6 +198,11 @@ const ApiContextItemsRoute = ApiContextItemsRouteImport.update({ path: '/api/context-items', getParentRoute: () => rootRouteImport, } as any) +const ApiCheckHlsRoute = ApiCheckHlsRouteImport.update({ + id: '/api/check-hls', + path: '/api/check-hls', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatThreadsRoute = ApiChatThreadsRouteImport.update({ id: '/api/chat-threads', path: '/api/chat-threads', @@ -408,6 +414,7 @@ export interface FileRoutesByFullPath { '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-threads': typeof ApiChatThreadsRoute + '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute @@ -471,6 +478,7 @@ export interface FileRoutesByTo { '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-threads': typeof ApiChatThreadsRoute + '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute @@ -536,6 +544,7 @@ export interface FileRoutesById { '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute '/api/chat-threads': typeof ApiChatThreadsRoute + '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute @@ -602,6 +611,7 @@ export interface FileRouteTypes { | '/api/canvas' | '/api/chat-messages' | '/api/chat-threads' + | '/api/check-hls' | '/api/context-items' | '/api/profile' | '/api/stream' @@ -665,6 +675,7 @@ export interface FileRouteTypes { | '/api/canvas' | '/api/chat-messages' | '/api/chat-threads' + | '/api/check-hls' | '/api/context-items' | '/api/profile' | '/api/stream' @@ -729,6 +740,7 @@ export interface FileRouteTypes { | '/api/canvas' | '/api/chat-messages' | '/api/chat-threads' + | '/api/check-hls' | '/api/context-items' | '/api/profile' | '/api/stream' @@ -794,6 +806,7 @@ export interface RootRouteChildren { ApiCanvasRoute: typeof ApiCanvasRouteWithChildren ApiChatMessagesRoute: typeof ApiChatMessagesRoute ApiChatThreadsRoute: typeof ApiChatThreadsRoute + ApiCheckHlsRoute: typeof ApiCheckHlsRoute ApiContextItemsRoute: typeof ApiContextItemsRoute ApiProfileRoute: typeof ApiProfileRoute ApiStreamRoute: typeof ApiStreamRoute @@ -1003,6 +1016,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiContextItemsRouteImport parentRoute: typeof rootRouteImport } + '/api/check-hls': { + id: '/api/check-hls' + path: '/api/check-hls' + fullPath: '/api/check-hls' + preLoaderRoute: typeof ApiCheckHlsRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat-threads': { id: '/api/chat-threads' path: '/api/chat-threads' @@ -1419,6 +1439,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCanvasRoute: ApiCanvasRouteWithChildren, ApiChatMessagesRoute: ApiChatMessagesRoute, ApiChatThreadsRoute: ApiChatThreadsRoute, + ApiCheckHlsRoute: ApiCheckHlsRoute, ApiContextItemsRoute: ApiContextItemsRoute, ApiProfileRoute: ApiProfileRoute, ApiStreamRoute: ApiStreamRoute, diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 7086c504..f757ab5f 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -263,43 +263,101 @@ function StreamPage() { const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD") const hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART") - return !hasEndlist && !isVod && hasSegments + // 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) + useEffect(() => { + if (username !== "nikiv") return + + let isActive = true + + const checkHlsViaApi = async () => { + console.log("[HLS Check] Calling /api/check-hls...") + try { + const res = await fetch("/api/check-hls", { cache: "no-store" }) + if (!isActive) return + + const data = await res.json() + console.log("[HLS Check] API response:", data) + + if (data.isLive) { + setStreamReady(true) + setHlsLive(true) + } else { + setStreamReady(false) + setHlsLive(false) + } + } catch (err) { + console.error("[HLS Check] API error:", err) + if (isActive) { + setStreamReady(false) + setHlsLive(false) + } + } + } + + // Initial check + setStreamReady(false) + setHlsLive(null) + checkHlsViaApi() + + // Poll every 5 seconds to detect when stream goes live + const interval = setInterval(checkHlsViaApi, 5000) + + return () => { + isActive = false + clearInterval(interval) + } + }, [username]) + + // For non-nikiv users, use direct HLS check useEffect(() => { let isActive = true - if (!activePlayback || activePlayback.type !== "hls") { + if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") { return () => { isActive = false } } - const checkHlsLive = () => { + const checkHlsLive = async () => { console.log("[HLS Check] Fetching manifest:", activePlayback.url) - fetch(activePlayback.url, { cache: "no-store" }) - .then(async (res) => { - if (isActive) { - console.log("[HLS Check] Response status:", res.status, res.ok) - if (!res.ok) { - setStreamReady(false) - setHlsLive(false) - return - } - const manifest = await res.text() - if (!isActive) return - const live = isHlsPlaylistLive(manifest) - console.log("[HLS Check] Manifest live check:", { live, manifestLength: manifest.length, first200: manifest.slice(0, 200) }) - setStreamReady(live) - setHlsLive(live) - } + try { + const res = await fetch(activePlayback.url, { + cache: "no-store", + mode: "cors", }) - .catch((err) => { - console.error("[HLS Check] Fetch error:", err) - if (isActive) { - setStreamReady(false) - setHlsLive(false) - } + + if (!isActive) return + + console.log("[HLS Check] Response status:", res.status, res.ok) + + if (!res.ok) { + setStreamReady(false) + setHlsLive(false) + return + } + + const manifest = await res.text() + if (!isActive) return + + const live = isHlsPlaylistLive(manifest) + console.log("[HLS Check] Manifest live check:", { + live, + manifestLength: manifest.length, + first200: manifest.slice(0, 200) }) + setStreamReady(live) + setHlsLive(live) + } catch (err) { + console.error("[HLS Check] Fetch error:", err) + if (isActive) { + setStreamReady(false) + setHlsLive(false) + } + } } // Initial check @@ -315,6 +373,7 @@ function StreamPage() { clearInterval(interval) } }, [ + username, activePlayback?.type, activePlayback?.type === "hls" ? activePlayback.url : null, ]) @@ -608,11 +667,7 @@ function StreamPage() { )} - ) : nowPlayingError ? ( - Spotify status unavailable right now. - ) : ( - Not playing anything right now. - )} + ) : null} + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +// Cloudflare Stream HLS URL +const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8" + +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") + return isValidManifest && !hasEndlist && !isVod && hasSegments +} + +export const Route = createFileRoute("/api/check-hls")({ + server: { + handlers: { + GET: async () => { + try { + const res = await fetch(HLS_URL, { + cache: "no-store", + }) + + console.log("[check-hls] Response status:", res.status) + + if (!res.ok) { + return json({ + isLive: false, + status: res.status, + error: "HLS not available", + }) + } + + const manifest = await res.text() + const isLive = isHlsPlaylistLive(manifest) + + console.log("[check-hls] Manifest check:", { + isLive, + manifestLength: manifest.length, + first200: manifest.slice(0, 200), + }) + + return json({ + isLive, + 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/spotify.now-playing.ts b/packages/web/src/routes/api/spotify.now-playing.ts index 394e5456..73c97fae 100644 --- a/packages/web/src/routes/api/spotify.now-playing.ts +++ b/packages/web/src/routes/api/spotify.now-playing.ts @@ -16,23 +16,10 @@ type SpotifyNowPlayingResponse = { } const JAZZ_READ_KEY = "nikiv-spotify@garden.co" +const JAZZ_SPOTIFY_STATE_ID = "co_zSxojQnqZ4v5FiMrk65v3SLayJi" -const resolveSpotifyStateId = (): string | undefined => { - let stateId: string | undefined - - try { - const { getServerContext } = require("@tanstack/react-start/server") as { - getServerContext: () => { cloudflare?: { env?: Record } } | null - } - const ctx = getServerContext() - if (ctx?.cloudflare?.env) { - stateId = ctx.cloudflare.env.JAZZ_SPOTIFY_STATE_ID - } - } catch { - // not in Cloudflare context - } - - return stateId ?? process.env.JAZZ_SPOTIFY_STATE_ID +const resolveSpotifyStateId = (): string => { + return JAZZ_SPOTIFY_STATE_ID } const parseTrackIdFromUrl = (url: string | null | undefined) => { diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index 283e63f0..d2812037 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -9,6 +9,8 @@ import { Sparkles, UserRoundPen, Lock, + MessageCircle, + HelpCircle, } from "lucide-react" type SectionId = "preferences" | "profile" | "billing" @@ -426,6 +428,35 @@ function ProfileSection({ + + +
+
+

Get help

+

+ Join our Discord community or contact support. +

+
+
+ + + Discord + + + + Support + +
+
+ )