From 2b48fee00f16a6e8e91bc4e670c462f72d785055 Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 9 Oct 2026 14:25:09 -0400 Subject: [PATCH] do it properly with the mediamtx giant publisher file that no one maintains a package of for some god forsaken reason agh --- .dockerignore | 1 + .gitignore | 1 + webui/package.json | 2 + webui/scripts/prepareMediaMtx.js | 45 ++ .../VipAudioUploadCardContent.jsx | 2 +- webui/src/lib/vendor/mediamtxPublisher.js | 476 ------------------ webui/src/mic/MicApp.jsx | 2 +- 7 files changed, 51 insertions(+), 478 deletions(-) create mode 100644 webui/scripts/prepareMediaMtx.js delete mode 100644 webui/src/lib/vendor/mediamtxPublisher.js diff --git a/.dockerignore b/.dockerignore index 044e3d08..9ec77a62 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ .git .gitignore **/node_modules +webui/.cache/mediamtx/ # Neither the legacy state directory nor the Compose-mounted replacement may # enter an image build. This prevents credentials and backups from becoming # image layers after an operator has started using either deployment layout. diff --git a/.gitignore b/.gitignore index 2ccf8dc4..f2c5f536 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ create_2_Open_Interface_Spec.txt logs node_modules/ +webui/.cache/mediamtx/ __pycache__/ *.py[cod] .pio diff --git a/webui/package.json b/webui/package.json index a8884dfe..62bea23b 100644 --- a/webui/package.json +++ b/webui/package.json @@ -4,7 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { + "predev": "node scripts/prepareMediaMtx.js", "dev": "VITE_ROVERD_URL=https://rovertest.otter.land vite", + "prebuild": "node scripts/prepareMediaMtx.js", "build": "vite build", "lint": "eslint .", "test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js", diff --git a/webui/scripts/prepareMediaMtx.js b/webui/scripts/prepareMediaMtx.js new file mode 100644 index 00000000..3bcb0e10 --- /dev/null +++ b/webui/scripts/prepareMediaMtx.js @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; + +const version = '1.15.3'; // Matches the Dockerfile's default MediaMTX version. +const baseUrl = `https://raw.githubusercontent.com/bluenviron/mediamtx/v${version}/`; +const output = new URL('../.cache/mediamtx/publisher.js', import.meta.url); +const moduleHash = 'e903555223f196aca86d7adce5cf4437824435291bf9b47f284aa767fffabaaa'; +const hash = (content) => createHash('sha256').update(content).digest('hex'); + +async function download(path, checksum) { + const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(30000) }); + if (!response.ok) throw new Error(`MediaMTX download failed: ${path} (${response.status})`); + const content = await response.text(); + if (hash(content) !== checksum) throw new Error(`MediaMTX checksum mismatch: ${path}`); + return content; +} + +let cached; +try { + cached = await readFile(output, 'utf8'); +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +if (cached && hash(cached) === moduleHash) { + console.info(`Using cached MediaMTX publisher v${version}`); +} else { + const [source, license] = await Promise.all([ + download('internal/servers/webrtc/publisher.js', '6b4448939b45c30507bce59acaf434e3133e6efac3bd77d7e20fb01462958509'), + download('LICENSE', 'ecae73b0a23185a35a1222edc0aae5245bb9739420bad0125b0c42e935301a80'), + ]); + // Keep upstream behavior and its license; only adapt the browser-global export for Vite. + const module = `// MediaMTX v${version} publisher client, matching the Dockerfile default.\n` + + `// Source: ${baseUrl}internal/servers/webrtc/publisher.js\n` + + '// Only modification: export the class as an ES module for Vite.\n' + + `/*\n${license}\n*/\n` + + source.replace('window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;', 'export default MediaMTXWebRTCPublisher;'); + if (hash(module) !== moduleHash) throw new Error('Generated MediaMTX module checksum mismatch'); + await mkdir(new URL('.', output), { recursive: true }); + // Publish the verified module atomically so an interrupted download cannot poison the cache. + const temporary = new URL(`${output.href}.${process.pid}.tmp`); + await writeFile(temporary, module); + await rename(temporary, output); + console.info(`Downloaded and verified MediaMTX publisher v${version}`); +} diff --git a/webui/src/components/vip/VipAudioUploadCard/VipAudioUploadCardContent.jsx b/webui/src/components/vip/VipAudioUploadCard/VipAudioUploadCardContent.jsx index 7f4498f8..066e80e6 100644 --- a/webui/src/components/vip/VipAudioUploadCard/VipAudioUploadCardContent.jsx +++ b/webui/src/components/vip/VipAudioUploadCard/VipAudioUploadCardContent.jsx @@ -9,7 +9,7 @@ import { useSettingsNamespace } from '../../../settings/index.js'; import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE } from './constants.js'; import { bytesToBase64 } from './base64.js'; import { waitForOutboundAudioFlow } from './whipTransport.js'; -import MediaMTXWebRTCPublisher from '../../../lib/vendor/mediamtxPublisher.js'; +import MediaMTXWebRTCPublisher from '../../../../.cache/mediamtx/publisher.js'; import useServerUrl from '../../../hooks/useServerUrl.js'; import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js'; import StatusIndicator from './StatusIndicator.jsx'; diff --git a/webui/src/lib/vendor/mediamtxPublisher.js b/webui/src/lib/vendor/mediamtxPublisher.js deleted file mode 100644 index e3876162..00000000 --- a/webui/src/lib/vendor/mediamtxPublisher.js +++ /dev/null @@ -1,476 +0,0 @@ -// MediaMTX v1.15.3 publisher client, matching the Dockerfile default. -// Source: https://raw.githubusercontent.com/bluenviron/mediamtx/v1.15.3/internal/servers/webrtc/publisher.js -// Only modification: export the class as an ES module for Vite. -/* -MIT License - -Copyright (c) 2019 aler9 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -*/ -'use strict'; - -/** - * @callback OnError - * @param {string} err - error. - */ - -/** - * @callback OnConnected - */ - -/** - * @typedef Conf - * @type {object} - * @property {string} url - absolute URL of the WHIP endpoint. - * @property {string} user - username. - * @property {string} pass - password. - * @property {string} token - token. - * @property {MediaStream} stream - stream that contains outgoing tracks. - * @property {string} videoCodec - outgoing video codec. - * @property {number} videoBitrate - outgoing video bitrate. - * @property {string} audioCodec - outgoing audio bitrate. - * @property {number} audioBitrate - outgoing audio bitrate. - * @property {boolean} audioVoice - whether audio is voice. - * @property {OnError} onError - called when there's an error. - * @property {OnConnected} onConnected - called when connected. - */ - -/** WebRTC/WHIP publisher. */ -class MediaMTXWebRTCPublisher { - /** - * Create a MediaMTXWebRTCPublisher. - * @param {Conf} conf - configuration. - */ - constructor(conf) { - this.retryPause = 2000; - this.conf = conf; - this.state = 'running'; - this.restartTimeout = null; - this.pc = null; - this.offerData = null; - this.sessionUrl = null; - this.queuedCandidates = []; - this.#start(); - } - - /** - * Close the publisher and all its resources. - */ - close = () => { - this.state = 'closed'; - - if (this.pc !== null) { - this.pc.close(); - } - - if (this.restartTimeout !== null) { - clearTimeout(this.restartTimeout); - } - }; - - static #unquoteCredential(v) { - return JSON.parse(`"${v}"`); - } - - static #linkToIceServers(links) { - return (links !== null) ? links.split(', ').map((link) => { - const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i); - const ret = { - urls: [m[1]], - }; - - if (m[3] !== undefined) { - ret.username = this.#unquoteCredential(m[3]); - ret.credential = this.#unquoteCredential(m[4]); - ret.credentialType = 'password'; - } - - return ret; - }) : []; - } - - static #parseOffer(offer) { - const ret = { - iceUfrag: '', - icePwd: '', - medias: [], - }; - - for (const line of offer.split('\r\n')) { - if (line.startsWith('m=')) { - ret.medias.push(line.slice('m='.length)); - } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { - ret.iceUfrag = line.slice('a=ice-ufrag:'.length); - } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) { - ret.icePwd = line.slice('a=ice-pwd:'.length); - } - } - - return ret; - } - - static #generateSdpFragment(od, candidates) { - const candidatesByMedia = {}; - for (const candidate of candidates) { - const mid = candidate.sdpMLineIndex; - if (candidatesByMedia[mid] === undefined) { - candidatesByMedia[mid] = []; - } - candidatesByMedia[mid].push(candidate); - } - - let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n' - + 'a=ice-pwd:' + od.icePwd + '\r\n'; - - let mid = 0; - - for (const media of od.medias) { - if (candidatesByMedia[mid] !== undefined) { - frag += 'm=' + media + '\r\n' - + 'a=mid:' + mid + '\r\n'; - - for (const candidate of candidatesByMedia[mid]) { - frag += 'a=' + candidate.candidate + '\r\n'; - } - } - mid++; - } - - return frag; - } - - static #setCodec(section, codec) { - const lines = section.split('\r\n'); - const lines2 = []; - const payloadFormats = []; - - for (const line of lines) { - if (!line.startsWith('a=rtpmap:')) { - lines2.push(line); - } else { - if (line.toLowerCase().includes(codec)) { - payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]); - lines2.push(line); - } - } - } - - const lines3 = []; - let firstLine = true; - - for (const line of lines2) { - if (firstLine) { - firstLine = false; - lines3.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' ')); - } else if (line.startsWith('a=fmtp:')) { - if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) { - lines3.push(line); - } - } else if (line.startsWith('a=rtcp-fb:')) { - if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) { - lines3.push(line); - } - } else { - lines3.push(line); - } - } - - return lines3.join('\r\n'); - } - - static #setVideoBitrate(section, bitrate) { - let lines = section.split('\r\n'); - - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('c=')) { - lines = [...lines.slice(0, i+1), 'b=TIAS:' + (parseInt(bitrate) * 1024).toString(), ...lines.slice(i+1)]; - break - } - } - - return lines.join('\r\n'); - } - - static #setAudioBitrate(section, bitrate, voice) { - let opusPayloadFormat = ''; - let lines = section.split('\r\n'); - - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) { - opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]; - break; - } - } - - if (opusPayloadFormat === '') { - return section; - } - - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) { - if (voice) { - lines[i] = 'a=fmtp:' + opusPayloadFormat + ' minptime=10;useinbandfec=1;maxaveragebitrate=' - + (parseInt(bitrate) * 1024).toString(); - } else { - lines[i] = 'a=fmtp:' + opusPayloadFormat + ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=' - + (parseInt(bitrate) * 1024).toString(); - } - } - } - - return lines.join('\r\n'); - } - - static #editOffer(sdp, videoCodec, audioCodec, audioBitrate, audioVoice) { - const sections = sdp.split('m='); - - for (let i = 0; i < sections.length; i++) { - if (sections[i].startsWith('video')) { - sections[i] = this.#setCodec(sections[i], videoCodec); - } else if (sections[i].startsWith('audio')) { - sections[i] = this.#setAudioBitrate(this.#setCodec(sections[i], audioCodec), audioBitrate, audioVoice); - } - } - - return sections.join('m='); - } - - static #editAnswer(sdp, videoBitrate) { - const sections = sdp.split('m='); - - for (let i = 0; i < sections.length; i++) { - if (sections[i].startsWith('video')) { - sections[i] = this.#setVideoBitrate(sections[i], videoBitrate); - } - } - - return sections.join('m='); - } - - #start() { - this.#requestICEServers() - .then((iceServers) => this.#setupPeerConnection(iceServers)) - .then((offer) => this.#sendOffer(offer)) - .then((answer) => this.#setAnswer(answer)) - .catch((err) => { - this.#handleError(err.toString()); - }); - } - - #handleError(err) { - if (this.state === 'running') { - if (this.pc !== null) { - this.pc.close(); - this.pc = null; - } - - this.offerData = null; - - if (this.sessionUrl !== null) { - fetch(this.sessionUrl, { - method: 'DELETE', - }); - this.sessionUrl = null; - } - - this.queuedCandidates = []; - this.state = 'restarting'; - - this.restartTimeout = window.setTimeout(() => { - this.restartTimeout = null; - this.state = 'running'; - this.#start(); - }, this.retryPause); - - if (this.conf.onError !== undefined) { - this.conf.onError(`${err}, retrying in some seconds`); - } - } - } - - #authHeader() { - if (this.conf.user !== undefined && this.conf.user !== '') { - const credentials = btoa(`${this.conf.user}:${this.conf.pass}`); - return {'Authorization': `Basic ${credentials}`}; - } - if (this.conf.token !== undefined && this.conf.token !== '') { - return {'Authorization': `Bearer ${this.conf.token}`}; - } - return {}; - } - - #requestICEServers() { - return fetch(this.conf.url, { - method: 'OPTIONS', - headers: { - ...this.#authHeader(), - }, - }) - .then((res) => MediaMTXWebRTCPublisher.#linkToIceServers(res.headers.get('Link'))); - } - - #setupPeerConnection(iceServers) { - if (this.state !== 'running') { - throw new Error('closed'); - } - - this.pc = new RTCPeerConnection({ - iceServers, - // https://webrtc.org/getting-started/unified-plan-transition-guide - sdpSemantics: 'unified-plan', - }); - - this.pc.onicecandidate = (evt) => this.#onLocalCandidate(evt); - this.pc.onconnectionstatechange = () => this.#onConnectionState(); - - this.conf.stream.getTracks().forEach((track) => { - this.pc.addTrack(track, this.conf.stream); - }); - - return this.pc.createOffer() - .then((offer) => { - this.offerData = MediaMTXWebRTCPublisher.#parseOffer(offer.sdp); - - return this.pc.setLocalDescription(offer) - .then(() => offer.sdp); - }); - } - - #sendOffer(offer) { - if (this.state !== 'running') { - throw new Error('closed'); - } - - offer = MediaMTXWebRTCPublisher.#editOffer( - offer, - this.conf.videoCodec, - this.conf.audioCodec, - this.conf.audioBitrate, - this.conf.audioVoice); - - return fetch(this.conf.url, { - method: 'POST', - headers: { - ...this.#authHeader(), - 'Content-Type': 'application/sdp', - }, - body: offer, - }) - .then((res) => { - switch (res.status) { - case 201: - break; - case 400: - return res.json().then((e) => { throw new Error(e.error); }); - default: - throw new Error(`bad status code ${res.status}`); - } - - this.sessionUrl = new URL(res.headers.get('location'), this.conf.url).toString(); - - return res.text(); - }); - } - - #setAnswer(answer) { - if (this.state !== 'running') { - throw new Error('closed'); - } - - answer = MediaMTXWebRTCPublisher.#editAnswer(answer, this.conf.videoBitrate); - - return this.pc.setRemoteDescription(new RTCSessionDescription({ - type: 'answer', - sdp: answer, - })) - .then(() => { - if (this.state !== 'running') { - return; - } - - if (this.queuedCandidates.length !== 0) { - this.#sendLocalCandidates(this.queuedCandidates); - this.queuedCandidates = []; - } - }); - } - - #onLocalCandidate(evt) { - if (this.state !== 'running') { - return; - } - - if (evt.candidate !== null) { - if (this.sessionUrl === null) { - this.queuedCandidates.push(evt.candidate); - } else { - this.#sendLocalCandidates([evt.candidate]); - } - } - } - - #sendLocalCandidates(candidates) { - fetch(this.sessionUrl, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/trickle-ice-sdpfrag', - 'If-Match': '*', - }, - body: MediaMTXWebRTCPublisher.#generateSdpFragment(this.offerData, candidates), - }) - .then((res) => { - switch (res.status) { - case 204: - break; - case 404: - throw new Error('stream not found'); - default: - throw new Error(`bad status code ${res.status}`); - } - }) - .catch((err) => { - this.#handleError(err.toString()); - }); - } - - #onConnectionState() { - if (this.state !== 'running') { - return; - } - - // "closed" can arrive before "failed" and without - // the close() method being called at all. - // It happens when the other peer sends a termination - // message like a DTLS CloseNotify. - if (this.pc.connectionState === 'failed' - || this.pc.connectionState === 'closed' - ) { - this.#handleError('peer connection closed'); - } else if (this.pc.connectionState === 'connected') { - if (this.conf.onConnected !== undefined) { - this.conf.onConnected(); - } - } - } - -} - -export default MediaMTXWebRTCPublisher; diff --git a/webui/src/mic/MicApp.jsx b/webui/src/mic/MicApp.jsx index 08ed1642..97fdfa98 100644 --- a/webui/src/mic/MicApp.jsx +++ b/webui/src/mic/MicApp.jsx @@ -5,7 +5,7 @@ import useUserIdentitySync from '../hooks/useUserIdentitySync.js'; import { RESTART_DELAY_MS } from '../lib/whepPlayback.js'; import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager, useControlSelector } from '../controls/index.js'; import ControlHint from '../components/ControlHint/index.jsx'; -import MediaMTXWebRTCPublisher from '../lib/vendor/mediamtxPublisher.js'; +import MediaMTXWebRTCPublisher from '../../.cache/mediamtx/publisher.js'; import useServerUrl from '../hooks/useServerUrl.js'; export default function MicApp() {