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 @@
-[](https://travis-ci.org/watsonbox/exportify)
+[](https://travis-ci.org/watsonbox/exportify)
+[](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 @@
-
-
-
-
-
Export your Spotify playlists.
- - --
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.
-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 ( -- | Name | -Owner | -Tracks | -Public? | -Collaborative? | -- |
---|
Export your Spotify playlists.
+Export your Spotify playlists.
+Analyze the data like this + by + launching the notebook, uploading your .csv, and running the .ipynb.
+ - --
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.
-