Video Deliver Solution -- Whats your thoughts?

This is the place to discuss your developments on Evernode
Post Reply
VodMeister
Posts: 5
Joined: Thu Aug 29, 2024 2:20 pm

Video Deliver Solution -- Whats your thoughts?

Post by VodMeister »

This video delivery solution is compiling videochunks into a stream, you make the chunks with ffmpeg

ffmpeg

Code: Select all

ffmpeg -i "path to videoname.mp4" -codec: copy -start_number 0 -hls_time 6 -hls_list_size 0 -f hls index.m3u8
Folder name for video chunks: video_segments
it contains a bunch of ts files (those are the chunks) and a file named index.m3u8. That is the file we load into the web player.

A simple webplayer I found somewhere, webplayer.html (can be locally hosted as it fetches from evernode instance).

Replace localhost with your evernode ipv4:port !

Code: Select all

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>HLS Video Stream</title>
  <link href="https://vjs.zencdn.net/7.11.4/video-js.css" rel="stylesheet" />
</head>
<body>

  <video
    id="my-video"
    class="video-js vjs-default-skin"
    controls
    preload="auto"
    width="640"
    height="264"
    data-setup='{}'>
    <source src="https://localhost:36525/index.m3u8" type="application/x-mpegURL">
    Your browser does not support the video tag.
  </video>

  <script src="https://vjs.zencdn.net/7.11.4/video.js"></script>

</body>
</html>
index.js

Code: Select all

const http = require('http');
const fs = require('fs');
const path = require('path');

const videoDir = path.join(__dirname, 'video_segments');

// Function to create and start a server on a specific port
function createServer(port) {
  const server = http.createServer((req, res) => {
    // Set CORS headers
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Range');

    // Handle OPTIONS method (CORS preflight)
    if (req.method === 'OPTIONS') {
      res.writeHead(204);
      res.end();
      return;
    }

    const filePath = path.join(videoDir, req.url);

    fs.stat(filePath, (err, stats) => {
      if (err) {
        res.writeHead(404, { 'Content-Type': 'text/plain' });
        res.end('404 Not Found');
        return;
      }

      if (stats.isDirectory()) {
        res.writeHead(403, { 'Content-Type': 'text/plain' });
        res.end('403 Forbidden');
        return;
      }

      let contentType = 'video/mp4';
      if (filePath.endsWith('.m3u8')) {
        contentType = 'application/x-mpegURL';
      } else if (filePath.endsWith('.mp4')) {
        contentType = 'video/mp4';
      }

      res.writeHead(200, { 'Content-Type': contentType });

      const readStream = fs.createReadStream(filePath);
      readStream.on('error', (streamErr) => {
        res.writeHead(500, { 'Content-Type': 'text/plain' });
        res.end('Internal Server Error');
      });
      readStream.pipe(res);
    });
  });

  server.listen(port, () => {
    console.log(`Server running at http://localhost:${port}/`);
  });
}

// Start the server on port rangee 36525-36535
for (let port = 36525; port <= 36535; port++) {
  createServer(port);
}
package.json

Code: Select all

{
  "name": "evernode-vod-solution",
  "version": "1.0.0",
  "description": "Evernode VOD solution",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
start.sh

Code: Select all

#!/bin/sh

# Run vod
/usr/bin/node /usr/local/bin/hotpocket/vod &

# Set the HotPocket binary as the entry point.
# $@ is used to pass all the commandline arguments fed to this script into hpcore.
/usr/local/bin/hotpocket/hpcore $@
Dockerfile

Code: Select all

FROM evernode/sashimono:hp.latest-ubt.20.04-njs.20

RUN mkdir -p /usr/local/bin/hotpocket/vod
RUN mkdir -p /usr/local/bin/hotpocket/vod/video_segments

COPY / /usr/local/bin/hotpocket/vod
COPY /video_segments /usr/local/bin/hotpocket/vod/video_segments

COPY start.sh /usr/local/bin/hotpocket/start.sh
RUN chmod +x /usr/local/bin/hotpocket/start.sh

ENTRYPOINT ["/usr/local/bin/hotpocket/start.sh"]
EXPOSE 36525-36535

This is a standalone video worker. The idea is to have a lot of these and rotate them, that's the load balancer I'm still trying to figure out!

So far I got the rotation working well, so when running through the load balancer you get a chunk from a list of nodes (which should distribute the load between all the nodes in the rotation list).

Thoughts?
Last edited by VodMeister on Thu Aug 29, 2024 2:39 pm, edited 1 time in total.
VodMeister
Posts: 5
Joined: Thu Aug 29, 2024 2:20 pm

Re: Video Deliver Solution -- Whats your thoughts?

Post by VodMeister »

Here's the current load balancer, it's always taking the next chunk from random nodes loacted in the text file servers.txt .

This file can be modified at any point in time, you can remove bad nodes and add good nodes with api calls.

index.js

Code: Select all

const https = require('https');
const httpProxy = require('http-proxy');
const fs = require('fs');
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');

// Load SSL certificate and key
const options = {
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.cert')
};

// Create a proxy server
const proxy = httpProxy.createProxyServer({});

// Authentication token (for simplicity)
const AUTH_TOKEN = 'your_secret_token';

// Function to read servers from servers.txt
function loadServersFromFile(filename) {
  const serverList = [];
  const lines = fs.readFileSync(filename, 'utf-8').split('\n');

  for (const line of lines) {
    const [host, port] = line.trim().split(':');
    if (host && port) {
      serverList.push({ host, port: parseInt(port) });
    }
  }
  return serverList;
}

// Load servers from servers.txt
let servers = loadServersFromFile('servers.txt');

// Function to save servers to servers.txt
function saveServersToFile(filename, servers) {
  const fileContent = servers.map(server => `${server.host}:${server.port}`).join('\n');
  fs.writeFileSync(filename, fileContent, 'utf-8');
}

// Function to watch the servers.txt file for changes
function watchServersFile(filename) {
  fs.watch(filename, (eventType) => {
    if (eventType === 'change') {
      console.log('servers.txt file has changed, reloading servers...');
      servers = loadServersFromFile(filename);
    }
  });
}

// Start watching the servers.txt file
watchServersFile('servers.txt');

// Function to get a random backend server
function getRandomServer() {
  const randomIndex = Math.floor(Math.random() * servers.length);
  return servers[randomIndex];
}

// Function to read load balancers from loadbalancers.txt
function loadLoadBalancersFromFile(filename) {
  const loadBalancerList = [];
  const lines = fs.readFileSync(filename, 'utf-8').split('\n');

  for (const line of lines) {
    const [host, port] = line.trim().split(':');
    if (host && port) {
      loadBalancerList.push({ host, port: parseInt(port) });
    }
  }
  return loadBalancerList;
}

// Load load balancers from loadbalancers.txt
let loadBalancers = loadLoadBalancersFromFile('loadbalancers.txt');

// Function to save load balancers to loadbalancers.txt
function saveLoadBalancersToFile(filename, loadBalancers) {
  const fileContent = loadBalancers.map(lb => `${lb.host}:${lb.port}`).join('\n');
  fs.writeFileSync(filename, fileContent, 'utf-8');
}

// Watch for changes in loadbalancers.txt
function watchLoadBalancersFile(filename) {
  fs.watch(filename, (eventType) => {
    if (eventType === 'change') {
      console.log('loadbalancers.txt file has changed, reloading load balancers...');
      loadBalancers = loadLoadBalancersFromFile(filename);
    }
  });
}

// Start watching the loadbalancers.txt file
watchLoadBalancersFile('loadbalancers.txt');

// Initialize Express app
const app = express();
app.use(bodyParser.json());

// Serve static files from the 'public' directory
app.use(express.static(path.join(__dirname, 'public')));

// Serve web.html at the root path
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'web.html'));
});

// Serve the list of load balancers as a JavaScript file
app.get('/servers.js', (req, res) => {
  const serverList = loadBalancers.map(lb => `'https://${lb.host}:${lb.port}'`);
  const jsContent = `
    const servers = [
      ${serverList.join(',\n')}
    ];
  `;
  res.setHeader('Content-Type', 'application/javascript');
  res.send(jsContent);
});

// Authentication middleware
function authenticate(req, res, next) {
  const token = req.headers['authorization'];
  if (token === `Bearer ${AUTH_TOKEN}`) {
    next();
  } else {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// API to bulk add servers
app.post('/api/servers/add', authenticate, (req, res) => {
  const newServers = req.body.servers; // Expecting an array of { host, port }

  if (!Array.isArray(newServers)) {
    return res.status(400).json({ error: 'Invalid input format. Expected an array of servers.' });
  }

  for (const server of newServers) {
    servers.push({ host: server.host, port: parseInt(server.port) });
  }

  saveServersToFile('servers.txt', servers);
  res.status(200).json({ message: 'Servers added successfully' });
});

// API to bulk remove servers
app.post('/api/servers/remove', authenticate, (req, res) => {
  const serversToRemove = req.body.servers; // Expecting an array of { host, port }

  if (!Array.isArray(serversToRemove)) {
    return res.status(400).json({ error: 'Invalid input format. Expected an array of servers.' });
  }

  servers = servers.filter(server => 
    !serversToRemove.some(remServer => remServer.host === server.host && parseInt(remServer.port) === server.port)
  );

  saveServersToFile('servers.txt', servers);
  res.status(200).json({ message: 'Servers removed successfully' });
});

// API to bulk add load balancers
app.post('/api/loadbalancers/add', authenticate, (req, res) => {
  const newLoadBalancers = req.body.loadbalancers; // Expecting an array of { host, port }

  if (!Array.isArray(newLoadBalancers)) {
    return res.status(400).json({ error: 'Invalid input format. Expected an array of load balancers.' });
  }

  for (const lb of newLoadBalancers) {
    loadBalancers.push({ host: lb.host, port: parseInt(lb.port) });
  }

  saveLoadBalancersToFile('loadbalancers.txt', loadBalancers);
  res.status(200).json({ message: 'Load balancers added successfully' });
});

// API to bulk remove load balancers
app.post('/api/loadbalancers/remove', authenticate, (req, res) => {
  const loadBalancersToRemove = req.body.loadbalancers; // Expecting an array of { host, port }

  if (!Array.isArray(loadBalancersToRemove)) {
    return res.status(400).json({ error: 'Invalid input format. Expected an array of load balancers.' });
  }

  loadBalancers = loadBalancers.filter(lb => 
    !loadBalancersToRemove.some(remLb => remLb.host === lb.host && parseInt(remLb.port) === lb.port)
  );

  saveLoadBalancersToFile('loadbalancers.txt', loadBalancers);
  res.status(200).json({ message: 'Load balancers removed successfully' });
});

// Handle proxy requests
app.use((req, res) => {
  // Set CORS headers
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Range');

  // Select a backend server randomly
  const target = `http://${getRandomServer().host}:${getRandomServer().port}`;

  // Proxy the request to the selected backend server
  proxy.web(req, res, { target }, (error) => {
    console.error(`Proxy error: ${error}`);
    res.writeHead(502, { 'Content-Type': 'text/plain' });
    res.end('Bad Gateway');
  });
});

// Listen on multiple ports (36525-36535)
for (let port = 36525; port <= 36535; port++) {
  https.createServer(options, app).listen(port, () => {
    console.log(`Node.js Load Balancer running on https://localhost:${port}`);
  });
}

The remaining challenge here is: What happens if the load balancer get's overloaded? How could the videoplayer remain active for the end user when node.js goes down?
Last edited by VodMeister on Sat Aug 31, 2024 12:48 am, edited 2 times in total.
VodMeister
Posts: 5
Joined: Thu Aug 29, 2024 2:20 pm

Re: Video Deliver Solution -- Whats your thoughts?

Post by VodMeister »

Solved the remaining part with a javascript on the player site! ;)

You would add your domain there, and have multiple A records in it to multiple load balancers. DNS resolvers are spreading traffic randomly to A records!

public/web.html

Code: Select all

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>HLS Video Stream</title>
  <style>
    video {
      width: 640px;
      height: 360px;
      background-color: black;
    }
    #playButton {
      margin-top: 20px;
      padding: 10px 20px;
      font-size: 16px;
    }
  </style>
</head>
<body>

  <video id="my-video" controls preload="auto" width="640" height="264">
    Your browser does not support the video tag.
  </video>
  <button id="playButton">Start Video</button>

  <!-- Include HLS.js library -->
  <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
  <script src="/servers.js"></script>
  <script>

    const videoPath = '/index.m3u8';
    const videoElement = document.getElementById('my-video');
    const playButton = document.getElementById('playButton');
    let lastPlaybackTime = 0;
    let hls = null;
    let currentServerIndex = 0;

    playButton.addEventListener('click', () => {
      playButton.style.display = 'none';  // Hide the play button after it's clicked
      shuffleServers();  // Shuffle servers before starting playback
      tryPlayVideo();
    });

    function shuffleServers() {
      for (let i = servers.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [servers[i], servers[j]] = [servers[j], servers[i]];
      }
      console.log("Shuffled server list:", servers);
    }

    function tryPlayVideo() {
      const videoUrl = servers[currentServerIndex] + videoPath;
      console.log(`Attempting to play video from: ${videoUrl}`);

      if (hls) {
        hls.destroy(); // Clean up any existing HLS instance
      }

      if (Hls.isSupported()) {
        hls = new Hls({
          manifestLoadingRetryDelay: 2000, // Delay between retries in milliseconds
          manifestLoadingMaxRetry: 3,      // Max number of retries before giving up
          manifestLoadingMaxRetryTimeout: 10000, // Max time in ms before giving up
          enableWorker: true,              // Use web workers to avoid blocking UI
          lowLatencyMode: true,            // Enable low latency mode
          backBufferLength: 90,            // Keep enough back buffer for smooth playback
        });

        hls.loadSource(videoUrl);
        hls.attachMedia(videoElement);

        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          console.log(`Manifest parsed. Attempting to resume video at time: ${lastPlaybackTime}`);
          videoElement.currentTime = lastPlaybackTime;

          videoElement.play().then(() => {
            console.log(`Playback started at time: ${videoElement.currentTime}`);
          }).catch(error => {
            console.error('Error starting playback:', error);
            capturePlaybackTimeAndSwitch(); // Capture time and try the next server
          });
        });

        hls.on(Hls.Events.ERROR, (event, data) => {
          console.error(`HLS.js Error: ${data.details}`);
          if (data.fatal) {
            capturePlaybackTimeAndSwitch(); // Handle fatal errors by switching server
          } else if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR || data.details === Hls.ErrorDetails.FRAG_LOAD_TIMEOUT) {
            // Continue playing buffered content while attempting to fetch new data
            console.warn('Fragment load error or timeout, continuing to play buffered content...');
          }
        });

      } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
        videoElement.src = videoUrl;
        videoElement.currentTime = lastPlaybackTime;

        videoElement.addEventListener('error', () => {
          console.error(`Playback error for: ${videoUrl}`);
          capturePlaybackTimeAndSwitch(); // Capture time and try the next server
        });

        videoElement.play().then(() => {
          console.log(`Playback started at time: ${videoElement.currentTime}`);
        }).catch(error => {
          console.error('Error starting playback:', error);
          capturePlaybackTimeAndSwitch(); // Capture time and try the next server
        });
      } else {
        alert('Your browser does not support HLS playback.');
      }
    }

    function capturePlaybackTimeAndSwitch() {
      lastPlaybackTime = videoElement.currentTime || lastPlaybackTime;
      console.log(`Captured playback time before switching: ${lastPlaybackTime}`);
      switchServer();
    }

    function switchServer() {
      currentServerIndex = (currentServerIndex + 1) % servers.length;
      console.log(`Switching to server ${currentServerIndex + 1} of ${servers.length}. Current playback time: ${lastPlaybackTime}`);
      tryPlayVideo(); // Try the next server
    }

    // Store the current playback time periodically
    videoElement.addEventListener('timeupdate', () => {
      if (videoElement.currentTime > 0) {
        lastPlaybackTime = videoElement.currentTime;
        console.log(`Updated playback time: ${lastPlaybackTime}`);
      }
    });

    // Explicitly set playback time after loading metadata
    videoElement.addEventListener('loadedmetadata', () => {
      console.log(`Metadata loaded. Setting currentTime to ${lastPlaybackTime}`);
      videoElement.currentTime = lastPlaybackTime;
    });
  </script>

</body>
</html>

Post Reply