Search code examples
google-drive-apivideo-streaminghtml5-videogoogle-api-js-client

GDrive API v3 files.get download progress?


How can I show progress of a download of a large file from GDrive using the gapi client-side v3 API?

I am using the v3 API, and I've tried to use a Range request in the header, which works, but the download is very slow (below). My ultimate goal is to playback 4K video. GDrive limits playback to 1920x1280. My plan was to download chunks to IndexedDB via v3 API and play from the locally cached data. I have this working using the code below via Range requests, but it is unusably slow. A normal download of the full 438 MB test file directly (e.g. via the GDrive web page) takes about 30-35s on my connection, and, coincidentally, each 1 MB Range requests takes almost exactly the same 30-35s. It feels like the GDrive back-end is reading and sending the full file for each subrange?

I've also tried using XHR and fetch to download the file, which fails. I've been using the webContent link (which typically ends in &export=download) but I cannot get access headers correct. I get either CORS or other odd permission issues. The webContent links work fine in <image> and <video> src tags. I expect this is due to special permission handling or some header information I'm missing that the browser handles specifically for these media tags. My solution must be able to read private (non-public, non-sharable) links, hence the use of the v3 API.

For video files that are smaller than the GDrive limit, I can set up a MediaRecorder and use a <video> element to get the data with progress. Unfortunately, the 1920x1080 limit kills this approach for larger files, where progress feedback is even more important.

This is the client-side gapi Range code, which works, but is unusably slow for large (400 MB - 2 GB) files:

const getRange = (start, end, size, fileId, onProgress) => (
  new Promise((resolve, reject) => gapi.client.drive.files.get(
    { fileId, alt: 'media', Range: `bytes=${start}-${end}` },
    // { responseType: 'stream' }, Perhaps this fails in the browser?
  ).then(res => {
    if (onProgress) {
      const cancel = onProgress({ loaded: end, size, fileId })
      if (cancel) {
        reject(new Error(`Progress canceled download at range ${start} to ${end} in ${fileId}`))
      }
    }
    return resolve(res.body)
  }, err => reject(err)))
)

export const downloadFileId = async (fileId, size, onProgress) => {
  const batch = 1024 * 1024
  try {
    const chunks = []
    for (let start = 0; start < size; start += batch) {
      const end = Math.min(size, start + batch - 1)
      const data = await getRange(start, end, size, fileId, onProgress)
      if (!data) throw new Error(`Unable to get range ${start} to ${end} in ${fileId}`)
      chunks.push(data)
    }
    return chunks.join('')
  } catch (err) {
    return console.error(`Error downloading file: ${err.message}`)
  }
}

Authentication works fine for me, and I use other GDrive commands just fine. I'm currently using drives.photos.readonly scope, but I have the same issues even if I use a full write-permission scope.

Tangentially, I'm unable to get a stream when running client-side using gapi (works fine in node on the server-side). This is just weird. If I could get a stream, I think I could use that to get progress. Whenever I add the commented-out line for the responseType: 'stream', I get the following error: The server encountered a temporary error and could not complete your request. Please try again in 30 seconds. That’s all we know. Of course waiting does NOT help, and I can get a successful response if I do not request the stream.


Solution

  • I switched to using XMLHttpRequest directly, rather than the gapi wrapper. Google provides these instructions for using CORS that show how to convert any request from using gapi to a XHR. Then you can attach to the onprogress event (and onload, onerror and others) to get progres.

    Here's the drop-in replacement code for the downloadFileId method in the question, with a bunch of debugging scaffolding:

    const xhrDownloadFileId = (fileId, onProgress) => new Promise((resolve, reject) => {
      const user = gapi.auth2.getAuthInstance().currentUser.get()
      const oauthToken = user.getAuthResponse().access_token
      const xhr = new XMLHttpRequest()
      xhr.open('GET', `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`)
      xhr.setRequestHeader('Authorization', `Bearer ${oauthToken}`)
      xhr.responseType = 'blob'
      xhr.onloadstart = event => {
        console.log(`xhr ${fileId}: on load start`)
        const { loaded, total } = event
        onProgress({ loaded, size: total })
      }
      xhr.onprogress = event => {
        console.log(`xhr ${fileId}: loaded ${event.loaded} of ${event.total} ${event.lengthComputable ? '' : 'non-'}computable`)
        const { loaded, total } = event
        onProgress({ loaded, size: total })
      }
      xhr.onabort = event => {
        console.warn(`xhr ${fileId}: download aborted at ${event.loaded} of ${event.total}`)
        reject(new Error('Download aborted'))
      }
      xhr.onerror = event => {
        console.error(`xhr ${fileId}: download error at ${event.loaded} of ${event.total}`)
        reject(new Error('Error downloading file'))
      }
      xhr.onload = event => {
        console.log(`xhr ${fileId}: download of ${event.total} succeeded`)
        const { loaded, total } = event
        onProgress({ loaded, size: total })
        resolve(xhr.response)
      }
      xhr.onloadend = event => console.log(`xhr ${fileId}: download of ${event.total} completed`)
      xhr.ontimeout = event => {
        console.warn(`xhr ${fileId}: download timeout after ${event.loaded} of ${event.total}`)
        reject(new Error('Timout downloading file'))
      }
      xhr.send()
    })