From c8725c964b62aabf4cc12092aed49bc19689b44c Mon Sep 17 00:00:00 2001 From: pavel-aicradle Date: Tue, 3 Nov 2020 09:59:04 -0700 Subject: [PATCH 1/2] Pavel's changes --- CNAME | 1 + FileSaver.js | 186 +++ README.md | 89 +- exportify.html | 146 --- exportify.js | 638 +++++----- favicon.png | Bin 0 -> 23192 bytes index.html | 167 +-- requirements.txt | 6 + style.css | 46 + taste_analysis.ipynb | 1312 ++++++++++++++++++++ test/assets/mocks/watsonbox.json | 15 - test/assets/mocks/watsonbox_playlists.json | 82 -- test/integration/exportify_tests.js | 132 -- test/support/casper_helpers.js | 24 - 14 files changed, 1935 insertions(+), 909 deletions(-) create mode 100644 CNAME create mode 100644 FileSaver.js delete mode 100644 exportify.html create mode 100644 favicon.png create mode 100644 requirements.txt create mode 100644 style.css create mode 100644 taste_analysis.ipynb delete mode 100644 test/assets/mocks/watsonbox.json delete mode 100644 test/assets/mocks/watsonbox_playlists.json delete mode 100644 test/integration/exportify_tests.js delete mode 100644 test/support/casper_helpers.js diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..7ba867c --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +exportify.net diff --git a/FileSaver.js b/FileSaver.js new file mode 100644 index 0000000..22e6f2f --- /dev/null +++ b/FileSaver.js @@ -0,0 +1,186 @@ +/* + * FileSaver.js + * A saveAs() FileSaver implementation. + * + * By Eli Grey, http://eligrey.com + * + * License : https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md (MIT) + * source : http://purl.eligrey.com/github/FileSaver.js + */ + +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define([], factory); + } else if (typeof exports !== "undefined") { + factory(); + } else { + var mod = { + exports: {} + }; + factory(); + global.FileSaver = mod.exports; + } +})(this, function () { + "use strict"; + + // The one and only way of getting global scope in all environments + // https://stackoverflow.com/q/3277182/1008999 + var _global = typeof window === 'object' && window.window === window ? window : typeof self === 'object' && self.self === self ? self : typeof global === 'object' && global.global === global ? global : void 0; + + function bom(blob, opts) { + if (typeof opts === 'undefined') opts = { + autoBom: false + };else if (typeof opts !== 'object') { + console.warn('Deprecated: Expected third argument to be a object'); + opts = { + autoBom: !opts + }; + } // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + + if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob([String.fromCharCode(0xFEFF), blob], { + type: blob.type + }); + } + + return blob; + } + + function download(url, name, opts) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.responseType = 'blob'; + + xhr.onload = function () { + saveAs(xhr.response, name, opts); + }; + + xhr.onerror = function () { + console.error('could not download file'); + }; + + xhr.send(); + } + + function corsEnabled(url) { + var xhr = new XMLHttpRequest(); // use sync to avoid popup blocker + + xhr.open('HEAD', url, false); + + try { + xhr.send(); + } catch (e) {} + + return xhr.status >= 200 && xhr.status <= 299; + } // `a.click()` doesn't work for all browsers (#465) + + + function click(node) { + try { + node.dispatchEvent(new MouseEvent('click')); + } catch (e) { + var evt = document.createEvent('MouseEvents'); + evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null); + node.dispatchEvent(evt); + } + } + + var saveAs = _global.saveAs || ( // probably in some web worker + typeof window !== 'object' || window !== _global ? function saveAs() {} + /* noop */ + // Use download attribute first if possible (#193 Lumia mobile) + : 'download' in HTMLAnchorElement.prototype ? function saveAs(blob, name, opts) { + var URL = _global.URL || _global.webkitURL; + var a = document.createElement('a'); + name = name || blob.name || 'download'; + a.download = name; + a.rel = 'noopener'; // tabnabbing + // TODO: detect chrome extensions & packaged apps + // a.target = '_blank' + + if (typeof blob === 'string') { + // Support regular links + a.href = blob; + + if (a.origin !== location.origin) { + corsEnabled(a.href) ? download(blob, name, opts) : click(a, a.target = '_blank'); + } else { + click(a); + } + } else { + // Support blobs + a.href = URL.createObjectURL(blob); + setTimeout(function () { + URL.revokeObjectURL(a.href); + }, 4E4); // 40s + + setTimeout(function () { + click(a); + }, 0); + } + } // Use msSaveOrOpenBlob as a second approach + : 'msSaveOrOpenBlob' in navigator ? function saveAs(blob, name, opts) { + name = name || blob.name || 'download'; + + if (typeof blob === 'string') { + if (corsEnabled(blob)) { + download(blob, name, opts); + } else { + var a = document.createElement('a'); + a.href = blob; + a.target = '_blank'; + setTimeout(function () { + click(a); + }); + } + } else { + navigator.msSaveOrOpenBlob(bom(blob, opts), name); + } + } // Fallback to using FileReader and a popup + : function saveAs(blob, name, opts, popup) { + // Open a popup immediately do go around popup blocker + // Mostly only available on user interaction and the fileReader is async so... + popup = popup || open('', '_blank'); + + if (popup) { + popup.document.title = popup.document.body.innerText = 'downloading...'; + } + + if (typeof blob === 'string') return download(blob, name, opts); + var force = blob.type === 'application/octet-stream'; + + var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari; + + var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent); + + if ((isChromeIOS || force && isSafari) && typeof FileReader === 'object') { + // Safari doesn't allow downloading of blob URLs + var reader = new FileReader(); + + reader.onloadend = function () { + var url = reader.result; + url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;'); + if (popup) popup.location.href = url;else location = url; + popup = null; // reverse-tabnabbing #460 + }; + + reader.readAsDataURL(blob); + } else { + var URL = _global.URL || _global.webkitURL; + var url = URL.createObjectURL(blob); + if (popup) popup.location = url;else location.href = url; + popup = null; // reverse-tabnabbing #460 + + setTimeout(function () { + URL.revokeObjectURL(url); + }, 4E4); // 40s + } + }); + _global.saveAs = saveAs.saveAs = saveAs; + + if (typeof module !== 'undefined') { + module.exports = saveAs; + } +}); + diff --git a/README.md b/README.md index 49222d2..77bc69e 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,65 @@ -[![Build Status](https://api.travis-ci.org/watsonbox/exportify.svg?branch=master)](https://travis-ci.org/watsonbox/exportify) +[![Build Status](http://img.shields.io/travis/watsonbox/exportify.svg?style=flat)](https://travis-ci.org/watsonbox/exportify) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/watsonbox/exportify/master) - - -Export your Spotify playlists using the Web API by clicking on the link below: - -[https://watsonbox.github.io/exportify/](https://watsonbox.github.io/exportify/) - -As many users have noted, there is no way to export/archive playlists from the Spotify client for safekeeping. This application provides a simple interface for doing that using the Spotify Web API. - -No data will be saved - the entire application runs in the browser. - - -## Usage - -Click 'Get Started', grant Exportify read-only access to your playlists, then click the 'Export' button to export a playlist. - -Click 'Export All' to save a zip file containing a CSV file for each playlist in your account. This may take a while when many playlists exist and/or they are large. - - -### Re-importing Playlists - -Once playlists are saved, it's also pretty straightforward to re-import them into Spotify. Open up the CSV file in Excel, for example, select and copy the `spotify:track:xxx` URIs, then simply create a playlist in Spotify and paste them in. +Export your Spotify playlists for analysis or just safekeeping: [exportify.net](https://exportify.net) + ### Export Format Track data is exported in [CSV](http://en.wikipedia.org/wiki/Comma-separated_values) format with the following fields: -- Spotify URI +- Spotify ID +- Artist IDs - Track Name -- Artist Name - Album Name -- Disc Number -- Track Number -- Track Duration (ms) +- Artist Name(s) +- Release Date +- Duration (ms) +- Popularity - Added By - Added At +- Genres +- Danceability +- Energy +- Key +- Loudness +- Mode +- Speechiness +- Acousticness +- Instrumentalness +- Liveness +- Valence +- Tempo +- Time Signature +### Analysis -## Development +Run the [Jupyter Notebook](https://github.com/watsonbox/exportify/blob/master/taste_analysis.ipynb) or [launch it in Binder](https://mybinder.org/v2/gh/watsonbox/exportify/master) to get a variety of plots about the music in a playlist including: -Developers wishing to make changes to Exportify should use a local web server. For example, using Python 2.7 (in the Exportify repo dir): +- Most common artists +- Most common genres +- Release date distribution +- Popularity distribution +- Comparisons of Acousticness, Valence, etc. to normal +- Time signatures and keys +- All songs plotted in 2D to indicate relative similarities -```bash -python -m SimpleHTTPServer -``` -For Python 3 (in the Exportify repo dir): +### Development + +Developers wishing to make changes to Exportify should use a local web server. For example, using Python (in the Exportify repo dir): ```bash -python -m http.server 8000 +python -m http.server ``` +Then open [http://localhost:8000](http://localhost:8000). -Then open [http://localhost:8000/exportify.html](http://localhost:8000/exportify.html). - - -## Notes - -- The CSV export uses the HTML5 download attribute which is not [supported](http://caniuse.com/#feat=download) in all browsers. Where not supported the CSV will be rendered in the browser and must be saved manually. - -- According to Spotify [documentation](https://developer.spotify.com/web-api/working-with-playlists/), "Folders are not returned through the Web API at the moment, nor can be created using it". - -- It has been [pointed out](https://github.com/watsonbox/exportify/issues/6) that due to the large number of requests required to export all playlists, rate limiting errors may sometimes be encountered. Features will soon be added to make handling these more robust, but in the meantime these issues can be overcome by [creating your own Spotify application](https://github.com/watsonbox/exportify/issues/6#issuecomment-110793132). - - -## Contributing +### Contributing -1. Fork it ( https://github.com/watsonbox/exportify/fork ) +1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) +3. Commit your changes (`git commit -m "message"`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request diff --git a/exportify.html b/exportify.html deleted file mode 100644 index 1c1814d..0000000 --- a/exportify.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - Exportify - - - - - - - - - - - - - - - - - - - -
Fork me on Github
-
- - -
- -
-

-

Oops, Exportify has encountered a rate limiting error while using the Spotify API. This might be because of the number of users currently exporting playlists, or perhaps because you have too many playlists to export all at once. Try creating your own Spotify application. If that doesn't work, please add a comment to this issue where possible resolutions are being discussed.

-

It should still be possible to export individual playlists, particularly when using your own Spotify application.

-
-
- - diff --git a/exportify.js b/exportify.js index fb81367..a1ab1ba 100644 --- a/exportify.js +++ b/exportify.js @@ -1,342 +1,318 @@ -window.Helpers = { - authorize: function() { - var client_id = this.getQueryParam('app_client_id'); - - // Use Exportify application client_id if none given - if (client_id == '') { - client_id = "9950ac751e34487dbbe027c4fd7f8e99" - } - - window.location = "https://accounts.spotify.com/authorize" + - "?client_id=" + client_id + - "&redirect_uri=" + encodeURIComponent([location.protocol, '//', location.host, location.pathname].join('')) + - "&scope=playlist-read-private%20playlist-read-collaborative" + - "&response_type=token"; - }, - - // http://stackoverflow.com/a/901144/4167042 - getQueryParam: function(name) { - name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); - var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"), - results = regex.exec(location.search); - return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); - }, - - apiCall: function(url, access_token) { - return $.ajax({ - url: url, - headers: { - 'Authorization': 'Bearer ' + access_token - } - }).fail(function (jqXHR, textStatus) { - if (jqXHR.status == 401) { - // Return to home page after auth token expiry - window.location = window.location.href.split('#')[0] - } else if (jqXHR.status == 429) { - // API Rate-limiting encountered - window.location = window.location.href.split('#')[0] + '?rate_limit_message=true' - } else { - // Otherwise report the error so user can raise an issue - alert(jqXHR.responseText); - } - }) - } +rateLimit = '

Exportify has encountered a rate limiting error. The browser is actually caching those packets, so if you rerun the script (wait a minute and click the button again) a few times, it keeps filling in its missing pieces until it succeeds. Open developer tools with ctrl+shift+E and watch under the network tab to see this in action. Good luck.

'; + +// A collection of functions to create and send API queries +utils = { + // Query the spotify server (by just setting the url) to let it know we want a session. This is literally + // accomplished by navigating to this web address, where we may have to enter Spotify credentials, then + // being redirected to the original website. + // https://developer.spotify.com/documentation/general/guides/authorization-guide/ + authorize() { + window.location = "https://accounts.spotify.com/authorize" + + "?client_id=d99b082b01d74d61a100c9a0e056380b" + + "&redirect_uri=" + encodeURIComponent([location.protocol, '//', location.host, location.pathname].join('')) + + "&scope=playlist-read-private%20playlist-read-collaborative" + + "&response_type=token"; + }, + + // Make an asynchronous call to the server. Promises are *wierd*. Careful here! You have to call .json() on the + // promise returned by the fetch to get a second promise that has the actual data in it! + // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch + async apiCall(url, access_token, delay=0) { + await new Promise(r => setTimeout(r, delay)); // JavaScript equivalent of sleep(delay) + let response = await fetch(url, { headers: { 'Authorization': 'Bearer ' + access_token} }); + if (response.ok) { return response.json(); } + else if (response.status == 401) { window.location = window.location.href.split('#')[0]; } // Return to home page after auth token expiry + else if (response.status == 429) { error.innerHTML = rateLimit; } // API Rate-limiting encountered + else { error.innerHTML = "The server returned an HTTP " + response.status + " response."; } // the caller will fail + }, + + // Logging out of Spotify is much like logging in: You have to navigate to a certain url. But unlike logging in, there is + // no way to redirect back to my home page. So open the logout page in an invisible iframe, then redirect to the homepage + // after a second, which is almost always long enough for the logout request to go through. + logout() { + playlistsContainer.innerHTML = ''; + playlistsContainer.style.display = 'none'; + setTimeout(() => window.location = [location.protocol, '//', location.host, location.pathname].join(''), 1500); + } } -var PlaylistTable = React.createClass({ - getInitialState: function() { - return { - playlists: [], - playlistCount: 0, - nextURL: null, - prevURL: null - }; - }, - - loadPlaylists: function(url) { - var userId = ''; - - window.Helpers.apiCall("https://api.spotify.com/v1/me", this.props.access_token).then(function(response) { - userId = response.id; - - return window.Helpers.apiCall( - typeof url !== 'undefined' ? url : "https://api.spotify.com/v1/users/" + userId + "/playlists", - this.props.access_token - ) - }.bind(this)).done(function(response) { - if (this.isMounted()) { - this.setState({ - playlists: response.items, - playlistCount: response.total, - nextURL: response.next, - prevURL: response.previous - }); - - $('#playlists').fadeIn(); - $('#subtitle').text((response.offset + 1) + '-' + (response.offset + response.items.length) + ' of ' + response.total + ' playlists for ' + userId) - } - }.bind(this)) - }, - - exportPlaylists: function() { - PlaylistsExporter.export(this.props.access_token, this.state.playlistCount); - }, - - componentDidMount: function() { - this.loadPlaylists(this.props.url); - }, - - render: function() { - if (this.state.playlists.length > 0) { - return ( -
- - - - - - - - - - - - - - - {this.state.playlists.map(function(playlist, i) { - return ; - }.bind(this))} - -
NameOwnerTracksPublic?Collaborative?
- -
- ); - } else { - return
- } - } -}); - -var PlaylistRow = React.createClass({ - exportPlaylist: function() { - PlaylistExporter.export(this.props.access_token, this.props.playlist); - }, - - renderTickCross: function(condition) { - if (condition) { - return - } else { - return - } - }, - - render: function() { - playlist = this.props.playlist - if(playlist.uri==null) return ( - - {this.renderIcon(playlist)} - {playlist.name} - This playlist is not supported - {this.renderTickCross(playlist.public)} - {this.renderTickCross(playlist.collaborative)} -   - - ); - return ( - - - {playlist.name} - {playlist.owner.display_name} - {playlist.tracks.total} - {this.renderTickCross(playlist.public)} - {this.renderTickCross(playlist.collaborative)} - - - ); - } -}); - -var Paginator = React.createClass({ - nextClick: function(e) { - e.preventDefault() - - if (this.props.nextURL != null) { - this.props.loadPlaylists(this.props.nextURL) - } - }, - - prevClick: function(e) { - e.preventDefault() +// The table of this user's playlists, to be displayed mid-page in the playlistsContainer +class PlaylistTable extends React.Component { + // By default the constructor passes props to super. If you want some additional stuff, you have to override. + // https://stackoverflow.com/questions/30668326/what-is-the-difference-between-using-constructor-vs-getinitialstate-in-react-r + constructor(props) { + super(props); + this.state = { playlists: [], nplaylists: 0, nextURL: null, prevURL: null }; + } + + // "componentDidMount() is invoked immediately after a component is mounted (inserted into the tree). + // Initialization that requires DOM nodes should go here." + async componentDidMount() { + let user = await utils.apiCall("https://api.spotify.com/v1/me", this.props.access_token); + this.loadPlaylists("https://api.spotify.com/v1/users/" + user.id + "/playlists"); + this.state.userid = user.id; + } + + // Retrieve the list of user playlists by querying the url and add them to this Component's state. + async loadPlaylists(url) { + let response = await utils.apiCall(url, this.props.access_token); + this.setState({ playlists: response.items, nplaylists: response.total, nextURL: response.next, + prevURL: response.previous }); + + playlists.style.display = 'block'; + subtitle.textContent = (response.offset + 1) + '-' + (response.offset + response.items.length) + + ' of ' + response.total + ' playlists\n'; + } + + exportPlaylists() { + ZipExporter.export(this.props.access_token, this.state.userid, this.state.nplaylists); + } + + // There used to be JSX syntax in here, but JSX was abandoned by the React community because Babel does it better. + // Around the web there seems to be a movement to not use this syntax if possible, because it means you literally + // have to pass this .js file through a transformer to get pure JavaScript, which slows down page loading significantly. + render() { + if (this.state.playlists.length > 0) { + return React.createElement("div", { id: "playlists" }, + React.createElement(Paginator, { nextURL: this.state.nextURL, prevURL: this.state.prevURL, + loadPlaylists: this.loadPlaylists.bind(this) }), + React.createElement("table", { className: "table table-hover" }, + React.createElement("thead", null, + React.createElement("tr", null, + React.createElement("th", { style: { width: "30px" }}), + React.createElement("th", null, "Name"), + React.createElement("th", { style: { width: "150px" } }, "Owner"), + React.createElement("th", { style: { width: "100px" } }, "Tracks"), + React.createElement("th", { style: { width: "120px" } }, "Public?"), + React.createElement("th", { style: { width: "120px" } }, "Collaborative?"), + React.createElement("th", { style: { width: "100px" }, className: "text-right"}, + React.createElement("button", { className: "btn btn-default btn-xs", type: "submit", id: "exportAll", + onClick: this.exportPlaylists.bind(this) }, + React.createElement("i", { className: "fa fa-file-archive-o"}), " Export All")))), + React.createElement("tbody", null, this.state.playlists.map((playlist, i) => { + return React.createElement(PlaylistRow, { playlist: playlist, access_token: this.props.access_token, row: i}); + }))), + React.createElement(Paginator, { nextURL: this.state.nextURL, prevURL: this.state.prevURL, + loadPlaylists: this.loadPlaylists.bind(this) })); + } else { + return React.createElement("div", { className: "spinner"}); + } + } +} - if (this.props.prevURL != null) { - this.props.loadPlaylists(this.props.prevURL) - } - }, +// Separated out for convenience, I guess. The table's render method defines a bunch of these in a loop, which I'm +// guessing implicitly calls this thing's render method. +class PlaylistRow extends React.Component { + exportPlaylist() { // this is the function that gets called when an export button is pressed + PlaylistExporter.export(this.props.access_token, this.props.playlist, this.props.row); + } + + renderTickCross(dark) { + if (dark) { + return React.createElement("i", { className: "fa fa-lg fa-check-circle-o" }); + } else { + return React.createElement("i", { className: "fa fa-lg fa-times-circle-o", style: { color: '#ECEBE8' } }); + } + } + + render() { + let p = this.props.playlist + return React.createElement("tr", { key: p.id }, + React.createElement("td", null, React.createElement("i", { className: "fa fa-music" })), + React.createElement("td", null, + React.createElement("a", { href: p.external_urls.spotify }, p.name)), + React.createElement("td", null, + React.createElement("a", { href: p.owner.external_urls.spotify }, p.owner.id)), + React.createElement("td", null, p.tracks.total), + React.createElement("td", null, this.renderTickCross(p.public)), + React.createElement("td", null, this.renderTickCross(p.collaborative)), + React.createElement("td", { className: "text-right" }, + React.createElement("button", { className: "btn btn-default btn-xs btn-success", type: "submit", + id: "export" + this.props.row, onClick: this.exportPlaylist.bind(this) }, + React.createElement("i", { className: "fa fa-download" }), " Export"))); + } +} - render: function() { - if (this.props.nextURL != null || this.props.prevURL != null) { - return ( - - ) - } else { - return
 
- } - } -}); +// For those users with a lot more playlists than necessary +class Paginator extends React.Component { + nextClick(e) { + e.preventDefault(); // keep React from putting us at # instead of #playlists + if (this.props.nextURL != null) { this.props.loadPlaylists(this.props.nextURL); } + } + + prevClick(e) { + e.preventDefault(); + if (this.props.prevURL != null) { this.props.loadPlaylists(this.props.prevURL); } + } + + render() { + if (!this.props.nextURL && !this.props.prevURL) { return React.createElement("div", null, "\xA0"); } + else { return React.createElement("nav", { className: "paginator text-right" }, + React.createElement("ul", { className: "pagination pagination-sm" }, + React.createElement("li", { className: this.props.prevURL == null ? 'disabled' : '' }, + React.createElement("a", { href: "#", "aria-label": "Previous", onClick: this.prevClick.bind(this) }, + React.createElement("span", { "aria-hidden": "true" }, "\xAB"))), + React.createElement("li", { className: this.props.nextURL == null ? 'disabled' : '' }, + React.createElement("a", { href: "#", "aria-label": "Next", onClick: this.nextClick.bind(this) }, + React.createElement("span", { "aria-hidden": "true" }, "\xBB"))))); + } + } +} // Handles exporting all playlist data as a zip file -var PlaylistsExporter = { - export: function(access_token, playlistCount) { - var playlistFileNames = []; - - window.Helpers.apiCall("https://api.spotify.com/v1/me", access_token).then(function(response) { - var requests = []; - var limit = 20; - - for (var offset = 0; offset < playlistCount; offset = offset + limit) { - var url = "https://api.spotify.com/v1/users/" + response.id + "/playlists"; - requests.push( - window.Helpers.apiCall(url + '?offset=' + offset + '&limit=' + limit, access_token) - ) - } - - $.when.apply($, requests).then(function() { - var playlists = []; - var playlistExports = []; - - // Handle either single or multiple responses - if (typeof arguments[0].href == 'undefined') { - $(arguments).each(function(i, response) { $.merge(playlists, response[0].items) }) - } else { - playlists = arguments[0].items - } - - $(playlists).each(function(i, playlist) { - playlistFileNames.push(PlaylistExporter.fileName(playlist)); - playlistExports.push(PlaylistExporter.csvData(access_token, playlist)); - }); - - return $.when.apply($, playlistExports); - }).then(function() { - var zip = new JSZip(); - var responses = []; - - $(arguments).each(function(i, response) { - zip.file(playlistFileNames[i], response) - }); - - var content = zip.generate({ type: "blob" }); - saveAs(content, "spotify_playlists.zip"); - }); - }); - } +let ZipExporter = { + async export(access_token, userid, nplaylists) { + exportAll.innerHTML = ' Exporting'; + error.innerHTML = ""; + let zip = new JSZip(); + + // Get a list of all the user's playlists + let playlists = []; + for (let offset = 0; offset < nplaylists; offset += 50) { + let batch = await utils.apiCall("https://api.spotify.com/v1/users/" + userid + "/playlists?limit=50&offset=" + + offset, access_token, offset*2); // only one query every 100 ms + playlists.push(batch.items); + } + playlists = playlists.flat(); + + // Now do the real work for each playlist + for (let playlist of playlists) { + try { + let csv = await PlaylistExporter.csvData(access_token, playlist); + let fileName = PlaylistExporter.fileName(playlist); + while (zip.file(fileName + ".csv")) { fileName += "_"; } // so filenames are always unique and nothing is overwritten + zip.file(fileName + ".csv", csv); + } catch (e) { // Surface all errors + error.innerHTML = error.innerHTML.slice(0, -120) + "Couldn't export " + playlist.name + " with id " + + playlist.id + ". Encountered " + e + ".
" + + 'Please let us know. ' + + "The others are still being zipped."; + } + } + exportAll.innerHTML= ' Export All'; + saveAs(zip.generate({ type: "blob" }), "spotify_playlists.zip"); + } } // Handles exporting a single playlist as a CSV file -var PlaylistExporter = { - export: function(access_token, playlist) { - this.csvData(access_token, playlist).then(function(data) { - var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" }); - saveAs(blob, this.fileName(playlist), true); - }.bind(this)) - }, - - csvData: function(access_token, playlist) { - var requests = []; - var limit = 100; - - for (var offset = 0; offset < playlist.tracks.total; offset = offset + limit) { - requests.push( - window.Helpers.apiCall(playlist.tracks.href + '?offset=' + offset + '&limit=' + limit, access_token) - ) - } - - return $.when.apply($, requests).then(function() { - var responses = []; - - // Handle either single or multiple responses - if (typeof arguments[0] != 'undefined') { - if (typeof arguments[0].href == 'undefined') { - responses = Array.prototype.slice.call(arguments).map(function(a) { return a[0] }); - } else { - responses = [arguments[0]]; - } - } - - var tracks = responses.map(function(response) { - return response.items.map(function(item) { - return [ - item.track.uri, - item.track.name, - item.track.artists.map(function (artist) { return String(artist.name).replace(/,/g, "\\,"); }).join(', '), - item.track.album.name, - item.track.disc_number, - item.track.track_number, - item.track.duration_ms, - item.added_by == null ? '' : item.added_by.uri, - item.added_at - ]; - }); - }); - - // Flatten the array of pages - tracks = $.map(tracks, function(n) { return n }) - - tracks.unshift([ - "Spotify URI", - "Track Name", - "Artist Name", - "Album Name", - "Disc Number", - "Track Number", - "Track Duration (ms)", - "Added By", - "Added At" - ]); - - csvContent = ''; - tracks.forEach(function(row, index){ - dataString = row.map(function (cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(","); - csvContent += dataString + "\n"; - }); - - return csvContent; - }); - }, - - fileName: function(playlist) { - return playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + ".csv"; - } +let PlaylistExporter = { + // Take the access token string and playlist object, generate a csv from it, and when that data is resolved and + // returned save to a file. + async export(access_token, playlist, row) { + document.getElementById("export"+row).innerHTML = ' Exporting'; + try { + let csv = await this.csvData(access_token, playlist); + saveAs(new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }), this.fileName(playlist) + ".csv"); + } catch (e) { + error.innerHTML = "Couldn't export " + playlist.name + ". Encountered " + e + + '. Please let us know.'; + } finally { + document.getElementById("export"+row).innerHTML = ' Export'; + } + }, + + // This is where the magic happens. The access token gives us permission to query this info from Spotify, and the + // playlist object gives us all the information we need to start asking for songs. + csvData(access_token, playlist) { + // Make asynchronous API calls for 100 songs at a time, and put the results (all Promises) in a list. + let requests = []; + for (let offset = 0; offset < playlist.tracks.total; offset = offset + 100) { + requests.push(utils.apiCall(playlist.tracks.href.split('?')[0] + '?offset=' + offset + '&limit=100', + access_token, offset)); + } + // "returns a single Promise that resolves when all of the promises passed as an iterable have resolved" + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all + let artist_ids = new Set(); + let data_promise = Promise.all(requests).then(responses => { // Gather all the data from the responses in a table. + return responses.map(response => { // apply to all responses + return response.items.map(song => { // appy to all songs in each response + song.track.artists.forEach(a => { if(a.id) { artist_ids.add(a.id) } }); + return [song.track.id, '"'+song.track.artists.map(artist => { return artist.id }).join(',')+'"', + '"'+song.track.name.replace(/"/g,'')+'"', '"'+song.track.album.name.replace(/"/g,'')+'"', + '"'+song.track.artists.map(artist => { return artist.name }).join(',')+'"', song.track.album.release_date, + song.track.duration_ms, song.track.popularity, song.added_by.uri, song.added_at]; + }); + }); + }); + + // Make queries on all the artists, because this json is where genre information lives. Unfortunately this + // means a second wave of traffic, 50 artists at a time the maximum allowed. + let genre_promise = data_promise.then(() => { + artist_ids = Array.from(artist_ids); // Make groups of 50 artists, to all be queried together + artist_chunks = []; while (artist_ids.length) { artist_chunks.push(artist_ids.splice(0, 50)); }; + let artists_promises = artist_chunks.map((chunk_ids, i) => utils.apiCall( + 'https://api.spotify.com/v1/artists?ids='+chunk_ids.join(','), access_token, 100*i)); + return Promise.all(artists_promises).then(responses => { + let artist_genres = {}; + responses.forEach(response => response.artists.forEach( + artist => artist_genres[artist.id] = artist.genres.join(','))); + return artist_genres; + }); + }); + + // Make queries for song audio features, 100 songs at a time. Depends on genre_promise too to build in delays. + let features_promise = Promise.all([data_promise, genre_promise]).then(values => { + data = values[0]; + let songs_promises = data.map((chunk, i) => { // remember data is an array of arrays, each subarray 100 tracks + ids = chunk.map(song => song[0]).join(','); // the id lives in the first position + return utils.apiCall('https://api.spotify.com/v1/audio-features?ids='+ids , access_token, 100*i); + }); + return Promise.all(songs_promises).then(responses => { + return responses.map(response => { // for each response + return response.audio_features.map(feats => { + return feats ? [feats.danceability, feats.energy, feats.key, feats.loudness, feats.mode, + feats.speechiness, feats.acousticness, feats.instrumentalness, feats.liveness, feats.valence, + feats.tempo, feats.time_signature] : Array(12); + }); + }); + }); + }); + + // join the tables, label the columns, and put all data in a single csv string + return Promise.all([data_promise, genre_promise, features_promise]).then(values => { + [data, artist_genres, features] = values; + // add genres + data = data.flat(); + data.forEach(row => { + artists = row[1].substring(1, row[1].length-1).split(','); // strip the quotes + deduplicated_genres = new Set(artists.map(a => artist_genres[a]).join(",").split(",")); // in case multiple artists + row.push('"'+Array.from(deduplicated_genres).filter(x => x != "").join(",")+'"'); // remove empty strings + }); + // add features + features = features.flat(); + data.forEach((row, i) => features[i].forEach(feat => row.push(feat))); + // add titles + data.unshift(["Spotify ID", "Artist IDs", "Track Name", "Album Name", "Artist Name(s)", "Release Date", + "Duration (ms)", "Popularity", "Added By", "Added At", "Genres", "Danceability", "Energy", "Key", "Loudness", + "Mode", "Speechiness", "Acousticness", "Instrumentalness", "Liveness", "Valence", "Tempo", "Time Signature"]); + // make a string + csv = ''; data.forEach(row => { csv += row.join(",") + "\n" }); + return csv; + }); + }, + + // take the playlist object and return an acceptable filename + fileName(playlist) { + return playlist.name.replace(/[^a-z0-9\- ]/gi, '').replace(/[ ]/gi, '_').toLowerCase(); + } } -$(function() { - var vars = window.location.hash.substring(1).split('&'); - var key = {}; - for (i=0; i, playlistsContainer); - } -}); +// runs when the page loads +window.onload = () => { + let [root, hash] = window.location.href.split('#'); + dict = {}; + if (hash) { + let params = hash.split('&'); + for (let i = 0; i < params.length; i++) { + let [k, v] = params[i].split('='); + dict[k] = v; + } + } + + if (dict.access_token) { // if we were just authorized and got a token + loginButton.style.display = 'none'; + ReactDOM.render(React.createElement(PlaylistTable, { access_token: dict.access_token }), playlistsContainer); + logoutContainer.innerHTML = ''; + window.location = root + "#playlists" + } +} diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..b75a6fd31820a220446b725ad1116a8d895b0245 GIT binary patch literal 23192 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 PG;keZ3V;8P)1&_bto)sC literal 0 HcmV?d00001 diff --git a/index.html b/index.html index d3d797b..56ff9bf 100644 --- a/index.html +++ b/index.html @@ -1,141 +1,48 @@ - - - Exportify - - - - - - - - - - - - - - - - + Exportify + + + + + + + + + + + + + + + + + + + + - -
-