implemented audio streaming to browser, but it didnt work
This commit is contained in:
7
management-ui/package-lock.json
generated
7
management-ui/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"hls.js": "^1.6.13",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-vue-next": "^0.525.0",
|
"lucide-vue-next": "^0.525.0",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.5.0",
|
||||||
@@ -2006,6 +2007,12 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
|
||||||
|
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"hls.js": "^1.6.13",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-vue-next": "^0.525.0",
|
"lucide-vue-next": "^0.525.0",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.5.0",
|
||||||
|
|||||||
@@ -8,41 +8,124 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { Device } from '@/lib/interfaces';
|
import type { Device } from '@/lib/interfaces'
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue'
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import DataTableNoCheckboxScroll from './DataTableNoCheckboxScroll.vue'
|
||||||
|
import type { ColumnDef } from '@tanstack/vue-table'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: Boolean as PropType<boolean>, required: true },
|
||||||
type: Boolean as PropType<boolean>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
device: { type: Object as PropType<Device>, required: false },
|
device: { type: Object as PropType<Device>, required: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
(e: 'update:modelValue', v: boolean): void
|
||||||
(e: 'confirm'): void
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function onSave() {
|
// --- DTOs from backend ---
|
||||||
emit('confirm')
|
type DeviceCertDto = {
|
||||||
// close the dialog
|
id: number
|
||||||
|
deviceGuid: string
|
||||||
|
serialHex: string
|
||||||
|
issuerCN: string
|
||||||
|
subjectDN: string
|
||||||
|
notBefore: string // ISO timestamps as strings when serialized
|
||||||
|
notAfter: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
type DeviceCertListDto = { certs: DeviceCertDto[] }
|
||||||
|
|
||||||
|
// --- local state ---
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const certs = ref<DeviceCertDto[]>([])
|
||||||
|
|
||||||
|
const guid = computed(() => props.device?.guid ?? '')
|
||||||
|
|
||||||
|
function fmt(ts?: string | null) {
|
||||||
|
if (!ts) return ''
|
||||||
|
try { return new Date(ts).toLocaleString() } catch { return ts ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<DeviceCertDto, any>[] = [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'serialHex', header: 'Serial (hex)' },
|
||||||
|
{ accessorKey: 'issuerCN', header: 'Issuer CN' },
|
||||||
|
{ accessorKey: 'subjectDN', header: 'Subject DN' },
|
||||||
|
{ accessorKey: 'notBefore', header: 'Not Before', cell: ({ row }) => fmt(row.original.notBefore) },
|
||||||
|
{ accessorKey: 'notAfter', header: 'Not After', cell: ({ row }) => fmt(row.original.notAfter) },
|
||||||
|
{ accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => fmt(row.original.createdAt) },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadCerts() {
|
||||||
|
if (!guid.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<DeviceCertListDto>(`/device/${encodeURIComponent(guid.value)}/certs`)
|
||||||
|
certs.value = Array.isArray(data?.certs) ? data.certs : []
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
error.value = e?.response?.data?.message ?? e?.message ?? 'Failed to load certificates.'
|
||||||
|
certs.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// open → fetch
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) loadCerts()
|
||||||
|
})
|
||||||
|
|
||||||
|
// guid changes while open → refetch
|
||||||
|
watch(guid, (g, prev) => {
|
||||||
|
if (props.modelValue && g && g !== prev) loadCerts()
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
|
<Dialog :open="props.modelValue" @update:open="(v:boolean) => emit('update:modelValue', v)">
|
||||||
<DialogContent class="sm:min-w-[800px]">
|
<DialogContent class="sm:min-w-[1000px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>List of certificates</DialogTitle>
|
<div class="flex items-center justify-between">
|
||||||
<DialogDescription>
|
<div>
|
||||||
{{ props.device?.guid }}
|
<DialogTitle>Device certificates</DialogTitle>
|
||||||
|
<DialogDescription class="mt-1 break-all">
|
||||||
|
GUID: {{ guid || '—' }}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" :disabled="loading || !guid" @click="loadCerts">
|
||||||
|
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-sm text-red-600 mb-3">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-sm text-muted-foreground py-6 text-center">
|
||||||
|
Loading certificates…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<DataTableNoCheckboxScroll
|
||||||
|
:columns="columns"
|
||||||
|
:data="certs"
|
||||||
|
minTableWidth="min-w-[900px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button @click="onSave">Close</Button>
|
<Button @click="close">Close</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,80 +1,197 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import DeviceDashboard from './DeviceDashboard.vue';
|
import DeviceDashboard from './DeviceDashboard.vue'
|
||||||
import { Button } from '@/components/ui/button';
|
import DeviceRecordings from './DeviceRecordings.vue'
|
||||||
import DeviceRecordings from './DeviceRecordings.vue';
|
import { Button } from '@/components/ui/button'
|
||||||
import { ref, type PropType } from 'vue';
|
import { ref, onBeforeUnmount, type PropType } from 'vue'
|
||||||
import type { Task } from '@/lib/interfaces';
|
import type { Task } from '@/lib/interfaces'
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
// NOTE: hls.js default import (see type stub note above)
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
|
type PublishTokenResp = { hlsUrl: string } | { HLS: string } // accept either key casing
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
guid: { type: String as PropType<string>, required: true },
|
guid: { type: String as PropType<string>, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** UI state */
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const streamError = ref<string | null>(null)
|
||||||
|
const hlsUrl = ref<string | null>(null)
|
||||||
|
const playing = ref(false)
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
|
||||||
|
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 */
|
||||||
|
async function attachAndPlay(url: string) {
|
||||||
|
streamError.value = null
|
||||||
|
hlsUrl.value = url
|
||||||
|
|
||||||
|
// Ensure previous instance is gone
|
||||||
|
teardownHls()
|
||||||
|
|
||||||
|
const el = audioEl.value
|
||||||
|
if (!el) {
|
||||||
|
streamError.value = 'Audio element is not available.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some UX niceties
|
||||||
|
el.controls = true
|
||||||
|
el.autoplay = true
|
||||||
|
|
||||||
|
// If hls.js is supported (all modern non-Safari browsers)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.loadSource(url)
|
||||||
|
instance.attachMedia(el)
|
||||||
|
instance.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||||
|
try {
|
||||||
|
await el.play()
|
||||||
|
playing.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
streamError.value = err?.message ?? 'Autoplay was blocked'
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Safari (and some iOS WebKit) supports HLS natively
|
||||||
|
if (el.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
el.src = url
|
||||||
|
try {
|
||||||
|
await el.play()
|
||||||
|
playing.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
streamError.value = err?.message ?? 'Autoplay was blocked'
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
streamError.value = 'HLS is not supported in this browser.'
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start streaming flow:
|
||||||
|
* 1) tell the device to start_stream
|
||||||
|
* 2) fetch a fresh HLS token URL
|
||||||
|
* 3) attach & play
|
||||||
|
*/
|
||||||
async function startStreaming() {
|
async function startStreaming() {
|
||||||
const dto: Task = {
|
streamError.value = null
|
||||||
type: 'start_stream',
|
|
||||||
payload: '' // empty as requested
|
|
||||||
}
|
|
||||||
// debug
|
|
||||||
console.log('CreateTaskDto →', dto)
|
|
||||||
|
|
||||||
try {
|
|
||||||
sending.value = true
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const dto: Task = { type: 'start_stream', payload: '' }
|
||||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create task:', e)
|
const url = await fetchHlsUrl()
|
||||||
|
await attachAndPlay(url)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Start streaming error', e)
|
||||||
|
streamError.value = e?.response?.data?.message || e?.message || 'Failed to start streaming'
|
||||||
|
playing.value = false
|
||||||
} finally {
|
} finally {
|
||||||
sending.value = false
|
sending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop streaming flow:
|
||||||
|
* 1) tell the device to stop_stream
|
||||||
|
* 2) teardown player
|
||||||
|
*/
|
||||||
async function stopStreaming() {
|
async function stopStreaming() {
|
||||||
const dto: Task = {
|
streamError.value = null
|
||||||
type: 'stop_stream',
|
|
||||||
payload: '' // empty as requested
|
|
||||||
}
|
|
||||||
// debug
|
|
||||||
console.log('CreateTaskDto →', dto)
|
|
||||||
|
|
||||||
try {
|
|
||||||
sending.value = true
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const dto: Task = { type: 'stop_stream', payload: '' }
|
||||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('Failed to create task:', e)
|
console.error('Stop streaming error', e)
|
||||||
|
// non-fatal for UI; still tear down locally
|
||||||
} finally {
|
} finally {
|
||||||
sending.value = false
|
sending.value = false
|
||||||
|
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)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Tabs default-value="records">
|
<Tabs default-value="records">
|
||||||
<TabsList class="space-x-8">
|
<TabsList class="space-x-8">
|
||||||
<TabsTrigger value="dashboard">
|
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||||
Dashboard
|
<TabsTrigger value="records">Records</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="livestream">Livestream</TabsTrigger>
|
||||||
<TabsTrigger value="records">
|
|
||||||
Records
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="livestream">
|
|
||||||
Livestream
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="dashboard">
|
<TabsContent value="dashboard">
|
||||||
<DeviceDashboard :guid="guid" />
|
<DeviceDashboard :guid="guid" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="records">
|
<TabsContent value="records">
|
||||||
<DeviceRecordings :guid="guid" />
|
<DeviceRecordings :guid="guid" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="livestream">
|
<TabsContent value="livestream">
|
||||||
<!-- <Button>
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
Start streaming
|
<div class="flex gap-3">
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
Stop streaming
|
|
||||||
</Button> -->
|
|
||||||
<div class="flex space-x-4 pt-2 gap-4">
|
|
||||||
<Button :disabled="sending" @click="startStreaming">
|
<Button :disabled="sending" @click="startStreaming">
|
||||||
{{ sending ? 'Starting…' : 'Start streaming' }}
|
{{ sending ? 'Starting…' : 'Start streaming' }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -82,6 +199,28 @@ async function stopStreaming() {
|
|||||||
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="streamError" class="text-sm text-red-600">
|
||||||
|
{{ streamError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The 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">
|
||||||
|
Press “Start streaming” to begin live audio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</template>
|
</template>
|
||||||
4
management-ui/src/types/hlsjs.d.ts
vendored
Normal file
4
management-ui/src/types/hlsjs.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'hls.js' {
|
||||||
|
const Hls: any
|
||||||
|
export default Hls
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*","src/types/**/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type PublishTokenReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublishTokenResp struct {
|
type PublishTokenResp struct {
|
||||||
WHIP string `json:"whipUrl"` // http://mediamtx:8889/whip/live/<guid>?token=...
|
HLS string `json:"hlsUrl"` // https://<public>/hls/live/<guid>/index.m3u8?token=...
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadTokenReq struct {
|
type ReadTokenReq struct {
|
||||||
|
|||||||
@@ -126,15 +126,46 @@ func (h *MediaMTXHandler) canPublish(sub, path string) bool {
|
|||||||
// --- 3.2 Mint publish token (device flow) -> returns WHIP URL
|
// --- 3.2 Mint publish token (device flow) -> returns WHIP URL
|
||||||
// POST /mediamtx/token/publish {guid}
|
// POST /mediamtx/token/publish {guid}
|
||||||
func (h *MediaMTXHandler) MintPublish(c *gin.Context) {
|
func (h *MediaMTXHandler) MintPublish(c *gin.Context) {
|
||||||
|
user, ok := GetUserContext(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req dto.PublishTokenReq
|
var req dto.PublishTokenReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission check (admin or assigned)
|
||||||
|
if user.Role != models.RoleAdmin {
|
||||||
|
var count int64
|
||||||
|
_ = h.db.Table("user_devices").
|
||||||
|
Where("user_id = ? AND device_guid = ?", user.ID, req.GUID).
|
||||||
|
Count(&count).Error
|
||||||
|
if count == 0 {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not allowed for this device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
path := "live/" + req.GUID
|
path := "live/" + req.GUID
|
||||||
tok, _ := h.jwtMgr.GenerateMediaToken(0, "publish", path, h.cfg.TokenTTL) // sub=0 (device)
|
|
||||||
whip := fmt.Sprintf("%s/whip/%s?token=%s", strings.TrimRight(h.cfg.WebRTCBaseURL, "/"), path, url.QueryEscape(tok))
|
// We mint a *read* token for the browser to consume HLS.
|
||||||
c.JSON(http.StatusCreated, dto.PublishTokenResp{WHIP: whip})
|
tok, err := h.jwtMgr.GenerateMediaToken(user.ID, "read", path, h.cfg.TokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "token error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := strings.TrimRight(h.cfg.PublicBaseURL, "/")
|
||||||
|
hls := fmt.Sprintf("%s/hls/%s/index.m3u8?token=%s",
|
||||||
|
pub,
|
||||||
|
path,
|
||||||
|
url.QueryEscape(tok),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, dto.PublishTokenResp{HLS: hls})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3.3 Mint read token (user flow) -> returns HLS + WHEP URLs
|
// --- 3.3 Mint read token (user flow) -> returns HLS + WHEP URLs
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
r.POST("/mediamtx/auth", mediamtxH.Auth)
|
r.POST("/mediamtx/auth", mediamtxH.Auth)
|
||||||
// Token minting for device/user flows
|
// Token minting for device/user flows
|
||||||
r.POST("/mediamtx/token/publish", mediamtxH.MintPublish)
|
r.POST("/mediamtx/token/publish", mediamtxH.MintPublish)
|
||||||
r.POST("/mediamtx/token/read", authMW, mediamtxH.MintRead)
|
r.POST("/mediamtx/token/read", authMW, middleware.DeviceAccessFilter(), mediamtxH.MintRead)
|
||||||
// Admin controls
|
// Admin controls
|
||||||
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
||||||
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
||||||
|
|||||||
Reference in New Issue
Block a user