├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cli.js ├── client ├── build.js ├── fake-video.html ├── index.html ├── loader.js └── sw.js ├── feed └── readme.md ├── lib ├── streamMaker.js └── wtManifest.js ├── package.json └── tests └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | feed/*.ts 4 | feed/*.m3u8 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8.1.3" 4 | cache: 5 | directories: 6 | - node_modules 7 | install: 8 | - npm install 9 | script: 10 | - npm run lint 11 | - npm run test 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pierre Dubouilh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | live-torrent 2 | ============= 3 | 4 | [![Build Status](https://travis-ci.org/pldubouilh/live-torrent.svg?branch=master)](https://travis-ci.org/pldubouilh/live-torrent) 5 | 6 | 7 | ![ex](https://user-images.githubusercontent.com/760637/36377295-f08d0ff2-1576-11e8-97c0-dcb91246529d.png) 8 | 9 | 10 | Simple proof-of-concept for a live streaming solution based on [webtorrent](https://github.com/webtorrent/webtorrent). Video player courtesy of [hls.js](https://github.com/video-dev/hls.js/). 11 | 12 | 13 | ### Demo 14 | Yes please ! Live demo with sintel at [live.computer](https://live.computer) 15 | 16 | ### Run it yourself 17 | ```sh 18 | # Install 19 | npm i -g live-torrent 20 | 21 | # Start with example live-feed 22 | live-torrent -v -u https://live.computer/manifest.m3u8 23 | 24 | # ... or Create a Webtorrent enabled feed from a folder with .ts files 25 | live-torrent -v -l -f feed 26 | 27 | # Open browser at http://127.0.0.1:8008 28 | ``` 29 | 30 | ### FAQ 31 | > I have a regular feed already 32 | 33 | live-torrent can convert your feed into a webtorrent enabled feed. The first example command above will download the feed at `https://live.computer/manifest.m3u8`, and generate a webtorrent-enabled HLS feed from it. Just open your web-browser at `http://127.0.0.1:8008` to have a look. 34 | 35 | > I want to create a feed ! 36 | 37 | No problem - the second example up here will generate a feed for the directory `feed`, how simple ! New chunks added to the directory will be pushed to the manifest. 38 | 39 | Have a look in the [feed directory](https://github.com/pldubouilh/live-torrent/tree/master/feed) for instructions on how to generate a sample feed from a mp4 file. 40 | 41 | > How to implement on a website ? 42 | 43 | Just host the script, serviceworker and videoplayer on your site and you're good to go. Also, there are some limitations to the use of SW ; the site hosting the videoplayer needs to be served from HTTPS, and serviceworker should be located at the root of the domain (e.g. `https://live.computer/sw.js`). Also feel free to open an issue if something's acting weird :) 44 | 45 | > Do I need CORS ? 46 | 47 | Yes ! [But it's easy to enable](https://enable-cors.org/server.html). It's enabled by default using the "create from a folder" option. 48 | 49 | ### How is it working ? 50 | TLDR(ish); A server script parses the video manifest and generates torrent magnet links from the video chunks. The magnets are pushed on the manifest. 51 | 52 | Now on the browser side, the videoplayer downloads the manifest, the serviceworker hijacks the request, extracts the magnet links, and tries to download the chunks via webtorrent. If it fails, it falls back to the manifest url (and then seed), otherwise, well p2p - yay ! 53 | 54 | Basically 3 different pieces are needed : 55 | 1. a server script to make a HLS manifest with magnet links in it 56 | 2. serviceworker to proxy the manifest/chunks requests 57 | 3. client script, that's the bit utilizing webtorrent (no webrtc in SW !) 58 | 59 | ### TODO: 60 | - [x] Implement CLI tool that could live on top of existing feeds 61 | - [ ] Optimise p2p - shave off more time for webtorrent to download the chunks 62 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const WtManifest = require('./lib/wtManifest') 3 | const argv = require('yargs').argv 4 | const express = require('express') 5 | const app = express() 6 | 7 | console.verbose = m => argv.v && (console.log('\x1Bc') || console.log(m)) 8 | 9 | const help = `🛰 Live-torrent 🛰 10 | 11 | # To convert an existing stream to a live-torrent feed 12 | -u manifest location 13 | 14 | # To create a stream from a folder with HLS chunks 15 | -f folder with chunks location 16 | -l start from beggining and loop - default false 17 | 18 | # Misc 19 | -s add simple testpage to server - default true 20 | -v display manifest when generated - default false 21 | -r manifest refresh rate (in sec.) - default 2 22 | 23 | 24 | eg. from existing feed 25 | live-torrent -v -u https://live.computer/manifest.m3u8 26 | 27 | eg. from local folder with ts files 28 | live-torrent -v -l -f feed/ 29 | ` 30 | 31 | function die (msg, code) { 32 | console.log(msg.error ? msg.error : '\n' + msg) 33 | process.exit(code) 34 | } 35 | 36 | if (argv.h || argv.help || !(argv.u || argv.f)) { 37 | die(help, 0) 38 | } 39 | 40 | console.log('\nStarting server on port 8008\n') 41 | const sampleWebserver = typeof argv.s === 'undefined' ? true : (argv.s === 'true') 42 | const delay = parseInt(argv.r || 10) 43 | 44 | const manifestLocation = argv.u 45 | const makeFromFolder = argv.f 46 | const loop = !!argv.l 47 | 48 | const wtm = new WtManifest(manifestLocation, makeFromFolder, delay, loop) 49 | 50 | app.use((req, res, next) => { 51 | res.header('Access-Control-Allow-Origin', '*') 52 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 53 | next() 54 | }) 55 | 56 | app.get('*.m3u8', (req, res) => res.send(wtm.manifest)) 57 | 58 | if (sampleWebserver) app.use(express.static('client')) 59 | 60 | if (makeFromFolder) app.use(express.static(makeFromFolder)) 61 | 62 | const makeManifest = async (cb) => { 63 | try { 64 | await wtm.doManifest() 65 | } catch (e) { die(e, 1) } 66 | 67 | if (!app.started) { 68 | app.started = true 69 | app.listen(8008) 70 | } 71 | 72 | console.verbose(` 73 | ${sampleWebserver ? '### Sample client fileserver running on http://127.0.0.1:8008' : ''} 74 | ### Manifest at: http://127.0.0.1:8008/manifest.m3u8 75 | ### Manifest generated on: ${new Date()}\n\n${wtm.manifest}`) 76 | } 77 | 78 | makeManifest() 79 | setInterval(makeManifest, delay * 1000) 80 | -------------------------------------------------------------------------------- /client/fake-video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | live-torrent demo 7 | 8 | 9 |

Tests

10 | 11 | 12 | 31 | 32 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | live-torrent demo 7 | 8 | 9 | 10 | 11 | 12 |

Simple demo for live-torrent

13 |

Pop the console to get the logs. To simulate p2p, just open another tab in incognito and browse to this very testpage.

14 |
15 | 16 |
17 |

Server

18 |
19 |

P2P

20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 47 | 48 | -------------------------------------------------------------------------------- /client/loader.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | const WebTorrent = require('webtorrent') 3 | const client = new WebTorrent() 4 | 5 | const torrents = [] // {name, magnetURI} 6 | const announceList = [['wss://tracker.openwebtorrent.com']] 7 | 8 | window.p2p = 0 9 | window.server = 0 10 | 11 | console.logColor = (msg, color) => console.log('%c' + msg, `color: ${color}; font-size: 11px;`) 12 | 13 | console.logNoisy = (msg, color) => !(new Date().getTime() % 12) && console.logColor(msg, color) 14 | 15 | navigator.serviceWorker.register('sw.js').then(() => { 16 | navigator.serviceWorker.addEventListener('message', msg => { 17 | return msg.data.ab ? newSeed(msg.data.name, msg.data.ab) : newTorrent(msg.data.magnets) 18 | }) 19 | }).catch(() => console.log('SW registration failure')) 20 | 21 | function cleanupTorrents () { 22 | if (torrents.length < 5) return 23 | const oldTorrent = torrents.shift() 24 | client.remove(oldTorrent.magnetURI) 25 | } 26 | 27 | function isTorrentAdded (input) { 28 | // Find per magnet and filename 29 | if (torrents.find(t => t.magnetURI === input)) return true 30 | if (torrents.find(t => t.name === input)) return true 31 | return false 32 | } 33 | 34 | function onUpload (t) { 35 | console.logNoisy(`+ P2P Upload on ${t.name}`, 'indianred') 36 | } 37 | 38 | function onDownload (t) { 39 | console.logNoisy(`+ P2P Download on ${t.name}, progress: ${t.progress.toString().slice(0, 3)}`, 'darkseagreen') 40 | } 41 | 42 | function onDone (t) { 43 | t.files[0].getBuffer((err, b) => { 44 | if (err) return console.log(err) 45 | console.logColor(`+ P2P over for ${t.files[0].name} - downloaded ${t.downloaded} bytes`, 'forestgreen') 46 | const ab = b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) 47 | window.p2p += ab.byteLength 48 | navigator.serviceWorker.controller.postMessage({ name: t.files[0].name, ab }, [ab]) 49 | }) 50 | } 51 | 52 | function onAdded (t, isSeed) { 53 | console.log(`+ ${isSeed ? 'Seeding' : 'Trying p2p for'} ${t.name}, magnet: ${t.magnetURI.slice(0, 30)}`) 54 | torrents.push({ name: t.name, magnetURI: t.magnetURI }) 55 | cleanupTorrents() 56 | } 57 | 58 | function newTorrent (magnets) { 59 | magnets.forEach(magnet => { 60 | if (isTorrentAdded(magnet)) return 61 | const t = client.add(magnet) 62 | t.on('infoHash', () => onAdded(t)) 63 | t.on('download', () => onDownload(t)) 64 | t.on('done', () => onDone(t)) 65 | t.on('upload', () => onUpload(t)) 66 | }) 67 | } 68 | 69 | function newSeed (name, ab) { 70 | console.logColor(`+ Server loaded ${name} - seeding content now`, 'cadetblue') 71 | window.server += ab.byteLength 72 | 73 | if (isTorrentAdded(name)) { 74 | const { magnetURI } = torrents.find(t => t.name === name) 75 | const i = torrents.findIndex(t => t.name === name) 76 | torrents.splice(i, 1) 77 | client.remove(magnetURI) 78 | } 79 | 80 | const buffer = Buffer.from(ab) 81 | buffer.name = name 82 | const t = client.seed(buffer, { announceList }, t => onAdded(t, true)) 83 | t.on('upload', () => onUpload(t)) 84 | } 85 | -------------------------------------------------------------------------------- /client/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-env serviceworker, browser */ 2 | 3 | let lastManifest 4 | let localCache = {} 5 | let localCacheOrder = [] 6 | 7 | async function msgClient (id, msg) { 8 | const c = await self.clients.get(id) 9 | if (!c) return 10 | const chan = new MessageChannel() 11 | c.postMessage(msg, msg.ab ? [chan.port2, msg.ab] : [chan.port2]) 12 | } 13 | 14 | const chunkName = url => url.match(/\d+(\.ts)/g)[0] 15 | 16 | self.addEventListener('message', event => { 17 | // Data from main thread ! Just store AB on look up table 18 | const { name, ab } = event.data 19 | localCache[name] = ab 20 | console.log('- Received p2p data from main thread data for ' + name) 21 | 22 | // Clean LUT if needed 23 | localCacheOrder.push(name) 24 | if (localCacheOrder.length > 5) { 25 | const pastName = localCacheOrder.shift() 26 | delete localCache[pastName] 27 | } 28 | }) 29 | 30 | async function loadManifest (req, url, id) { 31 | // Download manifest, extract filenames and magnet links 32 | const reply = await fetch(req) 33 | const manifestText = await reply.clone().text() 34 | 35 | // Just reply manifest if no magnet link, or if manifest unchanged 36 | if (manifestText === lastManifest || !manifestText.includes('magnet')) return reply 37 | 38 | // Extract magnet 39 | const magnets = manifestText.split('\n').filter(l => l.includes('magnet')).map(l => l.replace('###', '')) 40 | 41 | // If starting, only downlaod last chunk of manifest (instant server start) 42 | if (!lastManifest) { 43 | magnets.splice(0, magnets.length - 1) 44 | } 45 | 46 | // Ping main thread with magnet link. Lie to the video player to give WT some time to download new chunk 47 | msgClient(id, { magnets }) 48 | const resp = new Response(lastManifest || manifestText) 49 | lastManifest = manifestText 50 | return resp 51 | } 52 | 53 | async function loadChunk (req, url, id) { 54 | // Request has already been fetched by p2p ! 55 | const name = chunkName(url) 56 | if (localCache[name]) { 57 | console.log('- Feeding player with p2p data for ' + name) 58 | return new Response(localCache[name]) 59 | } 60 | 61 | // If not prefetched, go fetch it. Message the arraybuffer back to main thread for seeding. 62 | const res = await fetch(req) 63 | const ab = await res.clone().arrayBuffer() 64 | msgClient(id, { name, ab }) 65 | return res 66 | } 67 | 68 | self.addEventListener('install', event => self.skipWaiting()) 69 | 70 | self.addEventListener('activate', event => self.clients.claim()) 71 | 72 | self.addEventListener('fetch', event => { 73 | const url = event.request.url 74 | 75 | if (event.request.method === 'GET' && url.includes('.m3u8')) { 76 | event.respondWith(loadManifest(event.request, url, event.clientId)) 77 | } else if (event.request.method === 'GET' && url.includes('.ts')) { 78 | event.respondWith(loadChunk(event.request, url, event.clientId)) 79 | } 80 | }) 81 | -------------------------------------------------------------------------------- /feed/readme.md: -------------------------------------------------------------------------------- 1 | Here's a quick howto to get started from a video file. 2 | 3 | ```sh 4 | # Download test file, or use any mp4, h264 encoded file 5 | cd feed 6 | wget http://peach.themazzone.com/durian/movies/sintel-1024-surround.mp4 7 | 8 | 9 | # Convert to HLS (needs ffmpeg 3+) 10 | ffmpeg -i sintel-1024-surround.mp4 -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls chunks.m3u8 11 | rm chunks.m3u8 12 | cd .. 13 | 14 | # Start feed from folder. Note the -l argument to loop over when video is over 15 | live-torrent -l -v -f feed 16 | ``` 17 | -------------------------------------------------------------------------------- /lib/streamMaker.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const getDuration = require('get-video-duration') 3 | 4 | const chunkName = url => url.match(/\d+(\.ts)/g)[0].replace('.ts', '') 5 | 6 | const now = () => new Date().getTime() 7 | 8 | function StreamMaker (loc, targetDuration = 10, loop = false) { 9 | this.chunk1 = 0 10 | this.chunk2 = 1 11 | this.chunk3 = 2 12 | this.loop = loop 13 | this.targetDuration = targetDuration 14 | this.lastModif = now() 15 | this.loc = loc.endsWith('/') ? loc : loc + '/' 16 | 17 | fs.watch(loc, (ev, fn) => { this.lastModif = now() }) 18 | } 19 | 20 | StreamMaker.prototype.readLocal = async function (chunkNames) { 21 | // Read local folder, extract chunk ids and map ids to filenames 22 | const ls = await fs.readdir(this.loc) 23 | const ids = ls.filter(i => i.includes('.ts')).map(chunkName).map(i => parseInt(i)).sort((a, b) => a - b) 24 | 25 | const idToChunkname = {} 26 | ls.filter(i => i.includes('.ts')).forEach(el => { idToChunkname[chunkName(el)] = el }) 27 | return { idToChunkname, ids } 28 | } 29 | 30 | StreamMaker.prototype.loopFeed = async function () { 31 | // Make a looping stream out of the folder 32 | const { idToChunkname, ids } = await this.readLocal() 33 | 34 | this.chunk1 = this.chunk1 = this.chunk1 === (ids.length - 1) ? 0 : this.chunk1 + 1 35 | this.chunk2 = this.chunk2 = this.chunk2 === (ids.length - 1) ? 0 : this.chunk2 + 1 36 | this.chunk3 = this.chunk3 = this.chunk3 === (ids.length - 1) ? 0 : this.chunk3 + 1 37 | 38 | return idToChunkname 39 | } 40 | 41 | StreamMaker.prototype.normalFeed = async function () { 42 | // Make a normal stream out of the folder. Just takes the last chunks and make a manifest ouf of them 43 | const { idToChunkname, ids } = await this.readLocal() 44 | 45 | // Remove last item from list if file is currently being written 46 | if (now() - this.lastModif < 200) ids.pop() 47 | 48 | this.chunk1 = ids[ids.length - 3] 49 | this.chunk2 = ids[ids.length - 2] 50 | this.chunk3 = ids[ids.length - 1] 51 | 52 | return idToChunkname 53 | } 54 | 55 | StreamMaker.prototype.makeLiveStream = async function () { 56 | const idToChunkname = this.loop ? await this.loopFeed() : await this.normalFeed() 57 | 58 | const chunkname1 = idToChunkname[this.chunk1] 59 | const chunkname2 = idToChunkname[this.chunk2] 60 | const chunkname3 = idToChunkname[this.chunk3] 61 | const dur1 = await getDuration(this.loc + chunkname1) 62 | const dur2 = await getDuration(this.loc + chunkname2) 63 | const dur3 = await getDuration(this.loc + chunkname3) 64 | 65 | const discontinuity = this.chunk3 !== this.chunk2 + 1 66 | 67 | const manifest = `#EXTM3U 68 | #EXT-X-VERSION:3 69 | #EXT-X-TARGETDURATION:${this.targetDuration} 70 | #EXT-X-MEDIA-SEQUENCE:${this.chunk1} 71 | #EXTINF: ${dur1}\n${chunkname1} 72 | #EXTINF: ${dur2}\n${chunkname2}${discontinuity ? '\n#EXT-X-DISCONTINUITY' : ''} 73 | #EXTINF: ${dur3}\n${chunkname3}` 74 | 75 | return manifest 76 | } 77 | 78 | // const sm = new StreamMaker('..//feed') 79 | // setInterval(() => sm.makeLiveStream(), 500) 80 | 81 | module.exports = StreamMaker 82 | -------------------------------------------------------------------------------- /lib/wtManifest.js: -------------------------------------------------------------------------------- 1 | const parseTorrent = require('parse-torrent') 2 | const createTorrent = require('create-torrent') 3 | const request = require('request-promise-native') 4 | const fs = require('fs-extra') 5 | const StreamMaker = require('./streamMaker') 6 | 7 | const chunkName = url => url.match(/\d+(\.ts)/g)[0] 8 | 9 | const removeDanglingSlash = u => u.startsWith('/') ? u.substring(1) : u 10 | 11 | const addTrailingSlash = u => u.endsWith('/') ? u : u + '/' 12 | 13 | const isUrl = f => f.startsWith('http://') || f.startsWith('https://') 14 | 15 | function wtManifest (fullManifestPath = '', makeFromFolder = '', delay, loop = false) { 16 | this.isLocalStream = makeFromFolder.length > 1 17 | 18 | // Torrent from local folder 19 | if (this.isLocalStream) { 20 | this.chunksLoc = '' 21 | this.localPath = addTrailingSlash(makeFromFolder) 22 | this.sm = makeFromFolder ? new StreamMaker(makeFromFolder, delay, loop) : null 23 | } else { 24 | // Manifest location is splitted ; Filename goes in manifestname, rest of the path in manifestLoc 25 | this.manifestLoc = fullManifestPath.split('/').slice(0, -1).join('/') 26 | this.manifestLoc = addTrailingSlash(this.manifestLoc) 27 | this.manifestName = fullManifestPath.split('/').pop() 28 | 29 | // If no full path in manifest, chunk location is the manifest path minus the manifest name 30 | this.chunksLoc = addTrailingSlash(fullManifestPath.split('/').slice(0, -1).join('/')) 31 | } 32 | 33 | this.announceList = [['wss://tracker.openwebtorrent.com']] 34 | this.fileToMagnet = {} 35 | this.magnetsOrder = [] 36 | this.manifest = '' 37 | this.sequence = '' 38 | } 39 | 40 | wtManifest.prototype.computeMagnet = function (file, cn) { 41 | return new Promise((resolve, reject) => { 42 | file.name = cn 43 | createTorrent(file, { announceList: this.announceList }, (err, t) => { 44 | if (err) return console.log(err) 45 | const magnet = parseTorrent.toMagnetURI(parseTorrent(t)) 46 | resolve(magnet) 47 | }) 48 | }) 49 | } 50 | 51 | wtManifest.prototype.makeMagnet = async function (f) { 52 | // Extract chunk name. Return magnet if already computed 53 | const self = this 54 | const cn = chunkName(f) 55 | if (self.fileToMagnet[cn]) return 56 | 57 | // Fetch payload and compute magnet 58 | const url = isUrl(f) ? f : self.chunksLoc + removeDanglingSlash(f) 59 | const payload = self.isLocalStream ? await fs.readFile(self.localPath + f) : await request(url, { encoding: null }) 60 | 61 | const magnet = await self.computeMagnet(payload, cn) 62 | 63 | // Store magnet computed 64 | self.fileToMagnet[cn] = '###' + magnet + '\n' + url 65 | self.magnetsOrder.push(cn) 66 | 67 | if (self.magnetsOrder.length > 10) { 68 | const oldMagnet = self.magnetsOrder.shift() 69 | delete self.fileToMagnet[oldMagnet] 70 | } 71 | } 72 | 73 | wtManifest.prototype.makeAllMagnets = async function (files) { 74 | return Promise.all(files.map(this.makeMagnet, this)) 75 | } 76 | 77 | wtManifest.prototype.makeManifest = async function (manifest) { 78 | const self = this 79 | 80 | // Split manifest and get sequenece number 81 | let split = manifest.split('\n') 82 | const sequence = split.find(l => l.includes(`#EXT-X-MEDIA-SEQUENCE:`)) 83 | if (sequence === self.sequence) { 84 | return self.manifest 85 | } 86 | 87 | // Remove any existing magnet link from manifest (useful for testing) 88 | split = split.filter(l => !l.includes('magnet')) 89 | 90 | // Extract TS files and make magnet links 91 | const files = split.filter(l => l.includes('.ts')) 92 | await self.makeAllMagnets(files) 93 | 94 | // Pop manifest back, inject magnet links alongside TS files 95 | self.manifest = split.map(l => l.includes('.ts') ? self.fileToMagnet[chunkName(l)] : l).join('\n') 96 | self.sequence = sequence 97 | return self.manifest 98 | } 99 | 100 | wtManifest.prototype.doManifest = async function (extraManifestName) { 101 | let manifest 102 | 103 | if (this.isLocalStream) { 104 | manifest = await this.sm.makeLiveStream() 105 | } else { 106 | manifest = await request(this.manifestLoc + (extraManifestName || this.manifestName)) 107 | 108 | // Head over to the playlist, if what we got was a link to a playlist. Taking only last link for now. 109 | if (manifest.replace(/\n$/, '').endsWith('.m3u8')) { 110 | const m3u8 = manifest.split('\n').find(l => l.includes('.m3u8')) 111 | return this.doManifest(m3u8) 112 | } 113 | } 114 | 115 | return this.makeManifest(manifest) 116 | } 117 | 118 | module.exports = wtManifest 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live-torrent", 3 | "version": "0.0.6", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "browserify client/loader.js -o client/build.js", 8 | "cliRemote": "npm run build && node cli.js -v -u https://live.computer/manifest.m3u8", 9 | "cliLocal": "npm run build && node cli.js -v -l -f feed", 10 | "startTest": "node cli.js -u https://live.computer/manifest.m3u8 &", 11 | "stopTest": "kill `lsof -ti tcp:8008`", 12 | "lint": "standard .", 13 | "test": "npm run startTest && sleep 5 && node tests/test.js && npm run stopTest" 14 | }, 15 | "keywords": [ 16 | "webtorrent", 17 | "bittorrent", 18 | "torrent", 19 | "live-feed", 20 | "broadcast", 21 | "p2p", 22 | "hls" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/pldubouilh/live-torrent.git" 27 | }, 28 | "engines": { 29 | "node": ">=8" 30 | }, 31 | "bin": { 32 | "live-torrent": "./cli.js" 33 | }, 34 | "author": "pldubouilh", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/pldubouilh/live-torrent/issues" 38 | }, 39 | "homepage": "https://github.com/pldubouilh/live-torrent#readme", 40 | "dependencies": { 41 | "browserify": "^16.1.0", 42 | "create-torrent": "^3.29.2", 43 | "express": "^4.16.2", 44 | "fs-extra": "^5.0.0", 45 | "get-video-duration": "^0.2.0", 46 | "parse-torrent": "^5.8.3", 47 | "request": "^2.83.0", 48 | "request-promise-native": "^1.0.5", 49 | "webtorrent": "^0.98.21", 50 | "yargs": "^11.0.0" 51 | }, 52 | "devDependencies": { 53 | "puppeteer": "^1.1.0", 54 | "standard": "^11.0.0", 55 | "tape": "^4.9.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer') 2 | const test = require('tape') 3 | 4 | const url = 'http://127.0.0.1:8008/fake-video.html' 5 | 6 | const sleep = t => new Promise(resolve => setTimeout(resolve, t)) 7 | 8 | async function testp2p (t) { 9 | t.plan(1) 10 | 11 | // Spawn first chrome 12 | const browser1 = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }) 13 | const page1 = await browser1.newPage() 14 | await page1.goto(url) 15 | await sleep(6000) 16 | 17 | // Spawn second chrome 18 | const browser2 = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }) 19 | const page2 = await browser2.newPage() 20 | 21 | let p2p = false 22 | page2.on('console', msg => { 23 | console.log(msg.text()) 24 | p2p = msg.text().includes('P2P Download') || msg.text().includes('P2P Upload') ? true : p2p 25 | }) 26 | 27 | await page2.goto(url) 28 | await sleep(30000) 29 | 30 | // Cleanup 31 | await browser1.close() 32 | await browser2.close() 33 | 34 | console.log(' ') 35 | t.true(p2p, 'browsers exchanged content between each-others 👏') 36 | t.end() 37 | } 38 | 39 | const doTest = async () => { 40 | test('Test p2p between 2 browsers\n', testp2p) 41 | } 42 | 43 | doTest() 44 | --------------------------------------------------------------------------------