├── .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 | [](https://travis-ci.org/pldubouilh/live-torrent)
5 |
6 |
7 | 
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 |
13 | Pop the console to get the logs. To simulate p2p, just open another tab in incognito and browse to this very testpage.
14 |
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 |
--------------------------------------------------------------------------------