Expo React-Native Youtube video upload using Fetch()

Viewed 603

I am trying to upload a video on youtube using the V3 Youtube.video.insert API method. When I call the method I get the following error message: Bad request: Request contains an invalid argument.. Despite the error message my upload still appears in my personal YouTube account under My Videos. I am new to React Native and I'm struggling to understand the Youtube API docs, could someone please explain to me what I'm doing wrong or how could I fix it?

This is my current request:

let response = await fetch(
        'https://youtube.googleapis.com/youtube/v3/videos?key=' + API_KEY,
        {
          method: 'POST',
          headers: {
            'Authorization': 'Bearer ' + accessToken,
            'Accept': 'application/json',
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            part: 'id,snippet,status',
            notifySubscribers: false,
            requestBody: {
              snippet: {
                title: 'YouTube Upload Test',
                description: 'Testing YouTube upload',
              },
              status: {
                privacyStatus: 'private',
              },
            },
            media: {
              body: 'file:///data/user/0/host.exp.exponent/cache/ExperienceData/Camera/video.mp4',
            }
          })
        }
      );

I tried taking everything out from body: but I got the same response.

Here are the links that I am using trying to understand: https://developers.google.com/youtube/v3/docs/videos/insert https://github.com/googleapis/google-api-nodejs-client/blob/master/samples/youtube/upload.js

UPDATE:

Ok, I think I figured out but I still don't know how can I attach the video file... this is my code now:

let response = await fetch(
    'https://youtube.googleapis.com/youtube/v3/videos?part=snippet&part=status&key=' + API_KEY,
    {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer ' + accessToken,
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        snippet: {
          title: "This is the title",
          description: "This is the description.",
        },
        status: {
          privacyStatus: 'private',
        }
      }),          
    }
  );
2 Answers

I solved it! I took corse_upload.js from YouTube API Samples and implemented it in React Native / Expo.

Here is the API.js in React Native that I am using:

import React, { Component } from 'react';

export default class API extends Component {

    constructor(props) {
        super(props);

        const obj = this;    
        const DRIVE_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v2/files/';    

        var options = props;

        var noop = function() {};
        this.file = options.file;
        this.contentType = options.contentType || this.file.type || 'application/octet-stream';
        this.metadata = options.metadata || {
            'title': this.file.name,
            'mimeType': this.contentType
        };
        this.token = options.token;
        this.onComplete = options.onComplete || noop;
        this.onProgress = options.onProgress || noop;
        this.onError = options.onError || noop;
        this.offset = options.offset || 0;
        this.chunkSize = options.chunkSize || 0;
        //this.retryHandler = new RetryHandler();
        this.retryHandler = new obj.RetryHandler();

        this.url = options.url;
        if (!this.url) {
            var params = options.params || {};
            params.uploadType = 'resumable';
            //this.url = this.buildUrl_(options.fileId, params, options.baseUrl);
            this.url = obj.buildUrl_(options.fileId, params, options.baseUrl);
        }
        this.httpMethod = options.fileId ? 'PUT' : 'POST';    
    }

    RetryHandler = function() {
        this.interval = 1000; // Start at one second
        this.maxInterval = 60 * 1000; // Don't wait longer than a minute 
    };

    retry = function(fn) {
        setTimeout(fn, this.interval);
        this.interval = this.nextInterval_();
    };

    reset = function() {
        this.interval = 1000;
    };

    nextInterval_ = function() {
        var interval = this.interval * 2 + this.getRandomInt_(0, 1000);
        return Math.min(interval, this.maxInterval);
    };

    getRandomInt_ = function(min, max) {
        return Math.floor(Math.random() * (max - min + 1) + min);
    };

    buildQuery_ = function(params) {
        params = params || {};
        return Object.keys(params).map(function(key) {
            return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
        }).join('&');
    };

    buildUrl_ = function(id, params, baseUrl) {
        var url = baseUrl || DRIVE_UPLOAD_URL;
        if (id) {
            url += id;
        }
        var query = this.buildQuery_(params);
        if (query) {
            url += '?' + query;
        }
        return url;
    }; 

    upload = function() {
        //var self = this;
        var xhr = new XMLHttpRequest();  
        xhr.open(this.httpMethod, this.url, true);
        xhr.setRequestHeader('Authorization', 'Bearer ' + this.token);
        xhr.setRequestHeader('Content-Type', 'application/json');
        xhr.setRequestHeader('X-Upload-Content-Length', this.file.size);
        xhr.setRequestHeader('X-Upload-Content-Type', this.contentType);
    
        xhr.onload = function(e) {
            if (e.target.status < 400) {
            var location = e.target.getResponseHeader('Location');
            this.url = location;
            this.sendFile_();
            } else {
            this.onUploadError_(e);
            }
        }.bind(this);
        xhr.onerror = this.onUploadError_.bind(this);
        xhr.send(JSON.stringify(this.metadata));
    };

    sendFile_ = function() {
        var content = this.file;
        console.log(content);
        var end = this.file.size;
    
        if (this.offset || this.chunkSize) {
            // Only bother to slice the file if we're either resuming or uploading in chunks
            if (this.chunkSize) {
            end = Math.min(this.offset + this.chunkSize, this.file.size);
            }
            content = content.slice(this.offset, end);
            console.log(content);
        }
    
        var xhr = new XMLHttpRequest();
        xhr.open('PUT', this.url, true);
        xhr.setRequestHeader('Content-Type', this.contentType);
        xhr.setRequestHeader('Content-Range', 'bytes ' + this.offset + '-' + (end - 1) + '/' + this.file.size);
        xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
        if (xhr.upload) {
            xhr.upload.addEventListener('progress', this.onProgress);
        }
        xhr.onload = this.onContentUploadSuccess_.bind(this);
        xhr.onerror = this.onContentUploadError_.bind(this);
        xhr.send(content);
        console.log(content);
    };

    resume_ = function() {
        var xhr = new XMLHttpRequest();
        xhr.open('PUT', this.url, true);
        xhr.setRequestHeader('Content-Range', 'bytes */' + this.file.size);
        xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
        if (xhr.upload) {
            xhr.upload.addEventListener('progress', this.onProgress);
        }
        xhr.onload = this.onContentUploadSuccess_.bind(this);
        xhr.onerror = this.onContentUploadError_.bind(this);
        xhr.send();
    };

    extractRange_ = function(xhr) {
        var range = xhr.getResponseHeader('Range');
        if (range) {
            this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
        }
    };

    onContentUploadSuccess_ = function(e) {
        if (e.target.status == 200 || e.target.status == 201) {
            this.onComplete(e.target.response);
        } else if (e.target.status == 308) {
            this.extractRange_(e.target);
            this.reset();
            this.sendFile_();
        }
    };

    onContentUploadError_ = function(e) {
        if (e.target.status && e.target.status < 500) {
            this.onError(e.target.response);
        } else {
            this.retry(this.resume_.bind(this));
        }
    };

    onUploadError_ = function(e) {
        this.onError(e.target.response); // TODO - Retries for initial upload
    };

}

And here is the way I use it in my App.js:

import YTDAPI from './assets/API'
import RNFS from 'react-native-fs';

uploadMediaToYouTube = async function(accessToken, videoUri) {                
    const fileInfo = await RNFS.stat(videoUri);
    var file = {
      name: fileInfo.path.split('/').pop(),
      size: fileInfo.size,
      uri: fileInfo.path,
      type: 'video/mp4'
    }  

    var metadata = {
      snippet: {
        title: 'This is a new title',
        description: 'This is a new description',
        tags: ['youtube-cors-upload'],
        categoryId: 22
      },
      status: {
        privacyStatus: 'unlisted'
      }
    };

    var uploader = new YTDAPI({
      baseUrl: 'https://www.googleapis.com/upload/youtube/v3/videos',
      file: file,
      token: this.accessToken,
      metadata: metadata,
      params: {
        part: Object.keys(metadata).join(',')
      },
      onError: function(data) {
        console.log(data);
        var message = data;
        try {
        var errorResponse = JSON.parse(data);
        message = errorResponse.error.message;
        } finally {
        alert(message);
        }
      }.bind(this),
      onProgress: function(data) {
        var currentTime = Date.now();
        var bytesUploaded = data.loaded;
        var totalBytes = data.total;
        var bytesPerSecond = bytesUploaded / ((currentTime - window.uploadStartTime) / 1000);
        var estimatedSecondsRemaining = (totalBytes - bytesUploaded) / bytesPerSecond;
        var percentageComplete = (bytesUploaded * 100) / totalBytes;
        console.log("Uploaded: " + bytesUploaded + " | Total: " + totalBytes + " | Percentage: " + percentageComplete + " | Esitmated seconds remaining: " + estimatedSecondsRemaining);        
      }.bind(this),
      onComplete: function(data) {
        console.log("Complete");
      }.bind(this)
    });

    window.uploadStartTime = Date.now();
    uploader.upload();
  }

For this to work you need to configure an API key, web ClientId and oauth2 consent screen on your Google Cloud Console and allow Youtube Data V3 API library and authenticate an user to get an access_token that you will pass to my function.

UPDATE

Getting the file with Fetch() causes App crash with large files because its tries to load the whole video file into memory. I fixed this issue by using react-native-fs. I updated my code above.

After trying with my own youtube account I am facing same problem.

The documentation of Youtube is not also clear. This question have simillar problem as yours, but this guy used axios.

He said he spent months on it, but failed to figure out. After that he used youtube-video-api from npm, https://www.npmjs.com/package/youtube-video-api.

I think you should move on to some other solution, don't stuck with this code.

Like you can host a node.js backend, and then send your video and video information to the backend to upload it to youtube for the app user.

Related