mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
Add API route to check HLS stream status and integrate server-side HLS validation in StreamPage component
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
65
packages/web/src/routes/api/check-hls.ts
Normal file
65
packages/web/src/routes/api/check-hls.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user