From d4229ce2f853213660bd51b58f981a34765140ce Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Tue, 4 Jan 2022 20:19:03 +0200 Subject: [PATCH] create playlist --- .gitignore | 1 + README.md | 9 ++-- bits/frontend.js | 34 +++++++++++++++ bits/psuedoframework.js | 29 +++++++++++++ bits/spotify.js | 93 ++++++++++++++++++++++++++++++++++++++++ callback.html | 17 ++++++++ composeplaylist.js | 59 +++++++++++++++++++++++++ metasearcher.js | 87 ++----------------------------------- resultvisualizer.js | 95 ++++++++++++++++++++++++++++------------- 9 files changed, 308 insertions(+), 116 deletions(-) create mode 100644 bits/frontend.js create mode 100644 bits/psuedoframework.js create mode 100644 bits/spotify.js create mode 100644 callback.html create mode 100644 composeplaylist.js diff --git a/.gitignore b/.gitignore index 68ad85a..a7d14b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /node_modules *.json *.html +!callback.html !package.json !package-lock.json diff --git a/README.md b/README.md index f62b35b..a887626 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,13 @@ Looks for your local music files on Spotify and helps you create a playlist. This is useful for migrating your local music to Spotify. **Your tracks need to have valid metadata (artist and title is the minimum)!**. -Feel free to use `https://lunasqu.ee/callback` as a callback url. It just prints query parameters to html. even if i wanted to i couldn't steal your code as i do not know your client secret. +Feel free to use `https://lunasqu.ee/callback` as a callback url or put `callback.html` on your own server. It just prints query parameters to html. even if i wanted to i couldn't steal your code as i do not know your client secret. 1. create Spotify app: https://developer.spotify.com/dashboard/login 2. `npm i` 3. put your Spotify credentials in `credentials.json` following the example. -4. `node metasearcher.js /path/to/music/directory` scan the directory for music files and create a `spotify.json` file from matches. -5. `node resultvisualizer.js` create a html table from the `spotify.json` file. +4. `node metasearcher /path/to/music/directory` scan the directory for music files and create a `spotify.json` file from matches. +5. `node resultvisualizer` create a html table from the `spotify.json` file. Select the tracks from the HTML file and press "Export selection" +6. Put `track-selection.json` in this directory and run `node composeplaylist create/add [playlist name/id]` to put all of them into a playlist! + +Next time you run `resultvisualizer` your previous selection will be automagically checked! diff --git a/bits/frontend.js b/bits/frontend.js new file mode 100644 index 0000000..d89273b --- /dev/null +++ b/bits/frontend.js @@ -0,0 +1,34 @@ +(function() { + const checkboxes = document.querySelectorAll('input[type="checkbox"]'); + const button = document.querySelector('#export'); + const selectedTracks = []; + checkboxes.forEach((box) => { + const meta = [ + box.getAttribute('data-file'), box.getAttribute('data-spotify') + ]; + function clickBox() { + if (box.checked) { + selectedTracks.push(meta); + } else { + const i = selectedTracks.indexOf(meta); + if (i > -1) { + selectedTracks.splice(i, 1); + } + } + } + box.addEventListener('change', ($ev) => clickBox()); + box.parentElement.parentElement.addEventListener('click', () => box.click()); + clickBox(); + }); + + button.addEventListener('click', ($ev) => { + $ev.preventDefault(); + var dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(selectedTracks)); + var downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute("href", dataStr); + downloadAnchorNode.setAttribute("download", "track-selection.json"); + document.body.appendChild(downloadAnchorNode); + downloadAnchorNode.click(); + downloadAnchorNode.remove(); + }); +})(); diff --git a/bits/psuedoframework.js b/bits/psuedoframework.js new file mode 100644 index 0000000..608aa5a --- /dev/null +++ b/bits/psuedoframework.js @@ -0,0 +1,29 @@ +module.exports.createDocument = function(stylesheet, content) { + return ` + + + + + Spotify results + + + + ${content} + + + `; +} + +module.exports.$ = function(name, content, classList) { + return { + toString: () => `<${name}${classList && classList.length ? ` class="${classList.join(',')}"` : ''}>${content ? content.toString() : ''}`, + append: function ($el) { + content = content ? content + $el.toString() : $el.toString(); + return this; + }, + html: function(text) { + content = text; + return this; + } + }; +} diff --git a/bits/spotify.js b/bits/spotify.js new file mode 100644 index 0000000..32a6a5a --- /dev/null +++ b/bits/spotify.js @@ -0,0 +1,93 @@ +const SpotifyWebApi = require('spotify-web-api-node'); +const ReadLine = require('readline'); +const fs = require('fs').promises; +const path = require('path'); + +// Create the authorization URL +const rl = ReadLine.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const root = path.join(__dirname, '..'); + +/* +{ + "clientId": "", + "clientSecret": "", + "redirectUri": "https://lunasqu.ee/callback" +} +*/ +const spotifyApi = new SpotifyWebApi(require(path.join(root, 'credentials.json'))); + +function ask(msg) { + return new Promise((resolve) => rl.question(msg, resolve)); +} + +async function setupSpotify() { + let credentials = { + token: null, + expires: null, + refresh: null, + }; + + try { + credentials = JSON.parse(await fs.readFile(path.join(root, '.token.json'))); + } catch (e) {} + + if (!credentials.token) { + const authorizeURL = spotifyApi.createAuthorizeURL(['playlist-modify-private'], 'FPEPFERHR234234trGEWErjrjsdw3666sf06'); + console.log('Authorize the application'); + console.log(authorizeURL); + const code = await ask('Enter code []> '); + + if (!code) { + process.exit(1); + } + + const data = await spotifyApi.authorizationCodeGrant(code); + console.log('The access token has been received.'); + + // Set the access token on the API object to use it in later calls + spotifyApi.setAccessToken(data.body['access_token']); + spotifyApi.setRefreshToken(data.body['refresh_token']); + + credentials.token = data.body['access_token']; + credentials.expires = Math.floor(Date.now() / 1000) + data.body['expires_in']; + credentials.refresh = data.body['refresh_token']; + + await fs.writeFile(path.join(root, '.token.json'), JSON.stringify(credentials, undefined, 2)); + } else { + console.log('The access token is present'); + spotifyApi.setAccessToken(credentials.token); + spotifyApi.setRefreshToken(credentials.refresh); + } + + if ((credentials.expires * 1000) < Date.now()) { + console.log('Refreshing access token'); + try { + const refresh = await spotifyApi.refreshAccessToken(); + credentials.token = refresh.body['access_token']; + credentials.expires = Math.floor(Date.now() / 1000) + refresh.body['expires_in']; + spotifyApi.setAccessToken(credentials.token); + spotifyApi.setRefreshToken(credentials.refresh); + } catch (e) { + console.error(e); + console.log('need to reauthorize'); + credentials.token = null; + } + await fs.writeFile(path.join(root, '.token.json'), JSON.stringify(credentials, undefined, 2)); + if (!credentials.token) { + return setupSpotify(); + } + } + + console.log('the token is good until', new Date(credentials.expires * 1000).toString()); + rl.close(); + return spotifyApi; +} + +module.exports = { + setupSpotify, + spotifyApi, +}; diff --git a/callback.html b/callback.html new file mode 100644 index 0000000..753ef5a --- /dev/null +++ b/callback.html @@ -0,0 +1,17 @@ + + + + + + + Callback + + + + + + diff --git a/composeplaylist.js b/composeplaylist.js new file mode 100644 index 0000000..8ffc92a --- /dev/null +++ b/composeplaylist.js @@ -0,0 +1,59 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { setupSpotify, spotifyApi } = require(path.join(__dirname, 'bits', 'spotify')); + +const selectionFile = process.argv[4] + ? path.resolve(process.argv[4]) + : path.join(__dirname, 'track-selection.json'); + +const operation = process.argv[2]; +if (!operation || !['create', 'add'].includes(operation)) { + console.error('Invalid operation! Operations: create, add'); + process.exit(1); +} + +const playlist = process.argv[3]; +if (!playlist) { + console.error('No playlist ID or name provided'); + process.exit(1); +} + +async function readSelection() { + let spotifyUris = []; + let chunkedUris = []; + let discardFiles = []; + let total = 0; + + const content = JSON.parse(await fs.readFile(selectionFile)); + content.forEach((item) => { + spotifyUris.push(item[1]); + discardFiles.push(item[0]); + total++; + }); + + if (spotifyUris.length >= 100) { + while (spotifyUris.length >= 100) { + chunkedUris.push(spotifyUris.splice(0, 100)); + } + } + + if (spotifyUris.length) { + chunkedUris.push(spotifyUris); + } + + await setupSpotify(); + let playlistId = playlist; + + if (operation === 'create') { + const newPlaylist = await spotifyApi.createPlaylist(playlist, { public: false }); + playlistId = newPlaylist.body.id; + } + + for (const uris of chunkedUris) { + await spotifyApi.addTracksToPlaylist(playlistId, uris); + } + + console.log(operation === 'create' ? 'Created a playlist with' : 'Added', total.length, 'tracks'); +} + +readSelection().catch(console.error); diff --git a/metasearcher.js b/metasearcher.js index b28b13b..884ac22 100644 --- a/metasearcher.js +++ b/metasearcher.js @@ -1,95 +1,14 @@ -const SpotifyWebApi = require('spotify-web-api-node'); -const NodeID3 = require('node-id3') - +const NodeID3 = require('node-id3'); const fs = require('fs').promises; -const ReadLine = require('readline'); const path = require('path'); +const { setupSpotify, spotifyApi } = require(path.join(__dirname, 'bits', 'spotify')); -/* -{ - "clientId": "", - "clientSecret": "", - "redirectUri": "https://lunasqu.ee/callback" -} -*/ -const spotifyApi = new SpotifyWebApi(require('./credentials.json')); const audPath = process.argv[2]; if (!audPath) { throw new Error('No directory specified'); } -// Create the authorization URL -const rl = ReadLine.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -function ask(msg) { - return new Promise((resolve) => rl.question(msg, resolve)); -} - -async function setupSpotify() { - let credentials = { - token: null, - expires: null, - refresh: null, - }; - - try { - credentials = JSON.parse(await fs.readFile('.token.json')); - } catch (e) {} - - if (!credentials.token) { - const authorizeURL = spotifyApi.createAuthorizeURL(['playlist-modify-private'], 'FPEPFERHR234234trGEWErjrjsdw3666sf06'); - console.log('Authorize the application'); - console.log(authorizeURL); - const code = await ask('Enter code []> '); - - if (!code) { - process.exit(1); - } - - const data = await spotifyApi.authorizationCodeGrant(code); - console.log('The access token has been received.'); - - // Set the access token on the API object to use it in later calls - spotifyApi.setAccessToken(data.body['access_token']); - spotifyApi.setRefreshToken(data.body['refresh_token']); - - credentials.token = data.body['access_token']; - credentials.expires = Math.floor(Date.now() / 1000) + data.body['expires_in']; - credentials.refresh = data.body['refresh_token']; - - await fs.writeFile('.token.json', JSON.stringify(credentials, undefined, 2)); - } else { - console.log('The access token is present'); - spotifyApi.setAccessToken(credentials.token); - spotifyApi.setRefreshToken(credentials.refresh); - } - - if ((credentials.expires * 1000) < Date.now()) { - console.log('Refreshing access token'); - try { - const refresh = await spotifyApi.refreshAccessToken(); - credentials.token = refresh.body['access_token']; - credentials.expires = Math.floor(Date.now() / 1000) + refresh.body['expires_in']; - spotifyApi.setAccessToken(credentials.token); - spotifyApi.setRefreshToken(credentials.refresh); - } catch (e) { - console.error(e); - console.log('need to reauthorize'); - credentials.token = null; - } - await fs.writeFile('.token.json', JSON.stringify(credentials, undefined, 2)); - if (!credentials.token) { - return setupSpotify(); - } - } - - console.log('the token is good until', new Date(credentials.expires * 1000).toString()); -} - async function readTags(file) { return new Promise((resolve) => { NodeID3.read(file, {noRaw: true, exclude: ['image']}, function(err, tags) { @@ -168,4 +87,4 @@ async function searchAllOfSpotify() { await fs.writeFile('spotify.json', JSON.stringify(tracks, undefined, 2)); } -searchAllOfSpotify().catch(console.error).then(() => rl.close()); +searchAllOfSpotify().catch(console.error); diff --git a/resultvisualizer.js b/resultvisualizer.js index c49f068..e8568e6 100644 --- a/resultvisualizer.js +++ b/resultvisualizer.js @@ -1,5 +1,7 @@ const fs = require('fs/promises'); const path = require('path'); +const { createDocument, $ } = require(path.join(__dirname, 'bits', 'psuedoframework')); +const fescriptpath = path.join(__dirname, 'bits', 'frontend.js'); const stylesheet = ` html, body { @@ -23,55 +25,82 @@ body { table tr:nth-child(odd) { background-color: #030a38; } +tr { + cursor: pointer; +} .meta .file { font-weight: bold; } .meta .title, .meta .artist { font-size: .6rem; } +input[type=checkbox] { + width: 1.2rem; + height: 1.2rem; + border-radius: 8px; +} +button { + font-size: 2rem; + padding: 1.6rem 6rem; + margin: 1rem; + background-color: #1DB954; + text-transform: uppercase; + color: white; + font-weight: bold; + border: none; + border-radius: 4rem; + cursor: pointer; + transition: background-color .15s linear; +} +button:hover { + background-color: #25df67; +} `; -function createDocument(content) { - return ` - - - - - Spotify results - - - - ${content} - - - `; +async function getPreviousSelection() { + let selection; + try { + selection = JSON.parse(await fs.readFile(path.join(__dirname, 'track-selection.json'))); + } catch (e) { + selection = []; + } + return selection; } -function $(name, content, classList) { - return { - toString: () => `<${name}${classList && classList.length ? ` class="${classList.join(',')}"` : ''}>${content ? content.toString() : ''}`, - append: function ($el) { - content = content ? content + $el.toString() : $el.toString(); - return this; - }, - html: function(text) { - content = text; - return this; - } - }; +const checkCache = []; +function selectionContainsURI(list, uri) { + if (list.length === 0) { + return false; + } + + // Only check a single one with the same spotify uri + if (checkCache.includes(uri)) { + return false; + } + + const result = list.some((item) => item[1] === uri); + if (result) { + checkCache.push(uri); + } + + return result; } async function generate() { + const fescript = await fs.readFile(fescriptpath); + const selection = await getPreviousSelection(); const wrapper = $('div', null, ['wrapper']); const table = $('table'); const header = $('thead', ` + Choose Original File Type Album Title Artist + Preview Meta @@ -83,18 +112,24 @@ async function generate() { let row = []; for (const sfItem of entry.spotify) { row.push([ + '', '', sfItem.album.album_type, sfItem.album.name, sfItem.name, sfItem.artists.map((item) => item.name).join(', '), + sfItem.preview_url ? `` : '', `` + `` + '' ]); } - row[0][0] = $('div', + row[0][1] = $('div', $('span', path.basename(entry.file), ['file']) + '
' + $('span', `Title: ${entry.title}`, ['title']) + @@ -116,8 +151,10 @@ async function generate() { } rows.forEach((item) => tbody.append(item)); - wrapper.append(table.append(header).append(tbody)); - await fs.writeFile('test.html', createDocument(wrapper)); + wrapper.append(table.append(header).append(tbody)) + .append(``) + .append(``); + await fs.writeFile('search-results.html', createDocument(stylesheet, wrapper)); } generate().catch(console.error);