create playlist

This commit is contained in:
Evert Prants 2022-01-04 20:19:03 +02:00
parent 035a88627e
commit d4229ce2f8
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
9 changed files with 308 additions and 116 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
/node_modules
*.json
*.html
!callback.html
!package.json
!package-lock.json

View File

@ -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!

34
bits/frontend.js Normal file
View File

@ -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();
});
})();

29
bits/psuedoframework.js Normal file
View File

@ -0,0 +1,29 @@
module.exports.createDocument = function(stylesheet, content) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Spotify results</title>
<style>${stylesheet}</style>
</head>
<body>
${content}
</body>
</html>
`;
}
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;
}
};
}

93
bits/spotify.js Normal file
View File

@ -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,
};

17
callback.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Callback</title>
</head>
<body>
<span id="content" style="white-space: pre; font-size: 2rem;"></span>
<script defer>
const elem = document.querySelector('#content');
const lines = window.location.search.substring(1).split('&');
elem.textContent = lines.map(line => line.split('=').join(': ')).join('\n');
</script>
</body>
</html>

59
composeplaylist.js Normal file
View File

@ -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);

View File

@ -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);

View File

@ -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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Spotify results</title>
<style>${stylesheet}</style>
</head>
<body>
${content}
</body>
</html>
`;
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', `
<thead>
<tr>
<th>Choose</th>
<th>Original File</th>
<th>Type</th>
<th>Album</th>
<th>Title</th>
<th>Artist</th>
<th>Preview</th>
<th>Meta</th>
</tr>
</thead>
@ -83,18 +112,24 @@ async function generate() {
let row = [];
for (const sfItem of entry.spotify) {
row.push([
'<input type="checkbox" ' +
`data-spotify="${sfItem.uri}" ` +
`data-file="${entry.file}" ` +
(selectionContainsURI(selection, sfItem.uri) ? 'checked' : '') +
'>',
'',
sfItem.album.album_type,
sfItem.album.name,
sfItem.name,
sfItem.artists.map((item) => item.name).join(', '),
sfItem.preview_url ? `<audio preload="none" controls src="${sfItem.preview_url}"></audio>` : '',
`<a href="${sfItem.uri}" target="_blank">` +
`<img src="${sfItem.album.images[sfItem.album.images.length - 1].url}">` +
'</a>'
]);
}
row[0][0] = $('div',
row[0][1] = $('div',
$('span', path.basename(entry.file), ['file']) +
'<br>' +
$('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(`<button id="export">Export selection</button>`)
.append(`<script defer>${fescript}</script>`);
await fs.writeFile('search-results.html', createDocument(stylesheet, wrapper));
}
generate().catch(console.error);