Skip to content
Snippets Groups Projects
SpotifyScript.ts 160 KiB
Newer Older
    readonly headers: {
        Authorization: string,
        Accept: "application/json"
    }
} {
    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: string): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        uri: `spotify:artist:${artist_uri_id}`,
        locale: "",
        includePrerelease: true
    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "da986392124383827dc03cbb3d66c1de81225244b6e20f8d78f9f802cc43df6e"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "queryArtistOverview")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
//#endregion

//#region playlists
// https://open.spotify.com/album/6BzxX6zkDsYKFJ04ziU5xQ
// https://open.spotify.com/playlist/37i9dQZF1E38112qhvV3BT
// https://open.spotify.com/collection/your-episodes
function isPlaylistUrl(url: string): boolean {
    return PLAYLIST_REGEX.test(url)
}
function searchPlaylists(query: string): PlaylistPager {
    check_and_update_token()
    return new SpotifyPlaylistsPager(query, 0, 10)
}
class SpotifyPlaylistsPager extends PlaylistPager {
    private offset: number
    constructor(
        private readonly query: string,
        offset: number,
        private readonly limit: number
    ) {
        const { url, headers } = search_args(query, offset, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response: SearchResponse = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body)

        const has_more = are_more_playlist_results(search_response, offset, limit)
        super(format_playlist_results(search_response), has_more)
        this.offset = offset + limit
    }
    override nextPage(this: SpotifyPlaylistsPager): SpotifyPlaylistsPager {
        const { url, headers } = search_args(this.query, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response: SearchResponse = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body)

        this.results = format_playlist_results(search_response)
        this.hasMore = are_more_playlist_results(search_response, this.offset, this.limit)
        this.offset = this.offset + this.limit
        return this
    }
    override hasMorePagers(this: SpotifyPlaylistsPager): boolean {
        return this.hasMore
    }
}
function format_playlist_results(search_response: SearchResponse) {
    return [
        ...search_response.data.searchV2.albumsV2.items.map(function (album) {
            const album_artist = album.data.artists.items[0]
            if (album_artist === undefined) {
                throw new ScriptException("missing album artist")
            }
            return new PlatformPlaylist({
                id: new PlatformID(PLATFORM, id_from_uri(album.data.uri), plugin.config.id),
                name: album.data.name,
                author: new PlatformAuthorLink(
                    new PlatformID(PLATFORM, id_from_uri(album_artist.uri), plugin.config.id),
                    album_artist.profile.name,
                    `${ARTIST_URL_PREFIX}${id_from_uri(album_artist.uri)}`
                ),
                datetime: new Date(album.data.date.year, 0).getTime() / 1000,
                url: `${ALBUM_URL_PREFIX}${id_from_uri(album.data.uri)}`,
                // TODO load this some other way videoCount?: number
                thumbnail: album.data.coverArt.sources[0]?.url ?? HARDCODED_EMPTY_STRING
            })
        }),
        ...search_response.data.searchV2.playlists.items.map(function (playlist) {
            const created_iso = playlist.data.attributes.find(function (attribute) {
                return attribute.key === "created"
            })?.value
            const platform_playlist = {
                id: new PlatformID(PLATFORM, id_from_uri(playlist.data.uri), plugin.config.id),
                name: playlist.data.name,
                author: new PlatformAuthorLink(
                    new PlatformID(PLATFORM, playlist.data.ownerV2.data.username, plugin.config.id),
                    playlist.data.ownerV2.data.name,
                    `${USER_URL_PREFIX}${playlist.data.ownerV2.data.username}`
                ),
                url: `${PLAYLIST_URL_PREFIX}${id_from_uri(playlist.data.uri)}`,
                // TODO load this some other way videoCount?: number
                thumbnail: playlist.data.images.items[0]?.sources[0]?.url ?? HARDCODED_EMPTY_STRING
            }
            if (created_iso === undefined) {
                return new PlatformPlaylist(platform_playlist)
            }
            return new PlatformPlaylist({
                ...platform_playlist,
                datetime: new Date(created_iso).getTime() / 1000,
            })
        })
    ]
}
function are_more_playlist_results(search_response: SearchResponse, current_offset: number, limit: number): boolean {
    return search_response.data.searchV2.albumsV2.totalCount > current_offset + limit
        || search_response.data.searchV2.playlists.totalCount > current_offset + limit
}
function getPlaylist(url: string): PlatformPlaylistDetails {
    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: PlaylistType = maybe_playlist_type as PlaylistType
    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 as const
            const offset = 0

            const { url, headers } = album_metadata_args(playlist_uri_id, offset, pagination_limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const album_metadata_response: AlbumResponse = JSON.parse(throw_if_not_ok(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 as const
            const offset = 0

            const { url, headers } = fetch_playlist_args(playlist_uri_id, offset, pagination_limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const playlist_response: PlaylistResponse = JSON.parse(throw_if_not_ok(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)
            })
        }
        case "collection": {
            if (!bridge.isLoggedIn()) {
                throw new LoginRequiredException("login to open collections")
            }
            const collection_type: CollectionType = playlist_uri_id as CollectionType
            switch (collection_type) {
                case "your-episodes": {
                    const limit = 50
                    const { url, headers } = liked_episodes_args(0, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                    const response: LikedEpisodesResponse = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body)
                    const username = local_state.username
                    if (username === undefined) {
                        throw new ScriptException("unreachable")
                    }
                    return new PlatformPlaylistDetails({
                        id: new PlatformID(PLATFORM, collection_type, plugin.config.id),
                        name: "Your Episodes",
                        author: new PlatformAuthorLink(
                            new PlatformID(PLATFORM, username, plugin.config.id),
                            username, // TODO replace this with the signed in user's display name
                            `${USER_URL_PREFIX}${username}`
                        ),
                        url: "https://open.spotify.com/collection/your-episodes",
                        videoCount: response.data.me.library.episodes.totalCount,
                        contents: new LikedEpisodesPager(0, limit, response)
                    })
                }
                case "tracks": {
                    const limit = 50
                    const { url, headers } = liked_songs_args(0, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                    const response: LikedTracksResponse = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body)
                    const username = local_state.username
                    if (username === undefined) {
                        throw new ScriptException("unreachable")
                    }
                    return new PlatformPlaylistDetails({
                        id: new PlatformID(PLATFORM, collection_type, plugin.config.id),
                        name: "Liked Songs",
                        author: new PlatformAuthorLink(
                            new PlatformID(PLATFORM, username, plugin.config.id),
                            username, // TODO replace this with the signed in user's display name
                            `${USER_URL_PREFIX}${username}`
                        ),
                        url: "https://open.spotify.com/collection/tracks",
                        videoCount: response.data.me.library.tracks.totalCount,
                        contents: new LikedTracksPager(0, limit, response)
                    })
                }
                default:
                    throw assert_exhaustive(collection_type, "unreachable")
            }

        }
        default: {
            throw assert_exhaustive(playlist_type, "unreachable")
class LikedEpisodesPager extends VideoPager {
    private offset: number
    private readonly total_tracks: number
    constructor(
        offset: number,
        private readonly pagination_limit: number,
        collection_response: LikedEpisodesResponse
    ) {
        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.offset = offset + pagination_limit
        this.total_tracks = total_tracks
    }
    override nextPage(this: LikedEpisodesPager): LikedEpisodesPager {
        const { url, headers } = liked_episodes_args(this.offset, this.pagination_limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const response: LikedEpisodesResponse = 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
    }
    override hasMorePagers(this: LikedEpisodesPager): boolean {
        return this.hasMore
    }
}
function format_collection_episodes(response: LikedEpisodesResponse) {
    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 {
    private offset: number
    private readonly total_tracks: number
    constructor(
        offset: number,
        private readonly pagination_limit: number,
        collection_response: LikedTracksResponse
    ) {
        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.offset = offset + pagination_limit
        this.total_tracks = total_tracks
    }
    override nextPage(this: LikedTracksPager): LikedTracksPager {
        const { url, headers } = liked_songs_args(this.offset, this.pagination_limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const response: LikedTracksResponse = 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
    }
    override hasMorePagers(this: LikedTracksPager): boolean {
        return this.hasMore
    }
}
function format_collection_tracks(response: LikedTracksResponse) {
    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 {
    private offset: number
    private readonly total_tracks: number
    constructor(
        private readonly playlist_uri_id: string,
        offset: number,
        private readonly pagination_limit: number,
        playlist_response: PlaylistResponse
    ) {
        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.offset = offset + pagination_limit
        this.total_tracks = total_tracks
    }
    override nextPage(this: SpotifyPlaylistPager): SpotifyPlaylistPager {
        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: PlaylistContentResponse = 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
    }
    override hasMorePagers(this: SpotifyPlaylistPager): boolean {
        return this.hasMore
    }
}
function format_playlist_tracks(content: PlaylistContent) {
    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: string, offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    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: string, offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    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 {
    private offset: number
    private readonly thumbnails: Thumbnails
    private readonly album_artist: ArtistDetails
    private readonly unix_time: number
    private readonly total_tracks: number
    constructor(
        private readonly album_uri_id: string,
        offset: number,
        private readonly pagination_limit: number,
        album_metadata_response: AlbumResponse,
        album_artist: ArtistDetails,
        unix_time: number,
    ) {
        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.offset = offset + pagination_limit
        this.thumbnails = thumbnails
        this.album_artist = album_artist
        this.unix_time = unix_time
        this.total_tracks = total_tracks
    }
    override nextPage(this: AlbumPager): AlbumPager {
        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: AlbumTracksResponse = 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
    }
    override hasMorePagers(this: AlbumPager): boolean {
        return this.hasMore
    }
}
function format_album_tracks(tracks: Tracks, thumbnails: Thumbnails, album_artist: ArtistDetails, unix_time: number) {
    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: string, offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    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: string, offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    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: string): boolean {
    return CHANNEL_REGEX.test(url)
}
function searchChannels(query: string): ChannelPager {
    check_and_update_token()
    return new SpotifyChannelPager(query, 0, 10)
}
class SpotifyChannelPager extends ChannelPager {
    private offset: number
    constructor(
        private readonly query: string,
        offset: number,
        private readonly limit: number
    ) {
        const { url, headers } = search_args(query, offset, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response: SearchResponse = 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.offset = offset + limit
    }
    override nextPage(this: SpotifyChannelPager): SpotifyChannelPager {
        const { url, headers } = search_args(this.query, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const search_response: SearchResponse = 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
    }
    override hasMorePagers(this: SpotifyChannelPager): boolean {
        return this.hasMore
    }
}
function are_more_channel_results(search_response: SearchResponse, current_offset: number, limit: number): boolean {
    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: SearchResponse): PlatformChannel[] {
    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: string, offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    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: string): PlatformChannel {
    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: BrowseSectionResponse = 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: RecentlyPlayedUris = 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: RecentlyPlayedDetails = 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): (SectionItemAlbum | SectionItemPlaylist | SectionItemPseudoPlaylist)[] {
                    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: BrowsePageResponse = 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): (GenrePlaylistSection | HomePlaylistSection | WhatsNewSection | RecentlyPlayedSection)[] {
                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: ShowMetadataResponse = 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`
            const user_response: {
                readonly name: string
                readonly image_url: string
                readonly followers_count: number
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            } = JSON.parse(throw_if_not_ok(local_http.GET(
                url,
                { Authorization: `Bearer ${local_state.bearer_token}` },
                false
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            )).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
            })
        }
        case "artist":
            const { url, headers } = artist_metadata_args(channel_uri_id)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
            const artist_metadata_response: ArtistMetadataResponse = 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
            })
        case "content-feed":
            throw new ScriptException("not implemented")
        default:
            throw assert_exhaustive(channel_type, "unreachable")
    }
}
function is_playlist_section(item: Section): item is GenrePlaylistSection | HomePlaylistSection | WhatsNewSection | RecentlyPlayedSection {
    return item.data.__typename === "BrowseGenericSectionData"
        || item.data.__typename === "HomeGenericSectionData"
        || item.data.__typename === "WhatsNewSectionData"
        || item.data.__typename === "CustomRecentlyPlayedSectionData"
}
function browse_page_args(
    page_uri_id: string,
    pagePagination: {
        offset: number,
        limit: number
    },
    sectionPagination: {
        offset: number,
        limit: number
    }): { readonly url: string, readonly headers: { Authorization: string } } {
    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: number,
    limit: number
) {
    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: string[]): { readonly url: string, readonly headers: { Authorization: string } } {
    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)