Create your own download manager using Node.js

  • July 18, 2016
  • Mustapha Turki

You are about to create a download manager, which queues, starts and repeats over a list of downloadable content.

The example used is a download manager for albums of images.

The Rule

Album downloads are queued. They are not simultaneous, and this save us from server overload.

The Downloader object

Downloader is a singleton class, instantiated by the view component to listen for events.

import EventEmitter from 'events'
class DownloadEmitter extends EventEmitter {}
export default class Downloader {
  constructor() {
    this.downloadPath = 'PATH'

    if(!fs.existsSync(this.downloadPath)) {
      fs.mkdirSync(this.downloadPath)
    }

    this.downloadEmitter = new DownloadEmitter()
  }

  static getInstance() {
    if(!instance) {
      instance = new Downloader()
    }

    return instance
  }
  ...
}

In the constructor, we create the folder for downloads, initialize EventEmitter, etc.

But what is EventEmitter? Why do we emit events? View components are waiting for something to happend. Either it is a user action or background process result. Thanks to EventEmitter, Donwloader will process those variables to our views. Users will be happy to see the UI state changing because they only trust what they see.

The Queue vs The data

The queue will hold albums users are going to download. Once donwloaded, an album is removed from the queue.

Data can hold everything. But for simplicity sake, we represent it this way.

{ albums: [ { id: 1, images: [ { url: ‘http://i.imgur.com/aWaZqVs.jpg' }, … ] } … ] }

let queue = {}

getAlbum(index) {
  return queue[Object.keys(queue)[index]]
}

addToQueue(album, cb) {
  const self = this

  // XXX Save each album in a different location?
  let dir = path.join(self.downloadPath, (album.id).toString())
  album.dir = dir

  // Add properties to image donwload progress on a given album
  album.downloadStarted = false
  album.downloadProgress = 0
  album.downloadedImages = []

  queue[album.id] = album

  self.downloadQueuedAction(album.id)

  cb()
}

shiftQueue() {
  let album = this.getAlbum()
  delete queue[album.id]
}

Callback (cb) is a function parameter to execute when we are done with queueing. Callback can perform for example, UI update. Say, ‘Waiting…’

We will see more about ‘downloadQueuedAction’ later.

Download: the main part

Request is an awesome HTTP client for node. Its use is not limited to query an api, but to download data from the Internet, and much more.

In this section, we query the server for images, write them on disk, and move on.

Let’s start with download behaviour.

downloadRequestWithRetry(options, retry, started, progressed, finished) {
  let self = this
  var filename = (options.id).toString()
  var writeStream = fs.createWriteStream(path.join(options.dir, filename))

  let hasErrors = false
  request(options.file, {timeout: 15000})
  .on('response', function(data) {
    // Only init download started callback first time
    if(retry === 0) {
      started(parseInt(data.headers[ 'content-length' ]))
    }
  })
  .on('error', function(err) {
    // When an error occurs, re initiate download with retry incremented.
    // This supposes we are online.
    hasErrors = true
     if(retry < 5) {
      retry ++
      self.downloadRequestWithRetry(options, retry, started, progressed, finished)
    }
  })
  .on('data', function(chunk) {
    progressed(chunk.length)
  })
  .on('end', function() {
    // We don't call finished callback if an image is not successfully downloaded.
    if(!hasErrors) {
      finished()
    }
  })
  .pipe(writeStream)
}

I know it is a long method. But is it not simple enough? First thing that catch your attention is the name of the method. ‘Retry’. Well, if an image download fails due to bad networking, we re execute the same part of the function.

‘error’ and ‘end’ are both events, both called when request origins errors. ‘end’ event is he result of either successfull/failed download. That’s why we add a boolean to check if the download has been finished successfully. To retrieve the size of the image, we listens for ‘response’ event. Finally, ‘data’ event is triggered as the download progresses.

We need a starting point for our downloads. It looks this way:

downloadNext() {
  let self = this

  // Pick the first album on the queue
  let album = self.getAlbum(0)

  self.downloadEmitter.emit('album-download-started')

  let images = album.images

  // Variables to track download progress, totalSize equals to sum of
  // album images size. Received increments itself with every new chunk progress.
  let totalSize = 0
  let received = 0

  images.forEach(function(image) {
    let options = {
      id: (image.id).toString(),
      url: image.url,
      dir: album.dir
    }

    self.downloadRequestWithRetry(options, 0,
      ((size) => {
        totalSize += size
      }),
      ((data) => {
        received += data
        let progress = Math.floor(received / totalSize * 100)

        self.downloadEmitter.emit('album-download-progress', album.id, progress)
      }),
      (() => {
        album.downloadedImages.push(image.id)

        if(album.downloadedImages.length == images.length) {
          self.downloadEmitter.emit('playlist-download-finish', album.id)
          self.downloadFinishedAction()
        }
      })
    )
  })
}

We carefully notice the emitter part. We emit an events when we start downloading an album, to switch from waiting state to downloading. Then we emit the progress event to print progression percentage.

Starting point ‘downloadNext’ is called when first album is queued, or to process download for pending requests.

Methods ‘downloadQueuedAction’ and ‘downloadFinishedAction’ are there to initiate main download action over the queue object. You will notice that we also used underscore helpers to check if our queue object is empty. So don’t forget to import it.

donwloadQueuedAction() {
  const self = this
  if(isDownloading) {

  }else {
    self.downloadNext()
    isDownloading = true
  }
}

downloadFinishedAction() {
  const self = this
  self.shiftQueue()
  if(_.isEmpty(queue)) {
    isDownloading = false
  } else {
    self.downloadNext()
  }
}

Conclusion

We talked about components and views a bit. We could have used React or Vue.js, two awesome libraries for constructing views. Maybe we will add the UI part in the future.

Doing that way with downloads, we considerably avoided error responses from the server. So it is much safer to process download of one album, and queue others.