Page 1 of 1

Video Deliver Solution -- Whats your thoughts?

Posted: Thu Aug 29, 2024 2:31 pm
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?

Re: Video Deliver Solution -- Whats your thoughts?

Posted: Thu Aug 29, 2024 2:38 pm
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?

Re: Video Deliver Solution -- Whats your thoughts?

Posted: Fri Aug 30, 2024 7:39 pm
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>