mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20: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 ApiStreamRouteImport } from './routes/api/stream'
|
||||||
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
||||||
import { Route as ApiContextItemsRouteImport } from './routes/api/context-items'
|
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 ApiChatThreadsRouteImport } from './routes/api/chat-threads'
|
||||||
import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages'
|
import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages'
|
||||||
import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
|
import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
|
||||||
@@ -197,6 +198,11 @@ const ApiContextItemsRoute = ApiContextItemsRouteImport.update({
|
|||||||
path: '/api/context-items',
|
path: '/api/context-items',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiCheckHlsRoute = ApiCheckHlsRouteImport.update({
|
||||||
|
id: '/api/check-hls',
|
||||||
|
path: '/api/check-hls',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiChatThreadsRoute = ApiChatThreadsRouteImport.update({
|
const ApiChatThreadsRoute = ApiChatThreadsRouteImport.update({
|
||||||
id: '/api/chat-threads',
|
id: '/api/chat-threads',
|
||||||
path: '/api/chat-threads',
|
path: '/api/chat-threads',
|
||||||
@@ -408,6 +414,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||||
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
@@ -471,6 +478,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||||
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
@@ -536,6 +544,7 @@ export interface FileRoutesById {
|
|||||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||||
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
@@ -602,6 +611,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/canvas'
|
| '/api/canvas'
|
||||||
| '/api/chat-messages'
|
| '/api/chat-messages'
|
||||||
| '/api/chat-threads'
|
| '/api/chat-threads'
|
||||||
|
| '/api/check-hls'
|
||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
@@ -665,6 +675,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/canvas'
|
| '/api/canvas'
|
||||||
| '/api/chat-messages'
|
| '/api/chat-messages'
|
||||||
| '/api/chat-threads'
|
| '/api/chat-threads'
|
||||||
|
| '/api/check-hls'
|
||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
@@ -729,6 +740,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/canvas'
|
| '/api/canvas'
|
||||||
| '/api/chat-messages'
|
| '/api/chat-messages'
|
||||||
| '/api/chat-threads'
|
| '/api/chat-threads'
|
||||||
|
| '/api/check-hls'
|
||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
@@ -794,6 +806,7 @@ export interface RootRouteChildren {
|
|||||||
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
|
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
|
||||||
ApiChatMessagesRoute: typeof ApiChatMessagesRoute
|
ApiChatMessagesRoute: typeof ApiChatMessagesRoute
|
||||||
ApiChatThreadsRoute: typeof ApiChatThreadsRoute
|
ApiChatThreadsRoute: typeof ApiChatThreadsRoute
|
||||||
|
ApiCheckHlsRoute: typeof ApiCheckHlsRoute
|
||||||
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
||||||
ApiProfileRoute: typeof ApiProfileRoute
|
ApiProfileRoute: typeof ApiProfileRoute
|
||||||
ApiStreamRoute: typeof ApiStreamRoute
|
ApiStreamRoute: typeof ApiStreamRoute
|
||||||
@@ -1003,6 +1016,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiContextItemsRouteImport
|
preLoaderRoute: typeof ApiContextItemsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/chat-threads': {
|
||||||
id: '/api/chat-threads'
|
id: '/api/chat-threads'
|
||||||
path: '/api/chat-threads'
|
path: '/api/chat-threads'
|
||||||
@@ -1419,6 +1439,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiCanvasRoute: ApiCanvasRouteWithChildren,
|
ApiCanvasRoute: ApiCanvasRouteWithChildren,
|
||||||
ApiChatMessagesRoute: ApiChatMessagesRoute,
|
ApiChatMessagesRoute: ApiChatMessagesRoute,
|
||||||
ApiChatThreadsRoute: ApiChatThreadsRoute,
|
ApiChatThreadsRoute: ApiChatThreadsRoute,
|
||||||
|
ApiCheckHlsRoute: ApiCheckHlsRoute,
|
||||||
ApiContextItemsRoute: ApiContextItemsRoute,
|
ApiContextItemsRoute: ApiContextItemsRoute,
|
||||||
ApiProfileRoute: ApiProfileRoute,
|
ApiProfileRoute: ApiProfileRoute,
|
||||||
ApiStreamRoute: ApiStreamRoute,
|
ApiStreamRoute: ApiStreamRoute,
|
||||||
|
|||||||
@@ -263,43 +263,101 @@ function StreamPage() {
|
|||||||
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
|
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||||
const hasSegments =
|
const hasSegments =
|
||||||
upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
|
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(() => {
|
useEffect(() => {
|
||||||
let isActive = true
|
let isActive = true
|
||||||
if (!activePlayback || activePlayback.type !== "hls") {
|
if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") {
|
||||||
return () => {
|
return () => {
|
||||||
isActive = false
|
isActive = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkHlsLive = () => {
|
const checkHlsLive = async () => {
|
||||||
console.log("[HLS Check] Fetching manifest:", activePlayback.url)
|
console.log("[HLS Check] Fetching manifest:", activePlayback.url)
|
||||||
fetch(activePlayback.url, { cache: "no-store" })
|
try {
|
||||||
.then(async (res) => {
|
const res = await fetch(activePlayback.url, {
|
||||||
if (isActive) {
|
cache: "no-store",
|
||||||
console.log("[HLS Check] Response status:", res.status, res.ok)
|
mode: "cors",
|
||||||
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) return
|
||||||
if (isActive) {
|
|
||||||
setStreamReady(false)
|
console.log("[HLS Check] Response status:", res.status, res.ok)
|
||||||
setHlsLive(false)
|
|
||||||
}
|
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
|
// Initial check
|
||||||
@@ -315,6 +373,7 @@ function StreamPage() {
|
|||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
username,
|
||||||
activePlayback?.type,
|
activePlayback?.type,
|
||||||
activePlayback?.type === "hls" ? activePlayback.url : null,
|
activePlayback?.type === "hls" ? activePlayback.url : null,
|
||||||
])
|
])
|
||||||
@@ -608,11 +667,7 @@ function StreamPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : nowPlayingError ? (
|
) : null}
|
||||||
<span>Spotify status unavailable right now.</span>
|
|
||||||
) : (
|
|
||||||
<span>Not playing anything right now.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<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_READ_KEY = "nikiv-spotify@garden.co"
|
||||||
|
const JAZZ_SPOTIFY_STATE_ID = "co_zSxojQnqZ4v5FiMrk65v3SLayJi"
|
||||||
|
|
||||||
const resolveSpotifyStateId = (): string | undefined => {
|
const resolveSpotifyStateId = (): string => {
|
||||||
let stateId: string | undefined
|
return JAZZ_SPOTIFY_STATE_ID
|
||||||
|
|
||||||
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 parseTrackIdFromUrl = (url: string | null | undefined) => {
|
const parseTrackIdFromUrl = (url: string | null | undefined) => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
UserRoundPen,
|
UserRoundPen,
|
||||||
Lock,
|
Lock,
|
||||||
|
MessageCircle,
|
||||||
|
HelpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type SectionId = "preferences" | "profile" | "billing"
|
type SectionId = "preferences" | "profile" | "billing"
|
||||||
@@ -426,6 +428,35 @@ function ProfileSection({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SettingCard>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user