diff --git a/.gitignore b/.gitignore index e43b0f9..bfa8d22 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.DS_Store +*.csv +.ipynb_checkpoints +.~* diff --git a/.travis.yml b/.travis.yml index 739da42..c55b286 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,22 @@ -os: osx +language: python +python: +- 3.6 -before_install: - - brew cask install phantomjs - - git clone git://github.com/n1k0/casperjs.git casperjs - - export PATH=`pwd`/casperjs/bin/:$PATH - -before_script: - - phantomjs --version - - casperjs --version - - python -m SimpleHTTPServer 8080 & - - sleep 10 +install: +- pip install notebook script: - - casperjs test test/integration +- echo "Be excellent to each other!" +- python -m nbconvert --to html taste_analysis.ipynb +# Aight, this is definitively how you do these encrypted keys: `sudo apt install ruby ruby-dev libz-dev`, +# `gem install travis`, make sure you're in a folder with a .travis.yml, `travis encrypt thekey123456abcde`, and put +# the result under the thing it's supposed to represent. No VARIABLE=key, no worrying about capitalization or other +# command line flags, no putting the raw key in here lest github sniff it out and delete your token. deploy: provider: pages + github_token: + secure: "qhlPRkjNVw+KihpRoC/UBTaFOhtJD+FyY8V5eLozUxZEYkj/WkfJXyodSrUonDZpi3ycozpv0lUIbdOdW5SV13ewzf4PlOKXRCkjwS25P4dUiY1J8dbTfaeOuFWR4LozABt/TFenxPhocKPBmDTIN9i+R5EHdYfjMoAqs8uPZMgaf6Rzxah/Bwtl5+syEQI1ploFNKdP08Yc8MBsnZP2CJqNFpPwv4HfwncBjOHLldaKYFewG5j28L+E1qjOg0WVrwIe6weql9isg0jObEvmupFmxoh4vdJOVG/V+hsU29z0s0I+ZwmmVKskzT0NY1NvrM82xAr35fLUFF6Df6YtA9/KBIB5QHtDt6aZ2Tec6rhwzB5y3QwLF3RH5P3A+8cKLf2Z061FdMKQQ3JIUe3OMkXFtPGxJY42RJoqxfltL1gjYhGXQR9uhAh7BCafbx1NOXGbAOlcU/w82Ant+rJDofQXAMTUtmeZeVoG2lkztKwOwNY7TYBXhmlX+g8UNLG3f063ekJ+2Qfmls082masnjUycpYLdVX/syW/8iPhWT+HbwLpgBks7Fb8CAvXQHa4p9bnw80IjAtDwIH56EYHGp+BaRu6mwSAlPpdOPEyzZKisRf90uLo1XYO+VSPp2/JcHIo7aaXTkxG8s3HJ2gWKS4IRg5oHa/z2uRt6ZxDRH4=" skip_cleanup: true - github_token: $GITHUB_TOKEN - keep_history: true on: branch: master 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 0000000..b75a6fd Binary files /dev/null and b/favicon.png differ diff --git a/index.html b/index.html index d3d797b..56ff9bf 100644 --- a/index.html +++ b/index.html @@ -1,141 +1,48 @@ - - - Exportify - - - - - - - - - - - - - - - - + Exportify + + + + + + + + + + + + + + + + + + + + - -
-