commit
2f16f86c5a
5 changed files with 269 additions and 220 deletions
|
@ -7,7 +7,7 @@ This app lets you find duplicates in your Spotify playlists.
|
|||
## Usage
|
||||
When using the application, you should first login with Spotify. The app only requests access to your private and collaborative playlists.
|
||||
|
||||
After login, you can get all the playlists by clicking the `Get playlists` button. It may take a while if you have many ones.
|
||||
After login, you can get all the playlists by clicking the `Refresh` button. It may take a while if you have many ones.
|
||||
|
||||
After playlists are loaded, you can click on any name to launch duplicates finding. This may also take a while as Spotify only allows me to retrieve tracks 100 by 100.
|
||||
|
||||
|
|
2
app.js
2
app.js
|
@ -142,7 +142,7 @@ app.get('/get_playlists', function (req, res) {
|
|||
json: true
|
||||
};
|
||||
|
||||
getAllPages(authOptions, [], function(data) {
|
||||
getAllPages(authOptions, [], function (data) {
|
||||
res.send({
|
||||
'data': data
|
||||
});
|
||||
|
|
|
@ -3,40 +3,36 @@
|
|||
<head>
|
||||
<title>Spotify Duplicate Finder</title>
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
|
||||
<style type="text/css">
|
||||
#login, #loggedin {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-overflow {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 500px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://netdna.bootstrapcdn.com/font-awesome/3.0.2/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="login">
|
||||
<h1>This is an example of the Authorization Code flow</h1>
|
||||
<a href="/login" class="btn btn-primary">Log in with Spotify</a>
|
||||
</div>
|
||||
<h1>Duplicates finder
|
||||
<button class="btn btn-default has-spinner" id="obtain-new-token">
|
||||
Refresh token <span class="spinner"><i class="icon-spin icon-refresh"></i></span>
|
||||
</button>
|
||||
<span id="login">
|
||||
<a href="/login" class="btn btn-lg btn-success">Log in with Spotify</a>
|
||||
</span>
|
||||
</h1>
|
||||
<div id="error"></div>
|
||||
<div id="loggedin">
|
||||
<div id="user-profile">
|
||||
</div>
|
||||
|
||||
<h1>Duplicates finder</h1>
|
||||
<button class="btn btn-default" id="get-playlists">Get playlists</button>
|
||||
<div id="spin-playlists"></div>
|
||||
<div class="media">
|
||||
<div class="pull-left">
|
||||
<h2 id="playlists-title">Playlists
|
||||
<button class="btn btn-default has-spinner" id="get-playlists">
|
||||
Refresh <span class="spinner"><i class="icon-spin icon-refresh"></i></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="playlists">
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-body">
|
||||
<div id="spin-dups"></div>
|
||||
<div id="dups">
|
||||
</div>
|
||||
</div>
|
||||
|
@ -44,217 +40,42 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script id="user-profile-template" type="text/x-handlebars-template">
|
||||
<h1>Logged in as {{display_name}}</h1>
|
||||
<div id="oauth">
|
||||
</div>
|
||||
<button class="btn btn-default" id="obtain-new-token">Obtain new token using the refresh token</button>
|
||||
<script id="playlists-template" type="text/x-handlebars-template">
|
||||
{{#list playlists}}
|
||||
<a class="pl_item list-group-item has-spinner" href="/pl/{{pl_uid}}/{{pl_id}}">
|
||||
{{pl_name}}
|
||||
<span class="spinner"><i class="icon-spin icon-refresh"></i></span>
|
||||
<i class="glyphicon glyphicon-chevron-right"></i>
|
||||
</a>
|
||||
{{/list}}
|
||||
</script>
|
||||
|
||||
<script id="oauth-template" type="text/x-handlebars-template">
|
||||
<h2>oAuth info</h2>
|
||||
<dl class="dl-horizontal">
|
||||
<dt>Access token</dt>
|
||||
<dd class="text-overflow">{{access_token}}</dd>
|
||||
<dt>Refresh token</dt>
|
||||
<dd class="text-overflow">{{refresh_token}}></dd>
|
||||
</dl>
|
||||
<script id="user-profile-template" type="text/x-handlebars-template">
|
||||
Logged in as {{display_name}}
|
||||
</script>
|
||||
|
||||
<script id="dups-template" type="text/x-handlebars-template">
|
||||
<h2>Duplicates in {{pl_name}}</h2>
|
||||
{{message}}
|
||||
{{#list dups}}<div class="list-group-item">{{dup_trackname}} - {{dup_artist}}</div>{{/list}}
|
||||
{{#list dups}}
|
||||
<div class="list-group-item">{{dup_trackname}} - {{dup_artist}}</div>
|
||||
{{/list}}
|
||||
</script>
|
||||
|
||||
<script id="playlists-template" type="text/x-handlebars-template">
|
||||
<h2>Playlists</h2>
|
||||
{{#list playlists}}<a class="pl_item list-group-item" href="/pl/{{pl_uid}}/{{pl_id}}">{{pl_name}}</a>{{/list}}
|
||||
<script id="error-template" type="text/x-handlebars-template">
|
||||
<div class="alert alert-danger alert-dismissible" role="alert">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span
|
||||
aria-hidden="true">×</span></button>
|
||||
<strong>{{err_title}}</strong> {{{err_content}}}.
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/handlebars.js/2.0.0-alpha.1/handlebars.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-1.12.3.min.js"></script>
|
||||
<script src="https://fgnass.github.io/spin.js/spin.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
/**
|
||||
* Obtains parameters from the hash of the URL
|
||||
* @return Object
|
||||
*/
|
||||
function getHashParams() {
|
||||
var hashParams = {};
|
||||
var e, r = /([^&;=]+)=?([^&;]*)/g,
|
||||
q = window.location.hash.substring(1);
|
||||
while (e = r.exec(q)) {
|
||||
hashParams[e[1]] = decodeURIComponent(e[2]);
|
||||
}
|
||||
return hashParams;
|
||||
}
|
||||
|
||||
Handlebars.registerHelper('list', function (items, options) {
|
||||
var out = "<div class='list-group'>";
|
||||
|
||||
for (var i = 0, l = items.length; i < l; i++) {
|
||||
out = out + options.fn(items[i]);
|
||||
}
|
||||
|
||||
return out + "</div>";
|
||||
});
|
||||
|
||||
// Spinner options
|
||||
var spinnerOpts = {
|
||||
lines: 13 // The number of lines to draw
|
||||
, length: 0 // The length of each line
|
||||
, width: 28 // The line thickness
|
||||
, radius: 58 // The radius of the inner circle
|
||||
, scale: 0.25 // Scales overall size of the spinner
|
||||
, corners: 1 // Corner roundness (0..1)
|
||||
, color: '#000' // #rgb or #rrggbb or array of colors
|
||||
, opacity: 0.25 // Opacity of the lines
|
||||
, rotate: 0 // The rotation offset
|
||||
, direction: 1 // 1: clockwise, -1: counterclockwise
|
||||
, speed: 1 // Rounds per second
|
||||
, trail: 60 // Afterglow percentage
|
||||
, fps: 20 // Frames per second when using setTimeout() as a fallback for CSS
|
||||
, zIndex: 2e9 // The z-index (defaults to 2000000000)
|
||||
, className: 'spinner' // The CSS class to assign to the spinner
|
||||
, top: '50%' // Top position relative to parent
|
||||
, left: '50%' // Left position relative to parent
|
||||
, shadow: false // Whether to render a shadow
|
||||
, hwaccel: false // Whether to use hardware acceleration
|
||||
, position: 'absolute' // Element positioning
|
||||
};
|
||||
|
||||
var userProfileSource = document.getElementById('user-profile-template').innerHTML,
|
||||
userProfileTemplate = Handlebars.compile(userProfileSource),
|
||||
userProfilePlaceholder = document.getElementById('user-profile');
|
||||
|
||||
var playlistsSource = document.getElementById('playlists-template').innerHTML,
|
||||
playlistsTemplate = Handlebars.compile(playlistsSource),
|
||||
playlistsPlaceholder = document.getElementById('playlists');
|
||||
|
||||
var dupsSource = document.getElementById('dups-template').innerHTML,
|
||||
dupsTemplate = Handlebars.compile(dupsSource),
|
||||
dupsPlaceholder = document.getElementById('dups');
|
||||
|
||||
var oauthSource;
|
||||
var oauthTemplate;
|
||||
var oauthPlaceholder;
|
||||
|
||||
var params = getHashParams();
|
||||
|
||||
var access_token = params.access_token,
|
||||
refresh_token = params.refresh_token,
|
||||
error = params.error;
|
||||
|
||||
if (error) {
|
||||
alert('There was an error during the authentication');
|
||||
} else {
|
||||
if (access_token) {
|
||||
$.ajax({
|
||||
url: 'https://api.spotify.com/v1/me',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + access_token
|
||||
},
|
||||
success: function (response) {
|
||||
userProfilePlaceholder.innerHTML = userProfileTemplate(response);
|
||||
|
||||
oauthSource = document.getElementById('oauth-template').innerHTML;
|
||||
oauthTemplate = Handlebars.compile(oauthSource);
|
||||
oauthPlaceholder = document.getElementById('oauth');
|
||||
|
||||
// render oauth info
|
||||
oauthPlaceholder.innerHTML = oauthTemplate({
|
||||
access_token: access_token,
|
||||
refresh_token: refresh_token
|
||||
});
|
||||
|
||||
$('#login').hide();
|
||||
$('#loggedin').show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// render initial screen
|
||||
$('#login').show();
|
||||
$('#loggedin').hide();
|
||||
}
|
||||
|
||||
document.getElementById('get-playlists').addEventListener('click', function () {
|
||||
var target = document.getElementById('spin-playlists');
|
||||
var spinner = new Spinner(spinnerOpts).spin(target);
|
||||
$.ajax({
|
||||
url: '/get_playlists',
|
||||
data: {
|
||||
'access_token': access_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
var pl = data.data.map(function (item, index, array) {
|
||||
return {
|
||||
pl_uid: item.owner.id,
|
||||
pl_name: item.name,
|
||||
pl_id: item.id
|
||||
}
|
||||
});
|
||||
|
||||
spinner.stop();
|
||||
playlistsPlaceholder.innerHTML = playlistsTemplate({
|
||||
playlists: pl
|
||||
});
|
||||
})
|
||||
}, false);
|
||||
|
||||
$(document).on('click', '.pl_item', function (e) {
|
||||
e.preventDefault();
|
||||
var pl_name = $(this).text();
|
||||
var target = document.getElementById('spin-dups');
|
||||
var spinner = new Spinner(spinnerOpts).spin(target);
|
||||
$.ajax({
|
||||
url: $(this).attr('href'),
|
||||
data: {
|
||||
'access_token': access_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
var dups = data.data.map(function (item) {
|
||||
return {
|
||||
dup_trackname: item.track.name,
|
||||
dup_artist: item.track.artists[0].name
|
||||
}
|
||||
});
|
||||
spinner.stop();
|
||||
if (data.data.length > 0) {
|
||||
dupsPlaceholder.innerHTML = dupsTemplate({
|
||||
dups: dups,
|
||||
pl_name: pl_name
|
||||
});
|
||||
}
|
||||
else {
|
||||
dupsPlaceholder.innerHTML = dupsTemplate({
|
||||
dups: [],
|
||||
message: "No duplicate found.",
|
||||
pl_name: pl_name
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
$(document).on('click', '#obtain-new-token', function () {
|
||||
$.ajax({
|
||||
url: '/refresh_token',
|
||||
data: {
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
access_token = data.access_token;
|
||||
oauthPlaceholder.innerHTML = oauthTemplate({
|
||||
access_token: access_token,
|
||||
refresh_token: refresh_token
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"
|
||||
integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
182
public/script.js
Normal file
182
public/script.js
Normal file
|
@ -0,0 +1,182 @@
|
|||
'use strict';
|
||||
|
||||
var access_token;
|
||||
var refresh_token;
|
||||
var error;
|
||||
|
||||
var userProfileSource = document.getElementById('user-profile-template').innerHTML,
|
||||
userProfileTemplate = Handlebars.compile(userProfileSource),
|
||||
userProfilePlaceholder = document.getElementById('user-profile');
|
||||
|
||||
var playlistsSource = document.getElementById('playlists-template').innerHTML,
|
||||
playlistsTemplate = Handlebars.compile(playlistsSource),
|
||||
playlistsPlaceholder = document.getElementById('playlists');
|
||||
|
||||
var dupsSource = document.getElementById('dups-template').innerHTML,
|
||||
dupsTemplate = Handlebars.compile(dupsSource),
|
||||
dupsPlaceholder = document.getElementById('dups');
|
||||
|
||||
var errorSource = document.getElementById('error-template').innerHTML,
|
||||
errorTemplate = Handlebars.compile(errorSource),
|
||||
errorPlaceholder = document.getElementById('error');
|
||||
|
||||
(function () {
|
||||
|
||||
/**
|
||||
* Obtains parameters from the hash of the URL
|
||||
* @return Object
|
||||
*/
|
||||
function getHashParams() {
|
||||
var hashParams = {};
|
||||
var e, r = /([^&;=]+)=?([^&;]*)/g,
|
||||
q = window.location.hash.substring(1);
|
||||
while (e = r.exec(q)) {
|
||||
hashParams[e[1]] = decodeURIComponent(e[2]);
|
||||
}
|
||||
return hashParams;
|
||||
}
|
||||
|
||||
Handlebars.registerHelper('list', function (items, options) {
|
||||
var out = "<div class='list-group'>";
|
||||
|
||||
for (var i = 0, l = items.length; i < l; i++) {
|
||||
out = out + options.fn(items[i]);
|
||||
}
|
||||
|
||||
return out + "</div>";
|
||||
});
|
||||
|
||||
var params = getHashParams();
|
||||
|
||||
access_token = params.access_token;
|
||||
refresh_token = params.refresh_token;
|
||||
error = params.error;
|
||||
|
||||
if (error) {
|
||||
|
||||
errorPlaceholder.innerHTML = errorTemplate({
|
||||
err_title: 'Error!',
|
||||
err_content: 'There was an error during the authentication. Feel free to <a href="https://github.com/Crocmagnon/Spotify-Duplicate-Finder/issues" class="alert-link">open an issue</a>.'
|
||||
});
|
||||
} else {
|
||||
if (access_token) {
|
||||
getPersonnalInfo(true, userProfilePlaceholder, userProfileTemplate);
|
||||
} else {
|
||||
// render initial screen
|
||||
$('#login').show();
|
||||
$('#loggedin').hide();
|
||||
}
|
||||
|
||||
document.getElementById('get-playlists').addEventListener('click', function () {
|
||||
var button = $(this);
|
||||
button.addClass('loading');
|
||||
$.ajax({
|
||||
url: '/get_playlists',
|
||||
data: {
|
||||
'access_token': access_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
var pl = data.data.map(function (item) {
|
||||
return {
|
||||
pl_uid: item.owner.id,
|
||||
pl_name: item.name,
|
||||
pl_id: item.id
|
||||
}
|
||||
});
|
||||
|
||||
button.removeClass('loading');
|
||||
$('#dups').hide();
|
||||
|
||||
playlistsPlaceholder.innerHTML = playlistsTemplate({
|
||||
playlists: pl
|
||||
});
|
||||
})
|
||||
}, false);
|
||||
|
||||
$(document).on('click', '.pl_item', function (e) {
|
||||
e.preventDefault();
|
||||
var pl_name = $(this).text();
|
||||
$('.pl_item').removeClass('active');
|
||||
var currentElement = $(this);
|
||||
currentElement.addClass('active');
|
||||
currentElement.addClass('loading');
|
||||
$('#dups').hide();
|
||||
$.ajax({
|
||||
url: $(this).attr('href'),
|
||||
data: {
|
||||
'access_token': access_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
var dups = data.data.map(function (item) {
|
||||
return {
|
||||
dup_trackname: item.track.name,
|
||||
dup_artist: item.track.artists[0].name
|
||||
}
|
||||
});
|
||||
currentElement.removeClass('loading');
|
||||
if (data.data.length > 0) {
|
||||
dupsPlaceholder.innerHTML = dupsTemplate({
|
||||
dups: dups,
|
||||
pl_name: pl_name
|
||||
});
|
||||
}
|
||||
else {
|
||||
dupsPlaceholder.innerHTML = dupsTemplate({
|
||||
dups: [],
|
||||
message: "No duplicate found.",
|
||||
pl_name: pl_name
|
||||
});
|
||||
}
|
||||
$('#dups').show();
|
||||
})
|
||||
});
|
||||
|
||||
$(document).on('click', '#obtain-new-token', function () {
|
||||
refreshToken();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function refreshToken() {
|
||||
var button = $('#obtain-new-token');
|
||||
button.addClass('loading');
|
||||
$.ajax({
|
||||
url: '/refresh_token',
|
||||
data: {
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
}).done(function (data) {
|
||||
access_token = data.access_token;
|
||||
button.removeClass('loading');
|
||||
});
|
||||
}
|
||||
|
||||
function getPersonnalInfo(first, userProfilePlaceholder, userProfileTemplate) {
|
||||
$.ajax({
|
||||
url: 'https://api.spotify.com/v1/me',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + access_token
|
||||
},
|
||||
success: function (response) {
|
||||
userProfilePlaceholder.innerHTML = userProfileTemplate(response);
|
||||
|
||||
$('#login').hide();
|
||||
$('#obtain-new-token').show();
|
||||
$('#loggedin').show();
|
||||
},
|
||||
error: function (response) {
|
||||
if (response.status == 401) {
|
||||
if (first) {
|
||||
refreshToken();
|
||||
getPersonnalInfo(false, userProfilePlaceholder, userProfileTemplate);
|
||||
}
|
||||
else {
|
||||
errorPlaceholder.innerHTML = errorTemplate({
|
||||
err_title: 'Error!',
|
||||
err_content: 'Error while refreshing token. Please <a href="/" class="alert-link">return to login</a>.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
46
public/style.css
Normal file
46
public/style.css
Normal file
|
@ -0,0 +1,46 @@
|
|||
#login, #loggedin, #obtain-new-token {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-overflow {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
width: 0;
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.has-spinner.loading .spinner {
|
||||
display: inline-block;
|
||||
width: 16px; /* This doesn't work, just fix for unkown width elements */
|
||||
}
|
||||
|
||||
#get-playlists {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.pl_item {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.glyphicon.glyphicon-chevron-right {
|
||||
float: right;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#loggedin .media .pull-left {
|
||||
min-width: 20%;
|
||||
}
|
||||
|
||||
.loading .glyphicon-chevron-right.glyphicon-chevron-right {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
Loading…
Reference in a new issue