Skip to content
Snippets Groups Projects
SpotifyScript.js 83.5 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)\/([a-zA-Z0-9]*)($|\/)/;
const CHANNEL_REGEX = /^https:\/\/open\.spotify\.com\/(show|artist|user|genre|section|content-feed)\/(section|)([a-zA-Z0-9]*)($|\/)/;
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 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 HARDCODED_ZERO = 0;
const HARDCODED_EMPTY_STRING = "";
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.searchSuggestions = searchSuggestions
// source.getSearchCapabilities = getSearchCapabilities
// source.search = search
// source.searchChannels = searchChannels
source.isChannelUrl = isChannelUrl;
source.getChannel = getChannel;
source.getChannelCapabilities = getChannelCapabilities;
source.getChannelContents = getChannelContents;
// source.getSearchChannelContentsCapabilities = getSearchChannelContentsCapabilities
// source.searchChannelContents = searchChannelContents
source.isContentDetailsUrl = isContentDetailsUrl;
source.getContentDetails = getContentDetails;
source.isPlaylistUrl = isPlaylistUrl;
// source.searchPlaylists = searchPlaylists
source.getPlaylist = getPlaylist;
// source.getUserSubscriptions = getUserSubscriptions
// source.getUserPlaylists = getUserPlaylists
/*
if (IS_TESTING) {
    const assert_source: SpotifySource = {
        enable,
        disable,
        saveState,
        getHome,
        searchSuggestions,
        search,
        getSearchCapabilities,
        isContentDetailsUrl,
        getContentDetails,
        isChannelUrl,
        getChannel,
        getChannelContents,
        getChannelCapabilities,
        searchChannelContents,
        getSearchChannelContentsCapabilities,
        searchChannels,
        getComments,
        getSubComments,
        isPlaylistUrl,
        getPlaylist,
        searchPlaylists,
        getLiveChatWindow,
        getUserPlaylists,
        getUserSubscriptions
    }
    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.searchSuggestions === undefined) { assert_never(source.searchSuggestions) }
    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.searchChannelContents === undefined) { assert_never(source.searchChannelContents) }
    if (source.getSearchChannelContentsCapabilities === undefined) { assert_never(source.getSearchChannelContentsCapabilities) }
    if (source.searchChannels === undefined) { assert_never(source.searchChannels) }
    if (source.getComments === undefined) { assert_never(source.getComments) }
    if (source.getSubComments === undefined) { assert_never(source.getSubComments) }
    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.getLiveChatWindow === undefined) { assert_never(source.getLiveChatWindow) }
    if (source.getUserPlaylists === undefined) { assert_never(source.getUserPlaylists) }
    if (source.getUserSubscriptions === undefined) { assert_never(source.getUserSubscriptions) }
    if (IS_TESTING) {
        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();
        const { token_response, user_data } = download_bearer_token();
        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";
        const responses = local_http
            .batch()
            .GET(get_license_url_url, { Authorization: `Bearer ${bearer_token}` }, false)
            .GET(profile_attributes_url, { Authorization: `Bearer ${bearer_token}` }, false)
            .execute();
        if (responses[0] === undefined || responses[1] === 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);
        let state = {
            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() {
    if (bridge.isLoggedIn()) {
        const home_page = "https://open.spotify.com";
        const regex = /<script id="config" data-testid="config" type="application\/json">({.*?})<\/script><script id="session" data-testid="session" type="application\/json">({.*?})<\/script>/;
        // use the authenticated client to get a logged in bearer token
        const html = local_http.GET(home_page, {}, true).body;
        const match_result = html.match(regex);
        if (match_result === null || match_result[1] === undefined || match_result[2] === undefined) {
            throw new ScriptException("regex error");
        }
        const user_data = JSON.parse(match_result[1]);
        const token_response = JSON.parse(match_result[2]);
        return { token_response, user_data };
    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);
    return { token_response, user_data: { isPremium: false } };
}
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");
    const { token_response, user_data } = download_bearer_token();
    let state = {
        bearer_token: token_response.accessToken,
        expiration_timestamp_ms: token_response.accessTokenExpirationTimestampMs,
        license_uri: local_state.license_uri,
        is_premium: user_data.isPremium
    };
    if (local_state.username !== undefined) {
        state = { ...state, username: local_state.username };
    }
    if (local_state.country !== undefined) {
        state = { ...state, country: local_state.country };
    }
    if ("userCountry" in user_data) {
        state = { ...state, country: user_data.userCountry };
    }
    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_heaers } = whats_new_args(0, 50);
    const responses = local_http
        .batch()
        .GET(url, headers, false)
        .GET(new_url, new_heaers, false)
        .execute();
    if (responses[0] === undefined || responses[1] === 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 playlists = format_page(home_response.data.home.sectionContainer.sections.items, 4);
    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}` } };
//#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 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");
    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) {
                const lyrics_response = JSON.parse(results[2].body);
                const subtitle_name = function () {
                    switch (lyrics_response.lyrics.language) {
                        case "en":
                            return "English";
                        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 maybe_file_id = song_metadata_response.file.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;
            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: 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_metadata_response.canonical_uri,
                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 += `${section.text.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_metadata_response.data.episodeUnionV2.uri,
                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 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
function isPlaylistUrl(url) {
    return PLAYLIST_REGEX.test(url);
}
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");
            }
            const pagination_limit = 25;
            const offset = 0;
            const { url, headers } = fetch_playlist_args(playlist_uri_id, offset, pagination_limit);
            const playlist_response = JSON.parse(local_http.GET(url, headers, false).body);
            const owner = playlist_response.data.playlistV2.ownerV2.data;
            return new PlatformPlaylistDetails({
                id: new PlatformID(PLATFORM, playlist_uri_id, plugin.config.id),
                name: playlist_response.data.playlistV2.name,
                author: new PlatformAuthorLink(new PlatformID(PLATFORM, owner.username, plugin.config.id), owner.name, `${ARTIST_URL_PREFIX}${owner.username}`, owner.avatar?.sources[owner.avatar.sources.length - 1]?.url),
                url: `${ALBUM_URL_PREFIX}${playlist_uri_id}`,
                videoCount: playlist_response.data.playlistV2.content.totalCount,
                contents: new SpotifyPlaylistPager(playlist_uri_id, offset, pagination_limit, playlist_response)
            });
        }
        default: {
            throw assert_exhaustive(playlist_type, "unreachable");
        }
    }
}
class SpotifyPlaylistPager extends VideoPager {
    playlist_uri_id;
    pagination_limit;
    offset;
    total_tracks;
    constructor(playlist_uri_id, offset, pagination_limit, playlist_response) {
        const total_tracks = playlist_response.data.playlistV2.content.totalCount;
        const songs = format_playlist_tracks(playlist_response.data.playlistV2.content);
        super(songs, total_tracks > offset + pagination_limit);
        this.playlist_uri_id = playlist_uri_id;
        this.pagination_limit = pagination_limit;
        this.offset = offset + pagination_limit;
        this.total_tracks = total_tracks;
    }
    nextPage() {
        const { url, headers } = fetch_playlist_contents_args(this.playlist_uri_id, this.offset, this.pagination_limit);
        const playlist_content_response = JSON.parse(local_http.GET(url, headers, false).body);
        const songs = format_playlist_tracks(playlist_content_response.data.playlistV2.content);
        this.results = songs;
        this.hasMore = this.total_tracks > this.offset + this.pagination_limit;
        this.offset += this.pagination_limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_playlist_tracks(content) {
    return content.items.map(function (playlist_track_metadata) {
        const song = playlist_track_metadata.itemV2.data;
        const track_uri_id = id_from_uri(song.uri);
        const artist = song.artists.items[0];
        if (artist === undefined) {
            throw new ScriptException("missing artist");
        }
        const url = `${SONG_URL_PREFIX}${track_uri_id}`;
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, track_uri_id, plugin.config.id),
            name: song.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)}`
            // TODO figure out a way to get the artist thumbnail
            ),
            url,
            thumbnails: new Thumbnails(song.albumOfTrack.coverArt.sources.map(function (source) {
                return new Thumbnail(source.url, source.height);
            })),
            duration: song.trackDuration.totalMilliseconds / 1000,
            viewCount: parseInt(song.playcount),
            isLive: false,
            shareUrl: url,
            datetime: new Date(playlist_track_metadata.addedAt.isoString).getTime() / 1000
        });
    });
}
/**
 *
 * @param playlist_uri_id
 * @param offset the track to start loading from in the album (0 is the first track)
 * @param limit the maximum number of tracks to load information about
 * @returns
 */
function fetch_playlist_contents_args(playlist_uri_id, offset, limit) {
    const variables = JSON.stringify({
        uri: `spotify:playlist:${playlist_uri_id}`,
        offset: offset,
        limit: limit
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "91d4c2bc3e0cd1bc672281c4f1f59f43ff55ba726ca04a45810d99bd091f3f0e"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "fetchPlaylistContents");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
/**
 *
 * @param playlist_uri_id
 * @param offset the track to start loading from in the album (0 is the first track)
 * @param limit the maximum number of tracks to load information about
 * @returns
 */
function fetch_playlist_args(playlist_uri_id, offset, limit) {
    const variables = JSON.stringify({
        uri: `spotify:playlist:${playlist_uri_id}`,
        offset: offset,
        limit: limit
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "91d4c2bc3e0cd1bc672281c4f1f59f43ff55ba726ca04a45810d99bd091f3f0e"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "fetchPlaylist");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
class AlbumPager extends VideoPager {
    album_uri_id;
    pagination_limit;
    offset;
    thumbnails;
    album_artist;
    unix_time;
    total_tracks;
    constructor(album_uri_id, offset, pagination_limit, album_metadata_response, album_artist, unix_time) {
        const total_tracks = album_metadata_response.data.albumUnion.tracks.totalCount;
        const thumbnails = new Thumbnails(album_metadata_response.data.albumUnion.coverArt.sources.map(function (source) {
            return new Thumbnail(source.url, source.height);
        }));
        const songs = format_album_tracks(album_metadata_response.data.albumUnion.tracks, thumbnails, album_artist, unix_time);
        super(songs, total_tracks > offset + pagination_limit);
        this.album_uri_id = album_uri_id;
        this.pagination_limit = pagination_limit;
        this.offset = offset + pagination_limit;
        this.thumbnails = thumbnails;
        this.album_artist = album_artist;
        this.unix_time = unix_time;
        this.total_tracks = total_tracks;
    }
    nextPage() {
        const { url, headers } = album_tracks_args(this.album_uri_id, this.offset, this.pagination_limit);
        const album_tracks_response = JSON.parse(local_http.GET(url, headers, false).body);
        const songs = format_album_tracks(album_tracks_response.data.albumUnion.tracks, this.thumbnails, this.album_artist, this.unix_time);
        this.results = songs;
        this.hasMore = this.total_tracks > this.offset + this.pagination_limit;
        this.offset += this.pagination_limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_album_tracks(tracks, thumbnails, album_artist, unix_time) {
    return tracks.items.map(function (track) {
        const track_uri_id = id_from_uri(track.track.uri);
        const artist = track.track.artists.items[0];
        if (artist === undefined) {
            throw new ScriptException("missing artist");
        }
        const url = `${SONG_URL_PREFIX}${track_uri_id}`;
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, track_uri_id, plugin.config.id),
            name: track.track.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)}`, id_from_uri(artist.uri) === album_artist.id ? album_artist.visuals.avatarImage.sources[album_artist.visuals.avatarImage.sources.length - 1]?.url : undefined),
            url,
            thumbnails,
            duration: track.track.duration.totalMilliseconds / 1000,
            viewCount: parseInt(track.track.playcount),
            isLive: false,
            shareUrl: url,
            datetime: unix_time
        });
    });
}
/**
 *
 * @param album_uri_id
 * @param offset the track to start loading from in the album (0 is the first track)
 * @param limit the maximum number of tracks to load information about
 * @returns
 */
function album_tracks_args(album_uri_id, offset, limit) {
    const variables = JSON.stringify({
        uri: `spotify:album:${album_uri_id}`,
        offset: offset,
        limit: limit
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "469874edcad37b7a379d4f22f0083a49ea3d6ae097916120d9bbe3e36ca79e9d"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "queryAlbumTracks");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
/**
 *
 * @param album_uri_id
 * @param offset the track to start loading from in the album (0 is the first track)
 * @param limit the maximum number of tracks to load information about
 * @returns
 */
function album_metadata_args(album_uri_id, offset, limit) {
    const variables = JSON.stringify({
        uri: `spotify:album:${album_uri_id}`,
        locale: "",
        offset: offset,
        limit: limit
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "469874edcad37b7a379d4f22f0083a49ea3d6ae097916120d9bbe3e36ca79e9d"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "getAlbum");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
//#region channel
// https://open.spotify.com/show/4Pgcpzc9b3qTxyUr9DkXEn
// https://open.spotify.com/show/5VzFvh1JlEhBMS6ZHZ8CNO
// https://open.spotify.com/artist/1HtB6hptdVyK6cBTm9SMTu
// https://open.spotify.com/user/zelladay
// https://open.spotify.com/genre/0JQ5DAt0tbjZptfcdMSKl3
// https://open.spotify.com/genre/section0JQ5DACFo5h0jxzOyHOsIe
function isChannelUrl(url) {
    return CHANNEL_REGEX.test(url);
}
function getChannel(url) {
    check_and_update_token();
    const { channel_type, channel_uri_id } = parse_channel_url(url);
    switch (channel_type) {
        case "section": {
            // use limit of 4 to load minimal data but try to guarantee that we can get a cover photo
            const limit = 4;
            const { url, headers } = browse_section_args(channel_uri_id, 0, limit);
            const browse_section_response = JSON.parse(local_http.GET(url, headers, false).body);
            const name = browse_section_response.data.browseSection.data.title.transformedLabel;
            const channel_url = `${SECTION_URL_PREFIX}${channel_uri_id}`;
            const section = browse_section_response.data.browseSection;
            const section_items = section.sectionItems.items.flatMap(function (section_item) {
                const section_item_content = section_item.content.data;
                if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") {