├── .gitignore ├── .github ├── poster.png └── workflows │ └── node.js.yml ├── src ├── types │ └── memory-chunk-store.d.ts ├── bin.mts ├── webtorrent.ts └── webtorrent.node.ts ├── eslint.config.mjs ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build -------------------------------------------------------------------------------- /.github/poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrxdst/webtorrent-mpv-hook/HEAD/.github/poster.png -------------------------------------------------------------------------------- /src/types/memory-chunk-store.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'memory-chunk-store' { 2 | function Storage(...args: unknown[]): unknown; 3 | export = Storage; 4 | } 5 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | 3 | export default tseslint.config( 4 | ...tseslint.configs.strict, 5 | ...[ 6 | { 7 | ignores: [ "build/**" ] 8 | } 9 | ] 10 | ); 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 20 13 | - run: npm ci 14 | - run: npm run build 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "outDir": "./build/", 7 | "strict": true, 8 | "noUncheckedIndexedAccess": true, 9 | "allowJs": true, 10 | "downlevelIteration": true 11 | }, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webtorrent-mpv-hook", 3 | "version": "1.4.5", 4 | "description": "Adds a hook that allows mpv to stream torrents", 5 | "license": "Unlicense", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/mrxdst/webtorrent-mpv-hook.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/mrxdst/webtorrent-mpv-hook/issues" 12 | }, 13 | "homepage": "https://github.com/mrxdst/webtorrent-mpv-hook#readme", 14 | "keywords": [ 15 | "mpv", 16 | "webtorrent" 17 | ], 18 | "bin": "build/bin.mjs", 19 | "type": "module", 20 | "scripts": { 21 | "clean": "rimraf build", 22 | "prebuild": "npm run lint", 23 | "build": "npm run clean && tsc", 24 | "lint": "eslint" 25 | }, 26 | "files": [ 27 | "build" 28 | ], 29 | "dependencies": { 30 | "convert-units": "~2.3.4", 31 | "mpv-json-ipc": "~1.0.1", 32 | "webtorrent": "2.5.0" 33 | }, 34 | "devDependencies": { 35 | "@types/convert-units": "~2.3.3", 36 | "@types/mpv-script": "~0.32.1", 37 | "@types/node": "~22.5.2", 38 | "@types/webtorrent": "~0.109.3", 39 | "eslint": "~9.9.1", 40 | "rimraf": "~6.0.1", 41 | "typescript": "~5.5.4", 42 | "typescript-eslint": "~8.4.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/bin.mts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | import os from 'os'; 6 | import fs from 'fs'; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | const target = path.join(__dirname, '..', 'build', 'webtorrent.js'); 11 | const link = path.join(getScriptFolder(), 'webtorrent.js'); 12 | 13 | const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8')) as {version: string}; 14 | 15 | console.log([ 16 | `webtorrent-mpv-hook v${pkg.version}`, 17 | '', 18 | 'You need to symlink the script file to your mpv scripts folder:', 19 | '', 20 | ` ${os.platform() === 'win32' ? `mklink "${link}" "${target}"\n or\n New-Item -ItemType SymbolicLink -Path "${link}" -Target "${target}"` : `ln -s "${target}" "${link}"`}`, 21 | '', 22 | 'You can then run "mpv " to start streaming.', 23 | '' 24 | ].join('\n')); 25 | 26 | function getScriptFolder() { 27 | let mpvHome; 28 | 29 | if (os.platform() === 'win32') { 30 | mpvHome = process.env['MPV_HOME'] || path.join(process.env['APPDATA'] || '%APPDATA%', 'mpv'); 31 | } else { 32 | mpvHome = process.env['MPV_HOME']; 33 | if (!mpvHome) { 34 | const xdgConfigHome = process.env['XDG_CONFIG_HOME'] || '$HOME/.config'; 35 | mpvHome = path.join(xdgConfigHome, 'mpv'); 36 | } 37 | } 38 | 39 | return path.join(mpvHome, 'scripts'); 40 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![mpv streaming a torrent](https://github.com/mrxdst/webtorrent-mpv-hook/raw/master/.github/poster.png) 2 | 3 | # webtorrent-mpv-hook 4 | [![npm](https://img.shields.io/npm/v/webtorrent-mpv-hook)](https://www.npmjs.com/package/webtorrent-mpv-hook) 5 | [![mpv](https://img.shields.io/badge/mpv-v0.38.0-blue)](https://mpv.io/) 6 | 7 | Adds a hook that allows mpv to stream torrents using [webtorrent](https://github.com/webtorrent/webtorrent). 8 | 9 | 10 | ## Prerequisites 11 | 12 | * node.js 13 | 14 | ## Install 15 | 16 | 1. `npm install --global webtorrent-mpv-hook` 17 | 2. You need to symlink a script file to your mpv scripts folder. 18 | Run `webtorrent-mpv-hook` for instructions. 19 | You only need to do this once. 20 | 21 | ## Update 22 | 23 | `npm update --global webtorrent-mpv-hook` 24 | 25 | ## Usage 26 | 27 | `mpv ` 28 | 29 | Where `torrent-id` is one of: 30 | * magnet link 31 | * info-hash 32 | * path or url to `.torrent` file 33 | 34 | An overlay will be shown with info/progress. It will be closed automatically when playback starts. 35 | It can also be toggled manually with `p` (default). 36 | 37 | > Multi-file torrents are opened as a playlist. 38 | 39 | ## Configuration 40 | 41 | Default values are shown below. 42 | 43 | ### `input.conf` 44 | 45 | ```properties 46 | # Toggles info/progress overlay. 47 | p script-binding webtorrent/toggle-info 48 | ``` 49 | 50 | ### `script-opts/webtorrent.conf` 51 | ```properties 52 | # Path to save downloaded files in. Can be set to "memory" to store all files in RAM. 53 | path=./ 54 | # Maximum number of connections. 55 | maxConns=100 56 | # Port to use for webtorrent web-server. 57 | # If it's already in use a random port will be chosen instead. 58 | port=8888 59 | # Enable μTP support. 60 | utp=yes 61 | # Enable DHT. 62 | dht=yes 63 | # Enable local service discovery. 64 | lsd=yes 65 | # Download speed limit in bytes/sec. 66 | downloadLimit=-1 67 | # Upload speed limit in bytes/sec. 68 | uploadLimit=-1 69 | # Specify the node command to use. 70 | # Usefull if the command is called nodejs on your system. 71 | node_path=node 72 | 73 | # The same text style options as in stats.conf is also available. 74 | ``` 75 | -------------------------------------------------------------------------------- /src/webtorrent.ts: -------------------------------------------------------------------------------- 1 | let webTorrentRunning = false; 2 | let active = false; 3 | let initialyActive = false; 4 | let overlayText = ''; 5 | let playlist: string[] = []; 6 | 7 | // Sync with options in webtorrent.node.ts 8 | const options = { 9 | path: './', 10 | maxConns: 100, 11 | port: 8888, 12 | utp: true, 13 | dht: true, 14 | lsd: true, 15 | downloadLimit: -1, 16 | uploadLimit: -1, 17 | 18 | // Text style. from stats.lua 19 | font: 'sans', 20 | font_mono: 'monospace', 21 | font_size: 8, 22 | font_color: 'FFFFFF', 23 | border_size: 0.8, 24 | border_color: '262626', 25 | shadow_x_offset: 0.0, 26 | shadow_y_offset: 0.0, 27 | shadow_color: '000000', 28 | alpha: '11', 29 | 30 | // Node 31 | node_path: 'node' 32 | }; 33 | 34 | mp.options.read_options(options); 35 | 36 | options.path = mp.command_native(['expand-path', options.path], "") as string; 37 | 38 | function keyPressHandler() { 39 | if (active || initialyActive) { 40 | clearOverlay(); 41 | } else { 42 | showOverlay(); 43 | } 44 | } 45 | 46 | function showOverlay() { 47 | initialyActive = false; 48 | active = true; 49 | printOverlay(); 50 | } 51 | 52 | function printOverlay() { 53 | if (!overlayText) { 54 | return; 55 | } 56 | if (active || initialyActive) { 57 | const expanded = mp.command_native(['expand-text', overlayText], "") as string; 58 | mp.osd_message(expanded, 10); 59 | } 60 | } 61 | 62 | function clearOverlay() { 63 | active = false; 64 | initialyActive = false; 65 | mp.osd_message('', 0); 66 | } 67 | 68 | function openPlaylist() { 69 | for (let i = 0; i < playlist.length; i++) { 70 | const item = playlist[i]; 71 | if (!item) { 72 | continue; 73 | } 74 | if (i === 0) { 75 | mp.commandv('loadfile', item); 76 | } else { 77 | mp.commandv('loadfile', item, 'append'); 78 | } 79 | } 80 | } 81 | 82 | function onData(_data: unknown) { 83 | overlayText = _data as string; 84 | printOverlay(); 85 | } 86 | 87 | function onPlaylist(_playlist: unknown) { 88 | playlist = JSON.parse(_playlist as string) as string[]; 89 | openPlaylist(); 90 | } 91 | 92 | function onInfo(..._info: unknown[]) { 93 | mp.msg.info(..._info as string[]); 94 | } 95 | 96 | function onFileLoaded() { 97 | initialyActive = false; 98 | if (!active) { 99 | clearOverlay(); 100 | } 101 | } 102 | 103 | function onIdleActiveChange(name: string, idleActive?: boolean) { 104 | if (idleActive && playlist.length) { 105 | mp.set_property('pause', 'yes'); 106 | setTimeout(openPlaylist, 1000); 107 | } 108 | } 109 | 110 | function onLoadHook() { 111 | const url = mp.get_property('stream-open-filename', ''); 112 | 113 | try { 114 | if (/^magnet:/i.test(url)) { 115 | runHook(url); 116 | } else if (/\.torrent$/i.test(url)) { 117 | runHook(url); 118 | } else if (/^[0-9A-F]{40}$/i.test(url)) { 119 | runHook(url); 120 | } else if (/^[0-9A-Z]{32}$/i.test(url)) { 121 | runHook(url); 122 | } 123 | } catch (_e) { 124 | const e = _e as Error; 125 | mp.msg.error(e.message) 126 | } 127 | } 128 | 129 | function runHook(url: string) { 130 | mp.msg.info('Running WebTorrent hook'); 131 | mp.set_property('stream-open-filename', 'null://'); 132 | if (webTorrentRunning) { 133 | throw new Error('WebTorrent already running. Only one instance is allowed.'); 134 | } 135 | 136 | const socketName = getSocketName(); 137 | 138 | const scriptPath = getNodeScriptPath(); 139 | 140 | webTorrentRunning = true; 141 | initialyActive = true; 142 | 143 | mp.set_property('idle', 'yes'); 144 | mp.set_property('force-window', 'yes'); 145 | mp.set_property('keep-open', 'yes'); 146 | 147 | mp.register_script_message('osd-data', onData); 148 | mp.register_script_message('playlist', onPlaylist); 149 | mp.register_script_message('info', onInfo); 150 | mp.register_event('file-loaded', onFileLoaded); 151 | mp.observe_property('idle-active', 'bool', onIdleActiveChange); 152 | 153 | const args = { 154 | torrentId: url, 155 | socketName, 156 | ...options 157 | }; 158 | 159 | mp.command_native_async({ 160 | name: 'subprocess', 161 | args: [options.node_path, scriptPath, JSON.stringify(args)], 162 | playback_only: false, 163 | capture_stderr: true 164 | }, onWebTorrentExit); 165 | 166 | mp.add_key_binding('p', 'toggle-info', keyPressHandler); 167 | } 168 | 169 | function getSocketName(): string { 170 | let socketName = mp.get_property('input-ipc-server'); 171 | if (!socketName) { 172 | mp.set_property('input-ipc-server', `/tmp/webtorrent-mpv-hook-socket-${mp.utils.getpid()}-${Date.now()}`); 173 | socketName = mp.get_property('input-ipc-server'); 174 | } 175 | 176 | if (!socketName) { 177 | throw new Error(`Couldn't get input-ipc-server`); 178 | } 179 | 180 | return socketName; 181 | } 182 | 183 | function getNodeScriptPath(): string { 184 | const realPath = mp.command_native({ 185 | name: 'subprocess', 186 | args: [options.node_path, '-p', `require('fs').realpathSync('${mp.get_script_file().replace(/\\/g, '\\\\')}')`], 187 | playback_only: false, 188 | capture_stdout: true 189 | }); 190 | 191 | try { 192 | const scriptPath = realPath.stdout.split(/\r\n|\r|\n/)?.[0]?.replace(/webtorrent\.js$/, 'webtorrent.node.js'); 193 | if (!scriptPath) { 194 | throw new Error(); 195 | } 196 | return scriptPath; 197 | } catch { 198 | throw new Error(`Failed to get node script path. Possible causes are "${options.node_path}" not available in path or incorrect symlink.`); 199 | } 200 | } 201 | 202 | function onWebTorrentExit(success: boolean, _result: unknown): void { 203 | webTorrentRunning = false; 204 | overlayText = ''; 205 | clearOverlay(); 206 | 207 | const result = _result as mp.CapturedProcess; 208 | if (!success) { 209 | mp.msg.error('Failed to start WebTorrent'); 210 | } else if (result.stderr) { 211 | mp.msg.error(result.stderr); 212 | } else if (result.status) { 213 | mp.msg.error('WebTorrent exited with error'); 214 | } 215 | 216 | mp.unregister_script_message('osd-data'); 217 | mp.unregister_script_message('playlist'); 218 | mp.unregister_script_message('info'); 219 | mp.unregister_event(onFileLoaded); 220 | mp.unobserve_property(onIdleActiveChange as (...args: unknown[]) => void); 221 | mp.remove_key_binding('toggle-info'); 222 | } 223 | 224 | mp.add_hook('on_load', 50, onLoadHook); -------------------------------------------------------------------------------- /src/webtorrent.node.ts: -------------------------------------------------------------------------------- 1 | import WebTorrent from 'webtorrent'; 2 | import memoryChunkStore from 'memory-chunk-store'; 3 | import net from 'net'; 4 | import convert from 'convert-units'; 5 | import { MpvJsonIpc } from 'mpv-json-ipc'; 6 | 7 | declare module 'webtorrent' { 8 | interface Server { 9 | server: net.Server; 10 | listen(port: number): void; 11 | close(cb: () => void): void; 12 | destroy(cb?: () => void): void; 13 | } 14 | interface Instance { 15 | createServer(): Server; 16 | _server: Server | undefined; 17 | } 18 | interface TorrentFile { 19 | offset: number; 20 | } 21 | interface Options { 22 | downloadLimit?: number | undefined; 23 | uploadLimit?: number | undefined; 24 | lsd?: boolean | undefined; 25 | } 26 | } 27 | 28 | process.title = 'webtorrent-mpv-hook'; 29 | 30 | process.on('SIGINT', exit); 31 | process.on('SIGTERM', exit); 32 | process.on('uncaughtException', error); 33 | process.on('unhandledRejection', error); 34 | 35 | // Sync with options in webtorrent.ts 36 | type Options = { 37 | torrentId: string; 38 | socketName: string; 39 | 40 | path: string; 41 | maxConns: number; 42 | port: number; 43 | utp: boolean; 44 | dht: boolean; 45 | lsd: boolean; 46 | downloadLimit: number; 47 | uploadLimit: number; 48 | 49 | // Text style. from stats.lua 50 | font: string; 51 | font_mono: string; 52 | font_size: number; 53 | font_color: string; 54 | border_size: number; 55 | border_color: string; 56 | shadow_x_offset: number; 57 | shadow_y_offset: number; 58 | shadow_color: string; 59 | alpha: string; 60 | } 61 | 62 | const options = JSON.parse(process.argv[2] ?? '{}') as Options; 63 | 64 | if (process.platform === 'win32' && !options.socketName.startsWith("\\\\.\\pipe\\")) { 65 | options.socketName = "\\\\.\\pipe\\" + options.socketName; 66 | } 67 | 68 | const textStyle = [ 69 | `{\\r}{\\an7}`, 70 | `{\\fs${Math.floor(options.font_size)}}`, 71 | `{\\fn${options.font}}`, 72 | `{\\bord${options.border_size}}`, 73 | `{\\3c&H${options.border_color}&}`, 74 | `{\\1c&H${options.font_color}&}`, 75 | `{\\alpha&H${options.alpha}&}`, 76 | `{\\xshad${options.shadow_x_offset}}`, 77 | `{\\yshad${options.shadow_y_offset}}`, 78 | `{\\4c&H${options.shadow_color}&}` 79 | ].join(''); 80 | 81 | let exiting = false; 82 | 83 | let socket: net.Socket; 84 | let jsonIpc: MpvJsonIpc; 85 | let currentFile: string | undefined; 86 | 87 | const assStart = '${osd-ass-cc/0}'; 88 | const assStop = '${osd-ass-cc/1}'; 89 | 90 | connectMpv(); 91 | 92 | const client = new WebTorrent({ 93 | maxConns: options.maxConns, 94 | utp: options.utp, 95 | dht: options.dht, 96 | lsd: options.lsd, 97 | downloadLimit: options.downloadLimit, 98 | uploadLimit: options.uploadLimit 99 | }); 100 | client.on('error', error); 101 | 102 | const torrent = client.add(options.torrentId, { 103 | path: options.path, 104 | store: options.path === 'memory' ? memoryChunkStore : undefined 105 | }); 106 | 107 | torrent.on('infoHash', () => log('Info hash:', torrent.infoHash)); 108 | torrent.on('metadata', () => log('Metadata downloaded')); 109 | 110 | let server = client.createServer(); 111 | server.server.on('error', serverError); 112 | server.listen(options.port); 113 | 114 | function serverError(err: NodeJS.ErrnoException): void { 115 | if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { 116 | server.destroy(); 117 | client._server = undefined; 118 | server = client.createServer(); 119 | server.server.on('error', serverError); 120 | server.listen(0); 121 | return; 122 | } 123 | 124 | return error(err); 125 | } 126 | 127 | function connectMpv(): void { 128 | socket = net.createConnection(options.socketName); 129 | socket.unref(); 130 | 131 | jsonIpc = new MpvJsonIpc(socket); 132 | 133 | socket.on('connect', () => { 134 | updateCurrentFile(); 135 | sendOverlay(); 136 | setInterval(updateCurrentFile, 500); 137 | setInterval(sendOverlay, 500); 138 | if (torrent.ready) { 139 | startPlayback(); 140 | } else { 141 | torrent.once('ready', startPlayback); 142 | } 143 | }); 144 | } 145 | 146 | function updateCurrentFile(): void { 147 | void(jsonIpc.command('get_property', 'path').then(res => currentFile = res.data as string | undefined)); 148 | } 149 | 150 | function startPlayback(): void { 151 | log('Ready for playback'); 152 | const port = (server?.server?.address() as net.AddressInfo).port; 153 | 154 | const sortedFiles = [...torrent.files].sort((a, b) => a.path.localeCompare(b.path, undefined, {numeric: true})); 155 | 156 | const playlist = sortedFiles.map(file => `http://localhost:${port}/webtorrent/${torrent.infoHash}/${file.path.replace(/\\/g, '/').split('/').map(encodeURI).join('/')}`); 157 | 158 | void(jsonIpc.command('script-message-to', 'webtorrent', 'playlist', JSON.stringify(playlist))); 159 | } 160 | 161 | function sendOverlay(): void { 162 | const B = (text: string): string => '{\\b1}' + text + '{\\b0}'; 163 | const raw = (text: string): string => assStop + text.replace(/\$/g, '$$$$') + assStart; 164 | 165 | const bar = buildProgressBar(torrent.pieces); 166 | const progress = Math.floor(Math.max(Math.min(torrent.progress, 1), 0) * 1000) / 10; 167 | const downloaded = formatNumber(torrent.downloaded, 'B', 2); 168 | const uploaded = formatNumber(torrent.uploaded, 'B', 2); 169 | const size = formatNumber(torrent.length, 'B', 2); 170 | const timeRemaining = torrent.timeRemaining ? formatNumber(torrent.timeRemaining, 'ms', 1) : ''; 171 | const download = formatNumber(torrent.downloadSpeed, 'B', 2) + '/s'; 172 | const upload = formatNumber(torrent.uploadSpeed, 'B', 2) + '/s'; 173 | const ratio = torrent.uploaded / torrent.downloaded; 174 | 175 | const lines = [ 176 | `${B('Torrent:')} ${raw(torrent.name ?? torrent.infoHash ?? '')}`, 177 | ` ${B('Progress:')} ${bar} ${progress === 100 ? progress : progress.toFixed(1)}%`, 178 | ` ${B('Downloaded:')} ${downloaded.padEnd(10, '\u2003')} ${B('Size:')} ${size.padEnd(10, '\u2003')} ${B('Uploaded:')} ${uploaded}`, 179 | ` ${B('Download:')} ${download.padEnd(10, '\u2003')} ${B('Upload:')} ${upload}`, 180 | ` ${B('Time remaining:')} ${timeRemaining}`, 181 | ` ${B('Ratio:')} ${(ratio || 0).toFixed(2)}`, 182 | ` ${B('Peers:')} ${torrent.numPeers}`, 183 | ]; 184 | 185 | if (currentFile) { 186 | const match = /http:\/\/localhost:\d+\/webtorrent\/(.+)/.exec(currentFile); 187 | const pathname = match?.[1]; 188 | if (pathname) { 189 | const [, ..._filePath] = pathname.split('/') 190 | const filePath = decodeURI(_filePath.join('/')) 191 | const file = torrent.files.find(file => file.path.replace(/\\/g, '/') === filePath) 192 | if (file) { 193 | 194 | const startPiece = Math.floor(file.offset / torrent.pieceLength | 0); 195 | const endPiece = Math.ceil((file.offset + file.length - 1) / torrent.pieceLength | 0); 196 | 197 | const pieces = torrent.pieces.slice(startPiece, endPiece + 1); 198 | 199 | const _downloaded = Math.max(Math.min(torrent.downloaded, file.downloaded), 0); 200 | 201 | const bar = buildProgressBar(pieces); 202 | const progress = Math.floor(Math.max(Math.min(file.progress, 1), 0) * 1000) / 10; 203 | const downloaded = formatNumber(_downloaded, 'B', 2); 204 | const size = formatNumber(file.length, 'B', 2); 205 | 206 | lines.push(...[ 207 | '', 208 | `${B('File:')} ${raw(file.name)}`, 209 | ` ${B('Progress:')} ${bar} ${progress === 100 ? progress : progress.toFixed(1)}%`, 210 | ` ${B('Downloaded:')} ${downloaded.padEnd(10, '\u2003')} ${B('Size:')} ${size}` 211 | ]); 212 | } 213 | } 214 | } 215 | 216 | void(jsonIpc.command('script-message-to', 'webtorrent', 'osd-data', assStart + textStyle + lines.join('\n') + assStop)); 217 | } 218 | 219 | type Unit = Parameters['from']>[0]; 220 | function formatNumber(value: number, unit: Unit, fractionDigits = 0): string { 221 | value = value || 0; 222 | const res = convert(value).from(unit).toBest(); 223 | 224 | return res.val.toFixed(fractionDigits) + ' ' + res.unit; 225 | } 226 | 227 | function buildProgressBar(pieces: typeof torrent.pieces): string { 228 | const fullBar = pieces.map(p => p ? 1 - (p.missing / p.length) : 1); 229 | 230 | const barSize = 50; 231 | 232 | let bar: number[] = []; 233 | 234 | const sumFn = (acc: number, cur: number): number => acc + cur; 235 | 236 | if (fullBar.length > barSize) { 237 | const interval = fullBar.length / barSize; 238 | for (let n = 0; n <= (fullBar.length - 1); n += interval) { 239 | const i = Math.floor(n); 240 | const i2 = Math.floor(n + interval); 241 | const parts = fullBar.slice(i, i2); 242 | const sum = parts.reduce(sumFn, 0); 243 | bar.push(sum / parts.length); 244 | } 245 | } else { 246 | bar = fullBar; 247 | } 248 | 249 | const barText = bar.map(p => { 250 | if (p >= 1) { 251 | return '█'; 252 | } else if (p >= 2 / 3) { 253 | return '▓'; 254 | } else if (p >= 1 / 3) { 255 | return '▒'; 256 | } else if (p > 0) { 257 | return '░'; 258 | } else { 259 | return `{\\alpha&HCC&}{\\3a&HFF&}█{\\alpha&H${options.alpha}&}`; 260 | } 261 | }).join(''); 262 | 263 | return `{\\fn${options.font_mono}}{\\fscy80}{\\fscx80}${barText}{\\fscy}{\\fscx}{\\fn${options.font}}`; 264 | } 265 | 266 | function log(...args: Parameters): void { 267 | void(jsonIpc.command('script-message-to', 'webtorrent', 'info', ...args)); 268 | } 269 | 270 | function exit (): void { 271 | if (exiting) { 272 | return; 273 | } 274 | 275 | exiting = true; 276 | 277 | process.removeListener('SIGINT', exit); 278 | process.removeListener('SIGTERM', exit); 279 | 280 | server.close(() => { 281 | client.destroy(() => { 282 | process.exit(0); 283 | }); 284 | }); 285 | } 286 | 287 | function error (error: string | Error): void { 288 | if (typeof error === 'string') { 289 | error = new Error(error); 290 | } 291 | console.error(error.toString()); 292 | process.exit(1); 293 | } --------------------------------------------------------------------------------