Skip to content

Instantly share code, notes, and snippets.

@snoj
Created May 24, 2023 23:48
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save snoj/cb6470bf16a316a1fc39e861d42aaee9 to your computer and use it in GitHub Desktop.
Save snoj/cb6470bf16a316a1fc39e861d42aaee9 to your computer and use it in GitHub Desktop.
IPFS livestreaming

"Livestreaming" on IPFS

It's doable! Well, if we stretch the meaning of livestreaming. As of this writing, a delay of 1+ minutes seems to work well. This gives enough time for the segments to get uploaded to an IPFS node and IPNS keys to get propagated.

The real trick seems to be to have enough of a life time for the IPFS key to propagate to other nodes plus have segments large enough that the time to upload to the IPFS node is less than real time. That is, a 2 second segment must take less than that get it onto the node. For me and my setup here, the sweet spot seems to be 8s segment chunks. Depending on your server and internet connection, you may want to keep the number of adaptation sets low. In my case, this was 5 video + 1 audio.

Env

  • ~8Mbps upload
  • IPFS node running on a beefy VPS in the cloud. 12vCPU, 48GB RAM, 1TB block storage.

How it works

Using low TTL IPNS keys, MFS, and a bit of unholy Javascript to lie about the streams start time, we can constantly write to our IPFS node and update the IPNS pointer and provide a working stream.

For buffer free(tm) streaming, we need to have the streams start time be more than the IPNS ttl. So if our ttl is 30 seconds, the start time should be pushed back by at least that. The idea is to ensure that by the time the client tries to fetch the next chunk, that we've already uploaded the file and the IPNS has expired, so it looks for a new one.

Caveats

Doesn't work well with known public gateways. Ipfs.io seems to block IPNS ttl's lower than 30 minutes. Cloudflare...is Cloudflare and testing is difficult with their anti-bot measures.

So for the time being, this requires a local instance of IPFS to the user (like IPFS Desktop or Brave).

The kubo IPFS HTTP api seems to be capped at 6 concurrent actions at once. This looks to be an issue with running node on Windows, ubuntu on wsl does not seem to have this issue.

Improvements?

  • clean out chunks
  • persist initial chuncks
  • figure out how to increase concurrent http api connections.
import bodyParser from 'body-parser';
import express from 'express'
import * as _ from 'lodash';
import * as IPFS from 'ipfs-client';
import Queue from 'promise-queue';
import xmlp from 'fast-xml-parser';
import { readFileSync } from 'fs';
const app = express()
const port = 3000
const ttl = process.env.IPFS_TTL || "30s";
//need to refactor for /stream/:key/:prefix route.
//still needed for old default routing.
const ipfsPrefix = process.env.IPFS_PREFIX || "dashtest";
const ipnsKey = process.env.IPFS_KEY || "livestream";
const timeOffset = process.env.OFFSET || (45 * 1000);
const ipfs = IPFS.create({
http: process.env.IPFS_API || '/ip4/127.0.0.1/tcp/5001'
});
//for Windows, concurrent needs to be 6 instead of 10
//as that's all it seems to be able to handle.
const queue = new Queue(10, Infinity);
const m3u8Hits = {};
const chunkReps = {};
const playerDeployed = {};
const xmlc = { attributeNamePrefix: "$$", ignoreAttributes: false };
const XMLP = new xmlp.XMLParser(xmlc);
const XMLB = new xmlp.XMLBuilder(xmlc);
console.log("prefix", ipfsPrefix);
function handleIncoming(req, res, next) {
//ignore anything not put or post.
if (!/(put|post)/i.test(req.method)) return next();
if(/\.mpd/i.test(req.path) && !!!playerDeployed[req.originalUrl]) {
ipfs.files.write(`/${req.params?.ipfsPrefix || ipfsPrefix}/player.html`, readFileSync('./test-mpd.html'), { truncate: true, create: true, parents: true });
}
//don't need to publish the mpd all the time.
if (/\.(m3u8|mpd)/.test(req.path)) {
m3u8Hits[req.path] || (m3u8Hits[req.path] = 0);
m3u8Hits[req.path]++;
if (m3u8Hits[req.path] % 5 == 0) return res.send({ skipped: 1 });
}
//console.log("ACCEPTING", req.method, req.path);
let [, repId] = req.path.match(/chunk-stream_([0-9]+)-/i) || [, 'other'];
chunkReps[repId] || (chunkReps[repId] = [repId, 0]);
chunkReps[repId][1]++;
queue.add(() => new Promise((resol) => {
console.log("PROCESSING", req.method, req.path);
let text = req.body;
//lie so we have enough time to upload and let IPNS do it's thing.
if (/\.mpd/i.test(req.path)) {
try {
let pdoc = XMLP.parse(req.body.toString('utf8'));
let newtimeAT = (new Date(Date.parse(pdoc.MPD.$$availabilityStartTime) + timeOffset)).toISOString();
console.log("TIME", pdoc.MPD.$$availabilityStartTime, newtimeAT)
pdoc.MPD.$$availabilityStartTime = newtimeAT;
let newtimePT = (new Date(Date.parse(pdoc.MPD.$$publishTime) + timeOffset)).toISOString();
pdoc.MPD.$$publishTime = newtimePT;
let bdoc = XMLB.build(pdoc);
//we need a better way! ...?
let tmptext = bdoc.toString('utf8');
[1, 2, 3, 4].forEach(() => {
tmptext = tmptext.replaceAll(/ ([a-z]+)(\/>|>| )/gi, " $1=\"true\"$2");
});
text = Buffer.from(tmptext, 'utf8');
} catch (ex) {
console.error("!!!!!", ex);
}
}
//write/overwrite the mpd files.
ipfs.files.write(`/${req.params?.ipfsPrefix || ipfsPrefix}${req.path}`, text, { offset: 0, length: text.length, truncate: true, create: true, parents: true }).then(() => {
console.log("COMPLETED", req.method, req.path, queue.getQueueLength(), queue.getPendingLength());
res.send({ done: 1, length: req.body.length });
chunkReps[repId][1]--;
//update the ipns pointer.
//probably unnecessary to be done every time now that we lie about the start.
return ipfs.files.stat(`/${req.params?.ipfsPrefix || ipfsPrefix}`, { hash: 1 }).then(o => ipfs.name.publish(o.cid, { key: req.params?.ipnsKey || ipnsKey, ttl, lifetime: ttl }), console.error.bind(null, "fstat111")).catch(console.error.bind(null, "pub111"));
}, (err) => res.send({ done: 2 }) && console.error(req.method, req.path, err)).finally(resol);
}))
}
//untested
app.use("/stream/:ipnsKey/:ipfsPrefix", function (req, res) {
if (!/(put|post)/i.test(req.method))
return res.send({ hi: "world", path: req.path, key: req.params?.IPFS_KEY, prefix: req.params?.ipfsPrefix, url: req.url, ourl: req.originalUrl});
next();
}, bodyParser.raw({ limit: '50mb', type: () => true }), handleIncoming);
app.use(bodyParser.raw({ limit: '50mb', type: () => true }), handleIncoming);
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
{
"type": "module",
"name": "ipfs-livestream",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.20.2",
"express": "^4.18.2",
"fast-xml-parser": "^4.2.2",
"flatted": "^3.2.7",
"helia": "^1.1.2",
"ipfs-client": "^0.10.0",
"ipfs-core": "^0.18.0",
"lodash": "^4.17.21",
"promise-queue": "^2.2.5"
}
}
ffmpeg \
-re -fflags +genpts -i "6hourVideo.mkv" \
-flags +global_header -r 30000/1001 \
-filter_complex "split=2[s0][s1]; [s0]scale=1280x720[s0]; [s1]scale=480x270[s1]" \
-pix_fmt yuv420p \
-g:v 30 -keyint_min:v 30 -sc_threshold:v 0 \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
-c:a aac -ar 48000 -b:a 96k \
-pix_fmt yuv420p \
-c:v libx264 \
-b:v:0 3000K -maxrate:v:0 3000K -bufsize:v:0 3000K/2 \
-b:v:1 365K -maxrate:v:4 365K -bufsize:v:4 365K/2 \
-map [s0] -map [s1] -map 0:a:0 \
-adaptation_sets 'id=0,streams=v id=1,streams=a' \
-preset veryfast \
-tune zerolatency \
-use_timeline 0 \
-use_template 1 \
-streaming 1 \
-http_user_agent Akamai_Broadcaster_v1.0 \
-http_persistent 0 \
-target_latency 30 \
-write_prft 1 \
-seg_duration 8 \
-frag_duration 4 \
-media_seg_name 'chunk-stream_$RepresentationID$-$Number%05d$.$ext$' \
-init_seg_name 'init-stream_$RepresentationID$.$ext$' \
-index_correction 1 \
-f dash \
"http://127.0.0.1:3000/dash.mpd"
ffmpeg `
-re -fflags +genpts -i "4hourVideo.mp4" `
-flags +global_header -r 30000/1001 `
-filter_complex "split=5[s0][s1][s2][s3][s4]; [s0]scale=1280x720[s0]; [s1]scale=960x540[s1]; [s2]scale=768x432[s2]; [s3]scale=640x360[s3]; [s4]scale=480x270[s4]" `
-pix_fmt yuv420p `
-g:v 30 -keyint_min:v 30 -sc_threshold:v 0 `
-color_primaries bt709 -color_trc bt709 -colorspace bt709 `
-c:a aac -ar 48000 -b:a 96k `
-pix_fmt yuv420p `
-c:v libx264 `
-map 0:v:0 -map [s0] -map [s1] -map [s2] -map [s3] -map [s4] -map 0:a:0 `
-b:v:1 3000K -maxrate:v:0 3000K -bufsize:v:0 3000K/2 `
-b:v:2 2000K -maxrate:v:1 2000K -bufsize:v:1 2000K/2 `
-b:v:3 1100K -maxrate:v:2 1100K -bufsize:v:2 1100K/2 `
-b:v:4 730K -maxrate:v:3 730K -bufsize:v:3 730K/2 `
-b:v:5 365K -maxrate:v:4 365K -bufsize:v:4 365K/2 `
-adaptation_sets 'id=0,streams=v id=1,streams=a' `
-preset veryfast `
-tune zerolatency `
-use_timeline 0 `
-use_template 1 `
-streaming 1 `
-http_user_agent Akamai_Broadcaster_v1.0 `
-http_persistent 0 `
-target_latency 30 `
-write_prft 1 `
-seg_duration 8 `
-frag_duration 4 `
-media_seg_name 'chunk-stream_$RepresentationID$-$Number%05d$.$ext$' `
-init_seg_name 'init-stream_$RepresentationID$.$ext$' `
-index_correction 1 `
-f dash `
"http://localhost:3000/dash.mpd"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<!--quick and dirty html for browser testing-->
<!--<video width="600" height="400" controls></video>><source src="./master.m3u8" type="application/x-mpegURL"></video>-->
<!-- CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.3.0/dist/video-js.min.css">
<!-- HTML -->
<video id='hls-example' class="video-js vjs-default-skin" width="400" height="300" controls data-setup='{"liveui": true}'>
<source type="application/x-mpegURL" src="./dash.mpd">
</video>
<!-- JS code -->
<!-- If you'd like to support IE8 (for Video.js versions prior to v7) -->
<!---<script src="https://vjs.zencdn.net/ie8/ie8-version/videojs-ie8.min.js"></script>-->
<!---<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.14.1/videojs-contrib-hls.js"></script>-->
<!---<script src="https://vjs.zencdn.net/8.3.0/video.js"></script>-->
<script src="https://cdn.jsdelivr.net/npm/video.js@8.3.0/dist/video.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dashjs/4.7.0/dash.all.min.js" integrity="sha512-AoapuNAs/9o7wCVOZcmZObVs7YXf26tjaRBamJqhGVDibGoFjKlJbaIVj2jeTaMKDz+SyDZ6dEXT0GzEwEcEeA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-dash/5.1.1/videojs-dash.min.js" integrity="sha512-jmpCwJ7o9/MxR36dZX+SQc4Ta2PDvMwM5MHmW0oDcy/UzkuppIj+F9FiN+UW/Q8adlOwb5Tx06zHsY/7yg4OYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
var player = videojs('hls-example');
player.play();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment