Skip to content
Snippets Groups Projects
SpotifyScript.js 143 KiB
Newer Older
    total_tracks;
    constructor(offset, pagination_limit, collection_response) {
        const total_tracks = collection_response.data.me.library.episodes.totalCount;
        const episodes = format_collection_episodes(collection_response);
        super(episodes, total_tracks > offset + pagination_limit);
        this.pagination_limit = pagination_limit;
        this.offset = offset + pagination_limit;
        this.total_tracks = total_tracks;
    }
    nextPage() {
        const { url, headers } = liked_episodes_args(this.offset, this.pagination_limit);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
        const episodes = format_collection_episodes(response);
        this.results = episodes;
        this.hasMore = this.total_tracks > this.offset + this.pagination_limit;
        this.offset += this.pagination_limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_collection_episodes(response) {
    return response.data.me.library.episodes.items.map(function (episode) {
        if (episode.episode.data.podcastV2.data.__typename === "NotFound" || episode.episode.data.releaseDate === null) {
            throw new ScriptException("unreachable");
        }
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, id_from_uri(episode.episode._uri), plugin.config.id),
            name: episode.episode.data.name,
            author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(episode.episode.data.podcastV2.data.uri), plugin.config.id), episode.episode.data.podcastV2.data.name, `${SHOW_URL_PREFIX}${id_from_uri(episode.episode.data.podcastV2.data.uri)}`, episode.episode.data.podcastV2.data.coverArt?.sources[0]?.url),
            datetime: new Date(episode.episode.data.releaseDate.isoString).getTime() / 1000,
            url: `${EPISODE_URL_PREFIX}${id_from_uri(episode.episode._uri)}`,
            thumbnails: new Thumbnails(episode.episode.data.coverArt.sources.map(function (image) {
                return new Thumbnail(image.url, image.height);
            })),
            duration: episode.episode.data.duration.totalMilliseconds / 1000,
            viewCount: HARDCODED_ZERO,
            isLive: false,
            shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(episode.episode._uri)}`
        });
    });
}
class LikedTracksPager extends VideoPager {
    pagination_limit;
    offset;
    total_tracks;
    constructor(offset, pagination_limit, collection_response) {
        const total_tracks = collection_response.data.me.library.tracks.totalCount;
        const episodes = format_collection_tracks(collection_response);
        super(episodes, total_tracks > offset + pagination_limit);
        this.pagination_limit = pagination_limit;
        this.offset = offset + pagination_limit;
        this.total_tracks = total_tracks;
    }
    nextPage() {
        const { url, headers } = liked_songs_args(this.offset, this.pagination_limit);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
        const episodes = format_collection_tracks(response);
        this.results = episodes;
        this.hasMore = this.total_tracks > this.offset + this.pagination_limit;
        this.offset += this.pagination_limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function format_collection_tracks(response) {
    return response.data.me.library.tracks.items.map(function (track) {
        const artist = track.track.data.artists.items[0];
        if (artist === undefined) {
            throw new ScriptException("missing song artist");
        }
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, id_from_uri(track.track._uri), plugin.config.id),
            name: track.track.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)}`),
            datetime: HARDCODED_ZERO,
            url: `${SONG_URL_PREFIX}${id_from_uri(track.track._uri)}`,
            thumbnails: new Thumbnails(track.track.data.albumOfTrack.coverArt.sources.map(function (image) {
                return new Thumbnail(image.url, image.height);
            })),
            duration: track.track.data.duration.totalMilliseconds / 1000,
            viewCount: HARDCODED_ZERO,
            isLive: false,
            shareUrl: `${SONG_URL_PREFIX}${id_from_uri(track.track._uri)}`
        });
    });
}
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);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const playlist_content_response = JSON.parse(throw_if_not_ok(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.flatMap(function (playlist_track_metadata) {
        if (playlist_track_metadata.itemV2.__typename === "LocalTrackResponseWrapper") {
            return [];
        }
        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);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const album_tracks_response = JSON.parse(throw_if_not_ok(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 searchChannels(query) {
    check_and_update_token();
    return new SpotifyChannelPager(query, 0, 10);
}
class SpotifyChannelPager extends ChannelPager {
    query;
    limit;
    offset;
    constructor(query, offset, limit) {
        const { url, headers } = search_args(query, offset, limit);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
        const has_more = are_more_channel_results(search_response, offset, limit);
        super(format_channel_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);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
        this.results = format_channel_results(search_response);
        this.hasMore = are_more_channel_results(search_response, this.offset, this.limit);
        this.offset = this.offset + this.limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
function are_more_channel_results(search_response, current_offset, limit) {
    return search_response.data.searchV2.artists.totalCount > current_offset + limit
        || search_response.data.searchV2.podcasts.totalCount > current_offset + limit
        || search_response.data.searchV2.audiobooks.totalCount > current_offset + limit
        || search_response.data.searchV2.users.totalCount > current_offset + limit
        || search_response.data.searchV2.genres.totalCount > current_offset + limit;
}
function format_channel_results(search_response) {
    return [
        ...search_response.data.searchV2.artists.items.map(function (artist) {
            const thumbnail = artist.data.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING;
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, id_from_uri(artist.data.uri), plugin.config.id),
                name: artist.data.profile.name,
                thumbnail,
                url: `${ARTIST_URL_PREFIX}${id_from_uri(artist.data.uri)}`
            });
        }),
        ...search_response.data.searchV2.podcasts.items.map(function (podcasts) {
            const thumbnail = podcasts.data.coverArt.sources[0]?.url;
            if (thumbnail === undefined) {
                throw new ScriptException("missing podcast cover image");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, id_from_uri(podcasts.data.uri), plugin.config.id),
                name: podcasts.data.name,
                thumbnail,
                url: `${SHOW_URL_PREFIX}${id_from_uri(podcasts.data.uri)}`
            });
        }),
        ...search_response.data.searchV2.audiobooks.items.map(function (audiobook) {
            const thumbnail = audiobook.data.coverArt.sources[0]?.url;
            if (thumbnail === undefined) {
                throw new ScriptException("missing audiobook cover image");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, id_from_uri(audiobook.data.uri), plugin.config.id),
                name: audiobook.data.name,
                thumbnail,
                url: `${SHOW_URL_PREFIX}${id_from_uri(audiobook.data.uri)}`
            });
        }),
        ...search_response.data.searchV2.users.items.map(function (user) {
            const thumbnail = user.data.avatar?.sources[0]?.url ?? HARDCODED_EMPTY_STRING;
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, user.data.username, plugin.config.id),
                name: user.data.displayName,
                thumbnail,
                url: `${USER_URL_PREFIX}${user.data.username}`
            });
        }),
        ...search_response.data.searchV2.genres.items.map(function (genre) {
            const thumbnail = genre.data.image.sources[0]?.url;
            if (thumbnail === undefined) {
                throw new ScriptException("missing genre cover image");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, id_from_uri(genre.data.uri), plugin.config.id),
                name: genre.data.name,
                thumbnail,
                url: `${PAGE_URL_PREFIX}${id_from_uri(genre.data.uri)}`
            });
        }),
    ];
}
/**
 *
 * @param query
 * @param offset
 * @param limit really only works set to 10
 * @returns
 */
function search_args(query, offset, limit) {
    const variables = JSON.stringify({
        searchTerm: query,
        offset,
        // really only works set to 10
        limit,
        numberOfTopResults: 5,
        includeAudiobooks: true,
        includeArtistHasConcertsField: false,
        includePreReleases: true,
        includeLocalConcertsField: false
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "7a60179c5d6b6c385e849438efb1398392ef159d82f2ad7158be5e80bf7817a9"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "searchDesktop");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
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);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const browse_section_response = JSON.parse(throw_if_not_ok(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") {
                    return [section_item_content];
                }
                return [];
            });
            const first_section_item = section_items?.[0];
            if (first_section_item === undefined) {
                throw new LoginRequiredException("login to view custom genres");
            }
            const first_playlist_image = first_section_item.__typename === "Album"
                ? first_section_item.coverArt.sources[0]?.url
                : first_section_item.images.items[0]?.sources[0]?.url;
            if (first_playlist_image === undefined) {
                throw new ScriptException("missing playlist image");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                name,
                thumbnail: first_playlist_image,
                url: channel_url
            });
        }
        case "genre": {
            if (channel_uri_id === "recently-played") {
                if (!bridge.isLoggedIn()) {
                    throw new LoginRequiredException("login to open recently-played");
                }
                // Spotify just load the first 50
                const { url: uri_url, headers: uri_headers } = recently_played_ids_args(0, 50);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const recently_played_ids = JSON.parse(throw_if_not_ok(local_http.GET(uri_url, uri_headers, false)).body);
                const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) {
                    return uri_obj.uri;
                }));
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const recently_played_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
                const section_items = recently_played_response.data.lookup.flatMap(function (section_item) {
                    if (section_item.__typename === "UnknownTypeWrapper") {
                        return [{
                                image: {
                                    sources: [{
                                            "height": 640,
                                            "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png",
                                        }]
                                },
                                name: "Liked Songs",
                                __typename: "PseudoPlaylist",
                                uri: "spotify:collection:tracks"
                            }];
                    }
                    const section_item_content = section_item.data;
                    if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") {
                        return [section_item_content];
                    }
                    return [];
                });
                const first_section_item = section_items?.[0];
                if (first_section_item === undefined) {
                    throw new ScriptException("unreachable");
                }
                const first_section_first_playlist_image = function (section_item) {
                    switch (section_item.__typename) {
                        case "Album":
                            return section_item.coverArt.sources[0]?.url;
                        case "Playlist":
                            return section_item.images.items[0]?.sources[0]?.url;
                        case "PseudoPlaylist":
                            return section_item.image.sources[0]?.url;
                        default:
                            throw assert_exhaustive(section_item);
                    }
                }(first_section_item);
                if (first_section_first_playlist_image === undefined) {
                    throw new ScriptException("missing playlist image");
                }
                return new PlatformChannel({
                    id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                    name: "Recently played",
                    thumbnail: first_section_first_playlist_image,
                    url: "https://open.spotify.com/genre/recently-played"
                });
            }
            // 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_page_args(channel_uri_id, { offset: 0, limit }, { offset: 0, limit });
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const browse_page_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
            if (browse_page_response.data.browse.__typename === "GenericError") {
                throw new ScriptException("error loading genre page");
            }
            const name = browse_page_response.data.browse.header.title.transformedLabel;
            const sections = browse_page_response.data.browse.sections.items.flatMap(function (item) {
                if (is_playlist_section(item)) {
                    return [item];
                }
                return [];
            });
            const channel_url = `${PAGE_URL_PREFIX}${channel_uri_id}`;
            const section_items = sections[0]?.sectionItems.items.flatMap(function (section_item) {
                if (section_item.content.__typename === "UnknownType") {
                    return [];
                }
                const section_item_content = section_item.content.data;
                if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") {
                    return [section_item_content];
                }
                return [];
            });
            const first_section_item = section_items?.[0];
            if (first_section_item === undefined) {
                throw new LoginRequiredException("login to view custom genres");
            }
            const first_section_first_playlist_image = first_section_item.__typename === "Album"
                ? first_section_item.coverArt.sources[0]?.url
                : first_section_item.images.items[0]?.sources[0]?.url;
            if (first_section_first_playlist_image === undefined) {
                throw new ScriptException("missing playlist image");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                name,
                thumbnail: first_section_first_playlist_image,
                url: channel_url
            });
        }
        case "show": {
            const { url, headers } = show_metadata_args(channel_uri_id);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const show_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
            const sources = show_response.data.podcastUnionV2.coverArt.sources;
            const thumbnail = sources[sources.length - 1]?.url;
            if (thumbnail === undefined) {
                throw new ScriptException("missing cover art");
            }
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                name: show_response.data.podcastUnionV2.name,
                thumbnail,
                url: `${SHOW_URL_PREFIX}${channel_uri_id}`,
                description: show_response.data.podcastUnionV2.htmlDescription
            });
        }
        case "user": {
            const url = `https://spclient.wg.spotify.com/user-profile-view/v3/profile/${channel_uri_id}?playlist_limit=0&artist_limit=0&episode_limit=0`;
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const user_response = JSON.parse(throw_if_not_ok(local_http.GET(url, { Authorization: `Bearer ${local_state.bearer_token}` }, false)).body);
            return new PlatformChannel({
                id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                name: user_response.name,
                thumbnail: user_response.image_url,
                url: `${USER_URL_PREFIX}${channel_uri_id}`,
                subscribers: user_response.followers_count
            });
        }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        case "artist": {
            const { url, headers } = artist_metadata_args(channel_uri_id);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const artist_metadata_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
            const thumbnail = artist_metadata_response.data.artistUnion.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING;
            const banner = artist_metadata_response.data.artistUnion.visuals.headerImage?.sources[0]?.url;
            const channel = {
                id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id),
                name: artist_metadata_response.data.artistUnion.profile.name,
                thumbnail,
                url: `${ARTIST_URL_PREFIX}${channel_uri_id}`,
                subscribers: artist_metadata_response.data.artistUnion.stats.monthlyListeners,
                description: artist_metadata_response.data.artistUnion.profile.biography.text
            };
            if (banner === undefined) {
                return new PlatformChannel(channel);
            }
            return new PlatformChannel({
                ...channel,
                banner
            });
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        }
        case "content-feed":
            throw new ScriptException("not implemented");
        default:
            throw assert_exhaustive(channel_type, "unreachable");
    }
}
function is_playlist_section(item) {
    return item.data.__typename === "BrowseGenericSectionData"
        || item.data.__typename === "HomeGenericSectionData"
        || item.data.__typename === "WhatsNewSectionData"
        || item.data.__typename === "CustomRecentlyPlayedSectionData";
}
function browse_page_args(page_uri_id, pagePagination, sectionPagination) {
    const variables = JSON.stringify({
        uri: `spotify:page:${page_uri_id}`,
        pagePagination,
        sectionPagination
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "177a4ae12a90e35d335f060216ce5df7864a228c6ca262bd5ed90b37c2419dd9"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "browsePage");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
function recently_played_ids_args(offset, limit) {
    const url = `https://spclient.wg.spotify.com/recently-played/v3/user/${local_state.username}/recently-played?format=json&offset=${offset}&limit=${limit}&filter=default,collection-new-episodes`;
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
function recently_played_details_args(uris) {
    const variables = JSON.stringify({
        uris
    });
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "8e4eb5eafa2837eca337dc11321ac285a01f9a056a7ac83f77a66f9998b06a73"
        }
    });
    const url = new URL(QUERY_URL);
    url.searchParams.set("operationName", "fetchEntitiesForRecentlyPlayed");
    url.searchParams.set("variables", variables);
    url.searchParams.set("extensions", extensions);
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } };
}
function parse_channel_url(url) {
    const match_result = url.match(CHANNEL_REGEX);
    if (match_result === null) {
        throw new ScriptException("regex error");
    }
    const maybe_channel_type = match_result[1];
    if (maybe_channel_type === undefined) {
        throw new ScriptException("regex error");
    }
    const is_section = match_result[2] === "section";
    let channel_type = maybe_channel_type;
    if (is_section) {
        channel_type = "section";
    }
    const channel_uri_id = match_result[3];
    if (channel_uri_id === undefined) {
        throw new ScriptException("regex error");
    }
    return { channel_type, channel_uri_id: channel_uri_id === "recently-played" ? "recently-played" : channel_uri_id };
}
//#endregion
//#region channel content
function getChannelCapabilities() {
    return new ResultCapabilities([
        Type.Feed.Playlists,
        Type.Feed.Albums,
        Type.Feed.Videos
    ], [
        Type.Order.Chronological
    ], []);
}
function getChannelContents(url, type, order, filters) {
    if (filters !== null) {
        throw new ScriptException("unreachable");
    }
    if (order !== "CHRONOLOGICAL") {
        throw new ScriptException("unreachable");
    }
    if (type !== Type.Feed.Videos) {
        throw new ScriptException("unreachable");
    }
    check_and_update_token();
    const { channel_type, channel_uri_id } = parse_channel_url(url);
    switch (channel_type) {
        case "section": {
            const initial_limit = 20;
            const { url, headers } = browse_section_args(channel_uri_id, 0, initial_limit);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const browse_section_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
            const name = browse_section_response.data.browseSection.data.title.transformedLabel;
            const section = browse_section_response.data.browseSection;
            const section_uri_id = channel_uri_id;
            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") {
                    return [section_item_content];
                }
                return [];
            });
            if (section_items.length === 0) {
                return new ContentPager([], false);
            }
            const first_section_item = section_items[0];
            if (first_section_item === undefined) {
                throw new ScriptException("no section items");
            }
            const author = new PlatformAuthorLink(new PlatformID(PLATFORM, section_uri_id, plugin.config.id), name, `${SECTION_URL_PREFIX}${section_uri_id}`, first_section_item.__typename === "Album"
                ? first_section_item.coverArt.sources[0]?.url
                : first_section_item.images.items[0]?.sources[0]?.url);
            return new SectionPager(channel_uri_id, section_items, 0, initial_limit, author, section.sectionItems.totalCount > initial_limit);
        }
        case "genre": {
            if (channel_uri_id === "recently-played") {
                if (!bridge.isLoggedIn()) {
                    throw new LoginRequiredException("login to open recently-played");
                }
                // Spotify just load the first 50
                const { url: uri_url, headers: uri_headers } = recently_played_ids_args(0, 50);
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const recently_played_ids = JSON.parse(throw_if_not_ok(local_http.GET(uri_url, uri_headers, false)).body);
                const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) {
                    return uri_obj.uri;
                }));
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const recently_played_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
                const section_items = recently_played_response.data.lookup.flatMap(function (section_item) {
                    if (section_item.__typename === "UnknownTypeWrapper") {
                        return [{
                                image: {
                                    sources: [{
                                            "height": 640,
                                            "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png",
                                        }]
                                },
                                name: "Liked Songs",
                                __typename: "PseudoPlaylist",
                                uri: "spotify:collection:tracks"
                            }];
                    }
                    const section_item_content = section_item.data;
                    if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") {
                        return [section_item_content];
                    }
                    return [];
                });
                const first_section_item = section_items?.[0];
                if (first_section_item === undefined) {
                    throw new ScriptException("unreachable");
                }
                const first_section_first_playlist_image = function (section_item) {
                    switch (section_item.__typename) {
                        case "Album":
                            return section_item.coverArt.sources[0]?.url;
                        case "Playlist":
                            return section_item.images.items[0]?.sources[0]?.url;
                        case "PseudoPlaylist":
                            return section_item.image.sources[0]?.url;
                        default:
                            throw assert_exhaustive(section_item);
                    }
                }(first_section_item);
                if (first_section_first_playlist_image === undefined) {
                    throw new ScriptException("missing playlist image");
                }
                const author = new PlatformAuthorLink(new PlatformID(PLATFORM, "recently-played", plugin.config.id), "Recently played", `${PAGE_URL_PREFIX}recently-played`, first_section_first_playlist_image);
                const playlists = section_items.map(function (section_item) {
                    return format_section_item(section_item, author);
                });
                return new ContentPager(playlists, false);
            }
            const limit = 4;
            const { url, headers } = browse_page_args(channel_uri_id, { offset: 0, limit: 50 }, { offset: 0, limit: limit });
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const browse_page_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body);
            if (browse_page_response.data.browse.__typename === "GenericError") {
                throw new ScriptException("error loading genre page");
            }
            const playlists = format_page(browse_page_response.data.browse.sections.items, limit, browse_page_response.data.browse.header.title.transformedLabel);
            return new ContentPager(playlists, false);
        }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        case "show": {
            const { url: metadata_url, headers: metadata_headers } = show_metadata_args(channel_uri_id);
            const chapters_limit = 50;
            const episodes_limit = 6;
            const { url: chapters_url, headers: chapters_headers } = book_chapters_args(channel_uri_id, 0, chapters_limit);
            const { url: episodes_url, headers: episodes_headers } = podcast_episodes_args(channel_uri_id, 0, episodes_limit);
            const responses = local_http
                .batch()
                .GET(metadata_url, metadata_headers, false)
                .GET(chapters_url, chapters_headers, false)
                .GET(episodes_url, episodes_headers, false)
                .execute();
            if (responses[0] === undefined || responses[1] === undefined || responses[2] === undefined) {
                throw new ScriptException("unreachable");
            }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const show_metadata_response = JSON.parse(throw_if_not_ok(responses[0]).body);
            const author = new PlatformAuthorLink(new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), show_metadata_response.data.podcastUnionV2.name, `${SHOW_URL_PREFIX}${channel_uri_id}`, show_metadata_response.data.podcastUnionV2.coverArt.sources[0]?.url);
            switch (show_metadata_response.data.podcastUnionV2.__typename) {
                case "Audiobook": {
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                    const chapters_response = JSON.parse(throw_if_not_ok(responses[1]).body);
                    const publish_date_time = new Date(show_metadata_response.data.podcastUnionV2.publishDate.isoString).getTime() / 1000;
                    return new ChapterPager(channel_uri_id, chapters_response, 0, chapters_limit, author, publish_date_time);
                }
                case "Podcast": {
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                    const episodes_response = JSON.parse(throw_if_not_ok(responses[2]).body);
                    return new EpisodePager(channel_uri_id, episodes_response, 0, episodes_limit, author);
                }
                default:
                    throw assert_exhaustive(show_metadata_response.data.podcastUnionV2, "unreachable");
            }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        }
        case "artist":
            return new FlattenedArtistDiscographyPager(channel_uri_id, 0, 2);
        case "user":
            return new ContentPager([], false);
        case "content-feed":
            throw new ScriptException("not implemented");
        default:
            throw assert_exhaustive(channel_type, "unreachable");
    }
}
function getChannelPlaylists(url) {
    check_and_update_token();
    const { channel_type, channel_uri_id } = parse_channel_url(url);
    switch (channel_type) {
        case "section":
            return new PlaylistPager([], false);
        case "genre":
            return new PlaylistPager([], false);
        case "show":
            return new PlaylistPager([], false);
        case "artist":
            return new ArtistDiscographyPager(channel_uri_id, 0, 50);
        case "user":
            return new UserPlaylistPager(channel_uri_id, 0, 50);
        case "content-feed":
            throw new ScriptException("not implemented");
        default:
            throw assert_exhaustive(channel_type, "unreachable");
    }
}
/**
 *
 * @param sections
 * @param display_limit maximum number of items to display per section
 * @returns
 */
function format_page(sections, display_limit, page_title) {
    const filtered_sections = sections.flatMap(function (item) {
        if (is_playlist_section(item)) {
            return [item];
        }
        return [];
    });
    const content = filtered_sections.flatMap(function (section) {
        const section_title = section.data.title;
        const section_name = section_title === null ? page_title : "text" in section_title ? section_title.text : section_title.transformedLabel;
        const section_items = section.sectionItems.items.flatMap(function (section_item) {
            if (section_item.content.__typename === "UnknownType") {
                return [];
            }
            const section_item_content = section_item.content.data;
            if (section_item_content.__typename === "Playlist"
                || section_item_content.__typename === "Album"
                || section_item_content.__typename === "Episode"
                || section_item_content.__typename === "PseudoPlaylist") {
                return [section_item_content];
            }
            return [];
        });
        if (section_items.length === 0) {
            return [];
        }
        const first_section_item = section_items[0];
        if (first_section_item === undefined) {
            throw new ScriptException("no sections");
        }
        const author = function () {
            if ("section_url" in section) {
                return new PlatformAuthorLink(new PlatformID(PLATFORM, section.section_url, plugin.config.id), section_name, section.section_url);
            return new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(section.uri), plugin.config.id), section_name, `${SECTION_URL_PREFIX}${id_from_uri(section.uri)}`);
        }();
        return section_items.map(function (playlist) {
            return format_section_item(playlist, author);
        }).slice(0, display_limit);
    });
    return content;
}
class ArtistDiscographyPager extends PlaylistPager {
    artist_uri_id;
    limit;
    offset;
    artist;
    total_albums;
    constructor(artist_uri_id, offset, limit) {
        const { url: metadata_url, headers: metadata_headers } = artist_metadata_args(artist_uri_id);
        const { url: discography_url, headers: discography_headers } = discography_args(artist_uri_id, offset, limit);
        const responses = local_http
            .batch()
            .GET(metadata_url, metadata_headers, false)
            .GET(discography_url, discography_headers, false)
            .execute();
        if (responses[0] === undefined || responses[1] === undefined) {
            throw new ScriptException("unreachable");
        }
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const metadata_response = JSON.parse(throw_if_not_ok(responses[0]).body);
        const discography_response = JSON.parse(throw_if_not_ok(responses[1]).body);
        const avatar_url = metadata_response.data.artistUnion.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING;
        const author = new PlatformAuthorLink(new PlatformID(PLATFORM, artist_uri_id, plugin.config.id), metadata_response.data.artistUnion.profile.name, `${ARTIST_URL_PREFIX}${artist_uri_id}`, avatar_url, metadata_response.data.artistUnion.stats.monthlyListeners);
        const total_albums = discography_response.data.artistUnion.discography.all.totalCount;
        super(format_discography(discography_response, author), total_albums > offset + limit);
        this.artist_uri_id = artist_uri_id;
        this.limit = limit;
        this.artist = author;
        this.offset = offset + limit;
        this.total_albums = total_albums;
    }
    nextPage() {
        const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit);
        const discography_response = JSON.parse(local_http.GET(url, headers, false).body);
        this.results = format_discography(discography_response, this.artist);
        this.hasMore = this.total_albums > this.offset + this.limit;
        this.offset = this.offset + this.limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
class FlattenedArtistDiscographyPager extends VideoPager {
    artist_uri_id;
    limit;
    offset;
    total_albums;
    constructor(artist_uri_id, offset, limit) {
        const { url: discography_url, headers: discography_headers } = discography_args(artist_uri_id, offset, limit);
        const discography_response = JSON.parse(throw_if_not_ok(local_http.GET(discography_url, discography_headers, false)).body);
        const total_albums = discography_response.data.artistUnion.discography.all.totalCount;
        super(load_album_tracks_and_flatten(discography_response), total_albums > offset + limit);
        this.artist_uri_id = artist_uri_id;
        this.limit = limit;
        this.offset = offset + limit;
        this.total_albums = total_albums;
    }
    nextPage() {
        const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit);
        const discography_response = JSON.parse(local_http.GET(url, headers, false).body);
        this.results = load_album_tracks_and_flatten(discography_response);
        this.hasMore = this.total_albums > this.offset + this.limit;
        this.offset = this.offset + this.limit;
        return this;
    }
    hasMorePagers() {
        return this.hasMore;
    }
}
//TODO parallelize all of this album track loading code
function load_album_tracks_and_flatten(discography_response) {
    const songs = [];
    for (const album of discography_response.data.artistUnion.discography.all.items) {
        const first_release = album.releases.items[0];
        if (first_release === undefined) {
            throw new ScriptException("unreachable");
        }
        const pagination_limit = 50;
        const offset = 0;
        const { url, headers } = album_metadata_args(first_release.id, offset, pagination_limit);