Skip to content
Snippets Groups Projects
SpotifyScript.js 140 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_UR_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" as const
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";
//#region source methods
source.enable = enable;
source.disable = disable;
source.saveState = saveState;
source.getHome = getHome;
source.getSearchCapabilities = getSearchCapabilities;
source.search = search;
source.searchChannels = searchChannels;
source.isChannelUrl = isChannelUrl;
source.getChannel = getChannel;
source.getChannelCapabilities = getChannelCapabilities;
source.getChannelContents = getChannelContents;
source.isContentDetailsUrl = isContentDetailsUrl;
source.getContentDetails = getContentDetails;
source.isPlaylistUrl = isPlaylistUrl;
source.searchPlaylists = searchPlaylists;
source.getPlaylist = getPlaylist;
source.getUserSubscriptions = getUserSubscriptions;
source.getUserPlaylists = getUserPlaylists;
source.getPlaybackTracker = getPlaybackTracker;
    const assert_source = {
        enable,
        disable,
        saveState,
        getHome,
        search,
        getSearchCapabilities,
        isContentDetailsUrl,
        getContentDetails,
        isChannelUrl,
        getChannel,
        getChannelContents,
        getChannelCapabilities,
        searchChannels,
        isPlaylistUrl,
        getPlaylist,
        searchPlaylists,
        getUserPlaylists,
        getUserSubscriptions,
        getPlaybackTracker
    };
    if (source.enable === undefined) {
        assert_never(source.enable);
    }
    if (source.disable === undefined) {
        assert_never(source.disable);
    }
    if (source.saveState === undefined) {
        assert_never(source.saveState);
    }
    if (source.getHome === undefined) {
        assert_never(source.getHome);
    }
    if (source.search === undefined) {
        assert_never(source.search);
    }
    if (source.getSearchCapabilities === undefined) {
        assert_never(source.getSearchCapabilities);
    }
    if (source.isContentDetailsUrl === undefined) {
        assert_never(source.isContentDetailsUrl);
    }
    if (source.getContentDetails === undefined) {
        assert_never(source.getContentDetails);
    }
    if (source.isChannelUrl === undefined) {
        assert_never(source.isChannelUrl);
    }
    if (source.getChannel === undefined) {
        assert_never(source.getChannel);
    }
    if (source.getChannelContents === undefined) {
        assert_never(source.getChannelContents);
    }
    if (source.getChannelCapabilities === undefined) {
        assert_never(source.getChannelCapabilities);
    }
    if (source.searchChannels === undefined) {
        assert_never(source.searchChannels);
    }
    if (source.isPlaylistUrl === undefined) {
        assert_never(source.isPlaylistUrl);
    }
    if (source.getPlaylist === undefined) {
        assert_never(source.getPlaylist);
    }
    if (source.searchPlaylists === undefined) {
        assert_never(source.searchPlaylists);
    }
    if (source.getUserPlaylists === undefined) {
        assert_never(source.getUserPlaylists);
    }
    if (source.getUserSubscriptions === undefined) {
        assert_never(source.getUserSubscriptions);
    }
    if (source.getPlaybackTracker === undefined) {
        assert_never(source.getPlaybackTracker);
    }
        log(assert_source);
//#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);
    }
    if (savedState !== null) {
        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
        const html = local_http.GET(home_page, {}, true).body;
        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");
        }
        const get_license_response = JSON.parse(responses[0].body);
        const license_uri = `https://gue1-spclient.spotify.com/${get_license_response.uri}`;
        const profile_attributes_response = JSON.parse(responses[1].body);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const feature_version_match_result = responses[2].body.match(/"(web-player_(.*?))"/);
        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
    const access_token_response = 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");
    }
    const home_response = JSON.parse(responses[0].body);
    const sections = home_response.data.home.sectionContainer.sections.items;
    if (bridge.isLoggedIn()) {
        const whats_new_response = JSON.parse(responses[1].body);
        sections.push({
            data: {
                __typename: "WhatsNewSectionData",
                title: {
                    text: "What's New"
                },
            },
            section_url: "https://open.spotify.com/content-feed",
            sectionItems: whats_new_response.data.whatsNewFeedItems
        });
        const recently_played_ids = JSON.parse(responses[2].body);
        const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) {
            return uri_obj.uri;
        }));
        const recently_played_response = JSON.parse(local_http.GET(url, headers, false).body);
        sections.unshift({
            data: {
                __typename: "CustomRecentlyPlayedSectionData",
                title: {
                    text: "Recently played"
                },
            },
            section_url: "https://open.spotify.com/genre/recently-played",
            sectionItems: {
                items: recently_played_response.data.lookup.flatMap(function (section_item) {
                    if (section_item.__typename === "UnknownTypeWrapper") {
                        if (section_item._uri !== `spotify:user:${local_state.username}:collection`) {
                            throw new ScriptException("unexpected uri");
                        }
                        return {
                            content: {
                                data: {
                                    image: {
                                        sources: [{
                                                "height": 640,
                                                "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png",
                                                "width": 640
                                            }]
                                    },
                                    name: "Liked Songs",
                                    __typename: "PseudoPlaylist",
                                    uri: "spotify:collection:tracks"
                                },
                                __typename: "LibraryPseudoPlaylistResponseWrapper"
                            }
                        };
                    }
                    return {
                        content: {
                            data: section_item.data,
                            __typename: section_item.__typename
    const playlists = format_page(sections, 4, "Home");
    return new ContentPager(playlists, false);
}
function whats_new_args(offset, limit) {
    const variables = JSON.stringify({
        offset,
        limit,
        onlyUnPlayedItems: false,
        includedContentTypes: []
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "4c3281ff1c1c0b67f56e4a77568d6b143da7cf1260266ed5d5147a5e49481493"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "queryWhatsNewFeed");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
/**
 *
 * @param limit has incosistent behavior use 10 because that's what the spotify homepage uses
 * @returns
 */
function home_args(limit) {
    const variables = JSON.stringify({
        /** usually something like America/Chicago */
        timeZone: "America/Chicago", // TODO figure out a way to calculate this in Grayjay (maybe a setting) Intl.DateTimeFormat().resolvedOptions().timeZone,
        /** usually the logged in user cookie */
        sp_t: "",
        /** usually something like US */
        country: "US",
        facet: null,
        sectionItemsLimit: limit
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "a68635e823cd71d9f6810ec221d339348371ef0b878ec6b846fc36b234219c59"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "home");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
//#region search
function getSearchCapabilities() {
    return new ResultCapabilities([
        Type.Feed.Videos
    ], [], []);
}
function search(query, type, order, filters) {
    if (filters !== null && Object.keys(filters).length !== 0) {
        throw new ScriptException("unreachable");
    }
    if (order !== null) {
        throw new ScriptException("unreachable");
    }
    if (type !== null) {
        throw new ScriptException("unreachable");
    }
    check_and_update_token();
    return new SearchPager(query, 0, 100);
}
class SearchPager extends VideoPager {
    query;
    limit;
    offset;
    constructor(query, offset, limit) {
        const { url, headers } = search_args(query, offset, limit);
        const search_response = JSON.parse(local_http.GET(url, headers, false).body);
        const has_more = are_more_song_and_episode_results(search_response, offset, limit);
        super(format_song_and_episode_results(search_response), has_more);
        this.query = query;
        this.limit = limit;
        this.offset = offset + limit;
    }
    nextPage() {
        const { url, headers } = search_args(this.query, this.offset, this.limit);
        const search_response = JSON.parse(local_http.GET(url, headers, false).body);
        this.results = format_song_and_episode_results(search_response);
        this.hasMore = are_more_song_and_episode_results(search_response, this.offset, this.limit);
        this.offset = this.offset + this.limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_song_and_episode_results(search_response) {
    return [
        ...search_response.data.searchV2.tracksV2.items.map(function (track) {
            const artist = track.item.data.artists.items[0];
            if (artist === undefined) {
                throw new ScriptException("missing artist");
            }
            return new PlatformVideo({
                id: new PlatformID(PLATFORM, track.item.data.id, plugin.config.id),
                name: track.item.data.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(artist.uri), plugin.config.id), artist.profile.name, `${ARTIST_URL_PREFIX}${id_from_uri(artist.uri)}`),
                url: `${SONG_URL_PREFIX}${track.item.data.id}`,
                thumbnails: new Thumbnails(track.item.data.albumOfTrack.coverArt.sources.map(function (image) {
                    return new Thumbnail(image.url, image.height);
                })),
                duration: track.item.data.duration.totalMilliseconds / 1000,
                viewCount: HARDCODED_ZERO,
                isLive: false,
                shareUrl: `${SONG_URL_PREFIX}${track.item.data.id}`,
                datetime: HARDCODED_ZERO
            });
        }),
        ...search_response.data.searchV2.episodes.items.map(function (episode) {
            return new PlatformVideo({
                id: new PlatformID(PLATFORM, id_from_uri(episode.data.uri), plugin.config.id),
                name: episode.data.name,
                author: episode.data.podcastV2.data.__typename === "Podcast" ? new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(episode.data.podcastV2.data.uri), plugin.config.id), episode.data.podcastV2.data.name, `${ARTIST_URL_PREFIX}${id_from_uri(episode.data.podcastV2.data.uri)}`, episode.data.podcastV2.data.coverArt?.sources[0]?.url) : EMPTY_AUTHOR,
                url: `${EPISODE_URL_PREFIX}${id_from_uri(episode.data.uri)}`,
                thumbnails: new Thumbnails(episode.data.coverArt.sources.map(function (image) {
                    return new Thumbnail(image.url, image.height);
                })),
                duration: episode.data.duration.totalMilliseconds / 1000,
                viewCount: HARDCODED_ZERO,
                isLive: false,
                shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(episode.data.uri)}`,
                datetime: episode.data.releaseDate === null ? HARDCODED_ZERO : new Date(episode.data.releaseDate.isoString).getTime() / 1000
            });
        })
    ];
}
function are_more_song_and_episode_results(search_response, current_offset, limit) {
    return search_response.data.searchV2.tracksV2.totalCount > current_offset + limit
        || search_response.data.searchV2.episodes.totalCount > current_offset + limit;
}
//#endregion
//#region content
// https://open.spotify.com/track/6XXxKsu3RJeN3ZvbMYrgQW
// https://open.spotify.com/episode/3Z88ZE0i3L7AIrymrBwtqg
function isContentDetailsUrl(url) {
    return CONTENT_REGEX.test(url);
    if (!bridge.isLoggedIn()) {
        throw new LoginRequiredException("login to listen to songs");
    }
    check_and_update_token();
    const { content_uri_id, content_type } = parse_content_url(url);
    switch (content_type) {
        case "track": {
            const song_url = `${SONG_URL_PREFIX}${content_uri_id}`;
            const { url: metadata_url, headers: metadata_headers } = song_metadata_args(content_uri_id);
            const { url: track_metadata_url, headers: _track_metadata_headers } = track_metadata_args(content_uri_id);
            const batch = local_http
                .batch()
                .GET(metadata_url, metadata_headers, false)
                .GET(track_metadata_url, _track_metadata_headers, false);
            if (local_state.is_premium) {
                const { url, headers } = lyrics_args(content_uri_id);
                batch.GET(url, headers, false);
            }
            const results = batch
                .execute();
            if (results[0] === undefined || results[1] === undefined) {
                throw new ScriptException("unreachable");
            }
            const song_metadata_response = JSON.parse(results[0].body);
            const track_metadata_response = JSON.parse(results[1].body);
            const first_artist = track_metadata_response.data.trackUnion.firstArtist.items[0];
            if (first_artist === undefined) {
                throw new ScriptException("missing artist");
            }
            const artist_url = `https://open.spotify.com/artist/${first_artist.id}`;
            const highest_quality_artist_cover_art = first_artist.visuals.avatarImage.sources.reduce(function (accumulator, current) {
                return accumulator.height > current.height ? accumulator : current;
            });
            let subtitles = [];
            if (results[2] !== undefined && results[2].code !== 404) {
                const lyrics_response = JSON.parse(results[2].body);
                const subtitle_name = function () {
                    switch (lyrics_response.lyrics.language) {
                        case "en":
                            return "English";
                        case "es":
                            return "Español";
                        default:
                            throw assert_exhaustive(lyrics_response.lyrics.language, "unreachable");
                    }
                }();
                const convert = milliseconds_to_WebVTT_timestamp;
                let vtt_text = `WEBVTT ${subtitle_name}\n`;
                vtt_text += "\n";
                lyrics_response.lyrics.lines.forEach(function (line, index) {
                    const next = lyrics_response.lyrics.lines[index + 1];
                    let end = next?.startTimeMs;
                    if (end === undefined) {
                        end = track_metadata_response.data.trackUnion.duration.totalMilliseconds.toString();
                    }
                    vtt_text += `${convert(parseInt(line.startTimeMs))} --> ${convert(parseInt(end))}\n`;
                    vtt_text += `${line.words}\n`;
                    vtt_text += "\n";
                });
                subtitles = [{
                        url: song_url,
                        name: subtitle_name,
                        getSubtitles() {
                            return vtt_text;
                        },
                        format: "text/vtt",
                    }];
            }
            const format = local_state.is_premium ? "MP4_256" : "MP4_128";
            const files = song_metadata_response.file === undefined ? song_metadata_response.alternative?.[0]?.file : song_metadata_response.file;
            if (files === undefined) {
                throw new ScriptException("missing alternative file list");
            }
            const maybe_file_id = files.find(function (file) { return file.format === format; })?.file_id;
            if (maybe_file_id === undefined) {
                throw new ScriptException("missing expected format");
            }
            const { url, headers } = file_manifest_args(maybe_file_id);
            const { url: artist_metadata_url, headers: artist_metadata_headers } = artist_metadata_args(first_artist.id);
            const second_results = local_http
                .batch()
                .GET(url, headers, false)
                .GET(artist_metadata_url, artist_metadata_headers, false)
                .execute();
            if (second_results[0] === undefined || second_results[1] === undefined) {
                throw new ScriptException("unreachable");
            }
            const file_manifest = JSON.parse(second_results[0].body);
            const artist_metadata_response = JSON.parse(second_results[1].body);
            const duration = track_metadata_response.data.trackUnion.duration.totalMilliseconds / 1000;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const file_url = file_manifest.cdnurl[1];
            if (file_url === undefined) {
                throw new ScriptException("unreachable");
            }
            const codecs = "mp4a.40.2";
            const audio_sources = [new AudioUrlWidevineSource({
                    //audio/mp4; codecs="mp4a.40.2
                    name: codecs,
                    bitrate: function (format) {
                        switch (format) {
                            case "MP4_128":
                                return 128000;
                            case "MP4_256":
                                return 256000;
                            default:
                                throw assert_exhaustive(format, "unreachable");
                        }
                    }(format),
                    container: "audio/mp4",
                    codecs,
                    duration,
                    url: file_url,
                    language: Language.UNKNOWN,
                    bearerToken: local_state.bearer_token,
                    licenseUri: local_state.license_uri
                })];
            return new PlatformVideoDetails({
                id: new PlatformID(PLATFORM, content_uri_id, plugin.config.id),
                name: song_metadata_response.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, first_artist.id, plugin.config.id), first_artist.profile.name, artist_url, highest_quality_artist_cover_art.url, artist_metadata_response.data.artistUnion.stats.monthlyListeners),
                url: song_url,
                thumbnails: new Thumbnails(song_metadata_response.album.cover_group.image.map(function (image) {
                    return new Thumbnail(`${IMAGE_URL_PREFIX}${image.file_id}`, image.height);
                })),
                duration,
                viewCount: parseInt(track_metadata_response.data.trackUnion.playcount),
                isLive: false,
                shareUrl: song_url,
                datetime: new Date(track_metadata_response.data.trackUnion.albumOfTrack.date.isoString).getTime() / 1000,
                description: HARDCODED_EMPTY_STRING,
                video: new UnMuxVideoSourceDescriptor([], audio_sources),
                rating: new RatingLikes(HARDCODED_ZERO),
                subtitles
            });
        }
        case "episode": {
            const episode_url = `https://open.spotify.com/episode/${content_uri_id}`;
            const { url, headers } = episode_metadata_args(content_uri_id);
            const episode_metadata_response = JSON.parse(local_http.GET(url, headers, false).body);
            if (!episode_metadata_response.data.episodeUnionV2.playability.playable) {
                throw new UnavailableException("login or purchase to play premium content");
            if (episode_metadata_response.data.episodeUnionV2.mediaTypes.length === 2) {
                function assert_video(_mediaTypes) { }
                assert_video(episode_metadata_response.data.episodeUnionV2.mediaTypes);
                //TODO since we don't use the transcript we should only load it when audio only podcasts are played
                // TODO handle video podcasts. Grayjay doesn't currently support the websocket functionality necessary
                // the basic process to get the video play info is
                // connect to the websocket wss://gue1-dealer.spotify.com/?access_token=<bearer-token> 
                // register the device https://gue1-spclient.spotify.com/track-playback/v1/devices
                //      generate the device id using code found in the min js like this
                /*
                        web player js
                        const t = Math.ceil(e / 2);
                        return function(e) {
                            let t = "";
                            for (let n = 0; n < e.length; n++) {
                                const i = e[n];
                                i < 16 && (t += "0"),
                                t += i.toString(16)
                            }
                            return t
                        }(Oe(t))
                */
                // load devices info https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_aced97d86694f14d304dd4e6f1f7f8c3bff
                // transfer to our device https://gue1-spclient.spotify.com/connect-state/v1/connect/transfer/from/9a7079bd5b5605839c1d9080d0f4368bfcd6d2eb/to/aced97d86694f14d304dd4e6f1f7f8c3bff
                // signal the play of the given podcast (not quite sure how this works :/)
                // recieve the video play info via the websocket connection
                //
            }
            const format = "MP4_128";
            const maybe_file_id = episode_metadata_response.data.episodeUnionV2.audio.items.find(function (file) { return file.format === format; })?.fileId;
            if (maybe_file_id === undefined) {
                throw new ScriptException("missing expected format");
            }
            const limited_show_metadata = episode_metadata_response.data.episodeUnionV2.__typename === "Chapter"
                ? episode_metadata_response.data.episodeUnionV2.audiobookV2.data
                : episode_metadata_response.data.episodeUnionV2.podcastV2.data;
            const show_uri_id = id_from_uri(limited_show_metadata.uri);
            const highest_quality_cover_art = limited_show_metadata.coverArt.sources.reduce(function (accumulator, current) {
                return accumulator.height > current.height ? accumulator : current;
            });
            const { url: manifest_url, headers: manifest_headers } = file_manifest_args(maybe_file_id);
            const { url: show_metadata_url, headers: show_metadata_headers } = show_metadata_args(show_uri_id);
            const batch = local_http
                .batch()
                .GET(show_metadata_url, show_metadata_headers, false)
                .GET(manifest_url, manifest_headers, false);
            if (episode_metadata_response.data.episodeUnionV2.transcripts !== undefined) {
                const { url, headers } = transcript_args(content_uri_id);
                batch.GET(url, headers, false);
            }
            const results = batch.execute();
            if (results[0] === undefined || results[1] === undefined) {
                throw new ScriptException("unreachable");
            }
            const full_show_metadata = JSON.parse(results[0].body);
            const file_manifest = JSON.parse(results[1].body);
            const subtitles = function () {
                if (results[2] === undefined || results[2].code === 404) {
                    return [];
                }
                const transcript_response = JSON.parse(results[2].body);
                const subtitle_name = function () {
                    switch (transcript_response.language) {
                        case "en":
                            return "English";
                        default:
                            throw assert_exhaustive(transcript_response.language, "unreachable");
                    }
                }();
                let vtt_text = `WEBVTT ${subtitle_name}\n`;
                vtt_text += "\n";
                transcript_response.section.forEach(function (section, index) {
                    if ("title" in section) {
                        return;
                    }
                    const next = transcript_response.section[index + 1];
                    let end = next?.startMs;
                    if (end === undefined) {
                        end = episode_metadata_response.data.episodeUnionV2.duration.totalMilliseconds;
                    }
                    vtt_text += `${milliseconds_to_WebVTT_timestamp(section.startMs)} --> ${milliseconds_to_WebVTT_timestamp(end)}\n`;
                    vtt_text += `${"text" in section ? section.text.sentence.text : section.fallback.sentence.text}\n`;
                    vtt_text += "\n";
                });
                return [{
                        url: episode_url,
                        name: subtitle_name,
                        getSubtitles() {
                            return vtt_text;
                        },
                        format: "text/vtt",
                    }];
            }();
            const duration = episode_metadata_response.data.episodeUnionV2.duration.totalMilliseconds / 1000;
            const file_url = file_manifest.cdnurl[0];
            if (file_url === undefined) {
                throw new ScriptException("unreachable");
            }
            const codecs = "mp4a.40.2";
            const audio_sources = [new AudioUrlWidevineSource({
                    //audio/mp4; codecs="mp4a.40.2
                    name: codecs,
                    bitrate: 128000,
                    container: "audio/mp4",
                    codecs,
                    duration,
                    url: file_url,
                    language: Language.UNKNOWN,
                    bearerToken: local_state.bearer_token,
                    licenseUri: local_state.license_uri
                })];
            const datetime = function () {
                if (episode_metadata_response.data.episodeUnionV2.__typename === "Episode") {
                    return new Date(episode_metadata_response.data.episodeUnionV2.releaseDate.isoString).getTime() / 1000;
                else if (full_show_metadata.data.podcastUnionV2.__typename === "Audiobook") {
                    return new Date(full_show_metadata.data.podcastUnionV2.publishDate.isoString).getTime() / 1000;
                throw new ScriptException("unreachable");
            }();
            return new PlatformVideoDetails({
                id: new PlatformID(PLATFORM, content_uri_id, plugin.config.id),
                name: episode_metadata_response.data.episodeUnionV2.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, show_uri_id, plugin.config.id), limited_show_metadata.name, `${SHOW_URL_PREFIX}${show_uri_id}`, highest_quality_cover_art.url),
                url: episode_url,
                thumbnails: new Thumbnails(episode_metadata_response.data.episodeUnionV2.coverArt.sources.map(function (image) {
                    return new Thumbnail(image.url, image.height);
                })),
                duration,
                viewCount: HARDCODED_ZERO,
                isLive: false,
                shareUrl: episode_url,
                description: episode_metadata_response.data.episodeUnionV2.htmlDescription,
                video: new UnMuxVideoSourceDescriptor([], audio_sources),
                rating: new RatingScaler(full_show_metadata.data.podcastUnionV2.rating.averageRating.average),
                subtitles
            throw assert_exhaustive(content_type, "unreachable");
function parse_content_url(url) {
    const match_result = url.match(CONTENT_REGEX);
    if (match_result === null) {
        throw new ScriptException("regex error");
    }
    const maybe_content_type = match_result[1];
    if (maybe_content_type === undefined) {
        throw new ScriptException("regex error");
    }
    const content_type = maybe_content_type;
    const content_uri_id = match_result[2];
    if (content_uri_id === undefined) {
        throw new ScriptException("regex error");
    }
    return { content_uri_id, content_type };
}
function show_metadata_args(show_uri_id) {
    const variables = JSON.stringify({
        uri: `spotify:show:${show_uri_id}`
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "5fb034a236a3e8301e9eca0e23def3341ed66c891ea2d4fea374c091dc4b4a6a"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "queryShowMetadataV2");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
function transcript_args(episode_uri_id) {
    const transcript_url_prefix = "https://spclient.wg.spotify.com/transcript-read-along/v2/episode/";
    const url = new URL(`${transcript_url_prefix}${episode_uri_id}`);
    url.searchParams.set("format", "json");
    return {
        url: url.toString(),
        headers: { Authorization: `Bearer ${local_state.bearer_token}` }
    };
}
function lyrics_args(song_uri_id) {
    const url = new URL(`https://spclient.wg.spotify.com/color-lyrics/v2/track/${song_uri_id}`);
    return {
        url: url.toString(),
        headers: {
            Accept: "application/json",
            "app-platform": "WebPlayer",
            Authorization: `Bearer ${local_state.bearer_token}`
        }
    };
}
function file_manifest_args(file_id) {
    const file_manifest_url_prefix = "https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/10/";
    const file_manifest_params = "?product=9&alt=json";
    return {
        url: `${file_manifest_url_prefix}${file_id}${file_manifest_params}`,
        headers: { Authorization: `Bearer ${local_state.bearer_token}` }
    };
}
function episode_metadata_args(episode_uri_id) {
    const variables = JSON.stringify({
        uri: `spotify:episode:${episode_uri_id}`
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "9697538fe993af785c10725a40bb9265a20b998ccd2383bd6f586e01303824e9"
        }
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "getEpisodeOrChapter");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
function track_metadata_args(song_uri_id) {
    const variables = JSON.stringify({
        uri: `spotify:track:${song_uri_id}`
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "ae85b52abb74d20a4c331d4143d4772c95f34757bfa8c625474b912b9055b5c0"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "getTrack");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
function song_metadata_args(song_uri_id) {
    const song_metadata_url = "https://spclient.wg.spotify.com/metadata/4/track/";
    return {
        url: `${song_metadata_url}${get_gid(song_uri_id)}`,
        headers: {
            Authorization: `Bearer ${local_state.bearer_token}`,
            Accept: "application/json"
        }
    };
}
function artist_metadata_args(artist_uri_id) {
    const variables = JSON.stringify({
        uri: `spotify:artist:${artist_uri_id}`,
        locale: "",
        includePrerelease: true
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "da986392124383827dc03cbb3d66c1de81225244b6e20f8d78f9f802cc43df6e"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "queryArtistOverview");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
//#endregion
//#region playlists
// https://open.spotify.com/album/6BzxX6zkDsYKFJ04ziU5xQ
// https://open.spotify.com/playlist/37i9dQZF1E38112qhvV3BT
// https://open.spotify.com/collection/your-episodes
function isPlaylistUrl(url) {
    return PLAYLIST_REGEX.test(url);
}
function searchPlaylists(query) {
    check_and_update_token();
    return new SpotifyPlaylistsPager(query, 0, 10);
}
class SpotifyPlaylistsPager extends PlaylistPager {
    query;
    limit;
    offset;
    constructor(query, offset, limit) {
        const { url, headers } = search_args(query, offset, limit);
        const search_response = JSON.parse(local_http.GET(url, headers, false).body);
        const has_more = are_more_playlist_results(search_response, offset, limit);
        super(format_playlist_results(search_response), has_more);
        this.query = query;
        this.limit = limit;
        this.offset = offset + limit;
    }
    nextPage() {
        const { url, headers } = search_args(this.query, this.offset, this.limit);
        const search_response = JSON.parse(local_http.GET(url, headers, false).body);
        this.results = format_playlist_results(search_response);
        this.hasMore = are_more_playlist_results(search_response, this.offset, this.limit);
        this.offset = this.offset + this.limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_playlist_results(search_response) {
    return [
        ...search_response.data.searchV2.albumsV2.items.map(function (album) {
            const album_artist = album.data.artists.items[0];
            if (album_artist === undefined) {
                throw new ScriptException("missing album artist");
            }
            return new PlatformPlaylist({
                id: new PlatformID(PLATFORM, id_from_uri(album.data.uri), plugin.config.id),
                name: album.data.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(album_artist.uri), plugin.config.id), album_artist.profile.name, `${ARTIST_URL_PREFIX}${id_from_uri(album_artist.uri)}`),
                datetime: new Date(album.data.date.year, 0).getTime() / 1000,
                url: `${ALBUM_URL_PREFIX}${id_from_uri(album.data.uri)}`,
                // TODO load this some other way videoCount?: number
                thumbnail: album.data.coverArt.sources[0]?.url ?? HARDCODED_EMPTY_STRING
            });
        }),
        ...search_response.data.searchV2.playlists.items.map(function (playlist) {
            const created_iso = playlist.data.attributes.find(function (attribute) {
                return attribute.key === "created";
            })?.value;
            const platform_playlist = {
                id: new PlatformID(PLATFORM, id_from_uri(playlist.data.uri), plugin.config.id),
                name: playlist.data.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, playlist.data.ownerV2.data.username, plugin.config.id), playlist.data.ownerV2.data.name, `${USER_URL_PREFIX}${playlist.data.ownerV2.data.username}`),
                url: `${PLAYLIST_URL_PREFIX}${id_from_uri(playlist.data.uri)}`,
                // TODO load this some other way videoCount?: number
                thumbnail: playlist.data.images.items[0]?.sources[0]?.url ?? HARDCODED_EMPTY_STRING
            };
            if (created_iso === undefined) {
                return new PlatformPlaylist(platform_playlist);
            }
            return new PlatformPlaylist({
                ...platform_playlist,
                datetime: new Date(created_iso).getTime() / 1000,
            });
        })
    ];
}
function are_more_playlist_results(search_response, current_offset, limit) {
    return search_response.data.searchV2.albumsV2.totalCount > current_offset + limit
        || search_response.data.searchV2.playlists.totalCount > current_offset + limit;
}
function getPlaylist(url) {
    check_and_update_token();
    const match_result = url.match(PLAYLIST_REGEX);
    if (match_result === null) {
        throw new ScriptException("regex error");
    }
    const maybe_playlist_type = match_result[1];
    if (maybe_playlist_type === undefined) {
        throw new ScriptException("regex error");
    }
    const playlist_type = maybe_playlist_type;
    const playlist_uri_id = match_result[2];
    if (playlist_uri_id === undefined) {
        throw new ScriptException("regex error");
    }
    switch (playlist_type) {
        case "album": {
            // if the author is the same as the album then include the artist pick otherwise nothing
            // TODO we could load in extra info for all the other artists but it might be hard to do that in a request efficient way
            const pagination_limit = 50;
            const offset = 0;
            const { url, headers } = album_metadata_args(playlist_uri_id, offset, pagination_limit);
            const album_metadata_response = JSON.parse(local_http.GET(url, headers, false).body);
            const album_artist = album_metadata_response.data.albumUnion.artists.items[0];
            if (album_artist === undefined) {
                throw new ScriptException("missing album artist");
            }
            const unix_time = new Date(album_metadata_response.data.albumUnion.date.isoString).getTime() / 1000;
            return new PlatformPlaylistDetails({
                id: new PlatformID(PLATFORM, playlist_uri_id, plugin.config.id),
                name: album_metadata_response.data.albumUnion.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, album_artist.id, plugin.config.id), album_artist.profile.name, `${ARTIST_URL_PREFIX}${album_artist.id}`, album_artist.visuals.avatarImage.sources[album_artist.visuals.avatarImage.sources.length - 1]?.url),
                datetime: unix_time,
                url: `${ALBUM_URL_PREFIX}${playlist_uri_id}`,
                videoCount: album_metadata_response.data.albumUnion.tracks.totalCount,
                contents: new AlbumPager(playlist_uri_id, offset, pagination_limit, album_metadata_response, album_artist, unix_time)
            });
        }
        case "playlist": {
            if (!bridge.isLoggedIn()) {
                throw new LoginRequiredException("login to open playlists");