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() : ''}${name}>`,
+ 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() : ''}${name}>`,
- 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(`Export selection `)
+ .append(``);
+ await fs.writeFile('search-results.html', createDocument(stylesheet, wrapper));
}
generate().catch(console.error);