UNPKG

spotify-dl

Version:

Spotify Songs, Playlist & Album Downloader

508 lines (466 loc) 13.2 kB
import SpotifyWebApi from 'spotify-web-api-node'; import open from 'open'; import express from 'express'; import puppeteer from 'puppeteer'; import { cliInputs } from './setup.js'; import Config from '../config.js'; import Constants from '../util/constants.js'; import { logInfo, logFailure } from '../util/log-helper.js'; const { spotifyApi: { clientId, clientSecret, }, } = Config; const { AUTH: { SCOPES: { USERS_SAVED_PLAYLISTS, USERS_SAVED_TRACKS_ALBUMS, USERS_TOP_TRACKS, }, STATE, REFRESH_ACCESS_TOKEN_SECONDS, TIMEOUT_RETRY, }, INPUT_TYPES, MAX_LIMIT_DEFAULT, SERVER: { PORT, HOST, CALLBACK_URI }, } = Constants; const spotifyApi = new SpotifyWebApi({ clientId, clientSecret, redirectUri: `http://${HOST}:${PORT}${CALLBACK_URI}`, }); const scopes = [ USERS_SAVED_PLAYLISTS, USERS_SAVED_TRACKS_ALBUMS, USERS_TOP_TRACKS, ]; let nextTokenRefreshTime; Object.defineProperty(Array.prototype, 'chunk', { value: function (chunkSize) { var R = []; for (var i = 0; i < this.length; i += chunkSize) R.push(this.slice(i, i + chunkSize)); return R; }, }); const verifyCredentials = async () => { if (!nextTokenRefreshTime || (nextTokenRefreshTime < new Date())) { nextTokenRefreshTime = new Date(); nextTokenRefreshTime.setSeconds( nextTokenRefreshTime.getSeconds() + REFRESH_ACCESS_TOKEN_SECONDS, ); logInfo('Generating new access token'); await checkCredentials(); } }; const checkCredentials = async () => { if (await spotifyApi.getRefreshToken()) { await refreshToken(); } else { const { inputs, username, password, login, } = cliInputs(); const requiresLogin = inputs.find(input => input.type == INPUT_TYPES.SONG.SAVED_ALBUMS || input.type == INPUT_TYPES.SONG.SAVED_PLAYLISTS || input.type == INPUT_TYPES.SONG.SAVED_TRACKS || input.type == INPUT_TYPES.EPISODE.SAVED_SHOWS, ); const requestingLogin = (username && password) || login; if (requiresLogin || requestingLogin) { await requestAuthorizedTokens(); } else { await requestTokens(); } } }; const requestAuthorizedTokens = async () => { const { username, password, } = cliInputs(); const autoLogin = username.length > 0 && password.length > 0; const app = express(); let resolve; const getCode = new Promise(_resolve => { resolve = _resolve; }); app.get(CALLBACK_URI, function (req, res) { resolve(req.query.code); res.end(''); }); const server = await app.listen(PORT); const authURL = await spotifyApi.createAuthorizeURL( scopes, STATE, autoLogin, ); let browser = null; logInfo( 'Performing Spotify Auth Please Wait...', ); if (autoLogin) { browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', ], }); const page = await browser.newPage(); try { await page.goto(authURL); await page.type('#login-username', username); await page.type('#login-password', password); await page.click('#login-button'); await page.waitForSelector('#auth-accept'); await page.click('#auth-accept'); } catch (e) { logFailure(e.message); const screenshotPath = './failure.png'; await page.screenshot({ path: screenshotPath, fullPage: true, }); throw new Error( [ 'Could not generate token', 'Please find a screenshot of why the auto login failed at ', `${screenshotPath}`, ].join(' '), ); } } else { open(authURL); } const code = await getCode; setTokens( (await spotifyApi.authorizationCodeGrant(code)).body, ); if (browser) { browser.close(); } server.close(); }; const requestTokens = async () => { setTokens((await spotifyApi.clientCredentialsGrant()).body); }; const refreshToken = async () => { setTokens((await spotifyApi.refreshAccessToken()).body); }; const setTokens = tokens => { spotifyApi.setAccessToken(tokens['access_token']); spotifyApi.setRefreshToken(tokens['refresh_token']); }; // common wrapper for api calls // to have token verification and api throttling mitigation const callSpotifyApi = async function (apiCall) { const maxRetries = 5; let tries = 1; let error; while (tries <= maxRetries) { await verifyCredentials(); try { return await apiCall(); } catch (e) { error = e; logInfo( `Got a spotify api error (${e})\n` + `Timing out for 5 minutes x ${tries}`, ); await new Promise(resolve => setTimeout(resolve, TIMEOUT_RETRY * 1000)); tries++; } } // if it still fails after all the timeouts and retries throw again throw new Error(error); }; export async function extractTracks(trackIds) { const extractedTracks = []; const chunkedTracks = trackIds.chunk(20); for (let x = 0; x < chunkedTracks.length; x++) { logInfo('extracting track set ' + `${x + 1}/${chunkedTracks.length}`); const tracks = await callSpotifyApi( async () => (await spotifyApi.getTracks(chunkedTracks[x])).body.tracks, ); extractedTracks.push(...tracks); } const audioFeatures = await extractTrackAudioFeatures( extractedTracks.map(track => track.id), ); return extractedTracks.map(track => parseTrack(track, audioFeatures)); } const parseTrack = (track, audioFeatures) => { const audioFeature = audioFeatures.find( audioFeature => audioFeature.id == track.id, ); return { name: track.name, bpm: audioFeature ? audioFeature.tempo : undefined, popularity: track.popularity, artists: track.artists.map(artist => artist.name), album_name: track.album.name, release_date: track.album.release_date, track_number: track.track_number, total_tracks: track.album.total_tracks, cover_url: track.album.images.map(image => image.url)[0], id: track.id, }; }; const parseEpisode = (episode, index = 0) => { return { name: episode.name, artists: [episode.show.publisher], album_name: episode.show.name, release_date: episode.release_date, popularity: 100, bpm: 0, // shows dont have a way to see what episode they are guess via context track_number: index, total_tracks: episode.show.total_episodes, cover_url: episode.images.map(image => image.url)[0], id: episode.id, }; }; export async function extractPlaylist(playlistId) { const playlistInfo = await callSpotifyApi( async () => (await spotifyApi.getPlaylist( playlistId, { limit: 1 }, )).body, ); const tracks = []; let playlistData; let offset = 0; do { playlistData = await callSpotifyApi( async () => (await spotifyApi.getPlaylistTracks( playlistId, { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${playlistData.total} tracks`); } tracks.push( ...playlistData.items, ); offset += MAX_LIMIT_DEFAULT; } while (tracks.length < playlistData.total); const audioFeatures = await extractTrackAudioFeatures( tracks.map(track => track.id), ); return { name: `${playlistInfo.name} - ${playlistInfo.owner.display_name}`, items: tracks .filter(item => item.track) .map(item => parseTrack(item.track, audioFeatures)), }; } export async function extractAlbum(albumId) { const albumInfo = await callSpotifyApi( async () => (await spotifyApi.getAlbum( albumId, { limit: 1 }, )).body, ); const tracks = []; let offset = 0; let albumTracks; do { albumTracks = await callSpotifyApi( async () => (await spotifyApi.getAlbumTracks( albumId, { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${albumTracks.total} tracks`); } tracks.push(...albumTracks.items); offset += MAX_LIMIT_DEFAULT; } while (tracks.length < albumTracks.total); const trackParsed = (await extractTracks( tracks .filter(track => track) .map(track => track.id), )).map(track => { track.artists = [albumInfo.artists[0].name, ...track.artists]; return track; }); return { name: `${albumInfo.name} - ${albumInfo.label}`, items: trackParsed, }; } export async function extractArtist(artistId) { const data = await callSpotifyApi( async () => (await spotifyApi.getArtist(artistId)).body, ); return { id: data.id, name: data.name, href: data.href, }; } export async function extractArtistAlbums(artistId) { const albums = []; let offset = 0; let artistAlbums; do { artistAlbums = await callSpotifyApi( async () => (await spotifyApi.getArtistAlbums( artistId, { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${artistAlbums.total} albums`); } albums.push(...artistAlbums.items); offset += MAX_LIMIT_DEFAULT; } while (albums.length < artistAlbums.total); // remove albums that are not direct artist albums return albums; } export async function extractEpisodes(episodeIds) { const episodes = []; let episodesResult; const chunkedEpisodes = episodeIds.chunk(20); for (let x = 0; x < chunkedEpisodes.length; x++) { logInfo('extracting episode set ' + `${x + 1}/${chunkedEpisodes.length}`); episodesResult = await callSpotifyApi( async () => (await spotifyApi.getEpisodes( chunkedEpisodes[x], )).body.episodes, ); episodesResult = episodesResult.filter(episode => episode); episodes.push(...episodesResult); } return episodes.map((episode, index) => parseEpisode(episode, index)); } export async function extractShowEpisodes(showId) { const showInfo = await callSpotifyApi( async () => (await spotifyApi.getShow( showId, )).body, ); const episodes = []; let offset = 0; let showEpisodes; do { showEpisodes = await callSpotifyApi( async () => (await spotifyApi.getShowEpisodes( showId, { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${showEpisodes.total} episodes`); } episodes.push(...showEpisodes.items); offset += MAX_LIMIT_DEFAULT; } while (episodes.length < showEpisodes.total); return { name: `${showInfo.name} - ${showInfo.publisher}`, items: await extractEpisodes(episodes.map(episode => episode.id)), }; } export async function extractSavedShows() { const shows = []; let offset = 0; let savedShows; do { savedShows = await callSpotifyApi( async () => (await spotifyApi.getMySavedShows( { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${savedShows.total} shows`); } shows.push(...savedShows.items); offset += MAX_LIMIT_DEFAULT; } while (shows.length < savedShows.total); return shows.map(show => show.show); } export async function extractSavedAlbums() { const albums = []; let offset = 0; let savedAlbums; do { savedAlbums = await callSpotifyApi( async () => (await spotifyApi.getMySavedAlbums( { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${savedAlbums.total} albums`); } albums.push(...savedAlbums.items); offset += MAX_LIMIT_DEFAULT; } while (albums.length < savedAlbums.total); return albums.map(album => album.album); } export async function extractSavedPlaylists() { let offset = 0; const playlists = []; let savedPlaylists; do { savedPlaylists = await callSpotifyApi( async () => (await spotifyApi.getUserPlaylists( { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); if (!offset) { logInfo(`extracting ${savedPlaylists.total} playlists`); } playlists.push(...savedPlaylists.items); offset += MAX_LIMIT_DEFAULT; } while (playlists.length < savedPlaylists.total); return playlists; } export async function extractSavedTracks() { const tracks = []; let offset = 0; let savedTracks; do { savedTracks = await callSpotifyApi( async () => (await spotifyApi.getMySavedTracks( { limit: MAX_LIMIT_DEFAULT, offset: offset }, )).body, ); tracks.push( ...savedTracks.items.map(item => item.track), ); offset += MAX_LIMIT_DEFAULT; logInfo('extracting tracks ' + `${tracks.length}/${savedTracks.total}`); } while (tracks.length < savedTracks.total); const audioFeatures = await extractTrackAudioFeatures( tracks.map(track => track.id), ); return { name: 'Saved Tracks', items: tracks .filter(track => track) .map(track => parseTrack( track, audioFeatures, )), }; } export async function extractTrackAudioFeatures(trackIds) { return await callSpotifyApi( async () => (await spotifyApi.getAudioFeaturesForTracks( trackIds, )).body.audio_features, ); }