From db9e3e90c5d645fe912f4b3a573a76a921f18098 Mon Sep 17 00:00:00 2001 From: DC Date: Mon, 4 Apr 2016 08:43:27 -0700 Subject: [PATCH] WebTorrent process * Separate hidden window, with its own renderer process, for WebTorrent (Must be a window. You cannot run WebRTC at all in a Web Worker, and you can't run it well in a node process like the electron main process.) * Disabled the create-torrent-modal for now. That gives us a consistent UX regardless of whether the user dragged files or folders onto the app or opened the Create New Torrent menu item. * Main process routes all messages between the main and webtorrent windows. * The renderer index.js is smaller now (but still too big), with the WebTorrent interface moved to webtorrent.js / it's own process. * The UI should be faster now, and should not lag under load. --- config.js | 1 + main/index.js | 1 + main/ipc.js | 22 ++ main/menu.js | 5 +- main/windows.js | 28 +- package.json | 2 + renderer/index.js | 659 +++++++++++++++------------------ renderer/state.js | 2 +- renderer/views/player.js | 81 ++-- renderer/views/torrent-list.js | 51 +-- renderer/webtorrent.html | 3 + renderer/webtorrent.js | 298 +++++++++++++++ 12 files changed, 726 insertions(+), 427 deletions(-) create mode 100644 renderer/webtorrent.html create mode 100644 renderer/webtorrent.js diff --git a/config.js b/config.js index a5edb5c4..ee62ce8d 100644 --- a/config.js +++ b/config.js @@ -65,6 +65,7 @@ module.exports = { }, WINDOW_ABOUT: 'file://' + path.join(__dirname, 'renderer', 'about.html'), + WINDOW_WEBTORRENT: 'file://' + path.join(__dirname, 'renderer', 'webtorrent.html'), WINDOW_MAIN: 'file://' + path.join(__dirname, 'renderer', 'main.html') } diff --git a/main/index.js b/main/index.js index 82374204..26fe7f7c 100644 --- a/main/index.js +++ b/main/index.js @@ -54,6 +54,7 @@ function init () { app.on('ready', function () { menu.init() windows.createMainWindow() + windows.createWebTorrentHiddenWindow() shortcuts.init() tray.init() handlers.install() diff --git a/main/ipc.js b/main/ipc.js index d7cbd007..8c76c987 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -69,6 +69,28 @@ function init () { menu.onPlayerClose() shortcuts.unregisterPlayerShortcuts() }) + + // Capture all events + var oldEmit = ipcMain.emit + ipcMain.emit = function (name, e, ...args) { + // Relay messages between the main window and the WebTorrent hidden window + if (name.startsWith('wt-')) { + var recipient, recipientStr + if (e.sender.browserWindowOptions.title === 'webtorrent-hidden-window') { + recipient = windows.main + recipientStr = 'main' + } else { + recipient = windows.webtorrent + recipientStr = 'webtorrent' + } + console.log('sending %s to %s', name, recipientStr) + recipient.send(name, ...args) + return + } + + // Emit all other events normally + oldEmit.call(ipcMain, name, e, ...args) + } } function setBounds (bounds, maximize) { diff --git a/main/menu.js b/main/menu.js index 546144ba..76f4cb06 100644 --- a/main/menu.js +++ b/main/menu.js @@ -124,7 +124,10 @@ function showCreateTorrent () { properties: [ 'openFile', 'openDirectory' ] }, function (filenames) { if (!Array.isArray(filenames)) return - windows.main.send('dispatch', 'seed', filenames[0]) + var options = { + files: filenames[0] + } + windows.main.send('dispatch', 'createTorrent', options) }) } diff --git a/main/windows.js b/main/windows.js index d04d756b..ba906c95 100644 --- a/main/windows.js +++ b/main/windows.js @@ -1,9 +1,10 @@ var windows = module.exports = { about: null, main: null, - createAboutWindow: createAboutWindow, - createMainWindow: createMainWindow, - focusWindow: focusWindow + createAboutWindow, + createWebTorrentHiddenWindow, + createMainWindow, + focusWindow } var electron = require('electron') @@ -46,6 +47,25 @@ function createAboutWindow () { }) } +function createWebTorrentHiddenWindow () { + var win = windows.webtorrent = new electron.BrowserWindow({ + backgroundColor: '#ECECEC', + show: false, + center: true, + title: 'webtorrent-hidden-window', + width: 500, + height: 500, + minimizable: false, + maximizable: false, + fullscreen: false, + skipTaskbar: true + }) + win.loadURL(config.WINDOW_WEBTORRENT) + + // To debug the WebTorrent process, set `show` to true above and uncomment: + // win.webContents.openDevTools({detached: false}) +} + function createMainWindow () { if (windows.main) { return focusWindow(windows.main) @@ -56,7 +76,7 @@ function createMainWindow () { icon: config.APP_ICON + '.png', minWidth: 375, minHeight: 38 + (120 * 2), // header height + 2 torrents - show: false, // Hide window until DOM finishes loading + show: true, // Hide window until DOM finishes loading title: config.APP_WINDOW_TITLE, titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X) useContentSize: true, // Specify web page size without OS chrome diff --git a/package.json b/package.json index ba2889b8..3ff5fdfc 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "airplay-js": "guerrerocarlos/node-airplay-js", "application-config": "^0.2.0", "application-config-path": "^0.1.0", + "bitfield": "^1.0.2", "chromecasts": "^1.8.0", "create-torrent": "^3.22.1", + "deep-equal": "^1.0.1", "dlnacasts": "^0.0.2", "drag-drop": "^2.11.0", "electron-localshortcut": "^0.6.0", diff --git a/renderer/index.js b/renderer/index.js index f7645ea2..5ff6cbce 100644 --- a/renderer/index.js +++ b/renderer/index.js @@ -1,15 +1,11 @@ console.time('init') var cfg = require('application-config')('WebTorrent') -var defaultAnnounceList = require('create-torrent').announceList var dragDrop = require('drag-drop') var electron = require('electron') var EventEmitter = require('events') var fs = require('fs') var mainLoop = require('main-loop') -var mkdirp = require('mkdirp') -var musicmetadata = require('musicmetadata') -var networkAddress = require('network-address') var path = require('path') var remote = require('remote') @@ -21,14 +17,11 @@ var App = require('./views/app') var errors = require('./lib/errors') var config = require('../config') var TorrentPlayer = require('./lib/torrent-player') -var torrentPoster = require('./lib/torrent-poster') var util = require('./util') var {setDispatch} = require('./lib/dispatcher') setDispatch(dispatch) -// These two dependencies are the slowest-loading, so we lazy load them -// This cuts time from icon click to rendered window from ~550ms to ~150ms on my laptop -var WebTorrent = null +// This dependency is the slowest-loading, so we lazy load it var Cast = null // Electron apps have two processes: a main process (node) runs first and starts @@ -43,11 +36,6 @@ var dialog = remote.require('dialog') // For easy debugging in Developer Tools var state = global.state = require('./state') -// Force use of webtorrent trackers on all torrents -global.WEBTORRENT_ANNOUNCE = defaultAnnounceList - .map((arr) => arr[0]) - .filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0) - var vdomLoop // All state lives in state.js. `state.saved` is read from and written to a file. @@ -66,10 +54,8 @@ function init () { state.location.go({ url: 'home' }) // Lazily load the WebTorrent, Chromecast, and Airplay modules - window.setTimeout(function () { - lazyLoadClient() - lazyLoadCast() - }, 750) + initWebtorrent() + window.setTimeout(lazyLoadCast, 750) // The UI is built with virtual-dom, a minimalist library extracted from React // The concepts--one way data flow, a pure function that renders state to a @@ -125,12 +111,6 @@ function init () { console.timeEnd('init') } -// Lazily loads the WebTorrent module and creates the WebTorrent client -function lazyLoadClient () { - if (!WebTorrent) initWebtorrent() - return state.client -} - // Lazily loads Chromecast and Airplay support function lazyLoadCast () { if (!Cast) { @@ -140,41 +120,25 @@ function lazyLoadCast () { return Cast } -// Load the WebTorrent module, connect to both the WebTorrent and BitTorrent -// networks, resume torrents, start monitoring torrent progress +// Talk to WebTorrent process, resume torrents, start monitoring torrent progress function initWebtorrent () { - WebTorrent = require('webtorrent') - - // Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid - // client, as explained here: https://webtorrent.io/faq - state.client = new WebTorrent() - state.client.on('warning', onWarning) - state.client.on('error', function (err) { - // TODO: WebTorrent should have semantic errors - if (err.message.startsWith('There is already a swarm')) { - onError(new Error('Couldn\'t add duplicate torrent')) - } else { - onError(err) - } - }) - // Restart everything we were torrenting last time the app ran resumeTorrents() // Calling update() updates the UI given the current state // Do this at least once a second to give every file in every torrentSummary // a progress bar and to keep the cursor in sync when playing a video - setInterval(function () { - if (!updateTorrentProgress()) { - update() // If we didn't just update(), do so now, for the video cursor - } - }, 1000) + setInterval(update, 1000) } // This is the (mostly) pure function from state -> UI. Returns a virtual DOM // tree. Any events, such as button clicks, will turn into calls to dispatch() function render (state) { - return App(state) + try { + return App(state) + } catch (e) { + console.log('rendering error: %s\n\t%s', e.message, e.stack) + } } // Calls render() to go from state -> UI, then applies to vdom to the real DOM. @@ -217,16 +181,8 @@ function dispatch (action, ...args) { if (action === 'showOpenTorrentFile') { ipcRenderer.send('showOpenTorrentFile') /* open torrent file */ } - if (action === 'seed') { - // TODO: right now, creating a torrent thru File > Create New Torrent - // creates and starts seeding a new torrent directly, while dragging files - // or folders onto the app opens the create-torrent-modal - // - // That's because the former gets a single string and the latter gets a list - // of W3C File objects. We should fix this inconsitency, ideally without - // duping this code in the drag-drop module: - // https://github.com/feross/drag-drop/blob/master/index.js - createTorrent({files: args[0]} /* file or folder path */) + if (action === 'createTorrent') { + createTorrent(args[0] /* options */) } if (action === 'openFile') { openFile(args[0] /* infoHash */, args[1] /* index */) @@ -321,14 +277,11 @@ function dispatch (action, ...args) { if (action === 'skipVersion') { if (!state.saved.skippedVersions) state.saved.skippedVersions = [] state.saved.skippedVersions.push(args[0] /* version */) - saveState() + saveStateThrottled() } if (action === 'saveState') { saveState() } - if (action === 'createTorrent') { - createTorrent(args[0] /* options */) - } // Update the virtual-dom, unless it's just a mouse move event if (action !== 'mediaMouseMoved') { @@ -404,6 +357,19 @@ function setupIpc () { state.devices[device] = player update() }) + + ipcRenderer.on('wt-infohash', (e, ...args) => torrentInfoHash(...args)) + ipcRenderer.on('wt-ready', (e, ...args) => torrentReady(...args)) + ipcRenderer.on('wt-done', (e, ...args) => torrentDone(...args)) + ipcRenderer.on('wt-warning', (e, ...args) => torrentWarning(...args)) + ipcRenderer.on('wt-error', (e, ...args) => torrentError(...args)) + + ipcRenderer.on('wt-progress', (e, ...args) => torrentProgress(...args)) + ipcRenderer.on('wt-file-modtimes', (e, ...args) => torrentFileModtimes(...args)) + ipcRenderer.on('wt-file-saved', (e, ...args) => torrentFileSaved(...args)) + ipcRenderer.on('wt-poster', (e, ...args) => torrentPosterSaved(...args)) + ipcRenderer.on('wt-audio-metadata', (e, ...args) => torrentAudioMetadata(...args)) + ipcRenderer.on('wt-server-running', (e, ...args) => torrentServerRunning(...args)) } // Load state.saved from the JSON state file @@ -429,24 +395,46 @@ function resumeTorrents () { .forEach((x) => startTorrentingSummary(x)) } +// Don't write state.saved to file more than once a second +function saveStateThrottled () { + if (state.saveStateTimeout) return + state.saveStateTimeout = setTimeout(function () { + delete state.saveStateTimeout + saveState() + }, 1000) +} + // Write state.saved to the JSON state file function saveState () { console.log('saving state to ' + cfg.filePath) // Clean up, so that we're not saving any pending state - var copy = JSON.parse(JSON.stringify(state.saved)) + var copy = Object.assign({}, state.saved) // Remove torrents pending addition to the list, where we haven't finished // reading the torrent file or file(s) to seed & don't have an infohash - copy.torrents = copy.torrents.filter((x) => x.infoHash) - copy.torrents.forEach(function (x) { - if (x.playStatus !== 'unplayable') delete x.playStatus - }) + copy.torrents = copy.torrents + .filter((x) => x.infoHash) + .map(function (x) { + var torrent = {} + for (var key in x) { + if (key === 'progress' || key === 'torrentKey') { + continue // Don't save progress info or key for the webtorrent process + } + if (key === 'playStatus' && x.playStatus !== 'unplayable') { + continue // Don't save whether a torrent is playing / pending + } + torrent[key] = x[key] + } + return torrent + }) cfg.write(copy, function (err) { if (err) console.error(err) ipcRenderer.send('savedState') - update() }) + + // Update right away, don't wait for the state to save + update() } function onOpen (files) { @@ -454,11 +442,11 @@ function onOpen (files) { // .torrent file = start downloading the torrent files.filter(isTorrent).forEach(function (torrentFile) { - addTorrent(torrentFile) + addTorrent(torrentFile.path) }) // everything else = seed these files - showCreateTorrentModal(files.filter(isNotTorrent)) + createTorrentFromFileObjects(files) } function onPaste (e) { @@ -485,316 +473,233 @@ function isNotTorrent (file) { // Gets a torrent summary {name, infoHash, status} from state.saved.torrents // Returns undefined if we don't know that infoHash -function getTorrentSummary (infoHash) { - if (!infoHash) return undefined - return state.saved.torrents.find((x) => x.infoHash === infoHash) -} - -// Get an active torrent from state.client.torrents -// Returns undefined if we are not currently torrenting that infoHash -function getTorrent (infoHash) { - if (!infoHash) return undefined - var pending = state.pendingTorrents[infoHash] - if (pending) return pending - return lazyLoadClient().torrents.find((x) => x.infoHash === infoHash) +function getTorrentSummary (torrentKey) { + if (!torrentKey) return undefined + return state.saved.torrents.find((x) => + x.torrentKey === torrentKey || x.infoHash === torrentKey) } // Adds a torrent to the list, starts downloading/seeding. TorrentID can be a // magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent- function addTorrent (torrentId) { - var torrent = startTorrentingID(torrentId) - torrent.on('infoHash', function () { - addTorrentToList(torrent) - }) -} - -function addTorrentToList (torrent) { - if (getTorrentSummary(torrent.infoHash)) { - return // Skip, torrent is already in state.saved - } - - var torrentSummary = { - status: 'new', - name: torrent.name - } - state.saved.torrents.push(torrentSummary) - playInterfaceSound('ADD') - - // If torrentId is a remote torrent (filesystem path, http url, etc.), wait for - // WebTorrent to finish reading it - if (torrent.infoHash) onInfoHash() - else torrent.on('infoHash', onInfoHash) - - function onInfoHash () { - torrentSummary.infoHash = torrent.infoHash - torrentSummary.magnetURI = torrent.magnetURI - saveState() - update() - } + var torrentKey = state.nextTorrentKey++ + var path = state.saved.downloadPath + ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path) } // Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object function startTorrentingSummary (torrentSummary) { var s = torrentSummary - if (s.torrentPath) { - var torrentPath = util.getAbsoluteStaticPath(s.torrentPath) - var ret = startTorrentingID(torrentPath, s.path, s.fileModtimes) - if (s.infoHash) state.pendingTorrents[s.infoHash] = ret - return ret - } else if (s.magnetURI) { - return startTorrentingID(s.magnetURI, s.path, s.fileModtimes) - } else { - return startTorrentingID(s.infoHash, s.path, s.fileModtimes) + + // Backward compatibility for config files save before we had torrentKey + if (!s.torrentKey) s.torrentKey = state.nextTorrentKey++ + + // Use Downloads folder by default + var path = s.path || state.saved.downloadPath + + var torrentID + if (s.torrentPath) { // Load torrent file from disk + torrentID = util.getAbsoluteStaticPath(s.torrentPath) + } else { // Load torrent from DHT + torrentID = s.magnetURI || s.infoHash } + + ipcRenderer.send('wt-start-torrenting', s.torrentKey, torrentID, path, s.fileModtimes) } -// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object -// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent- -function startTorrentingID (torrentID, path, fileModtimes) { - console.log('starting torrent ' + torrentID) - var torrent = lazyLoadClient().add(torrentID, { - path: path || state.saved.downloadPath, // Use downloads folder - fileModtimes: fileModtimes - }) - addTorrentEvents(torrent) - return torrent -} +// TODO: maybe have a "create torrent" modal in the future, with options like +// custom trackers, private flag, and so on? +// +// Right now create-torrent-modal is v basic, only user input is OK / Cancel +// +// Also, if you uncomment below below, creating a torrent thru +// File > Create New Torrent will still create a new torrent directly, while +// dragging files or folders onto the app opens the create-torrent-modal +// +// That's because the former gets a single string and the latter gets a list +// of W3C File objects. We should fix this inconsistency, ideally without +// duping this code in the drag-drop module: +// https://github.com/feross/drag-drop/blob/master/index.js +// +// function showCreateTorrentModal (files) { +// if (files.length === 0) return +// state.modal = { +// id: 'create-torrent-modal', +// files: files +// } +// } -// Stops downloading and/or seeding -function stopTorrenting (infoHash) { - var torrent = getTorrent(infoHash) - if (torrent) torrent.destroy() -} +// +// TORRENT MANAGEMENT +// Send commands to the WebTorrent process, handle events +// -// Prompts the user to create a torrent for a local file or folder -function showCreateTorrentModal (files) { - if (files.length === 0) return - state.modal = { - id: 'create-torrent-modal', - files: files +// Creates a new torrent from a drag-dropped file or folder +function createTorrentFromFileObjects (files) { + var filePaths = (files + .filter(isNotTorrent) + .map((x) => x.path)) + + // Single-file torrents are easy. Multi-file torrents require special handling + // make sure WebTorrent seeds all files in place, without copying to /tmp + if (filePaths.length === 1) { + return createTorrent({files: filePaths[0]}) } + + // First, extract the base folder that the files are all in + var pathPrefix = files.map((x) => x.path).reduce(findCommonPrefix) + if (files.length > 0 && !pathPrefix.endsWith('/') && !pathPrefix.endsWith('\\')) { + pathPrefix = path.dirname(pathPrefix) + } + + // Then, use the name of the base folder (or sole file, for a single file torrent) + // as the default name. Show all files relative to the base folder. + var defaultName = path.basename(pathPrefix) + var basePath = path.dirname(pathPrefix) + var options = { + // TODO: we can't let the user choose their own name if we want WebTorrent + // to use the files in place rather than creating a new folder. + name: defaultName, + path: basePath, + files: filePaths + } + + createTorrent(options) } // Creates a new torrent and start seeeding function createTorrent (options) { - var torrent = lazyLoadClient().seed(options.files, options) - addTorrentToList(torrent) - addTorrentEvents(torrent) + var torrentKey = state.nextTorrentKey++ + ipcRenderer.send('wt-create-torrent', torrentKey, options) } -function addTorrentEvents (torrent) { - torrent.on('infoHash', function () { - var infoHash = torrent.infoHash - if (state.pendingTorrents[infoHash]) delete state.pendingTorrents[infoHash] - update() - }) - torrent.on('ready', torrentReady) - torrent.on('done', torrentDone) - torrent.on('warning', onWarning) - torrent.on('error', torrentError) +function torrentInfoHash (torrentKey, infoHash) { + var torrentSummary = getTorrentSummary(torrentKey) + console.log('got infohash for %s torrent %s', + torrentSummary ? 'existing' : 'new', torrentKey) - function torrentError (err) { + if (!torrentSummary) { + torrentSummary = { + torrentKey: torrentKey, + status: 'new' + } + state.saved.torrents.push(torrentSummary) + playInterfaceSound('ADD') + } + + torrentSummary.infoHash = infoHash + update() +} + +function torrentWarning (torrentKey, message) { + onWarning(message) +} + +function torrentError (torrentKey, message) { + var torrentSummary = getTorrentSummary(torrentKey) + + // TODO: WebTorrent should have semantic errors + if (message.startsWith('There is already a swarm')) { + onError(new Error('Couldn\'t add duplicate torrent')) + } else if (!torrentSummary) { + onError(message) + } else { console.log('error, stopping torrent %s (%s):\n\t%o', - torrent.name, torrent.infoHash, err.message) - // TODO: update torrentSummary, even if it doesn't have an infohash yet - if (torrent.infoHash) { - getTorrentSummary(torrent.infoHash).status = 'paused' - update() - } - } - - function torrentReady () { - // Summarize torrent - var torrentSummary = getTorrentSummary(torrent.infoHash) - torrentSummary.status = 'downloading' - torrentSummary.ready = true - torrentSummary.name = torrentSummary.displayName || torrent.name - torrentSummary.path = torrent.path - - // Summarize torrent files - torrentSummary.files = torrent.files.map(summarizeFileInTorrent) - updateTorrentProgress() - - // Save the .torrent file, if it hasn't been saved already - if (!torrentSummary.torrentPath) saveTorrentFile(torrentSummary, torrent) - - // Auto-generate a poster image, if it hasn't been generated already - if (!torrentSummary.posterURL) generateTorrentPoster(torrent, torrentSummary) - - update() - } - - function torrentDone () { - // Update the torrent summary - var torrentSummary = getTorrentSummary(torrent.infoHash) - torrentSummary.status = 'seeding' - updateTorrentProgress() - torrent.getFileModtimes(function (err, fileModtimes) { - if (err) return onError(err) - torrentSummary.fileModtimes = fileModtimes - saveState() - }) - - // Notify the user that a torrent finished, but only if we actually DL'd at least part of it. - // Don't notify if we merely finished verifying data files that were already on disk. - if (torrent.received > 0) { - if (!state.window.isFocused) { - state.dock.badge += 1 - } - showDoneNotification(torrent) - } - + torrentSummary.name, torrentSummary.infoHash, message) + torrentSummary.status = 'paused' update() } } -function updateTorrentProgress () { - var changed = false +function torrentReady (torrentKey, torrentInfo) { + // Summarize torrent + var torrentSummary = getTorrentSummary(torrentKey) + torrentSummary.status = 'downloading' + torrentSummary.ready = true + torrentSummary.name = torrentSummary.displayName || torrentInfo.name + torrentSummary.path = torrentInfo.path + torrentSummary.files = torrentInfo.files + update() + + // Save the .torrent file, if it hasn't been saved already + if (!torrentSummary.torrentPath) ipcRenderer.send('wt-save-torrent-file', torrentKey) + + // Auto-generate a poster image, if it hasn't been generated already + if (!torrentSummary.posterURL) ipcRenderer.send('wt-generate-torrent-poster', torrentKey) +} + +function torrentDone (torrentKey, torrentInfo) { + // Update the torrent summary + var torrentSummary = getTorrentSummary(torrentKey) + torrentSummary.status = 'seeding' + + // Notify the user that a torrent finished, but only if we actually DL'd at least part of it. + // Don't notify if we merely finished verifying data files that were already on disk. + if (torrentInfo.bytesReceived > 0) { + if (!state.window.isFocused) { + state.dock.badge += 1 + } + showDoneNotification(torrentKey) + } + + update() +} + +function torrentProgress (progressInfo) { + // Overall progress across all active torrents, 0 to 1 + var progress = progressInfo.progress + var hasActiveTorrents = progressInfo.hasActiveTorrents - // First, track overall progress - var progress = lazyLoadClient().progress - var activeTorrentsExist = lazyLoadClient().torrents.some(function (torrent) { - return torrent.progress !== 1 - }) // Hide progress bar when client has no torrents, or progress is 100% - if (!activeTorrentsExist || progress === 1) { + // TODO: isn't this equivalent to: if (progress === 1) ? + if (!hasActiveTorrents || progress === 1) { progress = -1 } + // Show progress bar under the WebTorrent taskbar icon, on OSX - if (state.dock.progress !== progress) changed = true state.dock.progress = progress - // Track progress for every file in each torrentSummary - // TODO: ideally this would be tracked by WebTorrent, which could do it - // more efficiently than looping over torrent.bitfield - lazyLoadClient().torrents.forEach(function (torrent) { - var torrentSummary = getTorrentSummary(torrent.infoHash) - if (!torrentSummary || !torrent.ready) return - torrent.files.forEach(function (file, index) { - var numPieces = file._endPiece - file._startPiece + 1 - var numPiecesPresent = 0 - for (var piece = file._startPiece; piece <= file._endPiece; piece++) { - if (torrent.bitfield.get(piece)) numPiecesPresent++ - } - - var fileSummary = torrentSummary.files[index] - if (fileSummary.numPiecesPresent !== numPiecesPresent || fileSummary.numPieces !== numPieces) { - fileSummary.numPieces = numPieces - fileSummary.numPiecesPresent = numPiecesPresent - changed = true - } - }) - }) - - if (changed) update() - return changed -} - -function generateTorrentPoster (torrent, torrentSummary) { - torrentPoster(torrent, function (err, buf, extension) { - if (err) return onWarning(err) - // save it for next time - mkdirp(config.CONFIG_POSTER_PATH, function (err) { - if (err) return onWarning(err) - var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension) - fs.writeFile(posterFilePath, buf, function (err) { - if (err) return onWarning(err) - // show the poster - torrentSummary.posterURL = posterFilePath - update() - }) - }) - }) -} - -// Produces a JSON saveable summary of a file in a torrent -function summarizeFileInTorrent (file) { - return { - name: file.name, - length: file.length, - numPiecesPresent: 0, - numPieces: null - } -} - -// Every time we resolve a magnet URI, save the torrent file so that we never -// have to download it again. Never ask the DHT the same question twice. -function saveTorrentFile (torrentSummary, torrent) { - checkIfTorrentFileExists(torrentSummary.infoHash, function (torrentPath, exists) { - if (exists) { - // We've already saved the file - torrentSummary.torrentPath = torrentPath - saveState() + // Update progress for each individual torrent + progressInfo.torrents.forEach(function (p) { + var torrentSummary = getTorrentSummary(p.torrentKey) + if (!torrentSummary) { + console.log('warning: got progress for missing torrent %s', p.torrentKey) return } - - // Otherwise, save the .torrent file, under the app config folder - fs.mkdir(config.CONFIG_TORRENT_PATH, function (_) { - fs.writeFile(torrentPath, torrent.torrentFile, function (err) { - if (err) return console.log('error saving torrent file %s: %o', torrentPath, err) - console.log('saved torrent file %s', torrentPath) - torrentSummary.torrentPath = torrentPath - saveState() - }) - }) + torrentSummary.progress = p }) + + update() } -// Checks whether we've already resolved a given infohash to a torrent file -// Calls back with (torrentPath, exists). Logs, does not call back on error -function checkIfTorrentFileExists (infoHash, cb) { - var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent') - fs.exists(torrentPath, function (exists) { - cb(torrentPath, exists) - }) +function torrentFileModtimes (torrentKey, fileModtimes) { + var torrentSummary = getTorrentSummary(torrentKey) + torrentSummary.fileModtimes = fileModtimes + saveStateThrottled() } -function startServer (torrentSummary, index, cb) { - if (state.server) return cb() - - var torrent = getTorrent(torrentSummary.infoHash) - if (!torrent) torrent = startTorrentingSummary(torrentSummary) - if (torrent.ready) startServerFromReadyTorrent(torrent, index, cb) - else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index, cb)) +function torrentFileSaved (torrentKey, torrentPath) { + console.log('torrent file saved %s: %s', torrentKey, torrentPath) + var torrentSummary = getTorrentSummary(torrentKey) + torrentSummary.torrentPath = torrentPath + saveStateThrottled() } -function startServerFromReadyTorrent (torrent, index, cb) { - // automatically choose which file in the torrent to play, if necessary - if (index === undefined) index = pickFileToPlay(torrent.files) - if (index === undefined) return cb(new errors.UnplayableError()) - var file = torrent.files[index] +function torrentPosterSaved (torrentKey, posterPath) { + var torrentSummary = getTorrentSummary(torrentKey) + torrentSummary.posterURL = posterPath + saveStateThrottled() +} - // update state - state.playing.infoHash = torrent.infoHash - state.playing.fileIndex = index - state.playing.type = TorrentPlayer.isVideo(file) ? 'video' - : TorrentPlayer.isAudio(file) ? 'audio' - : 'other' - - // if it's audio, parse out the metadata (artist, title, etc) - var torrentSummary = getTorrentSummary(torrent.infoHash) +function torrentAudioMetadata (infoHash, index, info) { + var torrentSummary = getTorrentSummary(infoHash) var fileSummary = torrentSummary.files[index] - if (state.playing.type === 'audio' && !fileSummary.audioInfo) { - musicmetadata(file.createReadStream(), function (err, info) { - if (err) return - console.log('got audio metadata for %s: %o', file.name, info) - fileSummary.audioInfo = info - update() - }) - } + fileSummary.audioInfo = info + update() +} - // either way, start a streaming torrent-to-http server - var server = torrent.createServer() - server.listen(0, function () { - var port = server.address().port - var urlSuffix = ':' + port + '/' + index - state.server = { - server: server, - localURL: 'http://localhost' + urlSuffix, - networkURL: 'http://' + networkAddress() + urlSuffix - } - cb() - }) +function torrentServerRunning (serverInfo) { + state.server = serverInfo } // Picks the default file to play from a list of torrent or torrentSummary files @@ -820,18 +725,22 @@ function pickFileToPlay (files) { } function stopServer () { - if (!state.server) return - state.server.server.destroy() - state.server = null + ipcRenderer.send('wt-stop-server') state.playing.infoHash = null state.playing.fileIndex = null + state.server = null } // Opens the video player function openPlayer (infoHash, index, cb) { var torrentSummary = getTorrentSummary(infoHash) - var torrent = lazyLoadClient().get(infoHash) - if (!torrent || !torrent.done) playInterfaceSound('PLAY') + + // automatically choose which file in the torrent to play, if necessary + if (index === undefined) index = pickFileToPlay(torrentSummary.files) + if (index === undefined) return cb(new errors.UnplayableError()) + + // update UI to show pending playback + if (torrentSummary.progress !== 1) playInterfaceSound('PLAY') torrentSummary.playStatus = 'requested' update() @@ -842,14 +751,43 @@ function openPlayer (infoHash, index, cb) { update() }, 10000) /* give it a few seconds */ - startServer(torrentSummary, index, function (err) { + if (['downloading', 'seeding'].includes(torrentSummary.status)) { + openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb) + } else { + startTorrentingSummary(torrentSummary) + ipcRenderer.once('wt-ready-' + torrentSummary.infoHash, + () => openPlayerFromActiveTorrent(torrentSummary, index, timeout, cb)) + } +} + +function openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) { + var fileSummary = torrentSummary.files[index] + + // update state + state.playing.infoHash = torrentSummary.infoHash + state.playing.fileIndex = index + state.playing.type = TorrentPlayer.isVideo(fileSummary) ? 'video' + : TorrentPlayer.isAudio(fileSummary) ? 'audio' + : 'other' + + // if it's audio, parse out the metadata (artist, title, etc) + if (state.playing.type === 'audio' && !fileSummary.audioInfo) { + ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) + } + + ipcRenderer.send('wt-start-server', torrentSummary.infoHash, index) + ipcRenderer.once('wt-server-' + torrentSummary.infoHash, function (e, info) { clearTimeout(timeout) + + /** + TODO: where do we know if it's unplayable? if (err) { torrentSummary.playStatus = 'unplayable' playInterfaceSound('ERROR') update() return onError(err) } + */ // if we timed out (user clicked play a long time ago), don't autoplay var timedOut = torrentSummary.playStatus === 'timeout' @@ -884,28 +822,25 @@ function closePlayer (cb) { } function openFile (infoHash, index) { - var torrent = lazyLoadClient().get(infoHash) - if (!torrent) return - - var filePath = path.join(torrent.path, torrent.files[index].path) + var torrentSummary = getTorrentSummary(infoHash) + var filePath = path.join( + torrentSummary.path, + torrentSummary.files[index].path) ipcRenderer.send('openItem', filePath) } function openFolder (infoHash) { - var torrent = lazyLoadClient().get(infoHash) - if (!torrent) return + var torrentSummary = getTorrentSummary(infoHash) - var folderPath = path.join(torrent.path, torrent.name) - // Multi-file torrents create their own folder, single file torrents just - // drop the file directly into the Downloads folder - fs.stat(folderPath, function (err, stats) { - if (err || !stats.isDirectory()) { - folderPath = torrent.path - } - ipcRenderer.send('openItem', folderPath) - }) + var firstFilePath = path.join( + torrentSummary.path, + torrentSummary.files[0].path) + var folderPath = path.dirname(firstFilePath) + + ipcRenderer.send('openItem', folderPath) } +// TODO: use torrentKey, not infoHash function toggleTorrent (infoHash) { var torrentSummary = getTorrentSummary(infoHash) if (torrentSummary.status === 'paused') { @@ -914,18 +849,18 @@ function toggleTorrent (infoHash) { playInterfaceSound('ENABLE') } else { torrentSummary.status = 'paused' - stopTorrenting(torrentSummary.infoHash) + ipcRenderer.send('wt-stop-torrenting', torrentSummary.infoHash) playInterfaceSound('DISABLE') } } +// TODO: use torrentKey, not infoHash function deleteTorrent (infoHash) { - var torrent = getTorrent(infoHash) - if (torrent) torrent.destroy() + ipcRenderer.send('wt-stop-torrenting', infoHash) var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash) if (index > -1) state.saved.torrents.splice(index, 1) - saveState() + saveStateThrottled() state.location.clearForward() // prevent user from going forward to a deleted torrent playInterfaceSound('DELETE') } @@ -1032,7 +967,7 @@ function onError (err) { } function onWarning (err) { - console.log('warning: %s', err.message) + console.log('warning: %s', err.message || err) } function showDoneNotification (torrent) { @@ -1067,3 +1002,13 @@ function setupCrashReporter () { submitURL: config.CRASH_REPORT_URL }) } + +// Finds the longest common prefix +function findCommonPrefix (a, b) { + for (var i = 0; i < a.length && i < b.length; i++) { + if (a.charCodeAt(i) !== b.charCodeAt(i)) break + } + if (i === a.length) return a + if (i === b.length) return b + return a.substring(0, i) +} diff --git a/renderer/state.js b/renderer/state.js index b3c70d26..854b6202 100644 --- a/renderer/state.js +++ b/renderer/state.js @@ -32,7 +32,6 @@ module.exports = { lastTimeUpdate: 0, /* Unix time in ms */ mouseStationarySince: 0 /* Unix time in ms */ }, - pendingTorrents: {}, /* infohash to WebTorrent handle */ devices: { /* playback devices like Chromecast and AppleTV */ airplay: null, /* airplay client. finds and manages AppleTVs */ chromecast: null /* chromecast client. finds and manages Chromecasts */ @@ -43,6 +42,7 @@ module.exports = { }, modal: null, /* modal popover */ errors: [], /* user-facing errors */ + nextTorrentKey: 1, /* identify torrents for IPC between the main and webtorrent windows */ /* * Saved state is read from and written to a file every time the app runs. diff --git a/renderer/views/player.js b/renderer/views/player.js index 76af7270..34ea4f62 100644 --- a/renderer/views/player.js +++ b/renderer/views/player.js @@ -5,6 +5,7 @@ var hyperx = require('hyperx') var hx = hyperx(h) var prettyBytes = require('prettier-bytes') +var Bitfield = require('bitfield') var util = require('../util') var {dispatch, dispatcher} = require('../lib/dispatcher') @@ -20,7 +21,7 @@ function Player (state) { onmousemove=${dispatcher('mediaMouseMoved')}> ${showVideo ? renderMedia(state) : renderCastScreen(state)} ${renderPlayerControls(state)} - + ` } @@ -161,18 +162,20 @@ function renderLoadingSpinner (state) { (new Date().getTime() - state.playing.lastTimeUpdate > 2000) if (!isProbablyStalled) return - var torrentSummary = getPlayingTorrentSummary(state) - var torrent = state.client.get(torrentSummary.infoHash) - var file = torrentSummary.files[state.playing.fileIndex] - var progress = Math.floor(100 * file.numPiecesPresent / file.numPieces) + var prog = getPlayingTorrentSummary(state).progress || {} + var fileProgress = 0 + if (prog.files) { + var file = prog.files[state.playing.fileIndex] + fileProgress = Math.floor(100 * file.numPiecesPresent / file.numPieces) + } return hx`
 
- ${progress}% downloaded, - ↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s - ↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s + ${fileProgress}% downloaded, + ↓ ${prettyBytes(prog.downloadSpeed || 0)}/s + ↑ ${prettyBytes(prog.uploadSpeed || 0)}/s
` @@ -203,25 +206,6 @@ function renderCastScreen (state) { ` } -// Returns the CSS background-image string for a poster image + dark vignette -function cssBackgroundImagePoster (state) { - var torrentSummary = getPlayingTorrentSummary(state) - if (!torrentSummary || !torrentSummary.posterURL) return '' - var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL) - var cleanURL = posterURL.replace(/\\/g, '/') - return cssBackgroundImageDarkGradient() + `, url(${cleanURL})` -} - -function cssBackgroundImageDarkGradient () { - return 'radial-gradient(circle at center, ' + - 'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' -} - -function getPlayingTorrentSummary (state) { - var infoHash = state.playing.infoHash - return state.saved.torrents.find((x) => x.infoHash === infoHash) -} - function renderPlayerControls (state) { var positionPercent = 100 * state.playing.currentTime / state.playing.duration var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } @@ -340,24 +324,24 @@ function renderPlayerControls (state) { // Renders the loading bar. Shows which parts of the torrent are loaded, which // can be "spongey" / non-contiguous function renderLoadingBar (state) { - var torrent = state.client.get(state.playing.infoHash) - if (torrent === null) { + var torrentSummary = getPlayingTorrentSummary(state) + if (!torrentSummary.progress) { return [] } - var file = torrent.files[state.playing.fileIndex] // Find all contiguous parts of the torrent which are loaded + var prog = torrentSummary.progress + var fileProg = prog.files[state.playing.fileIndex] var parts = [] - var lastPartPresent = false - var numParts = file._endPiece - file._startPiece + 1 - for (var i = file._startPiece; i <= file._endPiece; i++) { - var partPresent = torrent.bitfield.get(i) - if (partPresent && !lastPartPresent) { - parts.push({start: i - file._startPiece, count: 1}) + var lastPiecePresent = false + for (var i = fileProg.startPiece; i <= fileProg.endPiece; i++) { + var partPresent = Bitfield.prototype.get.call(prog.bitfield, i) + if (partPresent && !lastPiecePresent) { + parts.push({start: i - fileProg.startPiece, count: 1}) } else if (partPresent) { parts[parts.length - 1].count++ } - lastPartPresent = partPresent + lastPiecePresent = partPresent } // Output some bars to show which parts of the file are loaded @@ -365,8 +349,8 @@ function renderLoadingBar (state) {
${parts.map(function (part) { var style = { - left: (100 * part.start / numParts) + '%', - width: (100 * part.count / numParts) + '%' + left: (100 * part.start / fileProg.numPieces) + '%', + width: (100 * part.count / fileProg.numPieces) + '%' } return hx`
` @@ -374,3 +358,22 @@ function renderLoadingBar (state) {
` } + +// Returns the CSS background-image string for a poster image + dark vignette +function cssBackgroundImagePoster (state) { + var torrentSummary = getPlayingTorrentSummary(state) + if (!torrentSummary || !torrentSummary.posterURL) return '' + var posterURL = util.getAbsoluteStaticPath(torrentSummary.posterURL) + var cleanURL = posterURL.replace(/\\/g, '/') + return cssBackgroundImageDarkGradient() + `, url(${cleanURL})` +} + +function cssBackgroundImageDarkGradient () { + return 'radial-gradient(circle at center, ' + + 'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' +} + +function getPlayingTorrentSummary (state) { + var infoHash = state.playing.infoHash + return state.saved.torrents.find((x) => x.infoHash === infoHash) +} diff --git a/renderer/views/torrent-list.js b/renderer/views/torrent-list.js index 60f68dc4..e065e8cc 100644 --- a/renderer/views/torrent-list.js +++ b/renderer/views/torrent-list.js @@ -27,9 +27,6 @@ function TorrentList (state) { function renderTorrent (torrentSummary) { // Get ephemeral data (like progress %) directly from the WebTorrent handle var infoHash = torrentSummary.infoHash - var torrent = state.client - ? state.client.torrents.find((x) => x.infoHash === infoHash) - : null var isSelected = infoHash && state.selectedInfoHash === infoHash // Background image: show some nice visuals, like a frame from the movie, if possible @@ -57,34 +54,34 @@ function TorrentList (state) {
- ${renderTorrentMetadata(torrent, torrentSummary)} + ${renderTorrentMetadata(torrentSummary)} ${infoHash ? renderTorrentButtons(torrentSummary) : ''} - ${isSelected ? renderTorrentDetails(torrent, torrentSummary) : ''} + ${isSelected ? renderTorrentDetails(torrentSummary) : ''}
` } // Show name, download status, % complete - function renderTorrentMetadata (torrent, torrentSummary) { + function renderTorrentMetadata (torrentSummary) { var name = torrentSummary.name || 'Loading torrent...' var elements = [hx`
${name}
`] - // If a torrent is paused and we only get the torrentSummary - // If it's downloading/seeding then we have more information - if (torrent) { - var progress = Math.floor(100 * torrent.progress) - var downloaded = prettyBytes(torrent.downloaded) - var total = prettyBytes(torrent.length || 0) + // If it's downloading/seeding then show progress info + var prog = torrentSummary.progress + if (torrentSummary.state !== 'paused' && prog) { + var progress = Math.floor(100 * prog.progress) + var downloaded = prettyBytes(prog.downloaded) + var total = prettyBytes(prog.length || 0) if (downloaded !== total) downloaded += ` / ${total}` elements.push(hx`
${getFilesLength()} ${getPeers()} - ↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s - ↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s + ↓ ${prettyBytes(prog.downloadSpeed || 0)}/s + ↑ ${prettyBytes(prog.uploadSpeed || 0)}/s
`) elements.push(hx` @@ -98,13 +95,13 @@ function TorrentList (state) { return hx`
${elements}
` function getPeers () { - var count = torrent.numPeers === 1 ? 'peer' : 'peers' - return `${torrent.numPeers} ${count}` + var count = prog.numPeers === 1 ? 'peer' : 'peers' + return `${prog.numPeers} ${count}` } function getFilesLength () { - if (torrent.ready && torrent.files.length > 1) { - return hx`${torrent.files.length} files` + if (torrentSummary.files && torrentSummary.files.length > 1) { + return hx`${torrentSummary.files.length} files` } } } @@ -165,19 +162,19 @@ function TorrentList (state) { } // Show files, per-file download status and play buttons, and so on - function renderTorrentDetails (torrent, torrentSummary) { + function renderTorrentDetails (torrentSummary) { var infoHash = torrentSummary.infoHash var filesElement if (!torrentSummary.files) { // We don't know what files this torrent contains - var message = torrent - ? 'Downloading torrent info...' - : 'Failed to load torrent info. Click the download button to try again...' + var message = torrentSummary.status === 'paused' + ? 'Failed to load torrent info. Click the download button to try again...' + : 'Downloading torrent info...' filesElement = hx`
${message}
` } else { // We do know the files. List them and show download stats for each one var fileRows = torrentSummary.files.map( - (file, index) => renderFileRow(torrent, torrentSummary, file, index)) + (file, index) => renderFileRow(torrentSummary, file, index)) filesElement = hx`
Files @@ -200,10 +197,14 @@ function TorrentList (state) { } // Show a single torrentSummary file in the details view for a single torrent - function renderFileRow (torrent, torrentSummary, file, index) { + function renderFileRow (torrentSummary, file, index) { // First, find out how much of the file we've downloaded var isDone = file.numPiecesPresent === file.numPieces - var progress = Math.round(100 * file.numPiecesPresent / (file.numPieces || 0)) + '%' + var progress = '' + if (torrentSummary.progress) { + var fileProg = torrentSummary.progress.files[index] + progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%' + } // Second, render the file as a table row var infoHash = torrentSummary.infoHash diff --git a/renderer/webtorrent.html b/renderer/webtorrent.html new file mode 100644 index 00000000..f03d1755 --- /dev/null +++ b/renderer/webtorrent.html @@ -0,0 +1,3 @@ + + +

WebTorrent Hidden Window

diff --git a/renderer/webtorrent.js b/renderer/webtorrent.js new file mode 100644 index 00000000..225192a9 --- /dev/null +++ b/renderer/webtorrent.js @@ -0,0 +1,298 @@ +// To keep the UI snappy, we run WebTorrent in its own hidden window, a separate +// process from the main window. +console.time('init') + +var WebTorrent = require('webtorrent') +var defaultAnnounceList = require('create-torrent').announceList +var deepEqual = require('deep-equal') +var electron = require('electron') +var fs = require('fs') +var mkdirp = require('mkdirp') +var musicmetadata = require('musicmetadata') +var networkAddress = require('network-address') +var config = require('../config') +var torrentPoster = require('./lib/torrent-poster') +var path = require('path') + +// Send & receive messages from the main window +var ipc = electron.ipcRenderer + +// Force use of webtorrent trackers on all torrents +global.WEBTORRENT_ANNOUNCE = defaultAnnounceList + .map((arr) => arr[0]) + .filter((url) => url.indexOf('wss://') === 0 || url.indexOf('ws://') === 0) + +// Connect to the WebTorrent and BitTorrent networks. WebTorrent Desktop is a hybrid +// client, as explained here: https://webtorrent.io/faq +var client = new WebTorrent() + +// WebTorrent-to-HTTP streaming sever +var server = null + +// Used for diffing, so we only send progress updates when necessary +var prevProgress = null + +init() + +function init () { + client.on('warning', (err) => ipc.send('wt-warning', null, err.message)) + client.on('error', (err) => ipc.send('wt-error', null, err.message)) + + ipc.on('wt-start-torrenting', (e, torrentKey, torrentID, path, fileModtimes) => + startTorrenting(torrentKey, torrentID, path, fileModtimes)) + ipc.on('wt-stop-torrenting', (e, infoHash) => + stopTorrenting(infoHash)) + ipc.on('wt-create-torrent', (e, torrentKey, options) => + createTorrent(torrentKey, options)) + ipc.on('wt-save-torrent-file', (e, torrentKey) => + saveTorrentFile(torrentKey)) + ipc.on('wt-generate-torrent-poster', (e, torrentKey) => + generateTorrentPoster(torrentKey)) + ipc.on('wt-get-audio-metadata', (e, infoHash, index) => + getAudioMetadata(infoHash, index)) + ipc.on('wt-start-server', (e, infoHash, index) => + startServer(infoHash, index)) + ipc.on('wt-stop-server', (e) => + stopServer()) + + setInterval(updateTorrentProgress, 1000) +} + +// Starts a given TorrentID, which can be an infohash, magnet URI, etc. Returns WebTorrent object +// See https://github.com/feross/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent- +function startTorrenting (torrentKey, torrentID, path, fileModtimes) { + console.log('starting torrent %s: %s', torrentKey, torrentID) + var torrent = client.add(torrentID, { + path: path, + fileModtimes: fileModtimes + }) + torrent.key = torrentKey + addTorrentEvents(torrent) + return torrent +} + +function stopTorrenting (infoHash) { + var torrent = client.get(infoHash) + torrent.destroy() +} + +// Create a new torrent, start seeding +function createTorrent (torrentKey, options) { + console.log('creating torrent %s', torrentKey, options) + var torrent = client.seed(options.files, options) + torrent.key = torrentKey + addTorrentEvents(torrent) + ipc.send('wt-new-torrent') +} + +function addTorrentEvents (torrent) { + torrent.on('warning', (err) => + ipc.send('wt-warning', torrent.key, err.message)) + torrent.on('error', (err) => + ipc.send('wt-error', torrent.key, err.message)) + torrent.on('infoHash', () => + ipc.send('wt-infohash', torrent.key, torrent.infoHash)) + torrent.on('ready', torrentReady) + torrent.on('done', torrentDone) + + function torrentReady () { + var info = getTorrentInfo(torrent) + ipc.send('wt-ready', torrent.key, info) + ipc.send('wt-ready-' + torrent.infoHash, torrent.key, info) + + updateTorrentProgress() + } + + function torrentDone () { + var info = getTorrentInfo(torrent) + ipc.send('wt-done', torrent.key, info) + + updateTorrentProgress() + + torrent.getFileModtimes(function (err, fileModtimes) { + if (err) return onError(err) + ipc.send('wt-file-modtimes', torrent.key, fileModtimes) + }) + } +} + +// Produces a JSON saveable summary of a torrent +function getTorrentInfo (torrent) { + return { + infoHash: torrent.infoHash, + magnetURI: torrent.magnetURI, + name: torrent.name, + path: torrent.path, + files: torrent.files.map(getTorrentFileInfo), + bytesReceived: torrent.received + } +} + +// Produces a JSON saveable summary of a file in a torrent +function getTorrentFileInfo (file) { + return { + name: file.name, + length: file.length, + path: file.path, + numPiecesPresent: 0, + numPieces: null + } +} + +// Every time we resolve a magnet URI, save the torrent file so that we never +// have to download it again. Never ask the DHT the same question twice. +function saveTorrentFile (torrentKey) { + var torrent = getTorrent(torrentKey) + checkIfTorrentFileExists(torrent.infoHash, function (torrentPath, exists) { + if (exists) { + // We've already saved the file + return ipc.send('wt-file-saved', torrentKey, torrentPath) + } + + // Otherwise, save the .torrent file, under the app config folder + fs.mkdir(config.CONFIG_TORRENT_PATH, function (_) { + fs.writeFile(torrentPath, torrent.torrentFile, function (err) { + if (err) return console.log('error saving torrent file %s: %o', torrentPath, err) + console.log('saved torrent file %s', torrentPath) + return ipc.send('wt-file-saved', torrentKey, torrentPath) + }) + }) + }) +} + +// Checks whether we've already resolved a given infohash to a torrent file +// Calls back with (torrentPath, exists). Logs, does not call back on error +function checkIfTorrentFileExists (infoHash, cb) { + var torrentPath = path.join(config.CONFIG_TORRENT_PATH, infoHash + '.torrent') + fs.exists(torrentPath, function (exists) { + cb(torrentPath, exists) + }) +} + +// Save a JPG that represents a torrent. +// Auto chooses either a frame from a video file, an image, etc +function generateTorrentPoster (torrentKey) { + var torrent = getTorrent(torrentKey) + torrentPoster(torrent, function (err, buf, extension) { + if (err) return console.log('error generating poster: %o', err) + // save it for next time + mkdirp(config.CONFIG_POSTER_PATH, function (err) { + if (err) return console.log('error creating poster dir: %o', err) + var posterFilePath = path.join(config.CONFIG_POSTER_PATH, torrent.infoHash + extension) + fs.writeFile(posterFilePath, buf, function (err) { + if (err) return console.log('error saving poster: %o', err) + // show the poster + ipc.send('wt-poster', torrentKey, posterFilePath) + }) + }) + }) +} + +function updateTorrentProgress () { + var progress = getTorrentProgress() + // TODO: diff torrent-by-torrent, not once for the whole update + if (prevProgress && deepEqual(progress, prevProgress, {strict: true})) { + return /* don't send heavy object if it hasn't changed */ + } + ipc.send('wt-progress', progress) + prevProgress = progress +} + +function getTorrentProgress () { + // First, track overall progress + var progress = client.progress + var hasActiveTorrents = client.torrents.some(function (torrent) { + return torrent.progress !== 1 + }) + + // Track progress for every file in each torrent + // TODO: ideally this would be tracked by WebTorrent, which could do it + // more efficiently than looping over torrent.bitfield + var torrentProg = client.torrents.map(function (torrent) { + var fileProg = torrent.files && torrent.files.map(function (file, index) { + var numPieces = file._endPiece - file._startPiece + 1 + var numPiecesPresent = 0 + for (var piece = file._startPiece; piece <= file._endPiece; piece++) { + if (torrent.bitfield.get(piece)) numPiecesPresent++ + } + return { + startPiece: file._startPiece, + endPiece: file._endPiece, + numPieces, + numPiecesPresent + } + }) + return { + torrentKey: torrent.key, + ready: torrent.ready, + progress: torrent.progress, + downloaded: torrent.downloaded, + downloadSpeed: torrent.downloadSpeed, + uploadSpeed: torrent.uploadSpeed, + numPeers: torrent.numPeers, + length: torrent.length, + bitfield: torrent.bitfield, + files: fileProg + } + }) + + return { + torrents: torrentProg, + progress, + hasActiveTorrents + } +} + +function startServer (infoHash, index) { + if (server) return + var torrent = client.get(infoHash) + if (torrent.ready) startServerFromReadyTorrent(torrent, index) + else torrent.on('ready', () => startServerFromReadyTorrent(torrent, index)) +} + +function startServerFromReadyTorrent (torrent, index, cb) { + if (server) return + + // start the streaming torrent-to-http server + server = torrent.createServer() + server.listen(0, function () { + var port = server.address().port + var urlSuffix = ':' + port + '/' + index + var info = { + torrentKey: torrent.key, + localURL: 'http://localhost' + urlSuffix, + networkURL: 'http://' + networkAddress() + urlSuffix + } + + ipc.send('wt-server-running', info) + ipc.send('wt-server-' + torrent.infoHash, info) + }) +} + +function stopServer () { + if (!server) return + server.destroy() + server = null +} + +function getAudioMetadata (infoHash, index) { + var torrent = client.get(infoHash) + var file = torrent.files[index] + musicmetadata(file.createReadStream(), function (err, info) { + if (err) return + console.log('got audio metadata for %s: %o', file.name, info) + ipc.send('wt-audio-metadata', infoHash, index, info) + }) +} + +// Gets a WebTorrent handle by torrentKey +// Throws an Error if we're not currently torrenting anything w/ that key +function getTorrent (torrentKey) { + var ret = client.torrents.find((x) => x.key === torrentKey) + if (!ret) throw new Error('missing torrent key ' + torrentKey) + return ret +} + +function onError (err) { + console.log(err) +}