Skip to content
Snippets Groups Projects
SpotifyScript.js 145 KiB
Newer Older
const CONTENT_REGEX = /^https:\/\/open\.spotify\.com\/(track|episode)\/([a-zA-Z0-9]*)($|\/)/;
const PLAYLIST_REGEX = /^https:\/\/open\.spotify\.com\/(album|playlist|collection)\/([a-zA-Z0-9]*|your-episodes|tracks)($|\/)/;
const CHANNEL_REGEX = /^https:\/\/open\.spotify\.com\/(show|artist|user|genre|section|content-feed)\/(section|)([a-zA-Z0-9]*|recently-played)($|\/)/;
const SONG_URL_PREFIX = "https://open.spotify.com/track/";
const EPISODE_URL_PREFIX = "https://open.spotify.com/episode/";
const SHOW_URL_PREFIX = "https://open.spotify.com/show/";
const ARTIST_URL_PREFIX = "https://open.spotify.com/artist/";
const USER_URL_PREFIX = "https://open.spotify.com/user/";
const ALBUM_URL_PREFIX = "https://open.spotify.com/album/";
const PAGE_URL_PREFIX = "https://open.spotify.com/genre/";
const SECTION_URL_PREFIX = "https://open.spotify.com/section/";
const PLAYLIST_URL_PREFIX = "https://open.spotify.com/playlist/";
const COLLECTION_URL_PREFIX = "https://open.spotify.com/collection/";
const QUERY_URL = "https://api-partner.spotify.com/pathfinder/v1/query";
const IMAGE_URL_PREFIX = "https://i.scdn.co/image/";
const PLATFORM = "Spotify";
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0";
const PLATFORM_IDENTIFIER = "web_player linux undefined;firefox 126.0;desktop";
const SPOTIFY_CONNECT_NAME = "Web Player (Grayjay)";
const CLIENT_VERSION = "harmony:4.42.0-2780565f";
const HARDCODED_ZERO = 0;
const HARDCODED_EMPTY_STRING = "";
const EMPTY_AUTHOR = new PlatformAuthorLink(new PlatformID(PLATFORM, "", plugin.config.id), "", "");
const local_http = http;
// const local_utility = utility
// set missing constants
Type.Order.Chronological = "Latest releases";
Type.Order.Views = "Most played";
Type.Order.Favorites = "Most favorited";
Type.Feed.Playlists = "PLAYLISTS";
Type.Feed.Albums = "ALBUMS";
let local_settings;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
const local_source = {
    enable,
    disable,
    saveState,
    getHome,
    search,
    getSearchCapabilities,
    isContentDetailsUrl,
    getContentDetails,
    isChannelUrl,
    getChannel,
    getChannelContents,
    getChannelCapabilities,
    searchChannels,
    isPlaylistUrl,
    getPlaylist,
    searchPlaylists,
    getChannelPlaylists,
    getPlaybackTracker,
    getUserPlaylists,
    getUserSubscriptions
};
init_source(local_source);
function init_source(local_source) {
    for (const method_key of Object.keys(local_source)) {
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        // @ts-expect-error assign to readonly constant source object
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        source[method_key] = local_source[method_key];
//#endregion
//#region enable
function enable(conf, settings, savedState) {
    if (IS_TESTING) {
        log("IS_TESTING true");
        log("logging configuration");
        log(conf);
        log("logging settings");
        log(settings);
        log("logging savedState");
        log(savedState);
    }
    local_settings = settings;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    if (savedState !== null && savedState !== undefined) {
        const state = JSON.parse(savedState);
        local_state = state;
        // the token stored in state might be old
        check_and_update_token();
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const home_page = "https://open.spotify.com";
        const token_regex = /<script id="config" data-testid="config" type="application\/json">({.*?})<\/script><script id="session" data-testid="session" type="application\/json">({.*?})<\/script>/;
        const web_player_js_regex = /https:\/\/open\.spotifycdn\.com\/cdn\/build\/web-player\/web-player\..{8}\.js/;
        // use the authenticated client to get a logged in bearer token
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const html = throw_if_not_ok(local_http.GET(home_page, { "User-Agent": USER_AGENT }, true)).body;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const web_player_js_match_result = html.match(web_player_js_regex);
        if (web_player_js_match_result === null || web_player_js_match_result[0] === undefined) {
            throw new ScriptException("regex error");
        }
        const token_match_result = html.match(token_regex);
        if (token_match_result === null || token_match_result[1] === undefined || token_match_result[2] === undefined) {
            throw new ScriptException("regex error");
        }
        const user_data = JSON.parse(token_match_result[1]);
        const token_response = JSON.parse(token_match_result[2]);
        const bearer_token = token_response.accessToken;
        // download license uri and get logged in user
        const get_license_url_url = "https://gue1-spclient.spotify.com/melody/v1/license_url?keysystem=com.widevine.alpha&sdk_name=harmony&sdk_version=4.41.0";
        const profile_attributes_url = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=profileAttributes&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2253bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced%22%7D%7D";
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const web_player_js_url = web_player_js_match_result[0];
        const responses = local_http
            .batch()
            .GET(get_license_url_url, { Authorization: `Bearer ${bearer_token}` }, false)
            .GET(profile_attributes_url, { Authorization: `Bearer ${bearer_token}` }, false)
            .GET(web_player_js_url, { Authorization: `Bearer ${bearer_token}` }, false)
        if (responses[0] === undefined || responses[1] === undefined || responses[2] === undefined) {
            throw new ScriptException("unreachable");
        }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const get_license_response = JSON.parse(throw_if_not_ok(responses[0]).body);
        const license_uri = `https://gue1-spclient.spotify.com/${get_license_response.uri}`;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const profile_attributes_response = JSON.parse(throw_if_not_ok(responses[1]).body);
        const feature_version_match_result = throw_if_not_ok(responses[2]).body.match(/"(web-player_(.*?))"/);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        if (feature_version_match_result === null) {
            throw new ScriptException("regex error");
        }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const feature_version = feature_version_match_result[1];
        if (feature_version === undefined) {
            throw new ScriptException("regex error");
        }
            feature_version,
            expiration_timestamp_ms: token_response.accessTokenExpirationTimestampMs,
            license_uri: license_uri,
            is_premium: user_data.isPremium
        if (profile_attributes_response.data.me !== null) {
            state = {
                ...state,
                username: profile_attributes_response.data.me.profile.username
            };
        }
        if ("userCountry" in user_data) {
            state = { ...state, country: user_data.userCountry };
        }
        local_state = state;
}
function download_bearer_token() {
    const get_access_token_url = "https://open.spotify.com/get_access_token?reason=transport&productType=web-player";
    // use the authenticated client to get a logged in bearer token
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    const access_token_response = throw_if_not_ok(local_http.GET(get_access_token_url, {}, true)).body;
    const token_response = JSON.parse(access_token_response);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    return token_response;
}
function check_and_update_token() {
    // renew the token with 30 seconds to spare
    if (Date.now() - 30 * 1000 < local_state.expiration_timestamp_ms) {
        return;
    }
    log("Spotify log: refreshing bearer token");
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    const token_response = download_bearer_token();
        feature_version: local_state.feature_version,
        bearer_token: token_response.accessToken,
        expiration_timestamp_ms: token_response.accessTokenExpirationTimestampMs,
        license_uri: local_state.license_uri,
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        is_premium: local_state.is_premium
    };
    if (local_state.username !== undefined) {
        state = { ...state, username: local_state.username };
    }
    if (local_state.country !== undefined) {
        state = { ...state, country: local_state.country };
    }
    local_state = state;
    log("Spotify log: disabling");
}
function saveState() {
    return JSON.stringify(local_state);
}
//#region home
    check_and_update_token();
    const { url, headers } = home_args(10);
    const { url: new_url, headers: new_headers } = whats_new_args(0, 50);
    const { url: recent_url, headers: recent_headers } = recently_played_ids_args(0, 50);
    const responses = local_http
        .batch()
        .GET(url, headers, false)
        .GET(new_url, new_headers, false)
        .GET(recent_url, recent_headers, false)
    if (responses[0] === undefined || responses[1] === undefined || responses[2] === undefined) {
        throw new ScriptException("unreachable");
    }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    const home_response = JSON.parse(throw_if_not_ok(responses[0]).body);
    const sections = home_response.data.home.sectionContainer.sections.items;
    if (bridge.isLoggedIn()) {
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const whats_new_response = JSON.parse(throw_if_not_ok(responses[1]).body);
Loading
Loading full blame...