// ==UserScript==
// @name What's New for Web
// @description Adds Spotify's What's New feature to the web version
// @version 1.0.0
// @author j-weatherwax
// @homepageURL https://github.com/j-weatherwax
// @match https://open.spotify.com/*
// @license MIT
// @namespace https://github.com/j-weatherwax/whats-new-for-web
// @grant none
// ==/UserScript==
(function() {
'use strict';
var mainCss = `
/**** What's New Button ****/
#sidebar { z-index: 9999; position: fixed; inset: 0px 0px auto auto; margin: 0px; transform: translate(-40px, 64px); top: 0; right: 0; padding: 20px; width: 400px; max-height: 70%; border: 5px solid black; background-color: #121212; overflow: auto; }
`;
var buttonHtml = `
<button class="Button-sc-1dqy6lx-0 fXEXug encore-over-media-set SFgYidQmrqrFEVh65Zrg" data-testid="user-widget-link" aria-label="What's New" data-encore-id="buttonTertiary" aria-expanded="false">
<figure class="tp8rO9vtqBGPLOhwcdYv" title="What's New" style="width: 32px; height: 32px;">
<div class="" style="width: 32px; height: 32px; inset-inline-start: 0px;">
<div class="KdxlBanhDJjzmHfqhP0X m95Ymx847hCaxHjmyXKX" data-testid="placeholder-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bell" viewBox="0 0 16 16">
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/>
</svg>
</div>
</div>
</figure>
</button>
`;
// -------------------------- OATH ------------------------------- //
// Cryptographic helper functions
function generateRandomString(length) {
let text = '';
let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
async function generateCodeChallenge(codeVerifier) {
function base64encode(string) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(string)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return base64encode(digest);
}
// ClientID can be public
const clientId = 'cfeacce0918d4802964bffedd31b22b8';
const redirectUri = 'https://open.spotify.com/';
//Finish this later
async function APIKeyInvalid(accessToken){
const response = await fetch('https://api.spotify.com/v1/me', {
headers: {
Authorization: 'Bearer ' + accessToken
}
});
const data = await response.json();
//console.log(data)
if (response.status == 401) {
return true;
}
return false;
}
//If token is valid, return token. If it is not valid, request a new token and return that
async function getAndValidateAccessToken(){
const token = localStorage.getItem("access_token");
if (APIKeyInvalid(token)){
await requestNewAccessToken();
}
return localStorage.getItem("access_token");
}
async function requestNewAccessToken(){
await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: new URLSearchParams({
client_id: clientId,
grant_type: 'refresh_token',
refresh_token:localStorage.getItem("refresh_token"),
}),
}).then(response=>{
return response.json();
})
.then(data=>{
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
})
}
if (localStorage.getItem('access_token') == null) {
// get the "code" from the URL.
// After allowing access, the URL will contain a code stored in a URL parameter called "code"
// Using this code, we can send a POST request to get our access token.
const urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get('code');
// 2 cases
// case 1: User has just pressed "allow access", in this case the URL will contain the "code" parameter,
// and we need to use it to fetch the access token.
// case 2: User has previously granted access and we already generated and stored the access token in local storage.
// In this case, we don't need to send a request to get the access token, instead we just read the token from local storage.
if(code != null){
let codeVerif = localStorage.getItem('code_verifier');
let body = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: codeVerif
});
const response = fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP status ' + response.status);
}
return response.json();
})
.then(data => {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
//console.log(data)
// If user has just authenticated, move on to main logic
Main();
})
.catch(error => {
console.error('Error:', error);
});
} else {
// generate codeVerifier
let codeVerifier = generateRandomString(128);
// Generate CodeChallenge value, and open the spotify popup
generateCodeChallenge(codeVerifier).then(codeChallenge => {
let state = generateRandomString(16);
let scope = 'user-read-private user-read-email user-follow-read';
localStorage.setItem('code_verifier', codeVerifier);
let args = new URLSearchParams({
response_type: 'code',
client_id: clientId,
scope: scope,
redirect_uri: redirectUri,
state: state,
code_challenge_method: 'S256',
code_challenge: codeChallenge
});
window.location = 'https://accounts.spotify.com/authorize?' + args;
});
}
} else {
//If authenticated sometime in the past, move on to main logic function
localStorage.removeItem('market');
Main();
}
// function to get user's info
async function getProfile() {
let accessToken = await getAndValidateAccessToken();
const response = await fetch('https://api.spotify.com/v1/me', {
headers: {
Authorization: 'Bearer ' + accessToken
}
});
const data = await response.json();
localStorage.setItem('market', data.country);
}
// -------------------------- Logic ------------------------------- //
// Fetch user's followed artists
async function getFollowedArtists() {
let accessToken = await getAndValidateAccessToken();
const response = await fetch('https://api.spotify.com/v1/me/following?type=artist', {
headers: {
Authorization: 'Bearer ' + accessToken
}
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP status ' + response.status);
}
return response.json();
})
.then(data => {
return data;
})
.catch(error => {
console.error('Error:', error);
});
return response;
}
// Fetch artist's released from the last two months
const fetchArtistReleases = async (artistId) => {
let accessToken = localStorage.getItem('access_token');
let market = localStorage.getItem('market');
const date = new Date();
const twoMonthsAgo = new Date().setMonth(date.getMonth() - 2);
const response = await fetch(`https://api.spotify.com/v1/artists/${artistId}/albums?include_groups=album,single&market=${market}&limit=50`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const data = await response.json();
const albums = data.items.filter(album => Date.parse(album.release_date) >= twoMonthsAgo);
return albums;
};
const fetchAlbums = async () => {
const followedArtists = await getFollowedArtists();
const newAlbums = [];
let artistList = Object.values(followedArtists)[0];
for (const i in artistList.items) {
const artist = artistList.items[i];
const albums = await fetchArtistReleases(artist.id);
newAlbums.push(...albums);
}
return newAlbums;
};
function setDate(album){
const releaseDate = document.createElement('p');
releaseDate.classList.add('Type__TypeElement-sc-goli3j-0', 'ieTwfQ');
releaseDate.setAttribute("data-encore-id", "type");
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const today = new Date();
const release = new Date(album.release_date);
const difference = Math.floor((today - release)/ (1000 * 3600 * 24));
if (difference < 1){
releaseDate.innerHTML = "Today";
} else if (difference == 1) {
releaseDate.innerHTML = "1 day ago";
} else if (difference <= 7) {
releaseDate.innerHTML = `${difference} days ago`;
} else {
releaseDate.innerHTML = months[release.getMonth()] + ' ' + release.getDate();
}
return releaseDate;
}
function setArtists(album){
const tempDiv = document.createElement('span');
let counter = 1;
for (const idx in album.artists){
const artist = album.artists[idx];
const artistInfo = document.createElement('a');
artistInfo.setAttribute("draggable", "true");
artistInfo.setAttribute("dir", "auto");
artistInfo.setAttribute("href", artist.external_urls.spotify);
artistInfo.setAttribute("tabindex", "-1");
artistInfo.textContent = artist.name;
tempDiv.appendChild(artistInfo)
if (counter != album.artists.length){
const comma = document.createElement('a');
comma.style.textDecoration = "none";
comma.innerHTML = ", ";
tempDiv.appendChild(comma);
}
counter += 1;
}
return tempDiv;
}
function render(data) {
return fetchAlbums()
.then(albums => {
//sort the fetched releases by recency
albums.sort((a,b) => (Date.parse(a.release_date) < Date.parse(b.release_date)) ? 1 : -1)
const sidebarContainer = document.createElement('div')
//make the html for each release to insert into the sidebar
const albumDivs = albums.map(album => {
const albumDiv = document.createElement('div');
var albumDivContainer = `
<div role="row sidebar-row" aria-rowindex="2" aria-selected="false">
<div data-testid="tracklist-row" class="h4HgbO_Uu1JYg5UGANeQ wTUruPetkKdWAR1dd6w4" draggable="true" role="presentation" style="height: 72px;">
<div class="gvLrgQXBFVW6m9MscfFA" role="gridcell" aria-colindex="2" tabindex="-1">
<div class="iCQtmPqY0QvkumAOuCjr sidebar-info-row">
<div class="CmkY1Ag0tJDfnFXbGgju n1EzbHQahSKztskTUAm3 album-art-div" aria-hidden="true" style="width: 100%; float: left; height: 64px;">
</div>
<div class="release-info" style="width: 100%; float: left; margin: 0px 16px; ">
<a draggable="false" class="t_yrXoUO3qGsJS4Y6iXX standalone-ellipsis-one-line" data-testid="internal-track-link" tabindex="-1"></a>
<span class="artistInfoList Type__TypeElement-sc-goli3j-0 bDHxRN rq2VQ5mb9SDAFWbBIUIn standalone-ellipsis-one-line" data-encore-id="type">
</span>
</div>
</div>
</div>
</div>
</div>`;
albumDiv.innerHTML = albumDivContainer;
albumDiv.style.padding = "2px 0px";
const sidebarInfoRow = albumDiv.querySelector('.iCQtmPqY0QvkumAOuCjr')
const albumArtDiv = albumDiv.querySelector('.album-art-div')
const albumImage = document.createElement('img');
albumImage.src = album.images[2].url;
albumImage.alt = album.name;
albumArtDiv.appendChild(albumImage);
//albumImage.classList.add("mMx2LUixlnN_Fu45JpFB", "FqmFsMhuF4D0s35Z62Js", "Yn2Ei5QZn19gria6LjZj")
const albumName = albumDiv.querySelector('.t_yrXoUO3qGsJS4Y6iXX')
albumName.setAttribute("href", album.external_urls.spotify)
albumName.textContent = album.name;
const artistList = albumDiv.querySelector('.artistInfoList');
artistList.appendChild(setArtists(album));
//add release date for each album
const release_info = albumDiv.querySelector('.release-info');
//release_info.style.height = "64px";
release_info.insertBefore(setDate(album), release_info.firstElementChild);
//sidebarInfoRow.appendChild(albumName);
return albumDiv;
});
albumDivs.forEach(albumDiv => {
sidebarContainer.appendChild(albumDiv);
})
return sidebarContainer;
})
.catch(error => {
console.error(error);
});
}
// -------------------------- User interface ------------------------------- //
function insertCss(css) {
const style = document.createElement('style');
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
return style;
}
async function createUI(buttonHtml) {
let sidebarOpen = false
const mainContainer = document.createElement('div');
mainContainer.innerHTML = buttonHtml;
// Get the button element within the div
const whatsnewBtn = mainContainer.querySelector('button');
whatsnewBtn.setAttribute('id','whats-new-btn');
whatsnewBtn.addEventListener('mouseover', function () {
whatsnewBtn.setAttribute('data-context-menu-open', 'true');
});
whatsnewBtn.addEventListener('mouseout', function () {
whatsnewBtn.removeAttribute('data-context-menu-open');
});
//Create the sidebar
const sidebar = document.createElement('div');
sidebar.setAttribute('id','sidebar');
sidebar.classList.add('EZFyDnuQnx5hw78phLqP');
sidebar.style.display = 'none';
document.body.appendChild(sidebar);
// Add content to the sidebar
render()
.then(result => {
sidebar.appendChild(result);
})
.catch(error => {
console.error('Error:', error);
});
function toggleSidebar() {
if (sidebarOpen) {
// Close the sidebar
sidebar.style.display = 'none';
sidebarOpen = false;
} else {
// Open the sidebar
sidebar.style.display = 'block';
sidebarOpen = true;
}
}
whatsnewBtn.addEventListener('click', toggleSidebar);
let header = document.getElementsByClassName("facDIsOQo9q7kiWc4jSg")[0];
header.insertBefore(mainContainer, header.lastElementChild);
};
// -------------------------- Main ------------------------------- //
//Creates button for sidebar. If header div for button does not exist yet, wait 100ms before trying again.
function Init() {
if (document.getElementsByClassName("facDIsOQo9q7kiWc4jSg")[0] != null){
createUI(buttonHtml);
} else {
setTimeout(() => {Init();}, 100);
}
}
function Main() {
insertCss(mainCss);
Init();
getProfile();
}
})();