diff --git a/LICENSE.md b/LICENSE.md index d7f1051..8bf1479 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ GNU GENERAL PUBLIC LICENSE Version 2, June 1991 - Copyright (C) 1989, 1991 Free Software Foundation, Inc., + Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. diff --git a/README.md b/README.md index 1439975..8c4b77f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,175 @@ # SpotMyBackup + Backup and Restore your Spotify Playlists and "My Music". This javascript based app allows you to backup all your playlists and import them in any other Spotify Account. It uses the OAuth-Functionality of Spotify to be able to handle your personal playlists. In consequence, no credentials or data is stored or processed on the Webserver itself. -You can use it at www.spotmybackup.com or on your own webserver (see Q&A). +You can use it at [www.spotmybackup.com](http://www.spotmybackup.com) or on your own webserver (see Q&A). + +## Own hosted + +Configure `uri` in `config.json` to your host/ip/domain and port, if is different. +For example: `http://127.0.0.1:8888` + +Create or edit a Spotify application: + +* [Spotify: Developer Dashboard](https://developer.spotify.com/dashboard/) +* Edit settings + * Configure your redirect/callback uri for example to: `http://127.0.0.1:8888/callback-spotify.html` + * (Saved it) +* Copy your Cliend ID and store it in `config.json` file under `clientId`. + +Run a webserver, for example: + +* [XAMPP](https://www.apachefriends.org/) +* [Docker: Nginx](https://hub.docker.com/_/nginx) + +```bash +# Python 3 +python3 -m http.server -b 127.0.0.1 8888 + +# Python 2 +python -m SimpleHTTPServer -b 127.0.0.1 8888 + +# Docker non detached +docker run --rm -v ${PWD}:/usr/share/nginx/html:ro -p '127.0.0.1:8888:80' --name spotify-nginx nginx +``` + +... and open your configured url [127.0.0.1:8888](http://127.0.0.1:8888) in a web browser. + +If you run into a CORS error message: + +* You should use your ip instead of localhost +* You should add SSL (https) +* [CORS request did not succeed](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSDidNotSucceed) + +## Configuration + +The `config.json` file is for overwriting existing default configuration. +So it is not necessary to write everything down. + +*Note: Default JSON did not have comments. It is just easier to explain.* + +The minimal configuration, is mostly your uri and clientId: + +```json +{ + // Required, your Spotify application client id + "clientId": "[YOUR_TOKEN_HERE]", + + // Find the right window in callback-*.html + "uri": "http://127.0.0.1:8888", + + // Spotify's callback uri + "redirectUri": "http://127.0.0.1:8888/callback-spotify.html" +} +``` + +Extended configuration, mostly because you want it pretty: + +```json +{ + // Change exported: {filename}.json + // %u = username + // %Y-%m-%d = Year-month-day = 2013-02-30 + // %H-%i-%s = Hour-minutes-seconds = 14-59-01 + "filename": "spotify_%u_%Y-%m-%d_%H-%i-%s", + + "prettyPrint": 0, // Json pretty-printing spacing level + "extendedTrack": false, // More data, like track name, artist names, album name + "slowdownExport": 100, // Slow down api calls for tracks in milliseconds + "slowdownImport": 100, // Slow down api calls for tracks in milliseconds + "market": "US" // Track Relinking for is_playable https://developer.spotify.com/documentation/web-api/concepts/track-relinking +} +``` + +## Developers + +* [Spotify: Web Api Reference](https://developer.spotify.com/documentation/web-api/reference/) + +Developer configuration for `config.json`, you should know what you doing: + +```json +{ + "development": false, // A switch for some switches + + // You might not want to loading all tracks, because this could be huge! + "devShortenSpotifyExportTracks": 0, + + "dryrun": true, // Do not make any changes + "printPlaylists": false, // Just print playlist on website + "excludeSaved": false, // Exclude saved/my-music in export + "excludePlaylists": [] // Exclude playlist ids in export +} +``` + +Check export with jq: + +```bash +sudo apt install jq + +jq '{file: input_filename, savedCount: .saved? | length, playlistCount: .playlists? | length, playlists: [.playlists?[] | {name: .name, tracks: .tracks | length}]}' ~/Downloads/spotify_*.json +``` + +## Filter not playable tracks + +Since availability is not always guaranteed in Spotify, I would like to see which songs can no longer be played. + +First you need to add `market` in your `config.json` file: + +```json +{ + "market": "US" // Track Relinking for is_playable https://developer.spotify.com/documentation/web-api/concepts/track-relinking +} +``` + +Then create a new spotify backup. You will see a `is_playable` key in your track data. + +Download and install [JQ](https://jqlang.github.io/jq/). + +Execute this command: + +```bash +jq '{ + playlists: [.playlists[] | { + name: .name, + notPlayable: ([.tracks[] | select(.is_playable == false)] | length), + total: ([.tracks[]] | length), + tracks: ([.tracks[] | select(.is_playable == false) | {name: .name, artists: .artists}]) + }], + saved: { + notPlayable: ([.saved[] | select(.is_playable == false)] | length), + total: ([.saved[]] | length), + tracks: ([.saved[] | select(.is_playable == false) | {name: .name, artists: .artists}]) + } +}' backup.json > not-playable.json +``` + +If you want full track data, remove both " | {name: .name, artists: .artists}". + +***Note: You can not import the "not-playable.json" file as a recovery!*** + +Example Output: + +```json +{ + "playlists": [ + { + "name": "Games", + "notPlayable": 1, + "total": 24, + "tracks": [ + { + "name": "Exile Vilify (From the Game Portal 2)", + "artists": [ + "The National" + ] + } + ] + }, + ], + "saved": {...} +} +``` diff --git a/app.css b/app.css new file mode 100644 index 0000000..961204e --- /dev/null +++ b/app.css @@ -0,0 +1,168 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + margin: 0 0 40px 0; + padding: 0; + font-family: 'Lato', sans-serif; + font-size: 16px; + background: url(gplaypattern.png) repeat; + background-size: 188px 178px; +} + +a { + color: #121F59; + text-decoration: none; +} +a:hover { + color: #A2C852; +} + + +.header, +.footer, +.container { + margin: 0 auto; + padding: 12px; + width: 640px; + background-color: rgba(255,255,255,0.9); +} + +.header { + color: #444; + margin-bottom: 10px; + border-bottom: solid 1px #eaeaea; +} +.header h1 { + margin: 0 0 20px 0; + font-size: 44px; + font-weight: bold; +} +.header h1 span { + font-weight: 300; + color: #555; +} +.header p { + margin: 0; + font-size: 24px; +} + +.footer-wrapper { + position: fixed; + bottom: 0; + width: 100%; +} +.footer { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0; + border-top: solid 1px #eaeaea; +} +.footer a { + display: inline-block; + flex: 0 1 auto; + + font-size: 14px; + padding: 5px 15px; + color: #121F59; + text-decoration: none; +} +.footer a:hover { + color: #A2C852; +} + +.container { + border-bottom: solid 1px #eaeaea; +} +.container > *:last-child > *:last-child, +.container > *:last-child { + margin-bottom: 0 !important; +} + +.button { + display: block; + margin: 0 auto; + padding: 10px 47px; + min-width: 130px; + + color: #fff; + background-color: #2ebd59; + background-image: none; + border: none; + border-radius: 999em; + font-size: 18px; + font-weight: 600; + line-height: 1.5em; + letter-spacing: 1.2px; + text-transform: uppercase; + text-align: center; + white-space: nowrap; + white-space: normal; + cursor: pointer; +} + +.bg-red { + background-color: #bd2e2e; +} + +.avatar { + padding-bottom: 10px; + font-weight: bold; +} + +#login, +#btnDownload { + margin-bottom: 1em; +} + +#pnlImport { + display: flex; +} +#pnlImport label { + display: inline-block; + cursor: pointer; +} +#pnlImport input { + display: none; +} + +.badge-fork img { + position: absolute; + top: 0; + left: 0; + border: 0; +} + +.alert { + position: relative; + padding: .75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: .25rem; +} +.alert > *:last-child { + margin-bottom: 0; +} +.alert-info { + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb; +} +.alert-warning { + color: #856404; + background-color: #fff3cd; + border-color: #ffeeba; +} +.alert-danger { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +.count-track { + display: inline-block; + min-width: 2.5em; + text-align: right; +} \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..6d34c32 --- /dev/null +++ b/app.js @@ -0,0 +1,926 @@ +// @todo Check if old backups have track ids or uris for fallback +class Log { + constructor(container) { + this.container = container; + } + + createAlert(type, message) { + const div = document.createElement('div'); + div.classList.add('alert', `alert-${type}`); + div.innerHTML = message; + this.container.appendChild(div); + return div; + } + + message(type, message) { + switch(type) { + case 'success': type = '✅ '; break; + case 'error': type = '❌ '; break; + case 'warning': type = '⚠️ '; break; + case 'info': type = 'ℹ️ '; break; + default: type = ''; + } + return `${type}${message}`; + } + + createMessage(type, message) { + const div = document.createElement('div'); + div.innerHTML = this.message(type, message); + return div; + } + + asciiSpinner(key, message) { + const instance = this; + + // https://raw.githubusercontent.com/sindresorhus/cli-spinners/master/spinners.json + const spinners = { + dots: { + interval: 80, + frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + }, + time: { + interval: 100, + frames: ["🕐 ", "🕑 ", "🕒 ", "🕓 ", "🕔 ", "🕕 ", "🕖 ", "🕗 ", "🕘 ", "🕙 ", "🕚 ", "🕛 "] + } + }; + + const spinner = spinners[key]; + if (spinner) { + const div = document.createElement('div'); + + const el = document.createElement('span'); + el.style.display = 'inline-block'; + el.style.width = '1.3em'; + div.appendChild(el); + + const span = document.createElement('span'); + span.innerHTML = `${message}`; + div.appendChild(span); + + ((spinner, el) => { + let i = 0; + setInterval(() => { + el.innerHTML = spinner.frames[i]; + i = (i + 1) % spinner.frames.length; + }, spinner.interval); + })(spinner, el); + + return { + container: div, + spinner: div.children[0], + message: (message) => { + div.children[1].innerHTML = message; + }, + messageOnly: (type, message) => { + div.parentNode.replaceChild( + instance.createMessage(type, message), div + ); + } + }; + } + return null; + } +} + +class Download { + async urls(urls) { + const instance = this; + if (!urls) { + console.error('Urls to download missing!'); + return false; + } + if (typeof urls === 'string') { + urls = [urls]; + } + + let result = null; + await urls.forEach(async url => { + const resultUrl = await instance.url(null, url); + result = (result !== null ? result && resultUrl : false); + }); + return (result !== null ? result : false); + } + + // download.url('file.txt', 'https://example.org'); + async url(filename, url) { + if (!filename || filename === '') { + filename = url; + filename = filename.replace(/^.*[\\\/]/, ''); + filename = filename.replace(/\?.*$/, ''); + } + + return fetch(url).then(result => { + if (!result.ok) { + throw Error(result.statusText); + } + return result.blob(); + }).then(file => { + const tempUrl = URL.createObjectURL(file); + const result = this.runDownloadLinkClick(filename, tempUrl); + URL.revokeObjectURL(tempUrl); + return result; + }).catch(reason => { + console.error('Error downloading: ' + reason); + return false; + }); + } + + // download.contentText('file.txt', 'content'); + contentText(filename, content) { + if (!filename || filename === '') { + filename = 'file.txt'; + } + return this.runDownloadLinkClick(filename, 'data:text/plain;charset=utf-8,' + encodeURIComponent(content)); + } + + runDownloadLinkClick(filename, url) { + const element = document.createElement('a'); + element.setAttribute('href', url); + element.setAttribute('download', filename); + element.click(); + return true; + } +} + +const download = new Download(); + +class App { + constructor() { + this.configurationFile = 'config.json'; + this.authWindow = null; + + this.settings = { + clientId: '', // Spotify client id + uri: 'http://127.0.0.1:8888', // Callback uri for callback-*.html + redirectUri: 'http://127.0.0.1:8888/callback-spotify.html', // Spotify's callback uri + + filename: 'spotify_%u_%Y-%m-%d_%H-%i-%s', // Exported filename + prettyPrint: 0, // Json pretty-printing spacing level + extendedTrack: false, // Extend track data + slowdownExport: 100, // Slowdown api calls for tracks in milliseconds + slowdownImport: 100, // Slowdown api calls for tracks in milliseconds + market: '', // Track Relinking for is_playable https://developer.spotify.com/documentation/web-api/concepts/track-relinking + + development: false, // A switch for some switches + devShortenSpotifyExportTracks: 0, // Shorten track data + dryrun: false, // Do not make any changes + printPlaylists: false, // Just print playlist on website + excludeSaved: false, // Exclude saved/my-music in export + excludePlaylists: [] // Exclude playlist ids in export + }; + + this.export = null; + this.state = { + userId: '', + }; + + this.container = document.getElementById('container'); + this.log = new Log(this.container); + } + + async initialize() { + const instance = this; + if (this.isMicrosoftInternetExplorer()) { + return; // Note: Error handling in index.html + } + + if (!this.isServer()) { + return; + } + + const isLoaded = await this.loadConfiguration(); + if (!isLoaded) { + return; + } + + // Try automatically connect with last access token + let authentificatedData = false; + if (sessionStorage.getItem('accessToken')) { + authentificatedData = await this.checkAuthentification(true); + if (authentificatedData) { + this.showMenu(authentificatedData); + } + } + + // If fail let the user connect by self + if (!authentificatedData) { + const loginBtn = document.getElementById('login'); + if (loginBtn) { + loginBtn.style.display = 'block'; + loginBtn.addEventListener('click', (event) => { + instance.spotifyAuthorize(event); + }); + window.addEventListener('message', (event) => { + instance.spotifyAuthorizeCallback(event); + }, false); + } + } + } + + isMicrosoftInternetExplorer() { + return (navigator.userAgent.indexOf('MSIE') >= 0 || navigator.appVersion.indexOf('Trident/') >= 0); + } + + // Check if someone opens it the file with 'file://' + isServer() { + if (location.protocol.startsWith('http')) { + return true; + } + + const example = (title, code) => {return `

${title}:
${code}

`}; + this.container.innerHTML = ''; + + this.log.createAlert('danger', + 'Sorry, you must use a server! For example:' + + example('Python 3', 'python3 -m http.server -b 127.0.0.1 8888') + + example('Python 2', 'python -m SimpleHTTPServer -b 127.0.0.1 8888') + ); + return false; + } + + async loadConfiguration() { + const instance = this; + return await this.getJson(this.configurationFile).then(data => { + data.prettyPrint = parseInt(data.prettyPrint, 10); + instance.settings = {...this.settings, ...data} + return true; + }).catch(error => { + const message = instance.log.createAlert('danger', 'Warning: Configuration file not loaded!
' + error); + const pnlLoggedOut = document.getElementById('pnlLoggedOut'); + if (pnlLoggedOut) { + pnlLoggedOut.parentNode.insertBefore(message, pnlLoggedOut); + } else { + document.getElementById('body').parentNode.append(message); + } + return false; + }); + } + + async getJson(url) { + return fetch(url, { + method: 'GET', + cache: 'no-cache', + headers: { + 'Accept': 'application/json' + } + }).then(response => { + if (response.status === 200) { + return response.json(); + } else { + console.error('Response: ', response); + return Promise.reject('Response error'); + } + }).catch(error => { + console.error('Error: ', error); + return Promise.reject(error); + }); + } + + async api(url, data, method) { + const instance = this; + + if (url.startsWith('/')) { + url = 'https://api.spotify.com/v1' + url; + } + + if (this.settings.market !== '') { + url = new URL(url); + url.searchParams.append('market', this.settings.market); + url = url.toString(); + } + + let options = { + method: (method ?? 'GET'), + cache: 'no-cache', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Bearer ' + sessionStorage.getItem('accessToken') + } + }; + + if (data) { + if (options.method !== 'PUT') { + options.method = 'POST'; + } + options.headers['Content-type'] = 'application/json'; + options.body = JSON.stringify(data); + } + + if (options.method === 'PUT') { + delete options.headers.Accept; + } + + return fetch(url, options).then(response => { + if ([200, 201].includes(response.status)) { + if (options.method === 'PUT') { + return true; + } else { + return response.json(); + } + } else { + console.error('Response: ', response); + return Promise.reject(response); + } + }).catch(async error => { + let errorMessage = 'API failed.'; + if (error.statusText) { + errorMessage += ` ${error.statusText}` + } + if (error.status) { + errorMessage += ` (${error.status})` + } + try { + const errorText = await error.text(); + const errorJson = JSON.parse(errorText); + if (errorJson.error && errorJson.error.message) { + errorMessage += `
${errorJson.error.message}` + if (errorJson.error.status) { + errorMessage += ` (${errorJson.error.status})` + } + } + } catch(exeption) {} + console.error('Error: ', error); + instance.log.createAlert('danger', `Error: ${errorMessage}`); + return Promise.reject(error); + }); + } + + async getApi(url) { + return await this.api(url, null, null); + } + + async postApi(url, data) { + return await this.api(url, data, null); + } + + async putApi(url, data) { + return await this.api(url, data, 'PUT'); + } + + spotifyAuthorize() { + const width = 480; + const height = 640; + const left = (screen.width / 2) - (width / 2); + const top = (screen.height / 2) - (height / 2); + + const queryParams = { + client_id: this.settings.clientId, + redirect_uri: this.settings.redirectUri, + scope: 'playlist-read playlist-read-private playlist-modify-public playlist-modify-private user-library-read user-library-modify', + response_type: 'token', + show_dialog: 'true' + }; + this.authWindow = window.open( + 'https://accounts.spotify.com/authorize?' + this.arrayToQueryParameter(queryParams), + 'Spotify', + 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + width + ', height=' + height + ', top=' + top + ', left=' + left + ); + } + + arrayToQueryParameter(data) { + let list = []; + for (let key in data) { + if (data.hasOwnProperty(key)) { + list.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])); + } + } + return list.join('&'); + } + + async spotifyAuthorizeCallback(event) { + if (event.origin !== this.settings.uri) { + console.error('"uri" missconfigured', {uri: this.settings.uri, origin: origin}); + return; + } + if (this.authWindow) { + this.authWindow.close(); + } + + sessionStorage.setItem('accessToken', event.data); + const authentificatedData = await this.checkAuthentification(false); + if (authentificatedData) { + this.showMenu(authentificatedData); + } + } + + async checkAuthentification(automatic) { + const instance = this; + return this.getApi('/me').then(response => { + return { + userId: response.id.toLowerCase(), + userName: response.display_name ?? response.id, + images: response.images, + urlProfile: response.external_urls?.spotify, + }; + }).catch(error => { + if (!automatic) { + instance.log.createAlert('danger', + 'Error: Authentification with Spotify failed! Reload page and try again!' + ); + } + + sessionStorage.removeItem('accessToken'); + console.error('Error: ', error); + return null; + }); + } + + async showMenu(data) { + document.getElementById('pnlLoggedOut').remove(); + this.container.innerHTML = ''; + + this.state.userId = data.userId; + this.appendAvatar(data.userName, data.images[0]?.url, data.urlProfile); + + await this.spotifyExport(); + this.appendDownload(); + this.appendImport(); + } + + appendAvatar(username, image, url) { + const userAvatar = (image ? ' ' : ''); + let content = `${userAvatar}${username}`; + if (url && url !== '') { + content = `${content}`; + } + + const avatar = document.createElement('div'); + avatar.classList.add('avatar'); + avatar.innerHTML = content; + this.container.append(avatar); + } + + appendDownload() { + const instance = this; + + const button = document.createElement('button'); + button.id = 'btnDownload'; + button.classList.add('button'); + button.innerText = 'Download'; + this.container.append(button); + + button.addEventListener('click', () => { + const d = new Date(); + const dMonth = d.getMonth() + 1; + let filename = instance.settings.filename; + filename = filename.replaceAll('%Y', d.getFullYear()); + filename = filename.replaceAll('%m', ((dMonth < 10) ? '0' : '') + dMonth); + filename = filename.replaceAll('%d', ((d.getDate() < 10) ? '0' : '') + d.getDate()); + filename = filename.replaceAll('%H', ((d.getHours() < 10) ? '0' : '') + d.getHours()); + filename = filename.replaceAll('%i', ((d.getMinutes() < 10) ? '0' : '') + d.getMinutes()); + filename = filename.replaceAll('%s', ((d.getSeconds() < 10) ? '0' : '') + d.getSeconds()); + filename = filename.replaceAll('%u', instance.state.userId); + download.contentText(`${filename}.json`, + JSON.stringify(instance.export, null, instance.settings.prettyPrint) + ); + }); + } + + appendImport() { + const instance = this; + + const element = document.createElement('div'); + element.id = 'pnlImport'; + element.innerHTML = + `` + + ``; + this.container.append(element); + + element.querySelector('input').addEventListener('change', (event) => { + instance.spotifyImport(event); + }); + } + + async spotifyExport() { + this.export = {}; + this.progressLogExport = this.log.createAlert('info', 'Exporting Spotify'); + + let playlists = await this.spotifyExportPlaylists(); + this.printPlaylists('Playlists found', playlists); + + if (this.settings.excludePlaylists.length > 0) { + playlists = this.filterPlaylists(playlists); + this.printPlaylists('Playlists filtered', playlists); + } + + playlists = await this.spotifyExportPlaylistsTracks(playlists); + this.export.playlists = playlists; + + if (!this.settings.excludeSaved) { + let saved = await this.spotifyExportSavedTracks(); + this.export.saved = saved; + } + } + + async spotifyExportPlaylists() { + const spinner = this.log.asciiSpinner('time', `Loading playlists...`); + this.progressLogExport.appendChild(spinner.container); + let count = 0; + + let playlists = []; + let url = '/users/' + this.state.userId + '/playlists' + do { + const response = await this.getApi(url); + if (response.items) { + response.items.forEach(playlist => { + if (playlist.tracks && playlist.tracks.href) { + playlists.push({ + name: playlist.name, + description: playlist.description, + public: playlist.public, + collaborative: playlist.collaborative, + href: playlist.tracks.href, + id: playlist.id, + tracks: [] + }); + count++; + } + }); + } + url = response.next ?? null; + spinner.message(`Loading playlists... ${count}`); + } while(url); + + spinner.messageOnly('success', `${count} Playlists found`); + return playlists; + } + + printPlaylists(title, playlists) { + if (this.settings.printPlaylists) { + let data = []; + playlists.forEach(playlist => { + data.push(`${playlist.name} (${playlist.id})`); + }); + this.log.createAlert('info', `${title}:'); + } + } + + filterPlaylists(playlists) { + const instance = this; + let filtered = []; + playlists.forEach(playlist => { + if (!instance.settings.excludePlaylists.includes(playlist.id)) { + filtered.push(playlist); + } + }); + + this.progressLogExport.appendChild( + this.log.createMessage('success', `${filtered.length} Playlists filtered for export`) + ); + return filtered; + } + + async spotifyExportPlaylistsTracks(playlists) { + for (let i = 0; i < playlists.length; i++) { + const spinner = this.log.asciiSpinner('time', `Loading ${playlists[i].name} tracks...`); + this.progressLogExport.appendChild(spinner.container); + + playlists[i].tracks = await this.spotifyExportTracks(playlists[i].href); + delete playlists[i].href; + + spinner.messageOnly('success', `${playlists[i].tracks.length} Tracks: ${playlists[i].name}`); + } + return playlists; + } + + async spotifyExportSavedTracks() { + const spinner = this.log.asciiSpinner('time', `Loading saved tracks...`); + this.progressLogExport.appendChild(spinner.container); + + const tracks = await this.spotifyExportTracks('/me/tracks'); + + spinner.messageOnly('success', `${tracks.length} Tracks: Saved`); + return tracks; + } + + async spotifyExportTracks(url) { + const instance = this; + + const spinner = this.log.asciiSpinner('time', `Loading tracks for current list...`); + this.progressLogExport.appendChild(spinner.container); + + let count = 0; + let devCount = 0; + + let tracks = []; + do { + const response = await this.getApi(url); + if (!response) { + return; + } + if (response.items) { + response.items.forEach(track => { + if (track.track) { + let trackData = { + id: track.track.id, + uri: track.track.uri + }; + + if (instance.settings.extendedTrack) { + trackData.name = track.track.name; + trackData.album = track.track.album?.name; + if (track.track.artists) { + trackData.artists = []; + track.track.artists.forEach(artist => { + trackData.artists.push(artist.name); + }); + } + } + + if (track.track.hasOwnProperty('is_playable')) { + trackData.is_playable = track.track.is_playable; + } + + tracks.push(trackData); + count++; + } else { + console.log('Track is null', url, track); + instance.log.createAlert('warning', 'Warning: Track is null! See console log.'); + } + }); + } + url = response.next ?? null; + spinner.message(`Loading tracks for current list... ${count}`) + + // On development you might not want to loading all tracks, because this could be huge! + if (this.settings.development && this.settings.devShortenSpotifyExportTracks <= ++devCount) { + break; + } + + // @todo Better know the api limit + await new Promise(resolve => { + console.log(`Slowdown exporting tracks by ${instance.settings.slowdownExport} ms`); + setTimeout(resolve, instance.settings.slowdownExport); + }); + } while(url); + + spinner.container.remove(); + return tracks; + } + + async spotifyImport(event) { + // @todo We could support importing multiple files at once, but should we?! + if (event.target.files.length > 1) { + this.log.createAlert('warning', 'Warning: Importing multiple files is not supported!'); + return; + } + + if (event.target.files.length === 1) { + // Hide download and import button, because data has changed + document.getElementById('btnDownload').remove(); + document.getElementById('pnlImport').remove(); + + const data = await this.readFileAsync(event.target.files[0]); + + this.progressLogImport = this.log.createAlert('info', 'Importing to Spotify'); + + this.progressLogImport.appendChild( + this.log.createMessage('info', `Filename: ${event.target.files[0].name}`) + ); + + if (this.settings.dryrun) { + this.progressLogImport.appendChild( + this.log.createMessage('info', `Dry run: Nothing will be stored`) + ); + } + + // @todo Check starred import + if (data.starred) { + data.playlists.push({ + name: 'Deprecated "starred" playlist', + tracks: data.starred + }); + + this.progressLogImport.appendChild( + this.log.createMessage('warning', `Starred is deprecated and will be imported as a playlist!`) + ); + } + + if (data.playlists) { + await this.spotifyImportPlaylists(data.playlists); + } + + if (data.saved) { + await this.spotifyImportSaved(data.saved); + } + } + } + + readFileAsync(file) { + return new Promise((resolve, reject) => { + if (!file || file.type !== 'application/json') { + this.log.createAlert('danger', 'Error: File is not supported!'); + reject(); + } + + const reader = new FileReader(); + reader.onload = (event) => { + const json = event.target.result; + const data = JSON.parse(json); + resolve(data); + }; + reader.onerror = reject; + reader.readAsText(file); + }); + } + + async spotifyImportPlaylists(playlists) { + const instance = this; + const spinner = this.log.asciiSpinner('time', `Importing playlists...`); + this.progressLogImport.appendChild(spinner.container); + let count = 0; + + for (let i = 0; i < playlists.length; i++) { + let foundPlaylist = null; + instance.export.playlists.forEach(exportedPlaylist => { + if (playlists[i].id === exportedPlaylist.id) { + foundPlaylist = exportedPlaylist; + return; + } + }); + + // @todo I don't know if guess by name is a good idea + if (!foundPlaylist) { + instance.export.playlists.forEach(exportedPlaylist => { + if (playlists[i].name === exportedPlaylist.name) { + foundPlaylist = exportedPlaylist; + return; + } + }); + } + + if (!foundPlaylist) { + const newPlaylist = await this.createPlaylist(playlists[i]); + if (newPlaylist) { + foundPlaylist = newPlaylist; + } + } + + if (!foundPlaylist && !this.settings.dryrun) { + this.progressLogImport.appendChild( + this.log.createMessage('error', `Playlists not found or created in Spotify: ${playlists[i].name}`) + ); + } + + if (foundPlaylist) { + const tracksToImport = this.comparePlaylistTracks(playlists[i], foundPlaylist); + await this.spotifyImportTracks(foundPlaylist, tracksToImport); + } + + count++; + spinner.message(`Importing playlists... ${count}`); + } + + spinner.messageOnly('success', `${count} Playlists imported`) + } + + async createPlaylist(playlist) { + const collaborative = (playlist.collaborative ? true : false); + const data = { + name: playlist.name, + description: (playlist.description ?? ''), + public: (playlist.public ? !collaborative : false), + collaborative: collaborative + }; + + if (!this.settings.dryrun) { + const response = await this.postApi('/users/' + this.state.userId + '/playlists', data); + if (response && response.id !== '') { + this.progressLogImport.appendChild( + this.log.createMessage('success', `Playlists created: ${playlist.name}`) + ); + + return response; + } else { + this.progressLogImport.appendChild( + this.log.createMessage('error', `Playlists not created: ${playlist.name}`) + ); + } + } else { + this.progressLogImport.appendChild( + this.log.createMessage('info', `Playlists created: ${playlist.name}`) + ); + } + return null; + } + + // Note: Saved import want ids instead of uris + comparePlaylistTracks(importedPlaylist, storedPlaylist, onlyIds) { + let tracks = []; + importedPlaylist.tracks.forEach(importedTrack => { + let found = false; + if (storedPlaylist.tracks && storedPlaylist.tracks.length > 0) { + storedPlaylist.tracks.forEach(storedTrack => { + if (!onlyIds && importedTrack.uri === storedTrack.uri) { + found = true; + return; + } else if (onlyIds && importedTrack.id === storedTrack.id) { + found = true; + return; + } + }); + } + + // Select only only missing tracks for import + if (!found) { + tracks.push(!onlyIds ? importedTrack.uri : importedTrack.id); + } + }); + return tracks; + } + + async spotifyImportTracks(playlist, tracks) { + const instance = this; + tracks = tracks.reverse(); + let count = 0; + const chunkSize = 100; + for (let i = 0; i < tracks.length; i += chunkSize) { + const chunkTracks = tracks.slice(i, i + chunkSize); + + if (!this.settings.dryrun) { + const response = await this.postApi('/playlists/' + playlist.id + '/tracks', { + uris: chunkTracks.reverse(), + position: 0 + }); + + if (response && response.id !== '') { + count += chunkTracks.length; + } else { + console.error('Playlists not fully imported', playlist, chunkTracks); + this.progressLogImport.appendChild( + this.log.createMessage('error', `Playlists not fully imported! See console error.`) + ); + } + + // @todo Better know the api limit + await new Promise(resolve => { + console.log(`Slowdown importing tracks by ${instance.settings.slowdownImport} ms`); + setTimeout(resolve, instance.settings.slowdownImport); + }); + } else { + count += chunkTracks.length; + } + } + + const type = (!this.settings.dryrun ? 'success' : 'info'); + this.progressLogImport.appendChild( + this.log.createMessage(type, `${count} / ${tracks.length} tracks: ${playlist.name}`) + ); + } + + async spotifyImportSaved(saved) { + const instance = this; + const spinner = this.log.asciiSpinner('time', `Importing saved...`); + this.progressLogImport.appendChild(spinner.container); + + const tracksToImport = this.comparePlaylistTracks({tracks: saved}, {tracks: instance.export.saved}, true); + await this.spotifyImportSavedTracks(tracksToImport); + + spinner.messageOnly('success', `Saved imported`); + } + + async spotifyImportSavedTracks(tracks) { + const instance = this; + tracks = tracks.reverse(); + let count = 0; + const chunkSize = 50; + for (let i = 0; i < tracks.length; i += chunkSize) { + const chunkTracks = tracks.slice(i, i + chunkSize); + + if (!this.settings.dryrun) { + const response = await this.putApi('/me/tracks', { + ids: chunkTracks.reverse() + }); + + if (response) { + count += chunkTracks.length; + } else { + console.error('Saved not fully imported', tracks, chunkTracks); + this.progressLogImport.appendChild( + this.log.createMessage('error', `Saved not fully imported! See console error.`) + ); + } + + // @todo Better know the api limit + await new Promise(resolve => { + console.log(`Slowdown importing tracks by ${instance.settings.slowdownImport} ms`); + setTimeout(resolve, instance.settings.slowdownImport); + }); + } else { + count += chunkTracks.length; + } + } + + const type = (!this.settings.dryrun ? 'success' : 'info'); + this.progressLogImport.appendChild( + this.log.createMessage(type, `${count} / ${tracks.length} Tracks: Saved`) + ); + } +} + +(function() { + const app = new App(); + app.initialize(); +})(); + + diff --git a/callback-spotify.html b/callback-spotify.html new file mode 100644 index 0000000..38732a0 --- /dev/null +++ b/callback-spotify.html @@ -0,0 +1,21 @@ + + + + Spotify Login + + + + + diff --git a/config.json b/config.json index 81ee166..05680ae 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,11 @@ -config = { -"uri":"http://localhost:8888", -"redirect_uri":"http://localhost:8888/login.html", -"client_id":"[YOUR_TOKEN_HERE]", -"slowdown_import": 100, -"slowdown_export": 100 -}; +{ + "clientId": "[YOUR_TOKEN_HERE]", + "uri": "http://127.0.0.1:8888", + "redirectUri": "http://127.0.0.1:8888/callback-spotify.html", + + "filename": "spotify_%u_%Y-%m-%d_%H-%i-%s", + "prettyPrint": 0, + "extendedTrack": false, + "slowdownExport": 100, + "slowdownImport": 100 +} diff --git a/index.html b/index.html index 5b89408..6e587f8 100644 --- a/index.html +++ b/index.html @@ -1,771 +1,51 @@ - - - - SpotMyBackup - - - - - - - - - - Fork me on GitHub - -
-
- SpotMyBackup -
-
-
- Export your playlists and tracks setup into a file by a single click. Import the file to restore your setup at any time. -
-
- -
-
- - - - + + + SpotMyBackup + + + + + + + Fork me on GitHub + + +
+

SpotMyBackup

+

+ Export your playlists and tracks setup into a file by a single click. + Import the file to restore your setup at any time. +

+
+ +
+
+
+
+ + + + + + diff --git a/login.html b/login.html deleted file mode 100644 index d6d50f3..0000000 --- a/login.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - Login Window - - - - - - - - -