diff --git a/.github/workflows/k8s_broadcaster_cd.yml b/.github/workflows/k8s_broadcaster_cd.yml new file mode 100644 index 0000000..3e766ac --- /dev/null +++ b/.github/workflows/k8s_broadcaster_cd.yml @@ -0,0 +1,53 @@ +name: K8sBroadcaster CD + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + APP_NAME: k8s_broadcaster + +jobs: + build-publish-image: + name: "Build and publish image" + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + with: + sparse-checkout: ${{ env.APP_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/${{ env.APP_NAME }} + tags: type=match,pattern=${{ env.APP_NAME }}-v(.*),group=1 + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: ./${{ env.APP_NAME }} + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + \ No newline at end of file diff --git a/k8s_broadcaster/.formatter.exs b/k8s_broadcaster/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/k8s_broadcaster/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/k8s_broadcaster/.gitignore b/k8s_broadcaster/.gitignore new file mode 100644 index 0000000..a34dcdd --- /dev/null +++ b/k8s_broadcaster/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +k8s_broadcaster-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/k8s_broadcaster/Dockerfile b/k8s_broadcaster/Dockerfile new file mode 100644 index 0000000..a82e5d1 --- /dev/null +++ b/k8s_broadcaster/Dockerfile @@ -0,0 +1,105 @@ +# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian +# instead of Alpine to avoid DNS resolution issues in production. +# +# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu +# https://hub.docker.com/_/ubuntu?tab=tags +# +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image +# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim +# +ARG ELIXIR_VERSION=1.17.2 +ARG OTP_VERSION=27.0.1 +ARG DEBIAN_VERSION=bookworm-20240701-slim + +ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" + +FROM ${BUILDER_IMAGE} AS builder + +ARG TARGETPLATFORM +RUN echo "Building for $TARGETPLATFORM" + +# install build dependencies +RUN apt-get update -y && apt-get install -y build-essential git pkg-config libssl-dev +# ex_srtp doesn't include precompiled libsrtp2 for ARM64 linux +RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi +RUN apt-get clean && rm -f /var/lib/apt/lists/*_* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +COPY priv priv + +COPY lib lib + +COPY assets assets + +# compile assets +RUN mix assets.deploy + +# Compile the release +RUN mix compile + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} + +ARG TARGETPLATFORM +RUN echo "Building for $TARGETPLATFORM" + +RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates +RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get install -y libsrtp2-dev; fi +RUN apt-get clean && rm -f /var/lib/apt/lists/*_* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/k8s_broadcaster ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +CMD ["/app/bin/server"] diff --git a/k8s_broadcaster/README.md b/k8s_broadcaster/README.md new file mode 100644 index 0000000..342fad9 --- /dev/null +++ b/k8s_broadcaster/README.md @@ -0,0 +1,18 @@ +# K8sBroadcaster + +To start your Phoenix server: + + * Run `mix setup` to install and setup dependencies + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/k8s_broadcaster/assets/css/app.css b/k8s_broadcaster/assets/css/app.css new file mode 100644 index 0000000..d78d323 --- /dev/null +++ b/k8s_broadcaster/assets/css/app.css @@ -0,0 +1,25 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ + +::-webkit-scrollbar { + display: none; +} + +.details { + padding: 5px 0px; +} + +summary { + color: #0d0d0d; + align-items: center; + padding-bottom: 5px; + justify-content: space-between; +} + +.summary-content { + color: #606060; + padding: 5px 0px; +} \ No newline at end of file diff --git a/k8s_broadcaster/assets/js/app.js b/k8s_broadcaster/assets/js/app.js new file mode 100644 index 0000000..5c6a09d --- /dev/null +++ b/k8s_broadcaster/assets/js/app.js @@ -0,0 +1,53 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import 'phoenix_html'; +// Establish Phoenix Socket and LiveView configuration. +import { Socket } from 'phoenix'; +import { LiveSocket } from 'phoenix_live_view'; +import topbar from '../vendor/topbar'; + +import { Home } from './home.js'; +import { Panel } from './panel.js'; + +let Hooks = {}; +Hooks.Home = Home; +Hooks.Panel = Panel; + +let csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute('content'); +let liveSocket = new LiveSocket('/live', Socket, { + longPollFallbackMs: 2500, + params: { _csrf_token: csrfToken }, + hooks: Hooks, +}); + +// Show progress bar on live navigation and form submits +topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }); +window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300)); +window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide()); + +// connect if there are any LiveViews on the page +liveSocket.connect(); + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket; diff --git a/k8s_broadcaster/assets/js/home.js b/k8s_broadcaster/assets/js/home.js new file mode 100644 index 0000000..37ff427 --- /dev/null +++ b/k8s_broadcaster/assets/js/home.js @@ -0,0 +1,366 @@ +import { Socket, Presence } from 'phoenix'; + +import { WHEPClient } from './whep-client.js'; + +const viewercount = document.getElementById('viewercount'); +const videoQuality = document.getElementById('video-quality'); +const rtxCheckbox = document.getElementById('rtx-checkbox'); +const videoPlayerGrid = document.getElementById('videoplayer-grid'); +const statusMessage = document.getElementById('status-message'); +const packetLossRange = document.getElementById('packet-loss-range'); +const packetLossRangeOutput = document.getElementById('packet-loss-range-output'); + +const whepEndpointBase = `${window.location.origin}/api/whep`; +const inputsData = new Map(); +const stats = { + time: document.getElementById('time'), + audioBitrate: document.getElementById('audio-bitrate'), + videoBitrate: document.getElementById('video-bitrate'), + frameWidth: document.getElementById('frame-width'), + frameHeight: document.getElementById('frame-height'), + fps: document.getElementById('fps'), + keyframesDecoded: document.getElementById('keyframes-decoded'), + pliCount: document.getElementById('pli-count'), + packetLoss: document.getElementById('packet-loss'), + avgJitterBufferDelay: document.getElementById('avg-jitter-buffer-delay'), + freezeCount: document.getElementById('freeze-count'), + freezeDuration: document.getElementById('freeze-duration') +}; + +let defaultLayer = 'h'; +let url; +let inputId; + +const button1 = document.getElementById('button-1'); +const button2 = document.getElementById('button-2'); +const button3 = document.getElementById('button-3'); +const buttonAuto = document.getElementById('button-auto'); + +let channel; + +async function connectSignaling(socket) { + channel = socket.channel('k8s_broadcaster:signaling'); + + const presence = new Presence(channel); + presence.onSync(() => (viewercount.innerText = presence.list().length)); + + + channel.on('input_added', ({ id: id }) => { + console.log('New input:', id); + inputId = id; + connectInput(); + }); + + channel.on('input_removed', ({ id: id }) => { + console.log('Input removed:', id); + removeInput(); + }); + + channel + .join() + .receive('ok', () => { + console.log('Joined signaling channel successfully'); + statusMessage.innerText = 'Connected. Waiting for the stream to begin...'; + statusMessage.classList.remove('hidden'); + }) + .receive('error', (resp) => { + console.error('Unable to join signaling channel', resp); + statusMessage.innerText = 'Unable to join the stream, try again in a few minutes'; + statusMessage.classList.remove('hidden'); + }); +} + +async function connectInput() { + + let whepEndpoint; + if (url) { + whepEndpoint = url + 'api/whep?inputId=' + inputId; + } else { + whepEndpoint = whepEndpointBase + '?inputId=' + inputId; + } + + console.log("Trying to connect to: ", whepEndpoint); + + if (inputId) { + removeInput(); + } + + const pcConfigUrl = (url || window.location.origin) + '/api/pc-config' + const response = await fetch(pcConfigUrl, { + method: 'GET', + cache: 'no-cache', + }); + const pcConfig = await response.json(); + console.log('Fetched PC config from server: ', pcConfig) + + const whepClient = new WHEPClient(whepEndpoint, pcConfig); + + const inputData = { + whepClient: whepClient, + videoPlayer: undefined, + }; + inputsData.set(inputId, inputData); + + whepClient.id = inputId; + + whepClient.onstream = (stream) => { + console.log(`[${inputId}]: Creating new video element`); + + const videoPlayer = document.createElement('video'); + videoPlayer.srcObject = stream; + videoPlayer.autoplay = true; + videoPlayer.controls = true; + videoPlayer.muted = true; + videoPlayer.className = 'rounded-xl w-full h-full object-cover bg-black'; + + videoPlayerGrid.appendChild(videoPlayer); + inputData.videoPlayer = videoPlayer; + updateVideoGrid(); + statusMessage.classList.add('hidden'); + }; + + whepClient.onconnected = () => { + packetLossRange.onchange = () => { + packetLossRangeOutput.value = packetLossRange.value; + channel.push('packet_loss', {resourceId: whepClient.resourceId, value: packetLossRange.value}); + } + + rtxCheckbox.onchange = () => { + connectInput(); + } + + videoQuality.onchange = () => setDefaultLayer(videoQuality.value); + + + whepClient.changeLayer(defaultLayer); + + if (whepClient.pc.connectionState === "connected") { + stats.startTime = new Date(); + stats.intervalId = setInterval(async function () { + if (!whepClient.pc) { + clearInterval(stats.intervalId); + stats.intervalId = undefined; + return; + } + + stats.time.innerText = toHHMMSS(new Date() - stats.startTime); + + let bitrate; + + (await whepClient.pc.getStats(null)).forEach((report) => { + if (report.type === "inbound-rtp" && report.kind === "video") { + if (!stats.lastVideoReport) { + bitrate = (report.bytesReceived * 8) / 1000; + } else { + const timeDiff = + (report.timestamp - stats.lastVideoReport.timestamp) / 1000; + if (timeDiff == 0) { + // this should never happen as we are getting stats every second + bitrate = 0; + } else { + bitrate = + ((report.bytesReceived - stats.lastVideoReport.bytesReceived) * + 8) / + timeDiff; + } + } + + stats.videoBitrate.innerText = (bitrate / 1000).toFixed(); + stats.frameWidth.innerText = report.frameWidth; + stats.frameHeight.innerText = report.frameHeight; + stats.fps.innerText = report.framesPerSecond; + stats.keyframesDecoded.innerText = report.keyFramesDecoded; + stats.pliCount.innerText = report.pliCount; + stats.avgJitterBufferDelay.innerText = report.jitterBufferDelay * 1000 / report.jitterBufferEmittedCount; + stats.freezeCount.innerText = report.freezeCount; + stats.freezeDuration.innerText = report.totalFreezesDuration; + stats.lastVideoReport = report; + } else if ( + report.type === "inbound-rtp" && + report.kind === "audio" + ) { + if (!stats.lastAudioReport) { + bitrate = report.bytesReceived; + } else { + const timeDiff = + (report.timestamp - stats.lastAudioReport.timestamp) / 1000; + if (timeDiff == 0) { + // this should never happen as we are getting stats every second + bitrate = 0; + } else { + bitrate = + ((report.bytesReceived - stats.lastAudioReport.bytesReceived) * + 8) / + timeDiff; + } + } + + stats.audioBitrate.innerText = (bitrate / 1000).toFixed(); + stats.lastAudioReport = report; + } + }); + + let packetsLost = 0; + let packetsReceived = 0; + // calculate packet loss + if (stats.lastAudioReport) { + packetsLost += stats.lastAudioReport.packetsLost; + packetsReceived += stats.lastAudioReport.packetsReceived; + } + + if (stats.lastVideoReport) { + packetsLost += stats.lastVideoReport.packetsLost; + packetsReceived += stats.lastVideoReport.packetsReceived; + + } + + if (packetsReceived == 0) { + stats.packetLoss.innerText = 0; + } else { + stats.packetLoss.innerText = (packetsLost / packetsReceived * 100).toFixed(2); + } + + + }, 1000); + } else if (view.pc.connectionState === "failed") { + } + }; + + whepClient.connect(rtxCheckbox.checked); +} + +async function removeInput() { + const inputData = inputsData.get(inputId); + inputsData.delete(inputId); + + if (inputData) { + inputData.whepClient.disconnect(); + + if (inputData.videoPlayer) { + videoPlayerGrid.removeChild(inputData.videoPlayer); + updateVideoGrid(); + } + } + + if (inputsData.size === 0) { + statusMessage.innerText = 'Connected. Waiting for the stream to begin...'; + statusMessage.classList.remove('hidden'); + } + + clearInterval(stats.intervalId); + stats.lastAudioReport = null; + stats.lastVideoReport = null; + stats.time.innerText = 0; + stats.audioBitrate.innerText = 0; + stats.videoBitrate.innerText = 0; + stats.frameWidth.innerText = 0; + stats.frameHeight.innerText = 0; + stats.fps.innerText = 0; + stats.keyframesDecoded.innerText = 0; + stats.pliCount.innerText = 0; + stats.packetLoss.innerText = 0; + stats.avgJitterBufferDelay.innerText = 0; + stats.freezeCount.innerText = 0; + stats.freezeDuration.innerText = 0; + +} + +async function setDefaultLayer(layer) { + if (defaultLayer !== layer) { + defaultLayer = layer; + for (const { whepClient: whepClient } of inputsData.values()) { + whepClient.changeLayer(layer); + } + } +} + +function updateVideoGrid() { + const videoCount = videoPlayerGrid.children.length; + + let columns; + if (videoCount <= 1) { + columns = 'grid-cols-1'; + } else if (videoCount <= 4) { + columns = 'grid-cols-2'; + } else if (videoCount <= 9) { + columns = 'grid-cols-3'; + } else if (videoCount <= 16) { + columns = 'grid-cols-4'; + } else { + columns = 'grid-cols-5'; + } + + videoPlayerGrid.classList.remove( + 'grid-cols-1', + 'grid-cols-2', + 'grid-cols-3', + 'grid-cols-4', + 'grid-cols-5' + ); + videoPlayerGrid.classList.add(columns); +} + +function toHHMMSS(milliseconds) { + // Calculate hours + let hours = Math.floor(milliseconds / (1000 * 60 * 60)); + // Calculate minutes, subtracting the hours part + let minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + // Calculate seconds, subtracting the hours and minutes parts + let seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); + + // Formatting each unit to always have at least two digits + hours = hours < 10 ? "0" + hours : hours; + minutes = minutes < 10 ? "0" + minutes : minutes; + seconds = seconds < 10 ? "0" + seconds : seconds; + + return hours + ":" + minutes + ":" + seconds; + } + +function resetButtons() { + button1.classList.remove("border-green-500"); + button2.classList.remove("border-green-500"); + button3.classList.remove("border-green-500"); + buttonAuto.classList.remove("border-green-500"); +} + +export const Home = { + mounted() { + const socket = new Socket('/socket', { + params: { token: window.userToken }, + }); + socket.connect(); + + connectSignaling(socket); + + //videoQuality.onchange = () => setDefaultLayer(videoQuality.value); + + button1.onclick = () => { + url = button1.value + console.log(url); + resetButtons(); + button1.classList.toggle("border-green-500"); + connectInput(); + }; + + button2.onclick = () => { + url = button2.value + resetButtons(); + button2.classList.toggle("border-green-500"); + connectInput(); + }; + + button3.onclick = () => { + url = button3.value + resetButtons(); + button3.classList.toggle("border-green-500"); + connectInput(); + }; + + buttonAuto.onclick = () => { + url = buttonAuto.value + resetButtons(); + buttonAuto.classList.toggle("border-green-500"); + connectInput(); + }; + }, +}; diff --git a/k8s_broadcaster/assets/js/panel.js b/k8s_broadcaster/assets/js/panel.js new file mode 100644 index 0000000..0559f1e --- /dev/null +++ b/k8s_broadcaster/assets/js/panel.js @@ -0,0 +1,437 @@ +import { Socket } from 'phoenix'; + +const audioDevices = document.getElementById('audioDevices'); +const videoDevices = document.getElementById('videoDevices'); +const serverUrl = document.getElementById('serverUrl'); +const serverToken = document.getElementById('serverToken'); +const button = document.getElementById('button'); +const previewPlayer = document.getElementById('previewPlayer'); +const highVideoBitrate = document.getElementById('highVideoBitrate'); +const mediumVideoBitrate = document.getElementById('mediumVideoBitrate'); +const lowVideoBitrate = document.getElementById('lowVideoBitrate'); +const echoCancellation = document.getElementById('echoCancellation'); +const autoGainControl = document.getElementById('autoGainControl'); +const noiseSuppression = document.getElementById('noiseSuppression'); +const saveAudioConfigButton = document.getElementById('save-audio-config'); + +const audioBitrate = document.getElementById('audio-bitrate'); +const videoBitrate = document.getElementById('video-bitrate'); +const packetLoss = document.getElementById('packet-loss'); +const time = document.getElementById('time'); +const statusOff = document.getElementById('status-off'); +const statusOn = document.getElementById('status-on'); + +let lastAudioReport = undefined; +let lastVideoReport = undefined; +let statsIntervalId = undefined; +let startTime = undefined; + +const mediaConstraints = { + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 24 }, + }, + audio: true, +}; + +let localStream = undefined; +let pc = undefined; + + +let pcConfig; +const pcConfigData = document.body.getAttribute('data-pcConfig'); +if (pcConfigData) { + pcConfig = JSON.parse(pcConfigData); +} else { + pcConfig = {}; +} + + +async function setupStream() { + if (localStream != undefined) { + closeStream(); + } + + const videoDevice = videoDevices.value; + const audioDevice = audioDevices.value; + + console.log( + `Setting up stream: audioDevice: ${audioDevice}, videoDevice: ${videoDevice}` + ); + + localStream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: videoDevice }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: { + deviceId: { exact: audioDevice }, + echoCancellation: echoCancellation.checked, + autoGainControl: autoGainControl.checked, + noiseSuppression: noiseSuppression.checked, + }, + }); + + console.log(`Obtained stream with id: ${localStream.id}`); + + previewPlayer.srcObject = localStream; +} + +function closeStream() { + if (localStream != undefined) { + console.log(`Closing stream with id: ${localStream.id}`); + localStream.getTracks().forEach((track) => track.stop()); + localStream = undefined; + } +} + +function bindControls() { + audioDevices.onchange = setupStream; + videoDevices.onchange = setupStream; + button.onclick = startStreaming; +} + +function disableControls() { + audioDevices.disabled = true; + videoDevices.disabled = true; + serverUrl.disabled = true; + serverToken.disabled = true; + saveAudioConfigButton.disabled = true; + highVideoBitrate.disabled = true; + mediumVideoBitrate.disabled = true; + lowVideoBitrate.disabled = true; + echoCancellation.disabled = true; + autoGainControl.disabled = true; + noiseSuppression.disabled = true; +} + +function enableControls() { + audioDevices.disabled = false; + videoDevices.disabled = false; + serverUrl.disabled = false; + serverToken.disabled = false; + saveAudioConfigButton.disabled = false; + highVideoBitrate.disabled = false; + mediumVideoBitrate.disabled = false; + lowVideoBitrate.disabled = false; + echoCancellation.disabled = false; + autoGainControl.disabled = false; + noiseSuppression.disabled = false; +} + +async function startStreaming() { + disableControls(); + + const pcConfigUrl = window.location.origin + '/api/pc-config' + const response = await fetch(pcConfigUrl, { + method: 'GET', + cache: 'no-cache', + }); + const pcConfig = await response.json(); + console.log('Fetched PC config from server: ', pcConfig) + + const candidates = []; + let patchEndpoint = undefined; + pc = new RTCPeerConnection(pcConfig); + + pc.onicegatheringstatechange = () => + console.log('Gathering state change:', pc.iceGatheringState); + pc.onconnectionstatechange = () => { + console.log('Connection state change:', pc.connectionState); + if (pc.connectionState === 'connected') { + startTime = new Date(); + setStatusIcon(true); + + statsIntervalId = setInterval(async function () { + if (!pc) { + clearInterval(statsIntervalId); + statsIntervalId = undefined; + return; + } + + time.innerText = toHHMMSS(new Date() - startTime); + + const stats = await pc.getStats(null); + + let audioReport; + let videoReport = { + timestamp: undefined, + bytesSent: 0, + packetsSent: 0, + retransmittedPacketsSent: 0, + nackCount: 0, + }; + + stats.forEach((report) => { + if (report.type === 'outbound-rtp' && report.kind === 'video') { + videoReport.timestamp = report.timestamp; + videoReport.bytesSent += report.bytesSent; + videoReport.packetsSent += report.packetsSent; + videoReport.retransmittedPacketsSent += + report.retransmittedPacketsSent; + videoReport.nackCount += report.nackCount; + } else if ( + report.type === 'outbound-rtp' && + report.kind === 'audio' + ) { + audioReport = report; + } + }); + + // calculate bitrates + let bitrate; + if (!lastVideoReport) { + bitrate = (videoReport.bytesSent * 8) / 1000; + } else { + const timeDiff = + (videoReport.timestamp - lastVideoReport.timestamp) / 1000; + if (timeDiff == 0) { + // this should never happen as we are getting stats every second + bitrate = 0; + } else { + bitrate = + ((videoReport.bytesSent - lastVideoReport.bytesSent) * 8) / + timeDiff; + } + } + + videoBitrate.innerText = (bitrate / 1000).toFixed(); + lastVideoReport = videoReport; + + if (!lastAudioReport) { + bitrate = audioReport.bytesSent; + } else { + const timeDiff = + (audioReport.timestamp - lastAudioReport.timestamp) / 1000; + if (timeDiff == 0) { + // this should never happen as we are getting stats every second + bitrate = 0; + } else { + bitrate = + ((audioReport.bytesSent - lastAudioReport.bytesSent) * 8) / + timeDiff; + } + } + + audioBitrate.innerText = (bitrate / 1000).toFixed(); + lastAudioReport = audioReport; + + // calculate packet loss + if (!lastAudioReport || !lastVideoReport) { + packetLoss.innerText = 0; + } else { + const packetsSent = + lastVideoReport.packetsSent + lastAudioReport.packetsSent; + const rtxPacketsSent = + lastVideoReport.retransmittedPacketsSent + + lastAudioReport.retransmittedPacketsSent; + const nackReceived = + lastVideoReport.nackCount + lastAudioReport.nackCount; + + if (nackReceived == 0) { + packetLoss.innerText = 0; + } else { + packetLoss.innerText = ( + (nackReceived / (packetsSent - rtxPacketsSent)) * + 100 + ).toFixed(); + } + } + }, 1000); + } else if (pc.connectionState === 'disconnected') { + console.warn('Peer connection state changed to `disconnected`'); + } else if (pc.connectionState === 'failed') { + console.error('Peer connection state changed to `failed`'); + stopStreaming(); + } + }; + + pc.onicecandidate = (event) => { + if (event.candidate == null) { + return; + } + + const candidate = JSON.stringify(event.candidate); + if (patchEndpoint === undefined) { + candidates.push(candidate); + } else { + sendCandidate(patchEndpoint, candidate); + } + }; + + pc.addTrack(localStream.getAudioTracks()[0], localStream); + const { sender: videoSender } = pc.addTransceiver( + localStream.getVideoTracks()[0], + { + streams: [localStream], + sendEncodings: [ + { rid: 'h', maxBitrate: 1500 * 1024 }, + { rid: 'm', scaleResolutionDownBy: 2, maxBitrate: 600 * 1024 }, + { rid: 'l', scaleResolutionDownBy: 4, maxBitrate: 300 * 1024 }, + ], + } + ); + + // limit max bitrate + const params = videoSender.getParameters(); + params.encodings.find((e) => e.rid === 'h').maxBitrate = + parseInt(highVideoBitrate.value) * 1024; + params.encodings.find((e) => e.rid === 'm').maxBitrate = + parseInt(mediumVideoBitrate.value) * 1024; + params.encodings.find((e) => e.rid === 'l').maxBitrate = + parseInt(lowVideoBitrate.value) * 1024; + await videoSender.setParameters(params); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + try { + const response = await fetch(serverUrl.value, { + method: 'POST', + cache: 'no-cache', + headers: { + Accept: 'application/sdp', + 'Content-Type': 'application/sdp', + Authorization: `Bearer ${serverToken.value}`, + }, + body: offer.sdp, + }); + + if (response.status == 201) { + patchEndpoint = response.headers.get('location'); + console.log('Successfully initialized WHIP connection'); + + for (const candidate of candidates) { + sendCandidate(patchEndpoint, candidate); + } + + const sdp = await response.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: sdp }); + button.innerText = 'Stop Streaming'; + button.onclick = stopStreaming; + } else { + console.error('Request to server failed with response:', response); + pc.close(); + pc = undefined; + enableControls(); + } + } catch (err) { + console.error(err); + pc.close(); + pc = undefined; + enableControls(); + } +} + +async function sendCandidate(patchEndpoint, candidate) { + const response = await fetch(patchEndpoint, { + method: 'PATCH', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/trickle-ice-sdpfrag', + }, + body: candidate, + }); + + if (response.status === 204) { + console.log(`Successfully sent ICE candidate:`, candidate); + } else { + console.error( + `Failed to send ICE, status: ${response.status}, candidate:`, + candidate + ); + } +} +function stopStreaming() { + pc.close(); + pc = undefined; + + resetStats(); + enableControls(); + + button.innerText = 'Start Streaming'; + button.onclick = startStreaming; +} + +function resetStats() { + startTime = undefined; + lastAudioReport = undefined; + lastVideoReport = undefined; + audioBitrate.innerText = 0; + videoBitrate.innerText = 0; + packetLoss.innerText = 0; + time.innerText = '00:00:00'; + setStatusIcon(false); +} + +function toHHMMSS(milliseconds) { + // Calculate hours + let hours = Math.floor(milliseconds / (1000 * 60 * 60)); + // Calculate minutes, subtracting the hours part + let minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + // Calculate seconds, subtracting the hours and minutes parts + let seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); + + // Formatting each unit to always have at least two digits + hours = hours < 10 ? '0' + hours : hours; + minutes = minutes < 10 ? '0' + minutes : minutes; + seconds = seconds < 10 ? '0' + seconds : seconds; + + return hours + ':' + minutes + ':' + seconds; +} + +function setStatusIcon(isOn) { + if (isOn) { + statusOff.classList.add('hidden'); + statusOn.classList.remove('hidden'); + } else { + statusOn.classList.add('hidden'); + statusOff.classList.remove('hidden'); + } +} + +async function run() { + // ask for permissions + localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints); + + console.log(`Obtained stream with id: ${localStream.id}`); + + // enumerate devices + const devices = await navigator.mediaDevices.enumerateDevices(); + devices.forEach((device) => { + if (device.kind === 'videoinput') { + videoDevices.options[videoDevices.options.length] = new Option( + device.label, + device.deviceId + ); + } else if (device.kind === 'audioinput') { + audioDevices.options[audioDevices.options.length] = new Option( + device.label, + device.deviceId + ); + } + }); + + // for some reasons, firefox loses labels after closing the stream + // so we close it after filling audio/video devices selects + closeStream(); + + // setup preview + await setupStream(); + + // bind buttons + bindControls(); +} + +export const Panel = { + mounted() { + const socket = new Socket('/socket', { + params: { token: window.userToken }, + }); + socket.connect(); + + run(); + }, +}; diff --git a/k8s_broadcaster/assets/js/whep-client.js b/k8s_broadcaster/assets/js/whep-client.js new file mode 100644 index 0000000..98082c5 --- /dev/null +++ b/k8s_broadcaster/assets/js/whep-client.js @@ -0,0 +1,145 @@ +export class WHEPClient { + constructor(url, pcConfig = {}) { + this.url = url; + this.id = 'WHEP Client'; + this.pc = undefined; + this.resourceId = undefined; + this.patchEndpoint = undefined; + this.onstream = undefined; + this.onconnected = undefined; + this.pcConfig = pcConfig; + } + + async connect(rtx = true) { + const candidates = []; + const pc = new RTCPeerConnection(this.pcConfig); + this.pc = pc; + + pc.ontrack = (event) => { + if (event.track.kind == 'video') { + console.log(`[${this.id}]: Video track added`); + + if (this.onstream) { + this.onstream(event.streams[0]); + } + } else { + // Audio tracks are associated with the stream (`event.streams[0]`) and require no separate actions + console.log(`[${this.id}]: Audio track added`); + } + }; + + pc.onicegatheringstatechange = () => + console.log( + `[${this.id}]: Gathering state change:`, + pc.iceGatheringState + ); + + pc.onconnectionstatechange = () => { + console.log(`[${this.id}]: Connection state change:`, pc.connectionState); + if (pc.connectionState === 'connected' && this.onconnected) { + this.onconnected(); + } + }; + + pc.onicecandidate = (event) => { + if (event.candidate == null) { + return; + } + + const candidate = JSON.stringify(event.candidate); + if (this.patchEndpoint === undefined) { + candidates.push(candidate); + } else { + this.sendCandidate(candidate); + } + }; + + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + const response = await fetch(this.url, { + method: 'POST', + cache: 'no-cache', + headers: { + Accept: 'application/sdp', + 'Content-Type': 'application/sdp', + }, + body: JSON.stringify({ + rtx: rtx, + sdp: pc.localDescription.sdp, + }) + }); + + if (response.status !== 201) { + console.error( + `[${this.id}]: Failed to initialize WHEP connection, status: ${response.status}` + ); + return; + } + + this.patchEndpoint = response.headers.get('location'); + this.resourceId = this.patchEndpoint.split('/').pop(); + console.log(`[${this.id}]: Sucessfully initialized WHEP connection`); + + for (const candidate of candidates) { + this.sendCandidate(candidate); + } + + const sdp = await response.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: sdp }); + } + + async disconnect() { + this.pc.close(); + } + + async changeLayer(layer) { + // According to the spec, we should gather the info about available layers from the `layers` event + // emitted in the SSE stream tied to *one* given WHEP session. + // + // However, to simplify the implementation and decrease resource usage, we're assuming each stream + // has the layers with `encodingId` of `h`, `m` and `l`, corresponding to high, medium and low video quality. + // If that's not the case (e.g. the stream doesn't use simulcast), the server returns an error response which we ignore. + // + // Nevertheless, the server supports the `Server Sent Events` and `Video Layer Selection` WHEP extensions, + // and WHEP players other than this site are free to use them. + // + // For more info refer to https://www.ietf.org/archive/id/draft-ietf-wish-whep-01.html#section-4.6.2 + if (this.patchEndpoint) { + const response = await fetch(`${this.patchEndpoint}/layer`, { + method: 'POST', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encodingId: layer }), + }); + + if (response.status != 200) { + console.warn(`[${this.id}]: Changing layer failed`, response); + } + } + } + + async sendCandidate(candidate) { + const response = await fetch(this.patchEndpoint, { + method: 'PATCH', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/trickle-ice-sdpfrag', + }, + body: candidate, + }); + + if (response.status === 204) { + console.log(`[${this.id}]: Successfully sent ICE candidate:`, candidate); + } else { + console.error( + `[${this.id}]: Failed to send ICE, status: ${response.status}, candidate:`, + candidate + ); + } + } + } + \ No newline at end of file diff --git a/k8s_broadcaster/assets/tailwind.config.js b/k8s_broadcaster/assets/tailwind.config.js new file mode 100644 index 0000000..18ae026 --- /dev/null +++ b/k8s_broadcaster/assets/tailwind.config.js @@ -0,0 +1,76 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/k8s_broadcaster_web.ex", + "../lib/k8s_broadcaster_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#4339AC", + b: "#606060" + + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/k8s_broadcaster/assets/vendor/topbar.js b/k8s_broadcaster/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/k8s_broadcaster/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/k8s_broadcaster/config/config.exs b/k8s_broadcaster/config/config.exs new file mode 100644 index 0000000..9048b50 --- /dev/null +++ b/k8s_broadcaster/config/config.exs @@ -0,0 +1,66 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :k8s_broadcaster, + generators: [timestamp_type: :utc_datetime] + +# Configures the endpoint +config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: K8sBroadcasterWeb.ErrorHTML, json: K8sBroadcasterWeb.ErrorJSON], + layout: false + ], + pubsub_server: K8sBroadcaster.PubSub, + live_view: [signing_salt: "2KRXfjle"] + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + k8s_broadcaster: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + k8s_broadcaster: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +config :mime, :types, %{ + "application/sdp" => ["sdp"], + "application/trickle-ice-sdpfrag" => ["trickle-ice-sdpfrag"] +} + +config :k8s_broadcaster, + whip_token: "example", + admin_username: "admin", + admin_password: "admin" + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/k8s_broadcaster/config/dev.exs b/k8s_broadcaster/config/dev.exs new file mode 100644 index 0000000..7778bc3 --- /dev/null +++ b/k8s_broadcaster/config/dev.exs @@ -0,0 +1,71 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "qbvm8IEj0Eqk4SBhwAMhWthROktYxdSF8Bd+isbKYGyaP7vQM5zM4FkI9hX315/H", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:k8s_broadcaster, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:k8s_broadcaster, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/k8s_broadcaster_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :k8s_broadcaster, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, level: :info, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true diff --git a/k8s_broadcaster/config/prod.exs b/k8s_broadcaster/config/prod.exs new file mode 100644 index 0000000..2dcfcec --- /dev/null +++ b/k8s_broadcaster/config/prod.exs @@ -0,0 +1,15 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/k8s_broadcaster/config/runtime.exs b/k8s_broadcaster/config/runtime.exs new file mode 100644 index 0000000..419c11d --- /dev/null +++ b/k8s_broadcaster/config/runtime.exs @@ -0,0 +1,190 @@ +import Config + +read_cluster! = fn env -> + case System.get_env(env) do + nil -> + %{reg: nil, url: nil} + + cluster -> + [reg, url] = String.split(cluster, ";") + %{reg: reg, url: url} + end +end + +read_ice_port_range! = fn -> + case System.get_env("ICE_PORT_RANGE") do + nil -> + [0] + + raw_port_range -> + case String.split(raw_port_range, "-", parts: 2) do + [from, to] -> String.to_integer(from)..String.to_integer(to) + _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}" + end + end +end + +read_k8s_dist_config! = fn -> + case System.get_env("K8S_SERVICE_NAME") do + nil -> + raise "Distribution mode `k8s` requires setting the env variable K8S_SERVICE_NAME" + + service -> + [ + strategy: Cluster.Strategy.Kubernetes.DNS, + config: [ + application_name: "k8s_broadcaster", + service: service + ] + ] + end +end + +read_check_origin = fn -> + case System.get_env("CHECK_ORIGIN") do + nil -> true + "true" -> true + "false" -> false + value -> String.split(value, ";") + end +end + +ice_server_config = + %{ + urls: System.get_env("ICE_SERVER_URL") || "stun:stun.l.google.com:19302", + username: System.get_env("ICE_SERVER_USERNAME"), + credential: System.get_env("ICE_SERVER_CREDENTIAL") + } + |> Map.reject(fn {_k, v} -> is_nil(v) end) + +ice_transport_policy = + case System.get_env("ICE_TRANSPORT_POLICY") do + "relay" -> :relay + _other -> :all + end + +pc_config = [ + ice_servers: [ice_server_config], + ice_transport_policy: ice_transport_policy, + ice_port_range: read_ice_port_range!.() +] + +# Cluster info is in form of %{reg: reg, url: url}. +# If not provided, %{reg: nil, url: nil} will be used +# and interpreted as using current window location +cluster_info = + %{ + c0: read_cluster!.("C0"), + c1: read_cluster!.("C1"), + c2: read_cluster!.("C2"), + c3: read_cluster!.("C3") + } + |> IO.inspect() + +dist_config = + case System.get_env("DISTRIBUTION_MODE") do + "k8s" -> read_k8s_dist_config!.() + _else -> nil + end + +config :k8s_broadcaster, + cluster_info: cluster_info, + pc_config: pc_config, + dist_config: dist_config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/k8s_broadcaster start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, server: true +end + +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :k8s_broadcaster, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base, + check_origin: read_check_origin.() + + whip_token = System.get_env("WHIP_TOKEN") || raise "Environment variable WHIP_TOKEN is missing." + + admin_username = + System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing." + + admin_password = + System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing." + + config :k8s_broadcaster, + whip_token: whip_token, + admin_username: admin_username, + admin_password: admin_password + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. +end diff --git a/k8s_broadcaster/config/test.exs b/k8s_broadcaster/config/test.exs new file mode 100644 index 0000000..cd23e1f --- /dev/null +++ b/k8s_broadcaster/config/test.exs @@ -0,0 +1,18 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :k8s_broadcaster, K8sBroadcasterWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "IZC4GPoi59CquGdP0YpCdEZx/+/2zpcbdySxdLhfrydB7tQS/ac1fAwYQ2AC0Kv3", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/k8s_broadcaster/kubernetes.yaml b/k8s_broadcaster/kubernetes.yaml new file mode 100644 index 0000000..234fa5b --- /dev/null +++ b/k8s_broadcaster/kubernetes.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: Service +metadata: + name: k8sbroadcaster-headless +spec: + selector: + app: k8sbroadcaster + type: ClusterIP + clusterIP: None +--- +apiVersion: v1 +kind: Service +metadata: + name: k8sbroadcaster-external +spec: + selector: + app: k8sbroadcaster + type: LoadBalancer + ports: + - port: 8080 + targetPort: 4000 + protocol: TCP + name: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: k8sbroadcaster + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: k8sbroadcaster + template: + metadata: + labels: + app: k8sbroadcaster + spec: + containers: + - name: k8sbroadcaster + # image: ghcr.io/elixir-webrtc/apps/k8s_broadcaster:latest + image: k8s_broadcaster:dev + imagePullPolicy: Never + ports: + - name: http + containerPort: 4000 + protocol: TCP + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: DISTRIBUTION_MODE + value: k8s + - name: K8S_SERVICE_NAME + value: k8sbroadcaster-headless + # - name: ICE_SERVER_URL + # value: turn:turn.example.org:3478?transport=udp + # - name: ICE_SERVER_USERNAME + # value: user-1 + # - name: ICE_SERVER_CREDENTIAL + # value: pass-1 + # - name: ICE_TRANSPORT_POLICY + # value: relay + - name: ICE_PORT_RANGE + value: 51000-52000 + - name: SECRET_KEY_BASE + value: u1gYGbDNgA5RwdKGFe9CdK+5qLCVROAHZAFPgUVlcmjTEGdvpXqgYW9qFjLQvxZO + - name: PHX_HOST + value: localhost + - name: ADMIN_USERNAME + value: admin + - name: ADMIN_PASSWORD + value: admin + - name: WHIP_TOKEN + value: token diff --git a/k8s_broadcaster/lib/k8s_broadcaster.ex b/k8s_broadcaster/lib/k8s_broadcaster.ex new file mode 100644 index 0000000..ae99161 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster.ex @@ -0,0 +1,9 @@ +defmodule K8sBroadcaster do + @moduledoc """ + K8sBroadcaster keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster/application.ex b/k8s_broadcaster/lib/k8s_broadcaster/application.ex new file mode 100644 index 0000000..f7bc444 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster/application.ex @@ -0,0 +1,55 @@ +defmodule K8sBroadcaster.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @version Mix.Project.config()[:version] + + @spec version() :: String.t() + def version(), do: @version + + @spec cluster(:c0 | :c1 | :c2 | :c3) :: map() + def cluster(name), + do: Application.fetch_env!(:k8s_broadcaster, :cluster_info) |> Map.fetch!(name) + + @impl true + def start(_type, _args) do + dist_config = + case Application.fetch_env!(:k8s_broadcaster, :dist_config) do + nil -> + nil + + config -> + {Cluster.Supervisor, [[cluster: config], [name: K8sBroadcaster.ClusterSupervisor]]} + end + + children = + [ + K8sBroadcasterWeb.Telemetry, + {DNSCluster, query: Application.get_env(:k8s_broadcaster, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: K8sBroadcaster.PubSub}, + # Start to serve requests, typically the last entry + dist_config, + K8sBroadcaster.PeerSupervisor, + K8sBroadcaster.Forwarder, + K8sBroadcasterWeb.Endpoint, + K8sBroadcasterWeb.Presence + ] + |> Enum.reject(&is_nil(&1)) + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: K8sBroadcaster.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + K8sBroadcasterWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex b/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex new file mode 100644 index 0000000..4e2d495 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster/forwarder.ex @@ -0,0 +1,541 @@ +defmodule K8sBroadcaster.Forwarder do + @moduledoc false + + use GenServer + + require Logger + + alias Phoenix.PubSub + + alias ExWebRTC.PeerConnection + alias ExWebRTC.RTP.H264 + alias ExWebRTC.RTP.Munger + + alias K8sBroadcaster.PeerSupervisor + alias K8sBroadcasterWeb.Channel + + @pubsub K8sBroadcaster.PubSub + # Timeout for removing input/outputs that fail to connect + @connect_timeout_s 15 + @connect_timeout_ms @connect_timeout_s * 1000 + + @type id :: String.t() + + @type input_spec :: %{ + pc: pid(), + id: id(), + video: String.t() | nil, + audio: String.t() | nil, + available_layers: [String.t()] | nil + } + + @type output_spec :: %{ + video: String.t() | nil, + audio: String.t() | nil, + munger: Munger.t(), + packet_loss: non_neg_integer(), + layer: String.t() | nil, + pending_layer: String.t() | nil + } + + @type state :: %{ + # WHIP + pending_input: input_spec() | nil, + local_input: input_spec() | nil, + remote_inputs: %{pid() => input_spec()}, + + # WHEP + pending_outputs: MapSet.t(pid()), + outputs: %{pid() => output_spec()} + } + + @spec start_link(any()) :: GenServer.on_start() + def start_link(_arg) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @spec set_layer(pid(), String.t()) :: :ok | :error + def set_layer(pc, layer) do + GenServer.call(__MODULE__, {:set_layer, pc, layer}) + end + + @spec get_layers() :: {:ok, [String.t()] | nil} | :error + def get_layers() do + GenServer.call(__MODULE__, :get_layers) + end + + @spec connect_input(pid(), id()) :: :ok + def connect_input(pc, id) do + GenServer.call(__MODULE__, {:connect_input, id, pc}) + end + + @spec connect_output(pid(), id()) :: :ok + def connect_output(pc, input_id) do + GenServer.call(__MODULE__, {:connect_output, input_id, pc}) + end + + @spec get_input() :: input_spec() | nil + def get_input() do + GenServer.call(__MODULE__, :get_input) + end + + @spec get_local_input() :: input_spec() | nil + def get_local_input() do + GenServer.call(__MODULE__, :get_local_input) + end + + @spec set_packet_loss(pid(), non_neg_integer()) :: :ok + def set_packet_loss(pc, value) do + GenServer.cast(__MODULE__, {:set_packet_loss, pc, value}) + end + + @impl true + def init(_arg) do + state = %{ + pending_input: nil, + local_input: nil, + remote_input: nil, + pending_outputs: MapSet.new(), + outputs: %{} + } + + {:ok, state, {:continue, :after_init}} + end + + @impl true + def handle_continue(:after_init, state) do + # Get remote inputs already present in cluster + Node.list() + |> :erpc.multicall(__MODULE__, :get_local_input, [], 5000) + |> case do + input when is_map(input) -> send(self(), {:input_added, input}) + _ -> :ok + end + + PubSub.subscribe(@pubsub, "inputs") + + {:noreply, state} + end + + @impl true + def handle_call(:get_input, _from, state) do + {:reply, state.local_input || state.remote_input, state} + end + + @impl true + def handle_call(:get_local_input, _from, state) do + {:reply, state.local_input, state} + end + + @impl true + def handle_call({:set_layer, pc, layer}, _from, state) do + with {:ok, output} <- Map.fetch(state.outputs, pc), + input when not is_nil(input) <- state.local_input || state.remote_input, + true <- input.available_layers != nil, + true <- layer in input.available_layers do + output = %{output | pending_layer: layer} + state = %{state | outputs: Map.put(state.outputs, pc, output)} + + PeerConnection.send_pli(input.pc, input.video, layer) + + {:reply, :ok, state} + else + _other -> {:reply, :error, state} + end + end + + @impl true + def handle_call(:get_layers, _from, state) do + case state.local_input || state.remote_input do + nil -> {:reply, :error, state} + input -> {:reply, {:ok, input.available_layers}, state} + end + end + + @impl true + def handle_call({:connect_input, id, pc}, _from, %{local_input: nil, remote_input: nil} = state) do + terminate_pending_input(state) + Process.monitor(pc) + + PeerConnection.controlling_process(pc, self()) + + input = %{ + pc: pc, + id: id, + video: nil, + audio: nil, + available_layers: nil + } + + state = %{state | pending_input: input} + + Logger.info("Added new input #{id} (#{inspect(pc)})") + Process.send_after(self(), {:connect_timeout, pc}, @connect_timeout_ms) + + {:reply, :ok, state} + end + + @impl true + def handle_call({:connect_input, id, _pc}, _from, state) do + Logger.info("Cannot add input #{id} as there already is one connected.") + {:reply, :error, state} + end + + @impl true + def handle_call({:connect_output, id, pc}, _from, state) do + Process.monitor(pc) + + PeerConnection.controlling_process(pc, self()) + pending_outputs = MapSet.put(state.pending_outputs, pc) + + Logger.info("Added new output #{inspect(pc)} for input #{inspect(id)}") + Process.send_after(self(), {:connect_timeout, pc}, @connect_timeout_ms) + + {:reply, :ok, %{state | pending_outputs: pending_outputs}} + end + + @impl true + def handle_cast({:set_packet_loss, pc, value}, state) do + case Map.get(state.outputs, pc) do + nil -> + Logger.warning("Tried to set packet loss for non-existing peer connection.") + {:noreply, state} + + _output -> + PeerConnection.set_packet_loss(pc, String.to_integer(value)) + {:noreply, state} + end + end + + @impl true + def handle_info({:connect_timeout, pc}, state) do + direction = + cond do + pc == state.pending_input -> :input + MapSet.member?(state.pending_outputs, pc) -> :output + true -> nil + end + + if direction != nil do + Logger.warning(""" + Terminating #{direction} #{inspect(pc)} \ + because it didn't connect within #{@connect_timeout_s} seconds \ + """) + + PeerSupervisor.terminate_pc(pc) + end + + {:noreply, state} + end + + @impl true + def handle_info( + {:ex_webrtc, pc, {:connection_state_change, :connected}}, + %{pending_input: %{pc: pc} = input} = state + ) do + {audio_track, video_track} = get_tracks(pc, :receiver) + + input = %{ + input + | video: video_track.id, + audio: audio_track.id, + available_layers: video_track.rids + } + + state = %{state | local_input: input} + + Logger.info("Input #{input.id} (#{inspect(pc)}) has successfully connected") + Channel.input_added(input.id) + + # ID collisions in the cluster are unlikely and thus will not be checked against + PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_added, input}) + + {:noreply, state} + end + + @impl true + def handle_info({:ex_webrtc, pc, {:connection_state_change, :connected}}, state) do + case MapSet.member?(state.pending_outputs, pc) do + true -> + pending_outpus = MapSet.delete(state.pending_outputs, pc) + state = %{state | pending_outputs: pending_outpus} + + state = + case state.local_input || state.remote_input do + nil -> + Logger.info("Terminating output #{inspect(pc)} because there is no input") + PeerSupervisor.terminate_pc(pc) + state + + input -> + do_connect_output(pc, input, state) + end + + {:noreply, state} + + false -> + # We might have received this message at the same moment we where terminating peer connection + {:noreply, state} + end + end + + @impl true + def handle_info({:ex_webrtc, pc, {:connection_state_change, :failed}}, state) do + Logger.warning("Peer connection #{inspect(pc)} state changed to `failed`. Terminating") + PeerSupervisor.terminate_pc(pc) + {:noreply, state} + end + + @impl true + def handle_info({:ex_webrtc, input_pc, {:rtp, input_track, rid, packet}}, state) do + input = state.local_input || state.remote_input + + state = + cond do + input_track == input.audio and rid == nil -> + PubSub.broadcast(@pubsub, "input:#{input.id}", {:input, input_pc, :audio, nil, packet}) + forward_audio_packet(packet, state) + + input_track == input.video -> + PubSub.broadcast(@pubsub, "input:#{input.id}", {:input, input_pc, :video, rid, packet}) + forward_video_packet(packet, rid, state) + + true -> + Logger.warning("Received an RTP packet corresponding to unknown track. Ignoring") + state + end + + {:noreply, state} + end + + @impl true + def handle_info({:ex_webrtc, pc, {:rtcp, packets}}, state) do + with {:ok, %{layer: layer}} <- Map.fetch(state.outputs, pc), + input when not is_nil(input) <- state.local_input || state.remote_input do + for packet <- packets do + case packet do + {_id, %ExRTCP.Packet.PayloadFeedback.PLI{}} -> + PeerConnection.send_pli(input.pc, input.video, layer) + + _other -> + :ok + end + end + end + + {:noreply, state} + end + + @impl true + def handle_info({:input_added, input}, state) do + Logger.info("New remote input #{input.id}") + + state = + state + |> terminate_pending_input() + |> terminate_local_input() + |> unsubscribe_remote_input() + + PubSub.subscribe(@pubsub, "input:#{input.id}") + + Logger.info("Subscribed to remote input #{input.id}") + + {:noreply, %{state | remote_input: input}} + end + + @impl true + def handle_info({:input_removed, id}, state) do + case state.remote_input do + %{id: ^id, pc: pc} = input -> + Logger.info("Remote input #{input.id} (#{inspect(pc)}) removed") + state = unsubscribe_remote_input(state) + + for {output_pc, _output} <- state.outputs do + PeerSupervisor.terminate_pc(output_pc) + end + + {:noreply, state} + + %{id: id} -> + Logger.info("Remote input #{id} removed, but we are not subscribed to it. Ignoring.") + + {:noreply, state} + end + end + + @impl true + def handle_info({:input, pc, kind, rid, packet}, %{remote_input: %{pc: pc}} = state) do + state = + cond do + kind == :audio and rid == nil -> + forward_audio_packet(packet, state) + + kind == :video -> + forward_video_packet(packet, rid, state) + + true -> + Logger.warning("Received an RTP packet corresponding to unknown remote track. Ignoring") + state + end + + {:noreply, state} + end + + @impl true + def handle_info( + {:DOWN, _ref, :process, pid, reason}, + %{local_input: %{pc: pid} = input} = state + ) do + Logger.info( + "Input #{input.id}: process #{inspect(pid)} exited with reason #{inspect(reason)}" + ) + + for {pc, _} <- state.outputs do + PeerSupervisor.terminate_pc(pc) + end + + Channel.input_removed(input.id) + PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_removed, pid}) + + {:noreply, %{state | local_input: nil}} + end + + @impl true + def handle_info({:DOWN, _ref, :process, pid, reason}, state) do + cond do + pid == state.pending_input -> + Logger.info(""" + Pending input #{state.pending_input.id}: process #{inspect(pid)} \ + exited with reason #{inspect(reason)} \ + """) + + {:noreply, %{state | pending_input: nil}} + + Map.has_key?(state.outputs, pid) -> + {output, state} = pop_in(state, [:outputs, pid]) + + Logger.info(""" + Output process #{inspect(pid)} (input #{output.input_id}) \ + exited with reason #{inspect(reason)} \ + """) + + {:noreply, state} + + MapSet.member?(state.pending_outputs, pid) -> + pending_outputs = MapSet.delete(state.pending_outputs, pid) + state = %{state | pending_outputs: pending_outputs} + + Logger.info( + "Pending output process #{inspect(pid)} exited with reason #{inspect(reason)} " + ) + + {:noreply, state} + + true -> + Logger.warning("Unknown process #{inspect(pid)} died with reason #{inspect(reason)}") + {:noreply, state} + end + end + + @impl true + def handle_info(_msg, state) do + {:noreply, state} + end + + defp do_connect_output(pc, input, state) do + layer = default_layer(input) + + {audio_track, video_track} = get_tracks(pc, :sender) + munger = Munger.new(90_000) + + output = %{ + audio: audio_track.id, + video: video_track.id, + input_id: input.id, + munger: munger, + layer: layer, + pending_layer: layer + } + + Logger.info("Output #{inspect(pc)} has successfully connected") + + # We don't send a PLI on behalf of the newly connected output. + # Once the remote end sends a PLI to us, we'll forward it. + + put_in(state, [:outputs, pc], output) + end + + defp forward_video_packet(packet, rid, state) do + outputs = + Map.new(state.outputs, fn + {pc, %{layer: layer, pending_layer: p_layer} = output} -> + output = + if p_layer == rid and p_layer != layer and H264.keyframe?(packet) do + munger = Munger.update(output.munger) + %{output | layer: p_layer, munger: munger} + else + output + end + + output = + if rid == output.layer do + {packet, munger} = Munger.munge(output.munger, packet) + PeerConnection.send_rtp(pc, output.video, packet) + %{output | munger: munger} + else + output + end + + {pc, output} + end) + + %{state | outputs: outputs} + end + + defp forward_audio_packet(packet, state) do + for {pc, output} <- state.outputs do + PeerConnection.send_rtp(pc, output.audio, packet) + end + + state + end + + defp get_tracks(pc, type) do + transceivers = PeerConnection.get_transceivers(pc) + audio_transceiver = Enum.find(transceivers, fn tr -> tr.kind == :audio end) + video_transceiver = Enum.find(transceivers, fn tr -> tr.kind == :video end) + + audio_track = Map.fetch!(audio_transceiver, type).track + video_track = Map.fetch!(video_transceiver, type).track + + {audio_track, video_track} + end + + defp default_layer(%{available_layers: nil}), do: nil + defp default_layer(%{available_layers: [first | _]}), do: first + + defp terminate_pending_input(%{pending_input: nil} = state), do: state + + defp terminate_pending_input(%{pending_input: %{pc: pc, id: id}} = state) when is_pid(pc) do + Logger.info("Terminating pending local input: #{id}") + :ok = PeerSupervisor.terminate_pc(pc) + %{state | pending_input: nil} + end + + defp terminate_local_input(%{local_input: nil} = state), do: state + + defp terminate_local_input(%{local_input: %{pc: pc, id: id}} = state) when is_pid(pc) do + Logger.info("Terminating local input: #{id}") + :ok = PeerSupervisor.terminate_pc(pc) + PubSub.broadcast_from(@pubsub, self(), "inputs", {:input_removed, pc}) + %{state | local_input: nil} + end + + defp unsubscribe_remote_input(%{remote_input: nil} = state), do: state + + defp unsubscribe_remote_input(%{remote_input: %{id: id}} = state) do + Logger.info("Unsubscribing from remote input: #{id}") + PubSub.unsubscribe(@pubsub, "input:#{id}") + %{state | remote_input: nil} + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex b/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex new file mode 100644 index 0000000..77581f0 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster/peer_supervisor.ex @@ -0,0 +1,151 @@ +defmodule K8sBroadcaster.PeerSupervisor do + @moduledoc false + + use DynamicSupervisor + + require Logger + + alias ExWebRTC.{MediaStreamTrack, PeerConnection, SessionDescription, RTPCodecParameters} + + @audio_codecs [ + %RTPCodecParameters{ + payload_type: 111, + mime_type: "audio/opus", + clock_rate: 48_000, + channels: 2 + } + ] + + @video_codecs [ + %RTPCodecParameters{ + payload_type: 96, + mime_type: "video/H264", + clock_rate: 90_000 + } + ] + + @spec client_pc_config() :: String.t() + def client_pc_config() do + pc_config = Application.fetch_env!(:k8s_broadcaster, :pc_config) + + %{ + iceServers: pc_config[:ice_servers], + iceTransportPolicy: pc_config[:ice_transport_policy] + } + |> Jason.encode!() + end + + @spec start_link(any()) :: Supervisor.on_start() + def start_link(arg) do + :syn.add_node_to_scopes([K8sBroadcaster.GlobalPeerRegistry]) + + DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @spec start_whip(String.t()) :: {:ok, pid(), String.t(), String.t()} | {:error, term()} + def start_whip(offer_sdp), do: start_pc(offer_sdp, :recvonly) + + @spec start_whep(String.t(), boolean()) :: + {:ok, pid(), String.t(), String.t()} | {:error, term()} + def start_whep(offer_sdp, rtx \\ true), do: start_pc(offer_sdp, :sendonly, rtx) + + @spec fetch_pid(String.t()) :: {:ok, pid()} | {:error, :peer_not_found} + def fetch_pid(id) do + case :syn.lookup(K8sBroadcaster.GlobalPeerRegistry, id) do + :undefined -> {:error, :peer_not_found} + {pid, _val} -> {:ok, pid} + end + end + + @spec terminate_pc(pid()) :: :ok | {:error, :not_found} + def terminate_pc(pc) do + DynamicSupervisor.terminate_child(__MODULE__, pc) + end + + @impl true + def init(_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + defp start_pc(offer_sdp, direction, rtx \\ true) do + offer = %SessionDescription{type: :offer, sdp: offer_sdp} + pc_id = generate_pc_id() + {:ok, pc} = spawn_peer_connection(rtx) + :syn.register(K8sBroadcaster.GlobalPeerRegistry, pc_id, pc) + + Logger.info("Received offer for #{inspect(pc)}") + Logger.debug("Offer SDP for #{inspect(pc)}:\n#{offer.sdp}") + + with :ok <- PeerConnection.set_remote_description(pc, offer), + :ok <- setup_transceivers(pc, direction), + {:ok, answer} <- PeerConnection.create_answer(pc), + :ok <- PeerConnection.set_local_description(pc, answer), + :ok <- gather_candidates(pc), + answer <- PeerConnection.get_local_description(pc) do + Logger.info("Sent answer for #{inspect(pc)}") + Logger.debug("Answer SDP for #{inspect(pc)}:\n#{answer.sdp}") + + {:ok, pc, pc_id, answer.sdp} + else + {:error, _res} = err -> + Logger.info("Failed to complete negotiation for #{inspect(pc)}") + terminate_pc(pc) + err + end + end + + defp setup_transceivers(pc, direction) do + if direction == :sendonly do + stream_id = MediaStreamTrack.generate_stream_id() + {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:audio, [stream_id])) + {:ok, _sender} = PeerConnection.add_track(pc, MediaStreamTrack.new(:video, [stream_id])) + end + + transceivers = PeerConnection.get_transceivers(pc) + + for %{id: id} <- transceivers do + PeerConnection.set_transceiver_direction(pc, id, direction) + end + + :ok + end + + defp spawn_peer_connection(rtx) do + features = + if rtx do + ExWebRTC.PeerConnection.Configuration.default_features() + else + ExWebRTC.PeerConnection.Configuration.default_features() -- [:inbound_rtx, :outbound_rtx] + end + + pc_opts = + (Application.fetch_env!(:k8s_broadcaster, :pc_config) ++ + [ + audio_codecs: @audio_codecs, + video_codecs: @video_codecs, + controlling_process: self(), + features: features + ]) + |> Keyword.delete(:ice_transport_policy) + + child_spec = %{ + id: PeerConnection, + start: {PeerConnection, :start_link, [pc_opts, []]}, + restart: :temporary + } + + DynamicSupervisor.start_child(__MODULE__, child_spec) + end + + defp gather_candidates(pc) do + # we either wait for all of the candidates + # or whatever we were able to gather in one second + receive do + {:ex_webrtc, ^pc, {:ice_gathering_state_change, :complete}} -> :ok + after + 1000 -> :ok + end + end + + defp generate_pc_id(), do: for(_ <- 1..10, into: "", do: <>) +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web.ex b/k8s_broadcaster/lib/k8s_broadcaster_web.ex new file mode 100644 index 0000000..00d0818 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web.ex @@ -0,0 +1,111 @@ +defmodule K8sBroadcasterWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use K8sBroadcasterWeb, :controller + use K8sBroadcasterWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: K8sBroadcasterWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {K8sBroadcasterWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import K8sBroadcasterWeb.CoreComponents + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: K8sBroadcasterWeb.Endpoint, + router: K8sBroadcasterWeb.Router, + statics: K8sBroadcasterWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex new file mode 100644 index 0000000..f869631 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/channel.ex @@ -0,0 +1,46 @@ +defmodule K8sBroadcasterWeb.Channel do + @moduledoc false + + use K8sBroadcasterWeb, :channel + + alias K8sBroadcasterWeb.{Endpoint, Presence} + + @spec input_added(String.t()) :: :ok + def input_added(id) do + Endpoint.broadcast!("k8s_broadcaster:signaling", "input_added", %{id: id}) + end + + @spec input_removed(String.t()) :: :ok + def input_removed(id) do + Endpoint.broadcast!("k8s_broadcaster:signaling", "input_removed", %{id: id}) + end + + @impl true + def join("k8s_broadcaster:signaling", _, socket) do + send(self(), :after_join) + {:ok, socket} + end + + @impl true + def handle_info(:after_join, socket) do + {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{}) + push(socket, "presence_state", Presence.list(socket)) + + case K8sBroadcaster.Forwarder.get_input() do + nil -> :ok + input -> push(socket, "input_added", %{id: input.id}) + end + + {:noreply, socket} + end + + @impl true + def handle_in("packet_loss", payload, socket) do + case K8sBroadcaster.PeerSupervisor.fetch_pid(payload["resourceId"]) do + {:ok, pid} -> K8sBroadcaster.Forwarder.set_packet_loss(pid, payload["value"]) + _ -> :ok + end + + {:noreply, socket} + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex new file mode 100644 index 0000000..3321563 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/presence.ex @@ -0,0 +1,11 @@ +defmodule K8sBroadcasterWeb.Presence do + @moduledoc """ + Provides presence tracking to channels and processes. + + See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) + docs for more details. + """ + use Phoenix.Presence, + otp_app: :k8s_broadcaster, + pubsub_server: K8sBroadcaster.PubSub +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex new file mode 100644 index 0000000..89a8a77 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/channels/user_socket.ex @@ -0,0 +1,19 @@ +defmodule K8sBroadcasterWeb.UserSocket do + use Phoenix.Socket + + channel "k8s_broadcaster:*", K8sBroadcasterWeb.Channel + + @impl true + def connect(_params, socket, _connect_info) do + {:ok, assign(socket, :user_id, generate_id())} + end + + @impl true + def id(socket), do: "user_socket:#{socket.assigns.user_id}" + + defp generate_id do + 10 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64() + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex new file mode 100644 index 0000000..c1f3872 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/core_components.ex @@ -0,0 +1,672 @@ +defmodule K8sBroadcasterWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
{col[:label]} + Actions +
+
+ + + {render_slot(col, @row_item.(row))} + +
+
+
+ + + {render_slot(action, @row_item.(row))} + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
{item.title}
+
{render_slot(item)}
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + {render_slot(@inner_block)} + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(K8sBroadcasterWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(K8sBroadcasterWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex new file mode 100644 index 0000000..e9c3f9c --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule K8sBroadcasterWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use K8sBroadcasterWeb, :controller` and + `use K8sBroadcasterWeb, :live_view`. + """ + use K8sBroadcasterWeb, :html + + embed_templates "layouts/*" +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex new file mode 100644 index 0000000..bb1c158 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/app.html.heex @@ -0,0 +1,42 @@ +
+
+
+ + + +
+
+
+
+

0

+ <.icon name="hero-user-solid" /> +
+ +
+
+
+
+
+
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
+
+ {K8sBroadcaster.Application.version()} +
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex new file mode 100644 index 0000000..a7ae144 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title suffix=" · K8sBroadcaster"> + {assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex new file mode 100644 index 0000000..accbea6 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule K8sBroadcasterWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use K8sBroadcasterWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/k8s_broadcaster_web/controllers/error_html/404.html.heex + # * lib/k8s_broadcaster_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex new file mode 100644 index 0000000..5fb1015 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule K8sBroadcasterWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex new file mode 100644 index 0000000..d550d75 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/media_controller.ex @@ -0,0 +1,158 @@ +defmodule K8sBroadcasterWeb.MediaController do + use K8sBroadcasterWeb, :controller + + alias K8sBroadcaster.{Forwarder, PeerSupervisor} + alias ExWebRTC.PeerConnection + + @sse ~s/rel = "urn:ietf:params:whep:ext:core:server-sent-events"; events="layers"/ + @layer ~s/rel = "urn:ietf:params:whep:ext:core:layer"/ + + plug :accepts, ["sdp"] when action in [:whip, :whep] + plug :accepts, ["trickle-ice-sdpfrag"] when action in [:ice_candidate] + + # Used by Corsica in handling CORS requests: allows fetching response headers + # by external WHEP players implemented in a browser. + # + # All headers used in the WHEP responses should be specified here + def cors_expose_headers do + ["location", "link", "content-type", "connection", "cache-control"] + end + + def pc_config(conn, _params) do + conn + |> resp(200, K8sBroadcaster.PeerSupervisor.client_pc_config()) + |> send_resp() + end + + # TODO: use proper statuses in case of error + def whip(conn, _params) do + with :ok <- authenticate(conn), + {:ok, offer_sdp, conn} <- read_body(conn), + {:ok, pc, pc_id, answer_sdp} <- PeerSupervisor.start_whip(offer_sdp), + :ok <- Forwarder.connect_input(pc, pc_id) do + conn + |> put_resp_header("location", ~p"/api/resource/#{pc_id}") + |> put_resp_content_type("application/sdp") + |> resp(201, answer_sdp) + else + _other -> resp(conn, 400, "Bad request") + end + |> send_resp() + end + + def whep(conn, params) do + input_id = params["inputId"] + + with {:ok, body, conn} <- read_body(conn), + {:ok, %{"sdp" => offer_sdp, "rtx" => rtx}} <- Jason.decode(body), + {:ok, pc, pc_id, answer_sdp} <- PeerSupervisor.start_whep(offer_sdp, rtx), + :ok <- Forwarder.connect_output(pc, input_id) do + uri = ~p"/api/resource/#{pc_id}" + + conn + |> put_resp_header("location", uri) + # Plug does not allow adding multiple headers with the same name + |> put_resp_header("link", "<#{uri}/layer>; " <> @layer) + |> put_resp_header("link", "<#{uri}/sse>; " <> @sse) + |> put_resp_content_type("application/sdp") + |> resp(201, answer_sdp) + else + other -> + dbg(other) + resp(conn, 400, "Bad request") + end + |> send_resp() + end + + def ice_candidate(conn, %{"resource_id" => resource_id}) do + with {:ok, pid} <- PeerSupervisor.fetch_pid(resource_id), + {:ok, body, conn} <- read_body(conn), + {:ok, json} <- Jason.decode(body) do + candidate = ExWebRTC.ICECandidate.from_json(json) + :ok = PeerConnection.add_ice_candidate(pid, candidate) + resp(conn, 204, "") + else + _other -> resp(conn, 400, "Bad request") + end + |> send_resp() + end + + def sse(conn, %{"resource_id" => resource_id}) do + with {:ok, _pid} <- PeerSupervisor.fetch_pid(resource_id), + {:ok, events} when is_list(events) <- Map.fetch(conn.body_params, "_json") do + # for now, we just ignore events + conn + |> put_resp_header("location", ~p"/api/resource/#{resource_id}/sse/event-stream") + |> resp(201, "") + else + _other -> resp(conn, 400, "Bad request") + end + |> send_resp() + end + + def event_stream(conn, %{"resource_id" => resource_id}) do + case PeerSupervisor.fetch_pid(resource_id) do + {:ok, _pid} -> + conn + |> put_resp_header("content-type", "text/event-stream") + |> put_resp_header("connection", "keep-alive") + |> put_resp_header("cache-control", "no-cache") + |> send_chunked(200) + |> update_layers() + + {:error, :peer_not_found} -> + send_resp(conn, 400, "Bad request") + end + end + + def layer(conn, %{"resource_id" => resource_id}) do + with {:ok, pid} <- PeerSupervisor.fetch_pid(resource_id), + :ok <- Forwarder.set_layer(pid, conn.body_params["encodingId"]) do + resp(conn, 200, "") + else + _other -> + resp(conn, 400, "Bad reqeust") + end + |> send_resp() + end + + def remove_pc(conn, %{"resource_id" => resource_id}) do + case PeerSupervisor.fetch_pid(resource_id) do + {:ok, pid} -> + PeerSupervisor.terminate_pc(pid) + resp(conn, 200, "") + + _other -> + resp(conn, 400, "Bad request") + end + |> send_resp() + end + + defp authenticate(conn) do + valid_token = Application.fetch_env!(:k8s_broadcaster, :whip_token) + + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + true <- token == valid_token do + :ok + else + _other -> {:error, :unauthorized} + end + end + + defp update_layers(conn) do + case Forwarder.get_layers() do + {:ok, layers} -> + data = Jason.encode!(%{layers: layers}) + chunk(conn, ~s/data: #{data}\n\n/) + + Process.send_after(self(), :layers, 2000) + + receive do + :layers -> update_layers(conn) + end + + :error -> + conn + end + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex new file mode 100644 index 0000000..a67465d --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_controller.ex @@ -0,0 +1,11 @@ +defmodule K8sBroadcasterWeb.PageController do + use K8sBroadcasterWeb, :controller + + def home(conn, _params) do + render(conn, :home, page_title: "Home", current_url: current_url(conn)) + end + + def panel(conn, _params) do + render(conn, :panel, page_title: "Panel") + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex new file mode 100644 index 0000000..fe00faa --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule K8sBroadcasterWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use K8sBroadcasterWeb, :html + + embed_templates "page_html/*" +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..fdd997b --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/home.html.heex @@ -0,0 +1,133 @@ +
+
+
+
+ +
+
+
+ + + +
+ +
+
+ +
+ +

+ Received video stream +

+
+
+
+ + +
+
+ + 0 + <.input + id="packet-loss-range" + name="packet-loss" + type="range" + value="0" + min="0" + max="100" + /> +

+
+
+ + <.input id="rtx-checkbox" name="rtx" type="checkbox" checked /> +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
time0
audioBitrate (kbps)0
videoBitrate (kbps)0
frameWidth0
frameHeight0
framesPerSecond0
keyframesDecoded0
pliCount0
packetLoss (%)0
avgJitterBufferDelay (ms) VALIDATE0
freezeCount0
freezeDuration (s)0
+
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex new file mode 100644 index 0000000..6daa58f --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/controllers/page_html/panel.html.heex @@ -0,0 +1,139 @@ +
+
+
+ Devices +
+
+ + +
+
+ + +
+
+
+
+ Audio Settings +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ Video Settings +
+

Max bitrate (kbps)

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ Server +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ + 0 +
+
+ + 0 +
+
+ + 0 +
+
+ + 00:00:00 +
+
+
+
<.icon name="hero-signal-slash" />
+ +
+
+
+
+ +
+
+
diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex new file mode 100644 index 0000000..6d3cfce --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/endpoint.ex @@ -0,0 +1,62 @@ +defmodule K8sBroadcasterWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :k8s_broadcaster + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_k8s_broadcaster_key", + signing_salt: "Vui9HRTO", + same_site: "Lax" + ] + + socket "/socket", K8sBroadcasterWeb.UserSocket, + websocket: true, + longpoll: false + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + plug Corsica, + origins: "*", + allow_headers: :all, + allow_methods: :all, + expose_headers: K8sBroadcasterWeb.Router.cors_expose_headers() + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :k8s_broadcaster, + gzip: false, + only: K8sBroadcasterWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug K8sBroadcasterWeb.Router +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex new file mode 100644 index 0000000..f2c7597 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/router.ex @@ -0,0 +1,57 @@ +defmodule K8sBroadcasterWeb.Router do + use K8sBroadcasterWeb, :router + + import Phoenix.LiveDashboard.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {K8sBroadcasterWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :auth do + plug :admin_auth + end + + scope "/", K8sBroadcasterWeb do + pipe_through :browser + + get "/", PageController, :home + end + + scope "/api", K8sBroadcasterWeb do + get "/pc-config", MediaController, :pc_config + post "/whip", MediaController, :whip + post "/whep", MediaController, :whep + + scope "/resource/:resource_id" do + patch "/", MediaController, :ice_candidate + delete "/", MediaController, :remove_pc + get "/sse/event-stream", MediaController, :event_stream + post "/sse", MediaController, :sse + post "/layer", MediaController, :layer + end + end + + scope "/admin", K8sBroadcasterWeb do + pipe_through :auth + pipe_through :browser + + get "/panel", PageController, :panel + + live_dashboard "/dashboard", + metrics: K8sBroadcasterWeb.Telemetry, + additional_pages: [exwebrtc: ExWebRTCDashboard] + end + + def cors_expose_headers, do: K8sBroadcasterWeb.MediaController.cors_expose_headers() + + defp admin_auth(conn, _opts) do + username = Application.fetch_env!(:k8s_broadcaster, :admin_username) + password = Application.fetch_env!(:k8s_broadcaster, :admin_password) + Plug.BasicAuth.basic_auth(conn, username: username, password: password) + end +end diff --git a/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex b/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex new file mode 100644 index 0000000..f805129 --- /dev/null +++ b/k8s_broadcaster/lib/k8s_broadcaster_web/telemetry.ex @@ -0,0 +1,69 @@ +defmodule K8sBroadcasterWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {K8sBroadcasterWeb, :count_users, []} + ] + end +end diff --git a/k8s_broadcaster/mix.exs b/k8s_broadcaster/mix.exs new file mode 100644 index 0000000..d946c0b --- /dev/null +++ b/k8s_broadcaster/mix.exs @@ -0,0 +1,84 @@ +defmodule K8sBroadcaster.MixProject do + use Mix.Project + + def project do + [ + app: :k8s_broadcaster, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {K8sBroadcaster.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.18"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"}, + + # custom deps + # {:ex_webrtc, "~> 0.7.0"}, + {:ex_webrtc, github: "elixir-webrtc/ex_webrtc", branch: "packet-loss", override: true}, + {:ex_webrtc_dashboard, "~> 0.7.0"}, + {:syn, "~> 3.3"}, + {:corsica, "~> 2.1.3"}, + {:libcluster, "~> 3.4"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind k8s_broadcaster", "esbuild k8s_broadcaster"], + "assets.deploy": [ + "tailwind k8s_broadcaster --minify", + "esbuild k8s_broadcaster --minify", + "phx.digest" + ] + ] + end +end diff --git a/k8s_broadcaster/mix.lock b/k8s_broadcaster/mix.lock new file mode 100644 index 0000000..fd01adf --- /dev/null +++ b/k8s_broadcaster/mix.lock @@ -0,0 +1,57 @@ +%{ + "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, + "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, + "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, + "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"}, + "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, + "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, + "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, + "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, + "ex_dtls": {:hex, :ex_dtls, "0.16.0", "3ae38025ccc77f6db573e2e391602fa9bbc02253c137d8d2d59469a66cbe806b", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2a4e30d74c6ddf95cc5b796423293c06a0da295454c3823819808ff031b4b361"}, + "ex_ice": {:hex, :ex_ice, "0.9.2", "7f5513416a8fe33b36d988dd30d6bb79ddd7cfa408e09e2e3d3e3a97e075614d", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "70f688582dbe36a82cf8bbedf5adb2f0b89996620e229213bd7ff9a9b642e571"}, + "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"}, + "ex_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"}, + "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, + "ex_sdp": {:hex, :ex_sdp, "1.1.0", "a93d72d00704efd83f7e144e4ca9822ca4aea5b5d851353d092de40e1ad0ecdc", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "f5c033abcda958a9b090210f9429f24b74b003c28c24175c58a033a5205a1cfe"}, + "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, + "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, + "ex_webrtc": {:git, "https://github.com/elixir-webrtc/ex_webrtc.git", "87e9304a62fd0c69ff93fe4c0a33501c36197942", [branch: "packet-loss"]}, + "ex_webrtc_dashboard": {:hex, :ex_webrtc_dashboard, "0.7.0", "6b46a8271f0886345a7d7552e9642672009487b90efe45154e5f6a34b067233e", [:mix], [{:ex_webrtc, "~> 0.7.0", [hex: :ex_webrtc, repo: "hexpm", optional: false]}, {:phoenix_live_dashboard, "~> 0.8.3", [hex: :phoenix_live_dashboard, repo: "hexpm", optional: false]}], "hexpm", "9ffc0d0d7537db3f333fb94ace10415d7580b3e9667938046f1df02214fa57a2"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, + "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "libcluster": {:hex, :libcluster, "3.4.1", "271d2da892763bbef53c2872036c936fe8b80111eb1feefb2d30a3bb15c9b4f6", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1d568157f069c6afa70ec0d736704cf799734bdbb6343f0322af4a980301c853"}, + "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.5", "d5f44d7dbd7cfacaa617b70c5a14b2b598d6f93b9caa8e350c51d56cd4350a9b", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1d73920515554d7d6c548aee0bf10a4780568b029d042eccb336db29ea0dad70"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, + "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, + "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, + "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "thousand_island": {:hex, :thousand_island, "1.3.7", "1da7598c0f4f5f50562c097a3f8af308ded48cd35139f0e6f17d9443e4d0c9c5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0139335079953de41d381a6134d8b618d53d084f558c734f2662d1a72818dd12"}, + "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, + "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, +} diff --git a/k8s_broadcaster/priv/static/favicon.ico b/k8s_broadcaster/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/k8s_broadcaster/priv/static/favicon.ico differ diff --git a/k8s_broadcaster/priv/static/images/cluster.svg b/k8s_broadcaster/priv/static/images/cluster.svg new file mode 100644 index 0000000..d0c3502 --- /dev/null +++ b/k8s_broadcaster/priv/static/images/cluster.svg @@ -0,0 +1,4 @@ + + + +
POL
ITA
GER
\ No newline at end of file diff --git a/k8s_broadcaster/priv/static/images/logo.svg b/k8s_broadcaster/priv/static/images/logo.svg new file mode 100644 index 0000000..348a77c --- /dev/null +++ b/k8s_broadcaster/priv/static/images/logo.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/k8s_broadcaster/priv/static/robots.txt b/k8s_broadcaster/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/k8s_broadcaster/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/k8s_broadcaster/rel/overlays/bin/server b/k8s_broadcaster/rel/overlays/bin/server new file mode 100755 index 0000000..e4b517b --- /dev/null +++ b/k8s_broadcaster/rel/overlays/bin/server @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" + +if [ "${DISTRIBUTION_MODE-}" = "k8s" ]; then + export RELEASE_DISTRIBUTION=name + export RELEASE_NODE=k8s_broadcaster@${POD_IP} +fi + +PHX_SERVER=true exec ./k8s_broadcaster start diff --git a/k8s_broadcaster/rel/overlays/bin/server.bat b/k8s_broadcaster/rel/overlays/bin/server.bat new file mode 100755 index 0000000..ddc0c0e --- /dev/null +++ b/k8s_broadcaster/rel/overlays/bin/server.bat @@ -0,0 +1,7 @@ +if "%DISTRIBUTION_MODE%"=="k8s" ( + set RELEASE_DISTRIBUTION=name + set RELEASE_NODE="k8s_broadcaster@%POD_IP%" +) + +set PHX_SERVER=true +call "%~dp0\k8s_broadcaster" start diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs new file mode 100644 index 0000000..7c4acab --- /dev/null +++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule K8sBroadcasterWeb.ErrorHTMLTest do + use K8sBroadcasterWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(K8sBroadcasterWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(K8sBroadcasterWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs new file mode 100644 index 0000000..6307f0f --- /dev/null +++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule K8sBroadcasterWeb.ErrorJSONTest do + use K8sBroadcasterWeb.ConnCase, async: true + + test "renders 404" do + assert K8sBroadcasterWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert K8sBroadcasterWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..cfe1a96 --- /dev/null +++ b/k8s_broadcaster/test/k8s_broadcaster_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule K8sBroadcasterWeb.PageControllerTest do + use K8sBroadcasterWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/k8s_broadcaster/test/support/conn_case.ex b/k8s_broadcaster/test/support/conn_case.ex new file mode 100644 index 0000000..1f7bea4 --- /dev/null +++ b/k8s_broadcaster/test/support/conn_case.ex @@ -0,0 +1,37 @@ +defmodule K8sBroadcasterWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use K8sBroadcasterWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint K8sBroadcasterWeb.Endpoint + + use K8sBroadcasterWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import K8sBroadcasterWeb.ConnCase + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/k8s_broadcaster/test/test_helper.exs b/k8s_broadcaster/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/k8s_broadcaster/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()