added some changes to mediamtx and audiostreams
This commit is contained in:
15
management-ui/package-lock.json
generated
15
management-ui/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"hls.js": "^1.6.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify-json/radix-icons": "^1.2.2",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/leaflet": "^1.9.20",
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
@@ -1276,6 +1278,13 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/event-source-polyfill": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz",
|
||||
"integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
@@ -1830,6 +1839,12 @@
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/event-source-polyfill": {
|
||||
"version": "1.0.31",
|
||||
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
|
||||
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"hls.js": "^1.6.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify-json/radix-icons": "^1.2.2",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/leaflet": "^1.9.20",
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
|
||||
@@ -6,48 +6,71 @@ import { Button } from '@/components/ui/button'
|
||||
import { ref, onBeforeUnmount, type PropType } from 'vue'
|
||||
import type { Task } from '@/lib/interfaces'
|
||||
import { api } from '@/lib/api'
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
|
||||
// NOTE: hls.js default import (see type stub note above)
|
||||
// hls.js for non-Safari browsers
|
||||
import Hls from 'hls.js'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
type PublishTokenResp = { hlsUrl: string } | { HLS: string } // accept either key casing
|
||||
type PublishTokenResp = { hlsUrl: string } | { HLS: string }
|
||||
|
||||
const props = defineProps({
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
})
|
||||
|
||||
/** UI state */
|
||||
/** UI state **/
|
||||
const sending = ref(false)
|
||||
const streamError = ref<string | null>(null)
|
||||
const waitingLive = ref(false)
|
||||
const hlsUrl = ref<string | null>(null)
|
||||
const playing = ref(false)
|
||||
|
||||
/** Player bits */
|
||||
/** Player bits **/
|
||||
const audioEl = ref<HTMLAudioElement | null>(null)
|
||||
const hls = ref<any | null>(null)
|
||||
|
||||
/** Resolve server token → URL */
|
||||
async function fetchHlsUrl(): Promise<string> {
|
||||
// Adjust payload to what your server expects; common patterns:
|
||||
// - empty body
|
||||
// - { guid: props.guid }
|
||||
// - { path: `/hls/live/${props.guid}/index.m3u8` }
|
||||
const body = { guid: props.guid }
|
||||
/** SSE (wait-for-live) **/
|
||||
const es = ref<EventSource | null>(null)
|
||||
|
||||
/** Helpers **/
|
||||
function closeEventSource() {
|
||||
try { es.value?.close() } catch { }
|
||||
es.value = null
|
||||
waitingLive.value = false
|
||||
}
|
||||
|
||||
function teardownHls() {
|
||||
try {
|
||||
if (hls.value) {
|
||||
try { hls.value.stopLoad?.() } catch { }
|
||||
try { hls.value.detachMedia?.() } catch { }
|
||||
try { hls.value.destroy?.() } catch { }
|
||||
}
|
||||
} catch { }
|
||||
hls.value = null
|
||||
|
||||
if (audioEl.value) {
|
||||
audioEl.value.pause?.()
|
||||
audioEl.value.removeAttribute('src')
|
||||
try { audioEl.value.load?.() } catch { }
|
||||
}
|
||||
playing.value = false
|
||||
hlsUrl.value = null
|
||||
}
|
||||
|
||||
/** Get fresh read token → HLS URL **/
|
||||
async function fetchHlsUrl(): Promise<string> {
|
||||
const body = { guid: props.guid }
|
||||
const { data } = await api.post<PublishTokenResp>('/mediamtx/token/read', body)
|
||||
// server DTO example: { HLS: "https://.../index.m3u8?token=..." }
|
||||
// normalize both HLS / hlsUrl spellings
|
||||
const url = (data as any).HLS ?? (data as any).hlsUrl
|
||||
if (!url || typeof url !== 'string') throw new Error('No HLS url in token response')
|
||||
return url
|
||||
}
|
||||
|
||||
/** Attach HLS to the <audio> element and start playing */
|
||||
/** Attach player and play **/
|
||||
async function attachAndPlay(url: string) {
|
||||
streamError.value = null
|
||||
hlsUrl.value = url
|
||||
|
||||
// Ensure previous instance is gone
|
||||
teardownHls()
|
||||
|
||||
const el = audioEl.value
|
||||
@@ -56,14 +79,12 @@ async function attachAndPlay(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Some UX niceties
|
||||
el.controls = true
|
||||
el.autoplay = true
|
||||
|
||||
// If hls.js is supported (all modern non-Safari browsers)
|
||||
// Non-Safari: hls.js
|
||||
if (Hls && typeof (Hls as any).isSupported === 'function' && Hls.isSupported()) {
|
||||
const instance = new (Hls as any)({
|
||||
// a few sensible defaults; tweak if you want retries:
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 60,
|
||||
@@ -71,7 +92,6 @@ async function attachAndPlay(url: string) {
|
||||
hls.value = instance
|
||||
|
||||
instance.on(Hls.Events.ERROR, (_evt: any, data: any) => {
|
||||
// Only surface fatal errors to the UI
|
||||
if (data?.fatal) {
|
||||
streamError.value = `HLS fatal error: ${data?.details || 'unknown'}`
|
||||
teardownHls()
|
||||
@@ -90,7 +110,7 @@ async function attachAndPlay(url: string) {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Safari (and some iOS WebKit) supports HLS natively
|
||||
// Safari / iOS (native HLS)
|
||||
if (el.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
el.src = url
|
||||
try {
|
||||
@@ -107,20 +127,23 @@ async function attachAndPlay(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Start streaming flow:
|
||||
* 1) tell the device to start_stream
|
||||
* 2) fetch a fresh HLS token URL
|
||||
* 3) attach & play
|
||||
/** Start streaming:
|
||||
* 1) tell device to start_stream
|
||||
* 2) wait via SSE for “live”
|
||||
* 3) on live → fetch token → play
|
||||
*/
|
||||
async function startStreaming() {
|
||||
streamError.value = null
|
||||
sending.value = true
|
||||
closeEventSource()
|
||||
|
||||
try {
|
||||
// 1) ask device to start
|
||||
const dto: Task = { type: 'start_stream', payload: '' }
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
|
||||
const url = await fetchHlsUrl()
|
||||
await attachAndPlay(url)
|
||||
// 2) strictly wait for SSE “live”
|
||||
await waitForLiveThenPlay()
|
||||
} catch (e: any) {
|
||||
console.error('Start streaming error', e)
|
||||
streamError.value = e?.response?.data?.message || e?.message || 'Failed to start streaming'
|
||||
@@ -130,9 +153,64 @@ async function startStreaming() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop streaming flow:
|
||||
* 1) tell the device to stop_stream
|
||||
* 2) teardown player
|
||||
/** Wait for server-sent event “live” from /mediamtx/:guid/wait, then fetch token and play **/
|
||||
function waitForLiveThenPlay(): Promise<void> {
|
||||
waitingLive.value = true
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const url = `/api/mediamtx/${encodeURIComponent(props.guid)}/wait`
|
||||
const source = new EventSourcePolyfill(url,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` },
|
||||
// NOTE: in browsers there's no way to bypass TLS validation here.
|
||||
}
|
||||
)
|
||||
es.value = source
|
||||
|
||||
const cleanup = () => {
|
||||
source.removeEventListener('live', onLive)
|
||||
source.removeEventListener('timeout', onTimeout)
|
||||
source.onerror = null
|
||||
try { source.close() } catch { }
|
||||
es.value = null
|
||||
waitingLive.value = false
|
||||
}
|
||||
|
||||
const onLive = async () => {
|
||||
try {
|
||||
cleanup()
|
||||
const tokenUrl = await fetchHlsUrl()
|
||||
await attachAndPlay(tokenUrl)
|
||||
resolve()
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
const onTimeout = () => {
|
||||
cleanup()
|
||||
streamError.value = 'Stream did not become live in time. Try again.'
|
||||
reject(new Error('SSE wait timeout'))
|
||||
}
|
||||
|
||||
source.addEventListener('live', onLive)
|
||||
source.addEventListener('timeout', onTimeout)
|
||||
|
||||
source.onerror = () => {
|
||||
cleanup()
|
||||
streamError.value = 'Live-wait connection ended. Try again.'
|
||||
reject(new Error('SSE error'))
|
||||
}
|
||||
} catch (err) {
|
||||
waitingLive.value = false
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop streaming:
|
||||
* 1) tell device to stop_stream
|
||||
* 2) teardown player & close SSE
|
||||
*/
|
||||
async function stopStreaming() {
|
||||
streamError.value = null
|
||||
@@ -142,35 +220,18 @@ async function stopStreaming() {
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e: any) {
|
||||
console.error('Stop streaming error', e)
|
||||
// non-fatal for UI; still tear down locally
|
||||
} finally {
|
||||
sending.value = false
|
||||
closeEventSource()
|
||||
teardownHls()
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup helper */
|
||||
function teardownHls() {
|
||||
try {
|
||||
if (hls.value) {
|
||||
try { hls.value.stopLoad?.() } catch {}
|
||||
try { hls.value.detachMedia?.() } catch {}
|
||||
try { hls.value.destroy?.() } catch {}
|
||||
}
|
||||
} catch {}
|
||||
hls.value = null
|
||||
|
||||
if (audioEl.value) {
|
||||
audioEl.value.pause?.()
|
||||
audioEl.value.removeAttribute('src')
|
||||
try { audioEl.value.load?.() } catch {}
|
||||
}
|
||||
playing.value = false
|
||||
hlsUrl.value = null
|
||||
}
|
||||
|
||||
// Make sure we clean up when leaving the page
|
||||
onBeforeUnmount(teardownHls)
|
||||
// Cleanup
|
||||
onBeforeUnmount(() => {
|
||||
closeEventSource()
|
||||
teardownHls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,9 +252,10 @@ onBeforeUnmount(teardownHls)
|
||||
|
||||
<TabsContent value="livestream">
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<div class="flex gap-3">
|
||||
<Button :disabled="sending" @click="startStreaming">
|
||||
{{ sending ? 'Starting…' : 'Start streaming' }}
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button :disabled="sending || waitingLive" @click="startStreaming">
|
||||
<span v-if="waitingLive">Waiting live…</span>
|
||||
<span v-else>{{ sending ? 'Starting…' : 'Start streaming' }}</span>
|
||||
</Button>
|
||||
<Button :disabled="sending" @click="stopStreaming">
|
||||
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
||||
@@ -204,21 +266,18 @@ onBeforeUnmount(teardownHls)
|
||||
{{ streamError }}
|
||||
</div>
|
||||
|
||||
<!-- The player -->
|
||||
<!-- Player -->
|
||||
<div class="mt-2">
|
||||
<!-- Show URL for debugging/dev, hide in production if you like -->
|
||||
<p v-if="hlsUrl" class="text-xs text-muted-foreground break-all">
|
||||
HLS: {{ hlsUrl }}
|
||||
</p>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
class="w-full mt-2"
|
||||
preload="none"
|
||||
controls
|
||||
/>
|
||||
<p v-if="!hlsUrl && !playing" class="text-sm text-muted-foreground mt-2">
|
||||
<audio ref="audioEl" class="w-full mt-2" preload="none" controls />
|
||||
<p v-if="!hlsUrl && !playing && !waitingLive" class="text-sm text-muted-foreground mt-2">
|
||||
Press “Start streaming” to begin live audio.
|
||||
</p>
|
||||
<p v-if="waitingLive" class="text-sm text-muted-foreground mt-2">
|
||||
Waiting for the device to go live…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -16,6 +16,7 @@ hlsVariant: lowLatency
|
||||
# WebRTC (browser-friendly)
|
||||
webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
whip: yes
|
||||
webrtcLocalUDPAddress: :8189
|
||||
# Optional: add a STUN server if behind NAT
|
||||
# webrtcICEServers2:
|
||||
|
||||
@@ -144,10 +144,9 @@ server {
|
||||
|
||||
# MediaMTX HLS
|
||||
location ^~ /hls/ {
|
||||
# if ($ssl_client_verify != SUCCESS) {
|
||||
# return 495;
|
||||
# }
|
||||
proxy_pass http://mediamtx:8888/;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# MediaMTX WebRTC (WHIP/WHEP/test)
|
||||
|
||||
48
server/internal/handlers/broker.go
Normal file
48
server/internal/handlers/broker.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
subs map[string]map[chan struct{}]struct{} // key = path, val = set of channels
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{subs: make(map[string]map[chan struct{}]struct{})}
|
||||
}
|
||||
|
||||
func (b *Broker) Subscribe(path string) chan struct{} {
|
||||
ch := make(chan struct{}, 1)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.subs[path] == nil {
|
||||
b.subs[path] = make(map[chan struct{}]struct{})
|
||||
}
|
||||
b.subs[path][ch] = struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *Broker) Unsubscribe(path string, ch chan struct{}) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if set, ok := b.subs[path]; ok {
|
||||
delete(set, ch)
|
||||
if len(set) == 0 {
|
||||
delete(b.subs, path)
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (b *Broker) Publish(path string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.subs[path] {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,10 +23,11 @@ type MediaMTXHandler struct {
|
||||
jwtMgr *crypto.JWTManager
|
||||
db *gorm.DB
|
||||
cfg config.MediaMTXConfig
|
||||
bus *Broker
|
||||
}
|
||||
|
||||
func NewMediaMTXHandler(db *gorm.DB, jwt *crypto.JWTManager, c config.MediaMTXConfig) *MediaMTXHandler {
|
||||
return &MediaMTXHandler{db: db, jwtMgr: jwt, cfg: c}
|
||||
return &MediaMTXHandler{db: db, jwtMgr: jwt, cfg: c, bus: NewBroker()}
|
||||
}
|
||||
|
||||
// --- 3.1 External auth endpoint called by MediaMTX
|
||||
@@ -86,7 +88,18 @@ func (h *MediaMTXHandler) Auth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// allowed
|
||||
if req.Action == "publish" {
|
||||
if guid, ok := guidFromPath(req.Path); ok {
|
||||
_ = guid // not used here, but available
|
||||
// tell listeners this path is live (or at least authorized to start)
|
||||
if h.bus != nil {
|
||||
h.bus.Publish(req.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -340,3 +353,152 @@ func BodyLogger() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// --- poll MediaMTX API until path is live ------------------------------------
|
||||
|
||||
func (h *MediaMTXHandler) WaitUntilLive(path string, timeout time.Duration) bool {
|
||||
api := strings.TrimRight(h.cfg.APIBase, "/")
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(api + "/v3/paths/list")
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
var pl pathsListRes
|
||||
_ = json.NewDecoder(resp.Body).Decode(&pl)
|
||||
resp.Body.Close()
|
||||
for _, it := range pl.Items {
|
||||
if it.Name == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *MediaMTXHandler) expectedStartWait(guid string) time.Duration {
|
||||
var cfg models.DeviceConfig
|
||||
if err := h.db.Where("device_guid = ?", guid).First(&cfg).Error; err != nil {
|
||||
return 60 * time.Second // fallback if no config row
|
||||
}
|
||||
|
||||
poll := cfg.MPolling
|
||||
jit := cfg.MJitter
|
||||
if poll <= 0 {
|
||||
poll = 60
|
||||
}
|
||||
if jit < 0 {
|
||||
jit = 10
|
||||
}
|
||||
|
||||
safety := 5 // seconds
|
||||
return time.Duration(poll+jit+safety) * time.Second
|
||||
}
|
||||
|
||||
// GET /streams/:guid/wait
|
||||
func (h *MediaMTXHandler) WaitLiveSSE(c *gin.Context) {
|
||||
guid := c.Param("guid")
|
||||
if guid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
path := "live/" + guid
|
||||
|
||||
// Per-device max wait = MPolling + MJitter + safety
|
||||
timeout := h.expectedStartWait(guid)
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
flush := func() {
|
||||
if f, ok := c.Writer.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// If already live, notify immediately and exit.
|
||||
if h.IsLive(path) {
|
||||
fmt.Fprintf(c.Writer, "event: live\ndata: %s\n\n", path)
|
||||
flush()
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to bus for publish-auth events on this path
|
||||
ch := h.bus.Subscribe(path)
|
||||
defer h.bus.Unsubscribe(path, ch)
|
||||
|
||||
// Background short poller (safety net in case bus event is missed)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
pollDone := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(500 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
defer close(pollDone)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if h.IsLive(path) {
|
||||
// Normalize through bus so waiter below handles it uniformly
|
||||
h.bus.Publish(path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for either: bus event, timeout, or client disconnect.
|
||||
select {
|
||||
case <-ch:
|
||||
fmt.Fprintf(c.Writer, "event: live\ndata: %s\n\n", path)
|
||||
flush()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
// Optional: tell client we timed out so it can keep a gentle retry loop
|
||||
fmt.Fprintf(c.Writer, "event: timeout\ndata: {\"path\":\"%s\"}\n\n", path)
|
||||
flush()
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers: path/guid ------------------------------------------------------
|
||||
|
||||
func guidFromPath(path string) (string, bool) {
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) != 2 || parts[0] != "live" || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// IsLive performs a single check against MTX API to see if the path exists now.
|
||||
func (h *MediaMTXHandler) IsLive(path string) bool {
|
||||
api := strings.TrimRight(h.cfg.APIBase, "/")
|
||||
resp, err := http.Get(api + "/v3/paths/list")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
var pl pathsListRes
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pl); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, it := range pl.Items {
|
||||
if it.Name == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
||||
// Admin controls
|
||||
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
||||
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
||||
// SSE endpoint for audio stream UI
|
||||
r.GET("/mediamtx/:guid/wait", authMW, middleware.DeviceAccessFilter(), mediamtxH.WaitLiveSSE)
|
||||
|
||||
r.GET("/trackers", authMW, middleware.TrackerAccessFilter(), trackersH.List)
|
||||
r.POST("/trackers/create", authMW, trackersH.Create)
|
||||
|
||||
Reference in New Issue
Block a user