Skip to content
Snippets Groups Projects
SpotifyScript.ts 121 KiB
Newer Older
            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,
                    first_section_item.__typename === "Playlist"
                        ? first_section_item.images.items[0]?.sources[0]?.url
                        : first_section_item.coverArt.sources[0]?.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)}`,
                first_section_item.__typename === "Playlist"
                    ? first_section_item.images.items[0]?.sources[0]?.url
                    : first_section_item.coverArt.sources[0]?.url
            )
        }()
        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")
        }
        const metadata_response: ArtistMetadataResponse = JSON.parse(responses[0].body)
        const discography_response: DiscographyResponse = JSON.parse(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
    }
}
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, 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")
            }
            if (section.ownerV2.data.name !== "Spotify") {
                throw new ScriptException("unhandled playlist owner")
            }
            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,
                author: section_as_author,
                // 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": {
            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
2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491
                ),
                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
            })
        }
        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)
        const browse_section_response: BrowseSectionResponse = JSON.parse(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)
        const chapters_response: BookChaptersResponse = JSON.parse(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: 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)
        const chapters_response: PodcastEpisodesResponse = JSON.parse(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_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)
        const playlists_response: UserPlaylistsResponse = JSON.parse(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)
        const playlists_response: UserPlaylistsResponse = JSON.parse(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)
        const library_response: LibraryResponse = JSON.parse(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()
    const following_response: FollowingResponse = JSON.parse(local_http.GET(url, headers, false).body)
    let following: string[] = 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)
        const library_response: LibraryResponse = JSON.parse(local_http.GET(url, headers, false).body)

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

        if (library_response.data.me.libraryV3.totalCount <= offset + limit) {
            more = false
        }
        offset += limit
    }
    return following
}
function getPlaybackTracker(url: string): PlaybackTracker {
    const { content_uri_id } = parse_content_url(url)
    return new SpotifyPlaybackTracker(content_uri_id)
}
// let socket: SocketResult
class SpotifyPlaybackTracker extends PlaybackTracker {
    private play_recorded = false
    private socket: SocketResult
    constructor(private readonly uri_id: string) {
        const interval_seconds = 10
        super(interval_seconds * 1000)

        check_and_update_token()
        // const url = `wss://gue1-dealer.spotify.com/?access_token=${local_state.bearer_token}`
        let url = "wss://echo.websocket.in"
        this.socket = http.socket(url, {}, false)
        // socket.connect({
        //     open() {
        //         log("open")
        //         socket.send(JSON.stringify({
        //             type: "ping"
        //         }))
        //     },
        //     closed(code, reason) {
        //         console.log(code.toString())
        //         console.log(reason)
        //     },
        //     closing(code, reason) {
        //         console.log(code.toString())
        //         console.log(reason)
        //     },
        //     message(msg) {
        //         log(msg)
        //         socket.close()
        //     },
        //     failure(exception) {
        //         log("failure")
        //         console.log(exception)
        //     }
        // })
    }
    override onInit(seconds: number): void {
        log("connecting to websocket")
        log(seconds.toString())

    }
    override onProgress(seconds: number, is_playing: boolean): void {
        if (this.play_recorded) {
            return
        }
        if (!this.socket.isOpen && seconds > 10) {
            log("actually connecting")



        }
        if (seconds > 30) {
            log(`recording play of ${this.uri_id}`)
            this.play_recorded = true

            // this.socket.send(JSON.stringify({
            //     type: "ping"
            // }))
        }
        log(is_playing.toString())
    }
}
//#endregion

//#region utilities
function url_from_image_uri(image_uri: string) {
    const match_result = image_uri.match(/^spotify:(image|mosaic):([0-9a-zA-Z:]*)$/)
    if (match_result === null) {
        throw new ScriptException("regex error")
    }
    const image_type: "image" | "mosaic" = match_result[1] as "image" | "mosaic"
    if (image_type === undefined) {
        throw new ScriptException("regex error")
    }
    const uri_id = match_result[2]
    if (uri_id === undefined) {
        throw new ScriptException("regex error")
    }
    switch (image_type) {
        case "image":
            return `https://i.scdn.co/image/${uri_id}`
        case "mosaic":
            return `https://mosaic.scdn.co/300/${uri_id.split(":").join("")}`
        default:
            throw assert_exhaustive(image_type)
    }
}
function id_from_uri(uri: string): string {
    return parse_uri(uri).uri_id
}
function parse_uri(uri: string) {
    const match_result = uri.match(/^spotify:(show|album|track|artist|playlist|section|episode|user|genre|collection):([0-9a-zA-Z]*|tracks|your-episodes)$/)
    if (match_result === null) {
        throw new ScriptException("regex error")
    }
    const maybe_type = match_result[1]
    if (maybe_type === undefined) {
        throw new ScriptException("regex error")
    }
    const uri_type: UriType = maybe_type as UriType
    const uri_id = match_result[2]
    if (uri_id === undefined) {
        throw new ScriptException("regex error")
    }
    return { uri_id, uri_type }
/**
 * Converts seconds to the timestamp format used in WebVTT
 * @param seconds 
 * @returns 
 */
function milliseconds_to_WebVTT_timestamp(milliseconds: number) {
    return new Date(milliseconds).toISOString().substring(11, 23)
function assert_never(value: never) {
    log(value)
}
function log_passthrough<T>(value: T): T {
    log(value)
    return value
}
function assert_exhaustive(value: never): void
function assert_exhaustive(value: never, exception_message: string): ScriptException
function assert_exhaustive(value: never, exception_message?: string): ScriptException | undefined {
    log(["Spotify log:", value])
    if (exception_message !== undefined) {
        return new ScriptException(exception_message)
    }
    return
}
//#endregion

//#region bad

// https://open.spotifycdn.com/cdn/build/web-player/vendor~web-player.391a2438.js
const Z = "0123456789abcdef"
const Q = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const ee: string[] = []
ee.length = 256
for (let ke = 0; ke < 256; ke++)
    // @ts-expect-error
    ee[ke] = Z[ke >> 4] + Z[15 & ke]
const te: number[] = []
te.length = 128
for (let ke = 0; ke < Q.length; ++ke)
    te[Q.charCodeAt(ke)] = ke

function get_gid(song_uri_id: string) {
    return 22 === song_uri_id.length ? function (e) {
        if (22 !== e.length)
            return null
        const t = 2.3283064365386963e-10
            , n = 4294967296
            , i = 238328
        let o, r, a, s, c
        // @ts-expect-error
        return o = 56800235584 * te[e.charCodeAt(0)] + 916132832 * te[e.charCodeAt(1)] + 14776336 * te[e.charCodeAt(2)] + 238328 * te[e.charCodeAt(3)] + 3844 * te[e.charCodeAt(4)] + 62 * te[e.charCodeAt(5)] + te[e.charCodeAt(6)],
            r = o * t | 0,
            o -= r * n,
            // @ts-expect-error
            c = 3844 * te[e.charCodeAt(7)] + 62 * te[e.charCodeAt(8)] + te[e.charCodeAt(9)],
            o = o * i + c,
            o -= (c = o * t | 0) * n,
            r = r * i + c,
            // @ts-expect-error
            c = 3844 * te[e.charCodeAt(10)] + 62 * te[e.charCodeAt(11)] + te[e.charCodeAt(12)],
            o = o * i + c,
            o -= (c = o * t | 0) * n,
            r = r * i + c,
            r -= (c = r * t | 0) * n,
            a = c,
            // @ts-expect-error
            c = 3844 * te[e.charCodeAt(13)] + 62 * te[e.charCodeAt(14)] + te[e.charCodeAt(15)],
            o = o * i + c,
            o -= (c = o * t | 0) * n,
            r = r * i + c,
            r -= (c = r * t | 0) * n,
            a = a * i + c,
            // @ts-expect-error
            c = 3844 * te[e.charCodeAt(16)] + 62 * te[e.charCodeAt(17)] + te[e.charCodeAt(18)],
            o = o * i + c,
            o -= (c = o * t | 0) * n,
            r = r * i + c,
            r -= (c = r * t | 0) * n,
            a = a * i + c,
            a -= (c = a * t | 0) * n,
            s = c,
            // @ts-expect-error
            c = 3844 * te[e.charCodeAt(19)] + 62 * te[e.charCodeAt(20)] + te[e.charCodeAt(21)],
            o = o * i + c,
            o -= (c = o * t | 0) * n,
            r = r * i + c,
            r -= (c = r * t | 0) * n,
            a = a * i + c,
            a -= (c = a * t | 0) * n,
            s = s * i + c,
            s -= (c = s * t | 0) * n,
            // @ts-expect-error
            c ? null : ee[s >>> 24] + ee[s >>> 16 & 255] + ee[s >>> 8 & 255] + ee[255 & s] + ee[a >>> 24] + ee[a >>> 16 & 255] + ee[a >>> 8 & 255] + ee[255 & a] + ee[r >>> 24] + ee[r >>> 16 & 255] + ee[r >>> 8 & 255] + ee[255 & r] + ee[o >>> 24] + ee[o >>> 16 & 255] + ee[o >>> 8 & 255] + ee[255 & o]
    }(song_uri_id) : song_uri_id
}
//#endregion
// export statements are removed during build step
// used for unit testing in SpotifyScript.test.ts
    get_gid,
    assert_never,
    log_passthrough,
    getPlaybackTracker