Add API route to check HLS stream status and integrate server-side HLS validation in StreamPage component

This commit is contained in:
Nikita
2025-12-24 18:52:46 -08:00
parent 7c678b3110
commit 7e480305c4
5 changed files with 205 additions and 46 deletions

View File

@@ -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,

View File

@@ -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() {
</span>
)}
</span>
) : nowPlayingError ? (
<span>Spotify status unavailable right now.</span>
) : (
<span>Not playing anything right now.</span>
)}
) : null}
</div>
<a

View File

@@ -0,0 +1,65 @@
import { createFileRoute } from "@tanstack/react-router"
const json = (data: unknown, status = 200) =>
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,
})
}
},
},
},
})

View File

@@ -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<string, string> } } | 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) => {

View File

@@ -9,6 +9,8 @@ import {
Sparkles,
UserRoundPen,
Lock,
MessageCircle,
HelpCircle,
} from "lucide-react"
type SectionId = "preferences" | "profile" | "billing"
@@ -426,6 +428,35 @@ function ProfileSection({
</button>
</div>
</SettingCard>
<SettingCard title="Support">
<div className="flex items-start justify-between py-2">
<div className="flex flex-col gap-2">
<p className="font-medium text-white">Get help</p>
<p className="text-xs text-white/70">
Join our Discord community or contact support.
</p>
</div>
<div className="flex items-center gap-2">
<a
href="https://discord.com/invite/bxtD8x6aNF"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm bg-white/5 hover:bg-white/10 text-white px-3 py-2 rounded-lg border border-white/10 transition-colors"
>
<MessageCircle className="w-4 h-4" />
Discord
</a>
<a
href="mailto:support@linsa.io"
className="inline-flex items-center gap-2 text-sm bg-white/5 hover:bg-white/10 text-white px-3 py-2 rounded-lg border border-white/10 transition-colors"
>
<HelpCircle className="w-4 h-4" />
Support
</a>
</div>
</div>
</SettingCard>
</div>
</div>
)