create playlist
This commit is contained in:
parent
035a88627e
commit
d4229ce2f8
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
/node_modules
|
||||
*.json
|
||||
*.html
|
||||
!callback.html
|
||||
!package.json
|
||||
!package-lock.json
|
||||
|
||||
|
@ -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
34
bits/frontend.js
Normal 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
29
bits/psuedoframework.js
Normal 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
93
bits/spotify.js
Normal 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
17
callback.html
Normal 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
59
composeplaylist.js
Normal 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);
|
@ -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);
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user