├── .gitignore ├── LICENSE ├── README.md ├── exportify.html ├── exportify.js └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | 4 | # macOS 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Howard Wilson, delight.im (https://www.delight.im/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Export your Spotify playlists and library using the Web API by clicking on the link below: 4 | 5 | [https://rawgit.com/delight-im/exportify/master/exportify.html](https://rawgit.com/delight-im/exportify/master/exportify.html) 6 | 7 | As many users have noticed with Spotify, there is no way to export or archive one’s playlists or library from the official clients for safekeeping. This application provides a simple interface for doing that using the Spotify Web API. 8 | 9 | No data will be saved on any third-party server – the entire application runs in your browser. 10 | 11 | ## Usage 12 | 13 | Click “Get started”, grant Exportify *read-only* access to your playlists and library, then click the “Export” button to start the export. 14 | 15 | Click “Export all” to save a ZIP file containing one CSV file for each playlist in your account. This may take a while when many (large) playlists exist. 16 | 17 | ### Large libraries 18 | 19 | If you have a large number of items in your “Saved Tracks”, i.e. more than 2500 entries, you can change the limit on the number of tracks to export by executing the following piece of JavaScript in the console of your browser’s developer tools: 20 | 21 | ```javascript 22 | window.localStorage.setItem("librarySize", 3141); 23 | ``` 24 | 25 | ### Re-importing playlists 26 | 27 | Once playlists are saved, it’s also pretty straightforward to re-import them into Spotify. Open up an exported CSV file in Excel or LibreOffice Calc, for example, then select and copy the `spotify:track:xxx` URIs. Finally, create a playlist in Spotify and paste in the URIs. 28 | 29 | ### Export Format 30 | 31 | Track data is exported in [CSV](http://en.wikipedia.org/wiki/Comma-separated_values) format with the following fields: 32 | 33 | * Track URI 34 | * Track Name 35 | * Artist URI 36 | * Artist Name 37 | * Album URI 38 | * Album Name 39 | * Disc Number 40 | * Track Number 41 | * Track Duration (ms) 42 | * Added By 43 | * Added At 44 | 45 | ## Rate limiting or throttling 46 | 47 | If you hit Spotify’s rate limits for their Web API, you may want to [register your own “Client ID”](#registering-your-own-application-for-the-spotify-web-api), which allows for more API calls independent of other users. 48 | 49 | ## Development 50 | 51 | If you want to test changes in a development version, you should fire up a local web server, e.g. using Apache, nginx, Python or PHP, and navigate your browser to `exportify.html` on `localhost`. Additionally, you may want to [register your own “Client ID”](#registering-your-own-application-for-the-spotify-web-api) for the Spotify Web API. 52 | 53 | ## Registering your own application for the Spotify Web API 54 | 55 | 1. Go to [Spotify’s developer site](https://developer.spotify.com/my-applications) 56 | 1. Choose to create a new app 57 | 1. Enter an arbitrary title and description for your new app 58 | 1. Locate the “Client ID” for your new app and write it down 59 | 1. “Edit” your app 60 | 1. As “Redirect URIs”, add 61 | 62 | ``` 63 | https://rawgit.com/delight-im/exportify/master/exportify.html 64 | ``` 65 | 66 | and, if you want to work on a local development version, your local URL, e.g.: 67 | 68 | ``` 69 | http://localhost/exportify/exportify.html 70 | ``` 71 | 72 | 1. Save the new settings 73 | 1. Navigate your browser to 74 | 75 | ``` 76 | https://rawgit.com/delight-im/exportify/master/exportify.html?client_id= 77 | ``` 78 | 79 | to start using your own “Client ID” for the Spotify Web API 80 | 81 | 1. If you want to work on a local development version, you may want to change the “Client ID” directly in the code 82 | 83 | ## References 84 | 85 | * [Spotify Web API](https://developer.spotify.com/web-api/) 86 | * [Saved tracks](https://developer.spotify.com/web-api/get-users-saved-tracks/) 87 | * [List of playlists](https://developer.spotify.com/web-api/get-list-users-playlists/) 88 | * [Playlist details](https://developer.spotify.com/web-api/get-playlist/) 89 | * [Permissions](https://developer.spotify.com/web-api/using-scopes/) 90 | 91 | ## Contributing 92 | 93 | All contributions are welcome! If you wish to contribute, please create an issue first so that your feature, problem or question can be discussed. 94 | 95 | ## License 96 | 97 | This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT). 98 | -------------------------------------------------------------------------------- /exportify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Exportify 6 | 7 | 8 | 9 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 |
Fork me on Github
121 |
122 | 130 | 131 |
132 | 133 |
134 |

135 |

Oops, Exportify has encountered a rate limiting error while using the Spotify API. This might be because of large numbers of users currently exporting playlists, or perhaps because you have too many playlists to export all at once.

136 |

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

137 |
138 |
139 | 140 | 141 | -------------------------------------------------------------------------------- /exportify.js: -------------------------------------------------------------------------------- 1 | window.Helpers = { 2 | authorize: function() { 3 | var client_id = this.getQueryParam('client_id') || "efe1f60ee4484ea796b5c441214961df"; 4 | 5 | window.location = "https://accounts.spotify.com/authorize" + 6 | "?client_id=" + client_id + 7 | "&redirect_uri=" + encodeURIComponent([location.protocol, '//', location.host, location.pathname].join('')) + 8 | "&scope=playlist-read-private%20playlist-read-collaborative%20user-library-read" + 9 | "&response_type=token"; 10 | }, 11 | 12 | // http://stackoverflow.com/a/901144/4167042 13 | getQueryParam: function(name) { 14 | name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); 15 | var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"), 16 | results = regex.exec(location.search); 17 | return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); 18 | }, 19 | 20 | apiCall: function(url, access_token) { 21 | return $.ajax({ 22 | url: url, 23 | headers: { 24 | 'Authorization': 'Bearer ' + access_token 25 | } 26 | }).fail(function (jqXHR, textStatus) { 27 | if (jqXHR.status == 401) { 28 | // Return to home page after auth token expiry 29 | window.location = window.location.href.split('#')[0] 30 | } else if (jqXHR.status == 429) { 31 | // API Rate-limiting encountered 32 | window.location = window.location.href.split('#')[0] + '?rate_limit_message=true' 33 | } else { 34 | // Otherwise report the error so user can raise an issue 35 | alert(jqXHR.responseText); 36 | } 37 | }) 38 | } 39 | } 40 | 41 | var PlaylistTable = React.createClass({ 42 | getInitialState: function() { 43 | return { 44 | playlists: [], 45 | playlistCount: 0, 46 | nextURL: null, 47 | prevURL: null 48 | }; 49 | }, 50 | 51 | loadPlaylists: function(url) { 52 | var userId = ''; 53 | var firstPage = typeof url === 'undefined' || url.indexOf('offset=0') > -1; 54 | 55 | window.Helpers.apiCall("https://api.spotify.com/v1/me", this.props.access_token).then(function(response) { 56 | userId = response.id; 57 | 58 | if (firstPage) { 59 | return $.when.apply($, [ 60 | window.Helpers.apiCall( 61 | "https://api.spotify.com/v1/users/" + userId + "/playlists", 62 | this.props.access_token 63 | ) 64 | ]) 65 | } else { 66 | return window.Helpers.apiCall(url, this.props.access_token); 67 | } 68 | }.bind(this)).done(function() { 69 | var response; 70 | var playlists = []; 71 | 72 | if (arguments[1] === 'success') { 73 | response = arguments[0]; 74 | playlists = arguments[0].items; 75 | } else { 76 | response = arguments[1][0]; 77 | playlists = $.merge([arguments[0][0]], arguments[1][0].items); 78 | } 79 | 80 | // Show library of saved tracks if viewing first page 81 | if (firstPage) { 82 | playlists.unshift({ 83 | "id": "saved", 84 | "name": "Saved", 85 | "public": false, 86 | "collaborative": false, 87 | "owner": { 88 | "id": userId, 89 | "uri": "spotify:user:" + userId 90 | }, 91 | "tracks": { 92 | "href": "https://api.spotify.com/v1/me/tracks", 93 | "limit": 50, 94 | "total": window.localStorage && window.localStorage.getItem("librarySize") ? window.localStorage.getItem("librarySize") : 2500, // TODO: get rid of static library size 95 | "totalIsEstimate": true 96 | }, 97 | "uri": "spotify:user:" + userId + ":saved" 98 | }); 99 | } 100 | 101 | if (this.isMounted()) { 102 | this.setState({ 103 | playlists: playlists, 104 | playlistCount: response.total, 105 | nextURL: response.next, 106 | prevURL: response.previous 107 | }); 108 | 109 | $('#playlists').fadeIn(); 110 | $('#subtitle').text((response.offset + 1) + '-' + (response.offset + response.items.length) + ' of ' + response.total + ' playlists for ' + userId) 111 | } 112 | }.bind(this)) 113 | }, 114 | 115 | exportPlaylists: function() { 116 | PlaylistsExporter.export(this.props.access_token, this.state.playlistCount); 117 | }, 118 | 119 | componentDidMount: function() { 120 | this.loadPlaylists(this.props.url); 121 | }, 122 | 123 | render: function() { 124 | if (this.state.playlists.length > 0) { 125 | return ( 126 |
127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | {this.state.playlists.map(function(playlist, i) { 142 | return ; 143 | }.bind(this))} 144 | 145 |
NameOwnerTracksPublic?Collaborative?
146 | 147 |
148 | ); 149 | } else { 150 | return
151 | } 152 | } 153 | }); 154 | 155 | var PlaylistRow = React.createClass({ 156 | exportPlaylist: function() { 157 | PlaylistExporter.export(this.props.access_token, this.props.playlist); 158 | }, 159 | 160 | renderTickCross: function(condition) { 161 | if (condition) { 162 | return 163 | } else { 164 | return 165 | } 166 | }, 167 | 168 | renderIcon: function(playlist) { 169 | if (playlist.name == 'Starred' || playlist.name == 'Saved') { 170 | return ; 171 | } else { 172 | return ; 173 | } 174 | }, 175 | 176 | render: function() { 177 | playlist = this.props.playlist 178 | 179 | return ( 180 | 181 | {this.renderIcon(playlist)} 182 | {playlist.name} 183 | {playlist.owner.id} 184 | {playlist.tracks.totalIsEstimate ? "" : playlist.tracks.total} 185 | {this.renderTickCross(playlist.public)} 186 | {this.renderTickCross(playlist.collaborative)} 187 | 188 | 189 | ); 190 | } 191 | }); 192 | 193 | var Paginator = React.createClass({ 194 | nextClick: function(e) { 195 | e.preventDefault() 196 | 197 | if (this.props.nextURL != null) { 198 | this.props.loadPlaylists(this.props.nextURL) 199 | } 200 | }, 201 | 202 | prevClick: function(e) { 203 | e.preventDefault() 204 | 205 | if (this.props.prevURL != null) { 206 | this.props.loadPlaylists(this.props.prevURL) 207 | } 208 | }, 209 | 210 | render: function() { 211 | if (this.props.nextURL != null || this.props.prevURL != null) { 212 | return ( 213 | 227 | ) 228 | } else { 229 | return
 
230 | } 231 | } 232 | }); 233 | 234 | // Handles exporting all playlist data as a zip file 235 | var PlaylistsExporter = { 236 | export: function(access_token, playlistCount) { 237 | var playlistFileNames = []; 238 | 239 | window.Helpers.apiCall("https://api.spotify.com/v1/me", access_token).then(function(response) { 240 | var limit = 20; 241 | var userId = response.id; 242 | 243 | var requests = []; 244 | 245 | // Add other playlists 246 | for (var offset = 0; offset < playlistCount; offset = offset + limit) { 247 | var url = "https://api.spotify.com/v1/users/" + userId + "/playlists"; 248 | requests.push( 249 | window.Helpers.apiCall(url + '?offset=' + offset + '&limit=' + limit, access_token) 250 | ) 251 | } 252 | 253 | $.when.apply($, requests).then(function() { 254 | var playlists = []; 255 | var playlistExports = []; 256 | 257 | // Handle either single or multiple responses 258 | if (typeof arguments[0].href == 'undefined') { 259 | $(arguments).each(function(i, response) { 260 | if (typeof response[0].items === 'undefined') { 261 | // Single playlist 262 | playlists.push(response[0]); 263 | } else { 264 | // Page of playlists 265 | $.merge(playlists, response[0].items); 266 | } 267 | }) 268 | } else { 269 | playlists = arguments[0].items 270 | } 271 | 272 | // Add library of saved tracks 273 | playlists.unshift({ 274 | "id": "saved", 275 | "name": "Saved", 276 | "tracks": { 277 | "href": "https://api.spotify.com/v1/me/tracks", 278 | "limit": 50, 279 | "total": window.localStorage && window.localStorage.getItem("librarySize") ? window.localStorage.getItem("librarySize") : 2500, // TODO: get rid of static library size 280 | "totalIsEstimate": true 281 | }, 282 | }); 283 | 284 | $(playlists).each(function(i, playlist) { 285 | playlistFileNames.push(PlaylistExporter.fileName(playlist)); 286 | playlistExports.push(PlaylistExporter.csvData(access_token, playlist)); 287 | }); 288 | 289 | return $.when.apply($, playlistExports); 290 | }).then(function() { 291 | var zip = new JSZip(); 292 | var responses = []; 293 | 294 | $(arguments).each(function(i, response) { 295 | zip.file(playlistFileNames[i], response) 296 | }); 297 | 298 | var content = zip.generate({ type: "blob" }); 299 | saveAs(content, "spotify_playlists.zip"); 300 | }); 301 | }); 302 | } 303 | } 304 | 305 | // Handles exporting a single playlist as a CSV file 306 | var PlaylistExporter = { 307 | export: function(access_token, playlist) { 308 | this.csvData(access_token, playlist).then(function(data) { 309 | var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" }); 310 | saveAs(blob, this.fileName(playlist), true); 311 | }.bind(this)) 312 | }, 313 | 314 | csvData: function(access_token, playlist) { 315 | var requests = []; 316 | var limit = playlist.tracks.limit || 100; 317 | 318 | for (var offset = 0; offset < playlist.tracks.total; offset = offset + limit) { 319 | requests.push( 320 | window.Helpers.apiCall(playlist.tracks.href.split('?')[0] + '?offset=' + offset + '&limit=' + limit, access_token) 321 | ) 322 | } 323 | 324 | return $.when.apply($, requests).then(function() { 325 | var responses = []; 326 | 327 | // Handle either single or multiple responses 328 | if (typeof arguments[0] != 'undefined') { 329 | if (typeof arguments[0].href == 'undefined') { 330 | responses = Array.prototype.slice.call(arguments).map(function(a) { return a[0] }); 331 | } else { 332 | responses = [arguments[0]]; 333 | } 334 | } 335 | 336 | var tracks = responses.map(function(response) { 337 | return response.items.map(function(item) { 338 | return [ 339 | item.track.uri, 340 | item.track.name, 341 | item.track.artists.map(function (artist) { return String(artist.uri).replace(/,/g, "\\,"); }).join(', '), 342 | item.track.artists.map(function (artist) { return String(artist.name).replace(/,/g, "\\,"); }).join(', '), 343 | item.track.album.uri, 344 | item.track.album.name, 345 | item.track.disc_number, 346 | item.track.track_number, 347 | item.track.duration_ms, 348 | item.added_by == null ? '' : item.added_by.uri, 349 | item.added_at 350 | ]; 351 | }); 352 | }); 353 | 354 | // Flatten the array of pages 355 | tracks = $.map(tracks, function(n) { return n }) 356 | 357 | tracks.unshift([ 358 | "Track URI", 359 | "Track Name", 360 | "Artist URI", 361 | "Artist Name", 362 | "Album URI", 363 | "Album Name", 364 | "Disc Number", 365 | "Track Number", 366 | "Track Duration (ms)", 367 | "Added By", 368 | "Added At" 369 | ]); 370 | 371 | csvContent = ''; 372 | tracks.forEach(function (row, index){ 373 | dataString = row.map(function (cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(","); 374 | csvContent += dataString + "\n"; 375 | }); 376 | 377 | return csvContent; 378 | }); 379 | }, 380 | 381 | fileName: function(playlist) { 382 | return playlist.name.replace(/[\x00-\x1F\x7F/\\<>:;"|=,.?*[\] ]+/g, "_").toLowerCase() + ".csv"; 383 | } 384 | } 385 | 386 | $(function() { 387 | var vars = window.location.hash.substring(1).split('&'); 388 | var key = {}; 389 | for (i=0; i, playlistsContainer); 401 | } 402 | }); 403 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delight-im/exportify/17cd4fa093bfc5c4523a72ef13cd6dfb6a29fb28/screenshot.png --------------------------------------------------------------------------------