Skip to content
Snippets Groups Projects
SpotifyScript.ts 161 KiB
Newer Older
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function parse_channel_url(url: string): { channel_type: ChannelType, channel_uri_id: "recently-played" | string } {
    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: ChannelType = maybe_channel_type as ChannelType
    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<string, ChannelTypeCapabilities>(
        [
            Type.Feed.Playlists,
            Type.Feed.Albums,
            Type.Feed.Videos
        ],
        [
            Type.Order.Chronological
        ],
        []
    )
}
Kai DeLorenzo's avatar
Kai DeLorenzo committed
function getChannelContents(url: string, type: ChannelTypeCapabilities | null, order: Order | null, filters: FilterQuery<string> | null) {
    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: BrowseSectionResponse = JSON.parse(throw_if_not_200(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: RecentlyPlayedUris = JSON.parse(throw_if_not_200(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_200(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")
                }

                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: BrowsePageResponse = JSON.parse(throw_if_not_200(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)
        }
        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: ShowMetadataResponse = JSON.parse(throw_if_not_200(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: BookChaptersResponse = JSON.parse(throw_if_not_200(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: PodcastEpisodesResponse = JSON.parse(throw_if_not_200(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")
            }
        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: string): PlaylistPager {
    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: Section[], display_limit: number, page_title: string): (PlatformPlaylist | PlatformVideo)[] {
    const filtered_sections = sections.flatMap(function (item): (GenrePlaylistSection | HomePlaylistSection | WhatsNewSection | RecentlyPlayedSection)[] {
        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 {
    private offset: number
    private readonly artist: PlatformAuthorLink
    private readonly total_albums: number
    constructor(
        private readonly artist_uri_id: string,
        offset: number,
        private readonly limit: number

    ) {
        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: ArtistMetadataResponse = JSON.parse(throw_if_not_200(responses[0]).body)
        const discography_response: DiscographyResponse = JSON.parse(throw_if_not_200(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 = author
        this.offset = offset + limit
        this.total_albums = total_albums
    }
    override nextPage(this: ArtistDiscographyPager): ArtistDiscographyPager {
        const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit)
        const discography_response: DiscographyResponse = 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
    }
    override hasMorePagers(this: ArtistDiscographyPager): boolean {
        return this.hasMore
    }
}
class FlattenedArtistDiscographyPager extends VideoPager {
    private offset: number
    private readonly total_albums: number
    constructor(
        private readonly artist_uri_id: string,
        offset: number,
        private readonly limit: number

    ) {
        // TODO simplify by removing batching code
        const { url: discography_url, headers: discography_headers } = discography_args(artist_uri_id, offset, limit)
        const responses = local_http
            .batch()
            .GET(discography_url, discography_headers, false)
            .execute()
        if (responses[0] === undefined) {
            throw new ScriptException("unreachable")
        }
        const discography_response: DiscographyResponse = JSON.parse(throw_if_not_200(responses[0]).body)

        const total_albums = discography_response.data.artistUnion.discography.all.totalCount

        super(load_album_tracks_and_flatten(discography_response), total_albums > offset + limit)

        this.offset = offset + limit
        this.total_albums = total_albums
    }
    override nextPage(this: FlattenedArtistDiscographyPager): FlattenedArtistDiscographyPager {
        const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit)
        const discography_response: DiscographyResponse = 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
    }
    override hasMorePagers(this: FlattenedArtistDiscographyPager): boolean {
        return this.hasMore
    }
}
//TODO parallelize all of this album track loading code
function load_album_tracks_and_flatten(discography_response: DiscographyResponse) {
    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 as const
        const offset = 0

        const { url, headers } = album_metadata_args(first_release.id, offset, pagination_limit)
        const album_metadata_response: AlbumResponse = JSON.parse(throw_if_not_200(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

        const album_pager = new AlbumPager(first_release.id, offset, pagination_limit, album_metadata_response, album_artist, unix_time)
        songs.push(...album_pager.results)
        while (album_pager.hasMorePagers()){
            album_pager.nextPage()
            songs.push(...album_pager.results)
        }
        // const album_pager = new AlbumPager(first_release.id,)
        // const { url: tracks_url, headers: tracks_headers } = album_tracks_args(id_from_uri(album), track_album_index, 1)
        // const tracks_response: AlbumTracksResponse = JSON.parse(throw_if_not_200(local_http.GET(tracks_url, tracks_headers, false)).body)
    }
    log(songs.length)
    return songs
}
// function format_discography_flattened(discography_response: DiscographyResponse, artist: PlatformAuthorLink) {
//     return discography_response.data.artistUnion.discography.all.items.map(function (album) {
//         const first_release = album.releases.items[0]
//         if (first_release === undefined) {
//             throw new ScriptException("unreachable")
//         }
//         const thumbnail = first_release.coverArt.sources[0]?.url
//         if (thumbnail === undefined) {
//             throw new ScriptException("unreachable")
//         }
//         return new PlatformPlaylist({
//             id: new PlatformID(PLATFORM, first_release.id, plugin.config.id),
//             name: first_release.name,
//             author: artist,
//             datetime: new Date(first_release.date.isoString).getTime() / 1000,
//             url: `${ALBUM_URL_PREFIX}${first_release.id}`,
//             videoCount: first_release.tracks.totalCount,
//             thumbnail
//         })
//     })
// }
function format_discography(discography_response: DiscographyResponse, artist: PlatformAuthorLink) {
    return discography_response.data.artistUnion.discography.all.items.map(function (album) {
        const first_release = album.releases.items[0]
        if (first_release === undefined) {
            throw new ScriptException("unreachable")
        }
        const thumbnail = first_release.coverArt.sources[0]?.url
        if (thumbnail === undefined) {
            throw new ScriptException("unreachable")
        }
        return new PlatformPlaylist({
            id: new PlatformID(PLATFORM, first_release.id, plugin.config.id),
            name: first_release.name,
            author: artist,
            datetime: new Date(first_release.date.isoString).getTime() / 1000,
            url: `${ALBUM_URL_PREFIX}${first_release.id}`,
            videoCount: first_release.tracks.totalCount,
            thumbnail
        })
    })
}
function discography_args(
    artist_uri_id: string,
    offset: number,
    limit: number
): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        uri: `spotify:artist:${artist_uri_id}`,
        offset,
        limit

    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "9380995a9d4663cbcb5113fef3c6aabf70ae6d407ba61793fd01e2a1dd6929b0"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "queryArtistDiscographyAll")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function format_section_item(section: SectionItemAlbum | SectionItemPlaylist | SectionItemEpisode | SectionItemPseudoPlaylist, section_as_author: PlatformAuthorLink): PlatformVideo | PlatformPlaylist {
    switch (section.__typename) {
        case "Album":
            {
                const album_artist = section.artists.items[0]
                if (album_artist === undefined) {
                    throw new ScriptException("missing album artist")
                }
                const cover_art_url = section.coverArt.sources[0]?.url
                if (cover_art_url === undefined) {
                    throw new ScriptException("missing album cover art")
                }
                return new PlatformPlaylist({
                    id: new PlatformID(PLATFORM, id_from_uri(section.uri), plugin.config.id),
                    name: section.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)}`
                    ),
                    // TODO load datetime another way datetime: ,
                    url: `${ALBUM_URL_PREFIX}${id_from_uri(section.uri)}`,
                    // TODO load video count some other way videoCount?: number
                    thumbnail: cover_art_url
                })
            }
        case "Playlist": {
            const created_iso = section.attributes.find(function (attribute) {
                return attribute.key === "created"
            })?.value
            const image_url = section.images.items[0]?.sources[0]?.url
            if (image_url === undefined) {
                throw new ScriptException("missing playlist thumbnail")
            }
            let author = section_as_author
            // TODO we might want to look up the username of the playlist if it is missing instead of using the section/page/genre as the channel
            if (section.ownerV2.data.username) {
                if (!section.ownerV2.data.username) {
                    throw new ScriptException(`missing username for owner ${section.ownerV2}`)
                }
                author = new PlatformAuthorLink(
                    new PlatformID(PLATFORM, section.ownerV2.data.username, plugin.config.id),
                    section.ownerV2.data.name,
                    `${USER_URL_PREFIX}${section.ownerV2.data.username}`,
                    section.ownerV2.data.avatar?.sources[0]?.url
                )
            }
            const platform_playlist = {
                id: new PlatformID(PLATFORM, id_from_uri(section.uri), plugin.config.id),
                url: `${PLAYLIST_URL_PREFIX}${id_from_uri(section.uri)}`,
                name: section.name,
                // TODO load some other way videoCount:
                thumbnail: image_url
            }
            if (created_iso !== undefined) {
                return new PlatformPlaylist({
                    ...platform_playlist,
                    datetime: new Date(created_iso).getTime() / 1000
                })
            }
            return new PlatformPlaylist(platform_playlist)
        }
        case "Episode": {
            if (section.podcastV2.data.__typename === "NotFound" || section.releaseDate === null) {
                throw new ScriptException("unreachable")
            }
            return new PlatformVideo({
                id: new PlatformID(PLATFORM, section.id, plugin.config.id),
                name: section.name,
                author: new PlatformAuthorLink(
                    new PlatformID(PLATFORM, id_from_uri(section.podcastV2.data.uri), plugin.config.id),
                    section.podcastV2.data.name,
                    `${SHOW_URL_PREFIX}${id_from_uri(section.podcastV2.data.uri)}`,
                    section.podcastV2.data.coverArt?.sources[0]?.url
                ),
                url: `${EPISODE_URL_PREFIX}${section.id}`,
                thumbnails: new Thumbnails(section.coverArt.sources.map(function (source) {
                    return new Thumbnail(source.url, source.height)
                })),
                duration: section.duration.totalMilliseconds / 1000,
                viewCount: HARDCODED_ZERO,
                isLive: false,
                shareUrl: `${EPISODE_URL_PREFIX}${section.id}`,
                /** unix time */
                datetime: new Date(section.releaseDate.isoString).getTime() / 1000
            })
        }
        case "PseudoPlaylist": {
            const image_url = section.image.sources[0]?.url
            if (image_url === undefined) {
                throw new ScriptException("missing playlist thumbnail")
            }
            const author = section_as_author
            const platform_playlist = {
                id: new PlatformID(PLATFORM, id_from_uri(section.uri), plugin.config.id),
                url: `${COLLECTION_UR_PREFIX}${id_from_uri(section.uri)}`,
                name: section.name,
                author,
                // TODO load some other way videoCount:
                thumbnail: image_url
            }
            return new PlatformPlaylist(platform_playlist)
        }
        default:
            throw assert_exhaustive(section, "unreachable")
    }
}
class SectionPager extends ContentPager {
    private readonly limit: number
    private offset: number
    constructor(
        private readonly section_uri_id: string,
        section_items: (SectionItemAlbum | SectionItemPlaylist)[],
        offset: number,
        limit: number,
        private readonly section_as_author: PlatformAuthorLink,
        has_more: boolean
    ) {
        const playlists = section_items.map(function (section_item) {
            return format_section_item(section_item, section_as_author)
        })
        super(playlists, has_more)
        this.offset = offset + limit
        this.limit = limit
    }
    override nextPage(this: SectionPager): SectionPager {
        const { url, headers } = browse_section_args(this.section_uri_id, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const browse_section_response: BrowseSectionResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
        const section_items = browse_section_response.data.browseSection.sectionItems.items.flatMap(function (section_item) {
            const section_item_content = section_item.content.data
            if (section_item_content.__typename === "Album" || section_item_content.__typename === "Playlist") {
                return [section_item_content]
            }
            return []
        })
        const author = this.section_as_author
        if (section_items.length === 0) {
            this.results = []
        } else {
            this.results = section_items.map(function (this: SectionPager, section_item) {
                return format_section_item(section_item, author)
            })
        }

        const next_offset = browse_section_response.data.browseSection.sectionItems.pagingInfo.nextOffset
        if (next_offset !== null) {
            this.offset = next_offset
        }
        this.hasMore = next_offset !== null
        return this
    }
    override hasMorePagers(this: SectionPager): boolean {
        return this.hasMore
    }
}
function browse_section_args(
    page_uri_id: string,
    offset: number,
    limit: number
): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        uri: `spotify:section:${page_uri_id}`,
        pagination: {
            offset,
            limit
        }
    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "8cb45a0fea4341b810e6f16ed2832c7ef9d3099aaf0034ee2a0ce49afbe42748"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "browseSection")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function book_chapters_args(
    audiobook_uri_id: string,
    offset: number,
    limit: number
): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        uri: `spotify:show:${audiobook_uri_id}`,
        offset,
        limit

    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "9879e364e7cee8e656be5f003ac7956b45c5cc7dea1fd3c8039e6b5b2e1f40b4"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "queryBookChapters")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function podcast_episodes_args(
    podcast_uri_id: string,
    offset: number,
    limit: number
): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        uri: `spotify:show:${podcast_uri_id}`,
        offset,
        limit

    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "108deda91e2701403d95dc39bdade6741c2331be85737b804a00de22cc0acabf"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "queryPodcastEpisodes")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
class ChapterPager extends VideoPager {
    private offset: number
    constructor(
        private readonly audiobook_uri_id: string,
        chapters_response: BookChaptersResponse,
        offset: number,
        private readonly limit: number,
        private readonly author: PlatformAuthorLink,
        private readonly publish_date_time: number
    ) {
        const chapters = format_chapters(chapters_response, author, publish_date_time)
        const next_offset = chapters_response.data.podcastUnionV2.chaptersV2.pagingInfo.nextOffset

        super(chapters, next_offset !== null)

        this.offset = next_offset === null ? offset : next_offset
    }
    override nextPage(this: ChapterPager): ChapterPager {
        const { url, headers } = book_chapters_args(this.audiobook_uri_id, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const chapters_response: BookChaptersResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
        const chapters = format_chapters(chapters_response, this.author, this.publish_date_time)
        const next_offset = chapters_response.data.podcastUnionV2.chaptersV2.pagingInfo.nextOffset

        this.hasMore = next_offset !== null
        this.results = chapters
        this.offset = next_offset === null ? this.offset : next_offset

        return this
    }
    override hasMorePagers(this: ChapterPager): boolean {
        return this.hasMore
    }
}
function format_chapters(chapters_response: BookChaptersResponse, author: PlatformAuthorLink, publish_date_time: number): PlatformVideo[] {
    return chapters_response.data.podcastUnionV2.chaptersV2.items.map(function (chapter_container) {
        const chapter_data = chapter_container.entity.data
        const thumbnails = new Thumbnails(chapter_data.coverArt.sources.map(function (source) {
            return new Thumbnail(source.url, source.height)
        }))
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, id_from_uri(chapter_data.uri), plugin.config.id),
            name: chapter_data.name,
            author,
            datetime: publish_date_time,
            url: `${EPISODE_URL_PREFIX}${id_from_uri(chapter_data.uri)}`,
            thumbnails,
            duration: chapter_data.duration.totalMilliseconds / 1000,
            viewCount: HARDCODED_ZERO,
            isLive: false,
            shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(chapter_data.uri)}`
        })
    })
}
class EpisodePager extends VideoPager {
    private offset: number
    constructor(
        private readonly podcast_uri_id: string,
        episodes_response: PodcastEpisodesResponse,
        offset: number,
        private readonly limit: number,
        private readonly author: PlatformAuthorLink
    ) {
        const chapters = format_episodes(episodes_response, author)
        const next_offset = episodes_response.data.podcastUnionV2.episodesV2.pagingInfo.nextOffset

        super(chapters, next_offset !== null)

        this.offset = next_offset === null ? offset : next_offset
    }
    override nextPage(this: EpisodePager): EpisodePager {
        const { url, headers } = podcast_episodes_args(this.podcast_uri_id, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const chapters_response: PodcastEpisodesResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
        const chapters = format_episodes(chapters_response, this.author)
        const next_offset = chapters_response.data.podcastUnionV2.episodesV2.pagingInfo.nextOffset

        this.hasMore = next_offset !== null
        this.results = chapters
        this.offset = next_offset === null ? this.offset : next_offset

        return this
    }
    override hasMorePagers(this: EpisodePager): boolean {
        return this.hasMore
    }
}
function format_episodes(episodes_response: PodcastEpisodesResponse, author: PlatformAuthorLink): PlatformVideo[] {
    return episodes_response.data.podcastUnionV2.episodesV2.items.map(function (chapter_container) {
        const episode_data = chapter_container.entity.data
        const thumbnails = new Thumbnails(episode_data.coverArt.sources.map(function (source) {
            return new Thumbnail(source.url, source.height)
        }))
        return new PlatformVideo({
            id: new PlatformID(PLATFORM, id_from_uri(episode_data.uri), plugin.config.id),
            name: episode_data.name,
            author,
            datetime: new Date(episode_data.releaseDate.isoString).getTime() / 1000,
            url: `${EPISODE_URL_PREFIX}${id_from_uri(episode_data.uri)}`,
            thumbnails,
            duration: episode_data.duration.totalMilliseconds / 1000,
            viewCount: HARDCODED_ZERO,
            isLive: false,
            shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(episode_data.uri)}`
        })
    })
}
class UserPlaylistPager extends PlaylistPager {
    private offset: number
    private readonly total_playlists: number
    constructor(
        private readonly username: string,
        offset: number,
        private readonly limit: number,
    ) {
        const { url, headers } = user_playlists_args(username, offset, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const playlists_response: UserPlaylistsResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
        const playlists = format_user_playlists(playlists_response)
        const total_playlists = playlists_response.total_public_playlists_count

        super(playlists, offset + limit < total_playlists)

        this.offset = offset + limit
        this.total_playlists = total_playlists
    }
    override nextPage(this: UserPlaylistPager): UserPlaylistPager {
        const { url, headers } = user_playlists_args(this.username, this.offset, this.limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const playlists_response: UserPlaylistsResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
        const playlists = format_user_playlists(playlists_response)

        this.hasMore = this.offset + this.limit < this.total_playlists
        this.results = playlists
        this.offset = this.offset + this.limit

        return this
    }
    override hasMorePagers(this: UserPlaylistPager): boolean {
        return this.hasMore
    }
}
function user_playlists_args(
    username: string,
    offset: number,
    limit: number
): { readonly url: string, readonly headers: { Authorization: string } } {
    const url = new URL(`https://spclient.wg.spotify.com/user-profile-view/v3/profile/${username}/playlists`)
    url.searchParams.set("offset", offset.toString())
    url.searchParams.set("limit", limit.toString())
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function format_user_playlists(playlists_response: UserPlaylistsResponse) {
    return playlists_response.public_playlists.map(function (playlist) {
        const image_uri = playlist.image_url
        return new PlatformPlaylist({
            id: new PlatformID(PLATFORM, id_from_uri(playlist.uri), plugin.config.id),
            name: playlist.name,
            author: new PlatformAuthorLink(
                new PlatformID(PLATFORM, id_from_uri(playlist.owner_uri), plugin.config.id),
                playlist.owner_name,
                `${USER_URL_PREFIX}${id_from_uri(playlist.owner_uri)}`,
                // TODO load the owner's image somehow
            ),
            // TODO load the playlist creation or modificiation date somehow datetime?: number
            url: `${PLAYLIST_URL_PREFIX}${id_from_uri(playlist.uri)}`,
            // TODO load the video count somehow videoCount?: number
            thumbnail: url_from_image_uri(image_uri)
        })
    })
}
//#endregion

//#region other
function getUserPlaylists() {
    let playlists: string[] = []
    let more = true
    let offset = 0
    const limit = 50
    while (more) {
        const { url, headers } = library_args(offset, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const library_response: LibraryResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)

        playlists = [
            ...playlists,
            ...library_response.data.me.libraryV3.items.flatMap(function (library_item) {
                const item = library_item.item.data
                switch (item.__typename) {
                    case "Album":
                        return `${ALBUM_URL_PREFIX}${id_from_uri(item.uri)}`
                    case "Playlist":
                        return `${PLAYLIST_URL_PREFIX}${id_from_uri(item.uri)}`
                    case "PseudoPlaylist":
                        return `${COLLECTION_UR_PREFIX}${id_from_uri(item.uri)}`
                    case "Audiobook":
                        return []
                    case "Podcast":
                        return []
                    case "Artist":
                        return []
                    default:
                        throw assert_exhaustive(item, "unreachable")
                }
            })
        ]

        if (library_response.data.me.libraryV3.totalCount <= offset + limit) {
            more = false
        }
        offset += limit
    }
    return playlists
}
function library_args(offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        filters: [],
        order: null,
        textFilter: "",
        features: ["LIKED_SONGS", "YOUR_EPISODES", "PRERELEASES"],
        limit,
        offset,
        flatten: false,
        expandedFolders: [],
        folderUri: null,
        includeFoldersWhenFlattening: true,
        withCuration: false
    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "cb996f38c4e0f98c53e46546e0b58f1ed34ab6c31cd00d17698af6ce2ac0f3af"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "libraryV3")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function liked_songs_args(offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        limit,
        offset
    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "f6cdd87d7fc8598e4e7500fbacd4f661b0c4aea382fe28540aeb4cb7ea4d76c8"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "fetchLibraryTracks")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function liked_episodes_args(offset: number, limit: number): { readonly url: string, readonly headers: { Authorization: string } } {
    const variables = JSON.stringify({
        limit,
        offset
    })
    const extensions = JSON.stringify({
        persistedQuery: {
            version: 1,
            sha256Hash: "f6cdd87d7fc8598e4e7500fbacd4f661b0c4aea382fe28540aeb4cb7ea4d76c8"
        }
    })
    const url = new URL(QUERY_URL)
    url.searchParams.set("operationName", "fetchLibraryEpisodes")
    url.searchParams.set("variables", variables)
    url.searchParams.set("extensions", extensions)
    return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function following_args() {
    const url = `https://spclient.wg.spotify.com/user-profile-view/v3/profile/${local_state.username}/following`
    return { url, headers: { Authorization: `Bearer ${local_state.bearer_token}` } }
}
function getUserSubscriptions(): string[] {
    const { url, headers } = following_args()
Kai DeLorenzo's avatar
Kai DeLorenzo committed
    const following_response: FollowingResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)
    let following: string[] = following_response.profiles === undefined ? [] : following_response.profiles.map(function (profile) {
        const { uri_id, uri_type } = parse_uri(profile.uri)
        if (uri_type === "artist") {
            return `${ARTIST_URL_PREFIX}${uri_id}`
        } else if (uri_type === "user") {
            return `${USER_URL_PREFIX}${uri_id}`
        }
        throw new ScriptException("unreachable")
    })
    let more = true
    let offset = 0
    const limit = 50
    while (more) {
        const { url, headers } = library_args(offset, limit)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
        const library_response: LibraryResponse = JSON.parse(throw_if_not_200(local_http.GET(url, headers, false)).body)

        following = [
            ...following,