thelinmichael / spotify-web-api-node

A Node.js wrapper for Spotify's Web API.
http://thelinmichael.github.io/spotify-web-api-node/
MIT License
3.1k stars 499 forks source link

Incorrect documentation for transferMyPlayback endpoint #287

Open Dcai169 opened 4 years ago

Dcai169 commented 4 years ago

I'm building an application where nodejs opens a website that uses the Spotify Web Playback SDK. When the Playback SDK is ready, it sends its device_id to the server where it is being hosted. When looking at the current documentation of the spotifyWebApi.transferMyPlayback() method, on line 1074, it says that valid options for this endpoint are

Options, being market.

Comparing this with Spotify's docs which state:

Request Body Data Value Type Value
device_ids Array of Spotify Device IDs Required. A JSON array containing the ID of the device on which playback should be started/transferred. For example: {device_ids:["74ASZWbe4lXaubB36ztrGX"]} Note: Although an array is accepted, only a single device_id is currently supported. Supplying more than one will return 400 Bad Request
play boolean Optional. true: ensure playback happens on new device. false or not provided: keep the current playback state.

I want to know what the correct usage is, as my current implementation is not functional.

Bring your attention to the function transferToNewActiveDevice() in the code below: (Please excuse poor code; I'm a first-time JS dev)

const socket = require('socket.io-client')('commonsmusic.ddns.net');
const express = require('express');
const app = express();
const port = 2102;
const server = require("http").createServer(app);
const io = require('socket.io')(server);
const schedule = require('node-schedule');
const opn = require('opn');
const spotifyWebApi = require('spotify-web-api-node');
const fs = require('fs');
const credentials = JSON.parse(fs.readFileSync("credentials.json").toString().trim());

// Number of votes required to skip
const threshold = 1;
let votesToSkip = [];

const activePlaylistId = 'spotify:playlist:7cuCPpXRCCvfOZSIWgAJ7p';
const suggestionPlaylistId = '';
let activeDeviceId;

let expiresAt;
let scopes = ['streaming', 'user-read-currently-playing', 'user-modify-playback-state', 'user-read-playback-state', "user-read-email", "user-read-private"];
let savedState = Math.random().toString(36).substring(2, 15);
let state;
let code;

let startJob;
let endJob;

// Function to remove array item by value
Array.prototype.remove = function() {
    let what, a = arguments, L = a.length, ax;
    while (L && this.length) {
        what = a[--L];
        while ((ax = this.indexOf(what)) !== -1) {
            this.splice(ax, 1);
        }
    }
    return this;
};

function handleSkip(uid, vote) {
    if (!votesToSkip.includes(uid) && vote){
        votesToSkip.push(uid);
    } else if (!vote){
        votesToSkip.remove(uid);
    }

    console.log(votesToSkip);

    if (votesToSkip.length > threshold){
        votesToSkip = [];
        return true;
    } else {
        return false;
    }
}

function isLunchFriday() {
    let time = new Date();
    let isLunchFriday = (time.getDay() == 5) && (!(time.getHours() <= 11 && time.getMinutes() <= 44) && !(time.getHours() >= 13 && time.getMinutes() >= 12));
    return isLunchFriday;
}

function activateShuffle(){
    spotifyApi.setShuffle({state: true}).then(
        function(data){
            console.log('Shuffle set to true');
        },
        function(err){
            console.log('Could not set shuffle state to true\n', err);
        }
    );
}

function refreshToken(){
    // clientId, clientSecret and refreshToken has been set on the api object previous to this call.
    spotifyApi.refreshAccessToken().then(
        function(data) {
            console.log('The access token has been refreshed!');
            let now = new Date();
            expiresAt = new Date(now.getFullYear(), now.getMonth(), now.getDay(), now.getHours()+1, now.getMinutes(), now.getSeconds(), now.getMilliseconds());
            // Save the access token so that it's used in future calls
            spotifyApi.setAccessToken(data.body['access_token']);
        },
        function(err) {
            console.log('Could not refresh access token', err);
        }
    );
}

function skipTrack(){
    spotifyApi.skipToNext().then(
        function(data){
            // code
            console.log("Skip!")
            console.log({data});
            return data;
        },
        function(err){
            console.log('Could not skip to next\n', err);
            return null;
        }
    );
}

function addSuggestion(suggestionId){
    spotifyApi.addTracksToPlaylist(suggestionPlaylistId, [suggestionId]).then(
        function(data){
            return data;
        },
        function(err){
            console.log('Could not add track', err);
            return null;
        }
    );
}

function getPlaybackState(){
    spotifyApi.getMyCurrentPlaybackState({}).then(
        function(data){
            return data;
        },
        function(err){
            console.log('Could not get playback state', err);
            return null;
        }
    )
}

function transferToNewActiveDevice(){
    spotifyApi.transferMyPlayback({
        device_ids: [activeDeviceId], 
        // play: false 
    }).then(
        function(data){
            console.log('Transfer Success', data);
        },
        function(err){
            console.log('Could not transfer playback\n', err);
        }
    );
}

function callWithRefreshCheck(fn, args){
    if (expiresAt){
        if (new Date().getTime() > expiresAt.getTime()){
            return fn(args);
        } else {
            refreshToken();
            return fn(args)
        }
    } else {
        return false;
    }
}

function setup(){
    callWithRefreshCheck(activateShuffle, null);
    callWithRefreshCheck(transferToNewActiveDevice, null);
}

function commencePlayback(){
    spotifyApi.play({ context_uri: activePlaylistId }).then(
        function(data){

        },
        function(err){
            console.log('Could not start playback\n', err);
        }
    );
}

function haltPlayback(){
    spotifyApi.pause().then(
        function(data){

        },
        function(err){
            console.log('Could not stop playback\n', err);
        }
    );
}

function getMe(){
    spotifyApi.getMe().then(
        function(data){
            console.log(data);
        },
        function(err){
            console.log('Could not get me\n', err);
        }
    )
}

// credentials are optional
let spotifyApi = new spotifyWebApi({
    clientId: credentials.id,
    clientSecret: credentials.secret,
    redirectUri: 'http://localhost:2102/auth_redirect'
});

opn(spotifyApi.createAuthorizeURL(scopes, savedState), {app: 'firefox'});

// Socket.IO setup
io.on('connection', (socket) => {
    socket.on('player-init', (data) => {
        if (!!data){
            activeDeviceId = data;
            setup();
        }
    });

    socket.on('player-suggest', (data) => {
        // append to playlist using spotify web api
        callWithRefreshCheck(addSuggestion, data);
    });

    socket.on('player-skip', (data) => {
        let vote = JSON.parse(data);
        // if new vote caused skip, broadcast reset signal
        if(handleSkip(vote[0], vote[1])){
            socket.emit('track-skipped', true);
            callWithRefreshCheck(skipTrack, null);
        }
    });

    socket.on('player-meta-request', (data) => {
        socket.emit('player-meta-response', callWithRefreshCheck(getPlaybackState, null));
    });

    socket.on('sudo-skip', (data) => {
        callWithRefreshCheck(skipTrack, null);
    });
});
// Express routes
app.set('view engine', 'pug');
app.use(express.static(__dirname + "/public"));

app.get('/', (req, res) => {
    res.render('player', { 
        accessToken: spotifyApi.getAccessToken()
    });
});

app.get('/auth_redirect', (req, res) => {
    code = req.query.code || null ;
    state = req.query.state || null;
    if (state === savedState && state !== null){
        spotifyApi.authorizationCodeGrant(code).then(
            function(data) {
                // console.log('The token expires in ' + data.body['expires_in']);
                // console.log('The access token is ' + data.body['access_token']);
                // console.log('The refresh token is ' + data.body['refresh_token']);

                // console.log(JSON.stringify(data));

                // Set the access token on the API object to use it in later calls
                spotifyApi.setAccessToken(data.body['access_token']);
                spotifyApi.setRefreshToken(data.body['refresh_token']);

                if (spotifyApi.getAccessToken()){
                    let now = new Date();
                    expiresAt = new Date(now.getFullYear(), now.getMonth(), now.getDay(), now.getHours()+1, now.getMinutes(), now.getSeconds(), now.getMilliseconds());
                    res.redirect('/');
                    console.log(spotifyApi.getAccessToken());
                } else {
                    res.send('Credential Error');
                }
            },
            function(err) {
                // console.log('The client ID is ' + spotifyApi.getClientId());
                // console.log('The client secret is ' + spotifyApi.getClientSecret());
                console.log(err);
                res.send(err);
            }
        );
    } else {
        res.send('State Error');
    }
});

server.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

startJob = schedule.scheduleJob('44 11 * * 5', commencePlayback);
endJob = schedule.scheduleJob('12 13 * * 5', haltPlayback);
konstantinjdobler commented 4 years ago

The Spotify documentation is correct. In fact the implementation is correct too, it's just the JSDoc comment that is incorrect. Valid keys for options are deviceIds and play. You have device_ids, change that to deviceIds and you're good to go. See the excerpt from the source code below:

  /**
   * Transfer a User's Playback
   * @param {Object} [options] Options, being market.
   * @param {requestCallback} [callback] Optional callback method to be called instead of the promise.
   * @returns {Promise|undefined} A promise that if successful, resolves into a paging object of tracks,
   *          otherwise an error. Not returned if a callback is given.
   */
  transferMyPlayback: function(options, callback) {
    return WebApiRequest.builder(this.getAccessToken())
      .withPath('/v1/me/player')
      .withHeaders({ 'Content-Type': 'application/json' })
      .withBodyParameters({
        device_ids: options.deviceIds,
        play: !!options.play
      })
      .build()
      .execute(HttpManager.put, callback);
  }
andyruwruw commented 3 years ago

Your comment was a life saver @konstantinjdobler, I had read through that function over and over but somehow missed that the field name in options was lower camel case. I had been using device_ids.