What's New for Web

Adds Spotify's What's New feature to the web version

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();