const CONTENT_REGEX = /^https:\/\/open\.spotify\.com\/(track|episode)\/([a-zA-Z0-9]*)($|\/)/; const PLAYLIST_REGEX = /^https:\/\/open\.spotify\.com\/(album|playlist|collection)\/([a-zA-Z0-9]*|your-episodes|tracks)($|\/)/; const CHANNEL_REGEX = /^https:\/\/open\.spotify\.com\/(show|artist|user|genre|section|content-feed)\/(section|)([a-zA-Z0-9]*|recently-played)($|\/)/; const SONG_URL_PREFIX = "https://open.spotify.com/track/"; const EPISODE_URL_PREFIX = "https://open.spotify.com/episode/"; const SHOW_URL_PREFIX = "https://open.spotify.com/show/"; const ARTIST_URL_PREFIX = "https://open.spotify.com/artist/"; const USER_URL_PREFIX = "https://open.spotify.com/user/"; const ALBUM_URL_PREFIX = "https://open.spotify.com/album/"; const PAGE_URL_PREFIX = "https://open.spotify.com/genre/"; const SECTION_URL_PREFIX = "https://open.spotify.com/section/"; const PLAYLIST_URL_PREFIX = "https://open.spotify.com/playlist/"; const COLLECTION_URL_PREFIX = "https://open.spotify.com/collection/"; const QUERY_URL = "https://api-partner.spotify.com/pathfinder/v1/query"; const IMAGE_URL_PREFIX = "https://i.scdn.co/image/"; const PLATFORM = "Spotify"; const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0"; const PLATFORM_IDENTIFIER = "web_player linux undefined;firefox 126.0;desktop"; const SPOTIFY_CONNECT_NAME = "Web Player (Grayjay)"; const CLIENT_VERSION = "harmony:4.42.0-2780565f"; const HARDCODED_ZERO = 0; const HARDCODED_EMPTY_STRING = ""; const EMPTY_AUTHOR = new PlatformAuthorLink(new PlatformID(PLATFORM, "", plugin.config.id), "", ""); const local_http = http; // const local_utility = utility // set missing constants Type.Order.Chronological = "Latest releases"; Type.Order.Views = "Most played"; Type.Order.Favorites = "Most favorited"; Type.Feed.Playlists = "PLAYLISTS"; Type.Feed.Albums = "ALBUMS"; let local_settings; /** State */ let local_state; //#endregion //#region source methods const local_source = { enable, disable, saveState, getHome, search, getSearchCapabilities, isContentDetailsUrl, getContentDetails, isChannelUrl, getChannel, getChannelContents, getChannelCapabilities, searchChannels, isPlaylistUrl, getPlaylist, searchPlaylists, getChannelPlaylists, getPlaybackTracker, getUserPlaylists, getUserSubscriptions }; init_source(local_source); function init_source(local_source) { for (const method_key of Object.keys(local_source)) { // @ts-expect-error assign to readonly constant source object source[method_key] = local_source[method_key]; } } //#endregion //#region enable function enable(conf, settings, savedState) { if (IS_TESTING) { log("IS_TESTING true"); log("logging configuration"); log(conf); log("logging settings"); log(settings); log("logging savedState"); log(savedState); } local_settings = settings; if (savedState !== null && savedState !== undefined) { const state = JSON.parse(savedState); local_state = state; // the token stored in state might be old check_and_update_token(); } else { const home_page = "https://open.spotify.com"; const token_regex = /<script id="config" data-testid="config" type="application\/json">({.*?})<\/script><script id="session" data-testid="session" type="application\/json">({.*?})<\/script>/; const web_player_js_regex = /https:\/\/open\.spotifycdn\.com\/cdn\/build\/web-player\/web-player\..{8}\.js/; // use the authenticated client to get a logged in bearer token const html = throw_if_not_ok(local_http.GET(home_page, { "User-Agent": USER_AGENT }, true)).body; const web_player_js_match_result = html.match(web_player_js_regex); if (web_player_js_match_result === null || web_player_js_match_result[0] === undefined) { throw new ScriptException("regex error"); } const token_match_result = html.match(token_regex); if (token_match_result === null || token_match_result[1] === undefined || token_match_result[2] === undefined) { throw new ScriptException("regex error"); } const user_data = JSON.parse(token_match_result[1]); const token_response = JSON.parse(token_match_result[2]); const bearer_token = token_response.accessToken; // download license uri and get logged in user const get_license_url_url = "https://gue1-spclient.spotify.com/melody/v1/license_url?keysystem=com.widevine.alpha&sdk_name=harmony&sdk_version=4.41.0"; const profile_attributes_url = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=profileAttributes&variables=%7B%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2253bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced%22%7D%7D"; const web_player_js_url = web_player_js_match_result[0]; const responses = local_http .batch() .GET(get_license_url_url, { Authorization: `Bearer ${bearer_token}` }, false) .GET(profile_attributes_url, { Authorization: `Bearer ${bearer_token}` }, false) .GET(web_player_js_url, { Authorization: `Bearer ${bearer_token}` }, false) .execute(); if (responses[0] === undefined || responses[1] === undefined || responses[2] === undefined) { throw new ScriptException("unreachable"); } const get_license_response = JSON.parse(throw_if_not_ok(responses[0]).body); const license_uri = `https://gue1-spclient.spotify.com/${get_license_response.uri}`; const profile_attributes_response = JSON.parse(throw_if_not_ok(responses[1]).body); const feature_version_match_result = throw_if_not_ok(responses[2]).body.match(/"(web-player_(.*?))"/); if (feature_version_match_result === null) { throw new ScriptException("regex error"); } const feature_version = feature_version_match_result[1]; if (feature_version === undefined) { throw new ScriptException("regex error"); } let state = { feature_version, bearer_token, expiration_timestamp_ms: token_response.accessTokenExpirationTimestampMs, license_uri: license_uri, is_premium: user_data.isPremium }; if (profile_attributes_response.data.me !== null) { state = { ...state, username: profile_attributes_response.data.me.profile.username }; } if ("userCountry" in user_data) { state = { ...state, country: user_data.userCountry }; } local_state = state; } } function download_bearer_token() { const get_access_token_url = "https://open.spotify.com/get_access_token?reason=transport&productType=web-player"; // use the authenticated client to get a logged in bearer token const access_token_response = throw_if_not_ok(local_http.GET(get_access_token_url, {}, true)).body; const token_response = JSON.parse(access_token_response); return token_response; } function check_and_update_token() { // renew the token with 30 seconds to spare if (Date.now() - 30 * 1000 < local_state.expiration_timestamp_ms) { return; } log("Spotify log: refreshing bearer token"); const token_response = download_bearer_token(); let state = { feature_version: local_state.feature_version, bearer_token: token_response.accessToken, expiration_timestamp_ms: token_response.accessTokenExpirationTimestampMs, license_uri: local_state.license_uri, is_premium: local_state.is_premium }; if (local_state.username !== undefined) { state = { ...state, username: local_state.username }; } if (local_state.country !== undefined) { state = { ...state, country: local_state.country }; } local_state = state; } //#endregion function disable() { log("Spotify log: disabling"); } function saveState() { return JSON.stringify(local_state); } //#region home function getHome() { check_and_update_token(); const { url, headers } = home_args(10); const { url: new_url, headers: new_headers } = whats_new_args(0, 50); const { url: recent_url, headers: recent_headers } = recently_played_ids_args(0, 50); const responses = local_http .batch() .GET(url, headers, false) .GET(new_url, new_headers, false) .GET(recent_url, recent_headers, false) .execute(); if (responses[0] === undefined || responses[1] === undefined || responses[2] === undefined) { throw new ScriptException("unreachable"); } const home_response = JSON.parse(throw_if_not_ok(responses[0]).body); const sections = home_response.data.home.sectionContainer.sections.items; if (bridge.isLoggedIn()) { const whats_new_response = JSON.parse(throw_if_not_ok(responses[1]).body); sections.push({ data: { __typename: "WhatsNewSectionData", title: { text: "What's New" }, }, section_url: "https://open.spotify.com/content-feed", sectionItems: whats_new_response.data.whatsNewFeedItems }); if (responses[2].code !== 404) { const recently_played_ids = JSON.parse(throw_if_not_ok(responses[2]).body); const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) { return uri_obj.uri; })); const recently_played_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); sections.unshift({ data: { __typename: "CustomRecentlyPlayedSectionData", title: { text: "Recently played" }, }, section_url: "https://open.spotify.com/genre/recently-played", sectionItems: { items: recently_played_response.data.lookup.flatMap(function (section_item) { if (section_item.__typename === "UnknownTypeWrapper") { if (section_item._uri === `spotify:user:${local_state.username}:collection`) { return { content: { data: { image: { sources: [{ "height": 640, "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png", "width": 640 }] }, name: "Liked Songs", __typename: "PseudoPlaylist", uri: "spotify:collection:tracks" }, __typename: "LibraryPseudoPlaylistResponseWrapper" } }; } if (section_item._uri === `spotify:user:${local_state.username}:collection:your-episodes` || section_item._uri === "spotify:collection:podcasts:episodes") { return { content: { data: { image: { sources: [{ "height": 640, "url": "https://misc.spotifycdn.com/your-episodes/SE-640.png", "width": 640 }] }, name: "Your Episodes", __typename: "PseudoPlaylist", uri: "spotify:collection:your-episodes" }, __typename: "LibraryPseudoPlaylistResponseWrapper" } }; } const artist_collection_regex = /^spotify:user:[a-zA-Z0-9]*?:collection:artist/; if (artist_collection_regex.test(section_item._uri)) { return []; } // ignore legacy stations const station_regex = /^spotify:station:track:/; if (station_regex.test(section_item._uri)) { return []; } log(section_item); throw new ScriptException(`unexpected uri: ${section_item._uri}`); } return { content: { data: section_item.data, __typename: section_item.__typename } }; }) } }); } } const playlists = format_page(sections, 4, "Home"); return new ContentPager(playlists, false); } function whats_new_args(offset, limit) { const variables = JSON.stringify({ offset, limit, onlyUnPlayedItems: false, includedContentTypes: [] }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "4c3281ff1c1c0b67f56e4a77568d6b143da7cf1260266ed5d5147a5e49481493" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "queryWhatsNewFeed"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } /** * * @param limit has incosistent behavior use 10 because that's what the spotify homepage uses * @returns */ function home_args(limit) { const variables = JSON.stringify({ /** usually something like America/Chicago */ timeZone: "America/Chicago", // TODO figure out a way to calculate this in Grayjay (maybe a setting) Intl.DateTimeFormat().resolvedOptions().timeZone, /** usually the logged in user cookie */ sp_t: "", /** usually something like US */ country: "US", facet: null, sectionItemsLimit: limit }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "a68635e823cd71d9f6810ec221d339348371ef0b878ec6b846fc36b234219c59" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "home"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } //#endregion //#region search function getSearchCapabilities() { return new ResultCapabilities([ Type.Feed.Videos ], [], []); } function search(query, type, order, filters) { if (filters !== null && Object.keys(filters).length !== 0) { throw new ScriptException("unreachable"); } if (order !== null) { throw new ScriptException("unreachable"); } if (type !== null) { throw new ScriptException("unreachable"); } check_and_update_token(); return new SearchPager(query, 0, 100); } class SearchPager extends VideoPager { query; limit; offset; constructor(query, offset, limit) { const { url, headers } = search_args(query, offset, limit); const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const has_more = are_more_song_and_episode_results(search_response, offset, limit); super(format_song_and_episode_results(search_response), has_more); this.query = query; this.limit = limit; this.offset = offset + limit; } nextPage() { const { url, headers } = search_args(this.query, this.offset, this.limit); const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); this.results = format_song_and_episode_results(search_response); this.hasMore = are_more_song_and_episode_results(search_response, this.offset, this.limit); this.offset = this.offset + this.limit; return this; } hasMorePagers() { return this.hasMore; } } function format_song_and_episode_results(search_response) { return [ ...search_response.data.searchV2.tracksV2.items.map(function (track) { const artist = track.item.data.artists.items[0]; if (artist === undefined) { throw new ScriptException("missing artist"); } return new PlatformVideo({ id: new PlatformID(PLATFORM, track.item.data.id, plugin.config.id), name: track.item.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)}`), url: `${SONG_URL_PREFIX}${track.item.data.id}`, thumbnails: new Thumbnails(track.item.data.albumOfTrack.coverArt.sources.map(function (image) { return new Thumbnail(image.url, image.height); })), duration: track.item.data.duration.totalMilliseconds / 1000, viewCount: HARDCODED_ZERO, isLive: false, shareUrl: `${SONG_URL_PREFIX}${track.item.data.id}`, datetime: HARDCODED_ZERO }); }), ...search_response.data.searchV2.episodes.items.map(function (episode) { return new PlatformVideo({ id: new PlatformID(PLATFORM, id_from_uri(episode.data.uri), plugin.config.id), name: episode.data.name, author: episode.data.podcastV2.data.__typename === "Podcast" ? new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(episode.data.podcastV2.data.uri), plugin.config.id), episode.data.podcastV2.data.name, `${SHOW_URL_PREFIX}${id_from_uri(episode.data.podcastV2.data.uri)}`, episode.data.podcastV2.data.coverArt?.sources[0]?.url) : EMPTY_AUTHOR, url: `${EPISODE_URL_PREFIX}${id_from_uri(episode.data.uri)}`, thumbnails: new Thumbnails(episode.data.coverArt.sources.map(function (image) { return new Thumbnail(image.url, image.height); })), duration: episode.data.duration.totalMilliseconds / 1000, viewCount: HARDCODED_ZERO, isLive: false, shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(episode.data.uri)}`, datetime: episode.data.releaseDate === null ? HARDCODED_ZERO : new Date(episode.data.releaseDate.isoString).getTime() / 1000 }); }) ]; } function are_more_song_and_episode_results(search_response, current_offset, limit) { return search_response.data.searchV2.tracksV2.totalCount > current_offset + limit || search_response.data.searchV2.episodes.totalCount > current_offset + limit; } //#endregion //#region content // https://open.spotify.com/track/6XXxKsu3RJeN3ZvbMYrgQW // https://open.spotify.com/episode/3Z88ZE0i3L7AIrymrBwtqg function isContentDetailsUrl(url) { return CONTENT_REGEX.test(url); } function getContentDetails(url) { if (!bridge.isLoggedIn()) { throw new LoginRequiredException("login to listen to songs"); } check_and_update_token(); const { content_uri_id, content_type } = parse_content_url(url); switch (content_type) { case "track": { const song_url = `${SONG_URL_PREFIX}${content_uri_id}`; const { url: metadata_url, headers: metadata_headers } = song_metadata_args(content_uri_id); const { url: track_metadata_url, headers: _track_metadata_headers } = track_metadata_args(content_uri_id); const batch = local_http .batch() .GET(metadata_url, metadata_headers, false) .GET(track_metadata_url, _track_metadata_headers, false); if (local_state.is_premium) { const { url, headers } = lyrics_args(content_uri_id); batch.GET(url, headers, false); } const results = batch .execute(); if (results[0] === undefined || results[1] === undefined) { throw new ScriptException("unreachable"); } const song_metadata_response = JSON.parse(throw_if_not_ok(results[0]).body); const track_metadata_response = JSON.parse(throw_if_not_ok(results[1]).body); const first_artist = track_metadata_response.data.trackUnion.firstArtist.items[0]; if (first_artist === undefined) { throw new ScriptException("missing artist"); } const artist_url = `https://open.spotify.com/artist/${first_artist.id}`; const highest_quality_artist_cover_art = first_artist.visuals.avatarImage.sources.reduce(function (accumulator, current) { return accumulator.height > current.height ? accumulator : current; }); let subtitles = []; if (results[2] !== undefined && results[2].code !== 404) { const lyrics_response = JSON.parse(throw_if_not_ok(results[2]).body); const subtitle_name = function () { switch (lyrics_response.lyrics.language) { case "en": return "English"; case "es": return "EspaƱol"; case "fr": return "French"; default: log(`Spotify log: unknown language: ${lyrics_response.lyrics.language}`); return lyrics_response.lyrics.language; } }(); const convert = milliseconds_to_WebVTT_timestamp; let vtt_text = `WEBVTT ${subtitle_name}\n`; vtt_text += "\n"; lyrics_response.lyrics.lines.forEach(function (line, index) { const next = lyrics_response.lyrics.lines[index + 1]; let end = next?.startTimeMs; if (end === undefined) { end = track_metadata_response.data.trackUnion.duration.totalMilliseconds.toString(); } vtt_text += `${convert(parseInt(line.startTimeMs))} --> ${convert(parseInt(end))}\n`; vtt_text += `${line.words}\n`; vtt_text += "\n"; }); subtitles = [{ url: song_url, name: subtitle_name, getSubtitles() { return vtt_text; }, format: "text/vtt", }]; } const format = local_state.is_premium ? "MP4_256" : "MP4_128"; const files = song_metadata_response.file === undefined ? song_metadata_response.alternative?.[0]?.file : song_metadata_response.file; if (files === undefined) { throw new ScriptException("missing alternative file list"); } const maybe_file_id = files.find(function (file) { return file.format === format; })?.file_id; if (maybe_file_id === undefined) { throw new ScriptException("missing expected format"); } const { url, headers } = file_manifest_args(maybe_file_id); const { url: artist_metadata_url, headers: artist_metadata_headers } = artist_metadata_args(first_artist.id); const second_results = local_http .batch() .GET(url, headers, false) .GET(artist_metadata_url, artist_metadata_headers, false) .execute(); if (second_results[0] === undefined || second_results[1] === undefined) { throw new ScriptException("unreachable"); } const file_manifest = JSON.parse(throw_if_not_ok(second_results[0]).body); const artist_metadata_response = JSON.parse(throw_if_not_ok(second_results[1]).body); const duration = track_metadata_response.data.trackUnion.duration.totalMilliseconds / 1000; const file_url = file_manifest.cdnurl[1]; if (file_url === undefined) { throw new ScriptException("unreachable"); } const codec = "mp4a.40.2"; const audio_sources = [new AudioUrlWidevineSource({ //audio/mp4; codecs="mp4a.40.2 name: codec, bitrate: function (format) { switch (format) { case "MP4_128": return 128000; case "MP4_256": return 256000; default: throw assert_exhaustive(format, "unreachable"); } }(format), container: "audio/mp4", codec, duration, url: file_url, language: Language.UNKNOWN, bearerToken: local_state.bearer_token, licenseUri: local_state.license_uri })]; return new PlatformVideoDetails({ id: new PlatformID(PLATFORM, content_uri_id, plugin.config.id), name: song_metadata_response.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, first_artist.id, plugin.config.id), first_artist.profile.name, artist_url, highest_quality_artist_cover_art.url, artist_metadata_response.data.artistUnion.stats.monthlyListeners), url: song_url, thumbnails: new Thumbnails(song_metadata_response.album.cover_group.image.map(function (image) { return new Thumbnail(`${IMAGE_URL_PREFIX}${image.file_id}`, image.height); })), duration, viewCount: parseInt(track_metadata_response.data.trackUnion.playcount), isLive: false, shareUrl: song_url, datetime: new Date(track_metadata_response.data.trackUnion.albumOfTrack.date.isoString).getTime() / 1000, description: HARDCODED_EMPTY_STRING, video: new UnMuxVideoSourceDescriptor([], audio_sources), rating: new RatingLikes(HARDCODED_ZERO), subtitles }); } case "episode": { const episode_url = `https://open.spotify.com/episode/${content_uri_id}`; const { url, headers } = episode_metadata_args(content_uri_id); const episode_metadata_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); if (!episode_metadata_response.data.episodeUnionV2.playability.playable) { throw new UnavailableException("login or purchase to play premium content"); } if (episode_metadata_response.data.episodeUnionV2.mediaTypes.length === 2) { function assert_video(media_types) { log(media_types); } assert_video(episode_metadata_response.data.episodeUnionV2.mediaTypes); //TODO since we don't use the transcript we should only load it when audio only podcasts are played // TODO handle video podcasts. Grayjay doesn't currently support the websocket functionality necessary // the basic process to get the video play info is // connect to the websocket wss://gue1-dealer.spotify.com/?access_token=<bearer-token> // register the device https://gue1-spclient.spotify.com/track-playback/v1/devices // generate the device id using code found in the min js like this /* web player js const t = Math.ceil(e / 2); return function(e) { let t = ""; for (let n = 0; n < e.length; n++) { const i = e[n]; i < 16 && (t += "0"), t += i.toString(16) } return t }(Oe(t)) */ // load devices info https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_aced97d86694f14d304dd4e6f1f7f8c3bff // transfer to our device https://gue1-spclient.spotify.com/connect-state/v1/connect/transfer/from/9a7079bd5b5605839c1d9080d0f4368bfcd6d2eb/to/aced97d86694f14d304dd4e6f1f7f8c3bff // signal the play of the given podcast (not quite sure how this works :/) // recieve the video play info via the websocket connection // } const format = "MP4_128"; const maybe_file_id = episode_metadata_response.data.episodeUnionV2.audio.items.find(function (file) { return file.format === format; })?.fileId; if (maybe_file_id === undefined) { throw new ScriptException("missing expected format"); } const limited_show_metadata = episode_metadata_response.data.episodeUnionV2.__typename === "Chapter" ? episode_metadata_response.data.episodeUnionV2.audiobookV2.data : episode_metadata_response.data.episodeUnionV2.podcastV2.data; const show_uri_id = id_from_uri(limited_show_metadata.uri); const highest_quality_cover_art = limited_show_metadata.coverArt.sources.reduce(function (accumulator, current) { return accumulator.height > current.height ? accumulator : current; }); const { url: manifest_url, headers: manifest_headers } = file_manifest_args(maybe_file_id); const { url: show_metadata_url, headers: show_metadata_headers } = show_metadata_args(show_uri_id); const batch = local_http .batch() .GET(show_metadata_url, show_metadata_headers, false) .GET(manifest_url, manifest_headers, false); if (episode_metadata_response.data.episodeUnionV2.transcripts !== undefined) { const { url, headers } = transcript_args(content_uri_id); batch.GET(url, headers, false); } const results = batch.execute(); if (results[0] === undefined || results[1] === undefined) { throw new ScriptException("unreachable"); } const full_show_metadata = JSON.parse(throw_if_not_ok(results[0]).body); const file_manifest = JSON.parse(throw_if_not_ok(results[1]).body); const subtitles = function () { if (results[2] === undefined || results[2].code === 404) { return []; } const transcript_response = JSON.parse(throw_if_not_ok(results[2]).body); const subtitle_name = function () { switch (transcript_response.language) { case "en": return "English"; default: throw assert_exhaustive(transcript_response.language, "unreachable"); } }(); let vtt_text = `WEBVTT ${subtitle_name}\n`; vtt_text += "\n"; transcript_response.section.forEach(function (section, index) { if ("title" in section) { return; } const next = transcript_response.section[index + 1]; let end = next?.startMs; if (end === undefined) { end = episode_metadata_response.data.episodeUnionV2.duration.totalMilliseconds; } vtt_text += `${milliseconds_to_WebVTT_timestamp(section.startMs)} --> ${milliseconds_to_WebVTT_timestamp(end)}\n`; vtt_text += `${"text" in section ? section.text.sentence.text : section.fallback.sentence.text}\n`; vtt_text += "\n"; }); return [{ url: episode_url, name: subtitle_name, getSubtitles() { return vtt_text; }, format: "text/vtt", }]; }(); const duration = episode_metadata_response.data.episodeUnionV2.duration.totalMilliseconds / 1000; const file_url = file_manifest.cdnurl[0]; if (file_url === undefined) { throw new ScriptException("unreachable"); } const codec = "mp4a.40.2"; const audio_sources = [new AudioUrlWidevineSource({ //audio/mp4; codecs="mp4a.40.2 name: codec, bitrate: 128000, container: "audio/mp4", codec, duration, url: file_url, language: Language.UNKNOWN, bearerToken: local_state.bearer_token, licenseUri: local_state.license_uri })]; const datetime = function () { if (episode_metadata_response.data.episodeUnionV2.__typename === "Episode") { return new Date(episode_metadata_response.data.episodeUnionV2.releaseDate.isoString).getTime() / 1000; } else if (full_show_metadata.data.podcastUnionV2.__typename === "Audiobook") { return new Date(full_show_metadata.data.podcastUnionV2.publishDate.isoString).getTime() / 1000; } throw new ScriptException("unreachable"); }(); return new PlatformVideoDetails({ id: new PlatformID(PLATFORM, content_uri_id, plugin.config.id), name: episode_metadata_response.data.episodeUnionV2.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, show_uri_id, plugin.config.id), limited_show_metadata.name, `${SHOW_URL_PREFIX}${show_uri_id}`, highest_quality_cover_art.url), url: episode_url, thumbnails: new Thumbnails(episode_metadata_response.data.episodeUnionV2.coverArt.sources.map(function (image) { return new Thumbnail(image.url, image.height); })), duration, viewCount: HARDCODED_ZERO, isLive: false, shareUrl: episode_url, datetime, description: episode_metadata_response.data.episodeUnionV2.htmlDescription, video: new UnMuxVideoSourceDescriptor([], audio_sources), rating: new RatingScaler(full_show_metadata.data.podcastUnionV2.rating.averageRating.average), subtitles }); } default: throw assert_exhaustive(content_type, "unreachable"); } } function parse_content_url(url) { const match_result = url.match(CONTENT_REGEX); if (match_result === null) { throw new ScriptException("regex error"); } const maybe_content_type = match_result[1]; if (maybe_content_type === undefined) { throw new ScriptException("regex error"); } const content_type = maybe_content_type; const content_uri_id = match_result[2]; if (content_uri_id === undefined) { throw new ScriptException("regex error"); } return { content_uri_id, content_type }; } function show_metadata_args(show_uri_id) { const variables = JSON.stringify({ uri: `spotify:show:${show_uri_id}` }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "5fb034a236a3e8301e9eca0e23def3341ed66c891ea2d4fea374c091dc4b4a6a" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "queryShowMetadataV2"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function transcript_args(episode_uri_id) { const transcript_url_prefix = "https://spclient.wg.spotify.com/transcript-read-along/v2/episode/"; const url = new URL(`${transcript_url_prefix}${episode_uri_id}`); url.searchParams.set("format", "json"); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function lyrics_args(song_uri_id) { const url = new URL(`https://spclient.wg.spotify.com/color-lyrics/v2/track/${song_uri_id}`); return { url: url.toString(), headers: { Accept: "application/json", "app-platform": "WebPlayer", Authorization: `Bearer ${local_state.bearer_token}` } }; } function file_manifest_args(file_id) { const file_manifest_url_prefix = "https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/10/"; const file_manifest_params = "?product=9&alt=json"; return { url: `${file_manifest_url_prefix}${file_id}${file_manifest_params}`, headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function episode_metadata_args(episode_uri_id) { const variables = JSON.stringify({ uri: `spotify:episode:${episode_uri_id}` }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "9697538fe993af785c10725a40bb9265a20b998ccd2383bd6f586e01303824e9" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "getEpisodeOrChapter"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function track_metadata_args(song_uri_id) { const variables = JSON.stringify({ uri: `spotify:track:${song_uri_id}` }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "ae85b52abb74d20a4c331d4143d4772c95f34757bfa8c625474b912b9055b5c0" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "getTrack"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function song_metadata_args(song_uri_id) { const song_metadata_url = "https://spclient.wg.spotify.com/metadata/4/track/"; return { url: `${song_metadata_url}${get_gid(song_uri_id)}`, headers: { Authorization: `Bearer ${local_state.bearer_token}`, Accept: "application/json" } }; } function artist_metadata_args(artist_uri_id) { const variables = JSON.stringify({ uri: `spotify:artist:${artist_uri_id}`, locale: "", includePrerelease: true }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "da986392124383827dc03cbb3d66c1de81225244b6e20f8d78f9f802cc43df6e" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "queryArtistOverview"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } //#endregion //#region playlists // https://open.spotify.com/album/6BzxX6zkDsYKFJ04ziU5xQ // https://open.spotify.com/playlist/37i9dQZF1E38112qhvV3BT // https://open.spotify.com/collection/your-episodes function isPlaylistUrl(url) { return PLAYLIST_REGEX.test(url); } function searchPlaylists(query) { check_and_update_token(); return new SpotifyPlaylistsPager(query, 0, 10); } class SpotifyPlaylistsPager extends PlaylistPager { query; limit; offset; constructor(query, offset, limit) { const { url, headers } = search_args(query, offset, limit); const search_response = 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.query = query; this.limit = limit; this.offset = offset + limit; } nextPage() { const { url, headers } = search_args(this.query, this.offset, this.limit); const search_response = 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; } hasMorePagers() { return this.hasMore; } } function format_playlist_results(search_response) { 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, current_offset, limit) { return search_response.data.searchV2.albumsV2.totalCount > current_offset + limit || search_response.data.searchV2.playlists.totalCount > current_offset + limit; } function getPlaylist(url) { check_and_update_token(); const match_result = url.match(PLAYLIST_REGEX); if (match_result === null) { throw new ScriptException("regex error"); } const maybe_playlist_type = match_result[1]; if (maybe_playlist_type === undefined) { throw new ScriptException("regex error"); } const playlist_type = maybe_playlist_type; const playlist_uri_id = match_result[2]; if (playlist_uri_id === undefined) { throw new ScriptException("regex error"); } switch (playlist_type) { case "album": { // if the author is the same as the album then include the artist pick otherwise nothing // TODO we could load in extra info for all the other artists but it might be hard to do that in a request efficient way const pagination_limit = 50; const offset = 0; const { url, headers } = album_metadata_args(playlist_uri_id, offset, pagination_limit); const album_metadata_response = JSON.parse(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; const offset = 0; const { url, headers } = fetch_playlist_args(playlist_uri_id, offset, pagination_limit); const playlist_response = 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 = playlist_uri_id; switch (collection_type) { case "your-episodes": { const limit = 50; const { url, headers } = liked_episodes_args(0, limit); const response = 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); const response = 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 { pagination_limit; offset; total_tracks; constructor(offset, pagination_limit, collection_response) { const total_tracks = collection_response.data.me.library.episodes.totalCount; const episodes = format_collection_episodes(collection_response); super(episodes, total_tracks > offset + pagination_limit); this.pagination_limit = pagination_limit; this.offset = offset + pagination_limit; this.total_tracks = total_tracks; } nextPage() { const { url, headers } = liked_episodes_args(this.offset, this.pagination_limit); const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const episodes = format_collection_episodes(response); this.results = episodes; this.hasMore = this.total_tracks > this.offset + this.pagination_limit; this.offset += this.pagination_limit; return this; } hasMorePagers() { return this.hasMore; } } function format_collection_episodes(response) { return response.data.me.library.episodes.items.map(function (episode) { if (episode.episode.data.podcastV2.data.__typename === "NotFound" || episode.episode.data.releaseDate === null) { throw new ScriptException("unreachable"); } return new PlatformVideo({ id: new PlatformID(PLATFORM, id_from_uri(episode.episode._uri), plugin.config.id), name: episode.episode.data.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(episode.episode.data.podcastV2.data.uri), plugin.config.id), episode.episode.data.podcastV2.data.name, `${SHOW_URL_PREFIX}${id_from_uri(episode.episode.data.podcastV2.data.uri)}`, episode.episode.data.podcastV2.data.coverArt?.sources[0]?.url), datetime: new Date(episode.episode.data.releaseDate.isoString).getTime() / 1000, url: `${EPISODE_URL_PREFIX}${id_from_uri(episode.episode._uri)}`, thumbnails: new Thumbnails(episode.episode.data.coverArt.sources.map(function (image) { return new Thumbnail(image.url, image.height); })), duration: episode.episode.data.duration.totalMilliseconds / 1000, viewCount: HARDCODED_ZERO, isLive: false, shareUrl: `${EPISODE_URL_PREFIX}${id_from_uri(episode.episode._uri)}` }); }); } class LikedTracksPager extends VideoPager { pagination_limit; offset; total_tracks; constructor(offset, pagination_limit, collection_response) { const total_tracks = collection_response.data.me.library.tracks.totalCount; const episodes = format_collection_tracks(collection_response); super(episodes, total_tracks > offset + pagination_limit); this.pagination_limit = pagination_limit; this.offset = offset + pagination_limit; this.total_tracks = total_tracks; } nextPage() { const { url, headers } = liked_songs_args(this.offset, this.pagination_limit); const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const episodes = format_collection_tracks(response); this.results = episodes; this.hasMore = this.total_tracks > this.offset + this.pagination_limit; this.offset += this.pagination_limit; return this; } hasMorePagers() { return this.hasMore; } } function format_collection_tracks(response) { return response.data.me.library.tracks.items.map(function (track) { const artist = track.track.data.artists.items[0]; if (artist === undefined) { throw new ScriptException("missing song artist"); } return new PlatformVideo({ id: new PlatformID(PLATFORM, id_from_uri(track.track._uri), plugin.config.id), name: track.track.data.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(artist.uri), plugin.config.id), artist.profile.name, `${ARTIST_URL_PREFIX}${id_from_uri(artist.uri)}`), datetime: HARDCODED_ZERO, url: `${SONG_URL_PREFIX}${id_from_uri(track.track._uri)}`, thumbnails: new Thumbnails(track.track.data.albumOfTrack.coverArt.sources.map(function (image) { return new Thumbnail(image.url, image.height); })), duration: track.track.data.duration.totalMilliseconds / 1000, viewCount: HARDCODED_ZERO, isLive: false, shareUrl: `${SONG_URL_PREFIX}${id_from_uri(track.track._uri)}` }); }); } class SpotifyPlaylistPager extends VideoPager { playlist_uri_id; pagination_limit; offset; total_tracks; constructor(playlist_uri_id, offset, pagination_limit, playlist_response) { const total_tracks = playlist_response.data.playlistV2.content.totalCount; const songs = format_playlist_tracks(playlist_response.data.playlistV2.content); super(songs, total_tracks > offset + pagination_limit); this.playlist_uri_id = playlist_uri_id; this.pagination_limit = pagination_limit; this.offset = offset + pagination_limit; this.total_tracks = total_tracks; } nextPage() { const { url, headers } = fetch_playlist_contents_args(this.playlist_uri_id, this.offset, this.pagination_limit); const playlist_content_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const songs = format_playlist_tracks(playlist_content_response.data.playlistV2.content); this.results = songs; this.hasMore = this.total_tracks > this.offset + this.pagination_limit; this.offset += this.pagination_limit; return this; } hasMorePagers() { return this.hasMore; } } function format_playlist_tracks(content) { return content.items.flatMap(function (playlist_track_metadata) { if (playlist_track_metadata.itemV2.__typename === "LocalTrackResponseWrapper") { return []; } const song = playlist_track_metadata.itemV2.data; const track_uri_id = id_from_uri(song.uri); const artist = song.artists.items[0]; if (artist === undefined) { throw new ScriptException("missing artist"); } const url = `${SONG_URL_PREFIX}${track_uri_id}`; return new PlatformVideo({ id: new PlatformID(PLATFORM, track_uri_id, plugin.config.id), name: song.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(artist.uri), plugin.config.id), artist.profile.name, `${ARTIST_URL_PREFIX}${id_from_uri(artist.uri)}` // TODO figure out a way to get the artist thumbnail ), url, thumbnails: new Thumbnails(song.albumOfTrack.coverArt.sources.map(function (source) { return new Thumbnail(source.url, source.height); })), duration: song.trackDuration.totalMilliseconds / 1000, viewCount: parseInt(song.playcount), isLive: false, shareUrl: url, datetime: new Date(playlist_track_metadata.addedAt.isoString).getTime() / 1000 }); }); } /** * * @param playlist_uri_id * @param offset the track to start loading from in the album (0 is the first track) * @param limit the maximum number of tracks to load information about * @returns */ function fetch_playlist_contents_args(playlist_uri_id, offset, limit) { const variables = JSON.stringify({ uri: `spotify:playlist:${playlist_uri_id}`, offset: offset, limit: limit }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "91d4c2bc3e0cd1bc672281c4f1f59f43ff55ba726ca04a45810d99bd091f3f0e" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "fetchPlaylistContents"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } /** * * @param playlist_uri_id * @param offset the track to start loading from in the album (0 is the first track) * @param limit the maximum number of tracks to load information about * @returns */ function fetch_playlist_args(playlist_uri_id, offset, limit) { const variables = JSON.stringify({ uri: `spotify:playlist:${playlist_uri_id}`, offset: offset, limit: limit }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "91d4c2bc3e0cd1bc672281c4f1f59f43ff55ba726ca04a45810d99bd091f3f0e" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "fetchPlaylist"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } class AlbumPager extends VideoPager { album_uri_id; pagination_limit; offset; thumbnails; album_artist; unix_time; total_tracks; constructor(album_uri_id, offset, pagination_limit, album_metadata_response, album_artist, unix_time) { const total_tracks = album_metadata_response.data.albumUnion.tracks.totalCount; const thumbnails = new Thumbnails(album_metadata_response.data.albumUnion.coverArt.sources.map(function (source) { return new Thumbnail(source.url, source.height); })); const songs = format_album_tracks(album_metadata_response.data.albumUnion.tracks, thumbnails, album_artist, unix_time); super(songs, total_tracks > offset + pagination_limit); this.album_uri_id = album_uri_id; this.pagination_limit = pagination_limit; this.offset = offset + pagination_limit; this.thumbnails = thumbnails; this.album_artist = album_artist; this.unix_time = unix_time; this.total_tracks = total_tracks; } nextPage() { const { url, headers } = album_tracks_args(this.album_uri_id, this.offset, this.pagination_limit); const album_tracks_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const songs = format_album_tracks(album_tracks_response.data.albumUnion.tracks, this.thumbnails, this.album_artist, this.unix_time); this.results = songs; this.hasMore = this.total_tracks > this.offset + this.pagination_limit; this.offset += this.pagination_limit; return this; } hasMorePagers() { return this.hasMore; } } function format_album_tracks(tracks, thumbnails, album_artist, unix_time) { return tracks.items.map(function (track) { const track_uri_id = id_from_uri(track.track.uri); const artist = track.track.artists.items[0]; if (artist === undefined) { throw new ScriptException("missing artist"); } const url = `${SONG_URL_PREFIX}${track_uri_id}`; return new PlatformVideo({ id: new PlatformID(PLATFORM, track_uri_id, plugin.config.id), name: track.track.name, author: new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(artist.uri), plugin.config.id), artist.profile.name, `${ARTIST_URL_PREFIX}${id_from_uri(artist.uri)}`, id_from_uri(artist.uri) === album_artist.id ? album_artist.visuals.avatarImage.sources[album_artist.visuals.avatarImage.sources.length - 1]?.url : undefined), url, thumbnails, duration: track.track.duration.totalMilliseconds / 1000, viewCount: parseInt(track.track.playcount), isLive: false, shareUrl: url, datetime: unix_time }); }); } /** * * @param album_uri_id * @param offset the track to start loading from in the album (0 is the first track) * @param limit the maximum number of tracks to load information about * @returns */ function album_tracks_args(album_uri_id, offset, limit) { const variables = JSON.stringify({ uri: `spotify:album:${album_uri_id}`, offset: offset, limit: limit }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "469874edcad37b7a379d4f22f0083a49ea3d6ae097916120d9bbe3e36ca79e9d" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "queryAlbumTracks"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } /** * * @param album_uri_id * @param offset the track to start loading from in the album (0 is the first track) * @param limit the maximum number of tracks to load information about * @returns */ function album_metadata_args(album_uri_id, offset, limit) { const variables = JSON.stringify({ uri: `spotify:album:${album_uri_id}`, locale: "", offset: offset, limit: limit }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "469874edcad37b7a379d4f22f0083a49ea3d6ae097916120d9bbe3e36ca79e9d" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "getAlbum"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } //#endregion //#region channel // https://open.spotify.com/show/4Pgcpzc9b3qTxyUr9DkXEn // https://open.spotify.com/show/5VzFvh1JlEhBMS6ZHZ8CNO // https://open.spotify.com/artist/1HtB6hptdVyK6cBTm9SMTu // https://open.spotify.com/user/zelladay // https://open.spotify.com/genre/0JQ5DAt0tbjZptfcdMSKl3 // https://open.spotify.com/genre/section0JQ5DACFo5h0jxzOyHOsIe function isChannelUrl(url) { return CHANNEL_REGEX.test(url); } function searchChannels(query) { check_and_update_token(); return new SpotifyChannelPager(query, 0, 10); } class SpotifyChannelPager extends ChannelPager { query; limit; offset; constructor(query, offset, limit) { const { url, headers } = search_args(query, offset, limit); const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const has_more = are_more_channel_results(search_response, offset, limit); super(format_channel_results(search_response), has_more); this.query = query; this.limit = limit; this.offset = offset + limit; } nextPage() { const { url, headers } = search_args(this.query, this.offset, this.limit); const search_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); this.results = format_channel_results(search_response); this.hasMore = are_more_channel_results(search_response, this.offset, this.limit); this.offset = this.offset + this.limit; return this; } hasMorePagers() { return this.hasMore; } } function are_more_channel_results(search_response, current_offset, limit) { return search_response.data.searchV2.artists.totalCount > current_offset + limit || search_response.data.searchV2.podcasts.totalCount > current_offset + limit || search_response.data.searchV2.audiobooks.totalCount > current_offset + limit || search_response.data.searchV2.users.totalCount > current_offset + limit || search_response.data.searchV2.genres.totalCount > current_offset + limit; } function format_channel_results(search_response) { return [ ...search_response.data.searchV2.artists.items.map(function (artist) { const thumbnail = artist.data.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING; return new PlatformChannel({ id: new PlatformID(PLATFORM, id_from_uri(artist.data.uri), plugin.config.id), name: artist.data.profile.name, thumbnail, url: `${ARTIST_URL_PREFIX}${id_from_uri(artist.data.uri)}` }); }), ...search_response.data.searchV2.podcasts.items.map(function (podcasts) { const thumbnail = podcasts.data.coverArt.sources[0]?.url; if (thumbnail === undefined) { throw new ScriptException("missing podcast cover image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, id_from_uri(podcasts.data.uri), plugin.config.id), name: podcasts.data.name, thumbnail, url: `${SHOW_URL_PREFIX}${id_from_uri(podcasts.data.uri)}` }); }), ...search_response.data.searchV2.audiobooks.items.map(function (audiobook) { const thumbnail = audiobook.data.coverArt.sources[0]?.url; if (thumbnail === undefined) { throw new ScriptException("missing audiobook cover image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, id_from_uri(audiobook.data.uri), plugin.config.id), name: audiobook.data.name, thumbnail, url: `${SHOW_URL_PREFIX}${id_from_uri(audiobook.data.uri)}` }); }), ...search_response.data.searchV2.users.items.map(function (user) { const thumbnail = user.data.avatar?.sources[0]?.url ?? HARDCODED_EMPTY_STRING; return new PlatformChannel({ id: new PlatformID(PLATFORM, user.data.username, plugin.config.id), name: user.data.displayName, thumbnail, url: `${USER_URL_PREFIX}${user.data.username}` }); }), ...search_response.data.searchV2.genres.items.map(function (genre) { const thumbnail = genre.data.image.sources[0]?.url; if (thumbnail === undefined) { throw new ScriptException("missing genre cover image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, id_from_uri(genre.data.uri), plugin.config.id), name: genre.data.name, thumbnail, url: `${PAGE_URL_PREFIX}${id_from_uri(genre.data.uri)}` }); }), ]; } /** * * @param query * @param offset * @param limit really only works set to 10 * @returns */ function search_args(query, offset, limit) { const variables = JSON.stringify({ searchTerm: query, offset, // really only works set to 10 limit, numberOfTopResults: 5, includeAudiobooks: true, includeArtistHasConcertsField: false, includePreReleases: true, includeLocalConcertsField: false }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "7a60179c5d6b6c385e849438efb1398392ef159d82f2ad7158be5e80bf7817a9" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "searchDesktop"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function getChannel(url) { check_and_update_token(); const { channel_type, channel_uri_id } = parse_channel_url(url); switch (channel_type) { case "section": { // use limit of 4 to load minimal data but try to guarantee that we can get a cover photo const limit = 4; const { url, headers } = browse_section_args(channel_uri_id, 0, limit); const browse_section_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const name = browse_section_response.data.browseSection.data.title.transformedLabel; const channel_url = `${SECTION_URL_PREFIX}${channel_uri_id}`; const section = browse_section_response.data.browseSection; const section_items = section.sectionItems.items.flatMap(function (section_item) { const section_item_content = section_item.content.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") { return [section_item_content]; } return []; }); const first_section_item = section_items?.[0]; if (first_section_item === undefined) { throw new LoginRequiredException("login to view custom genres"); } const first_playlist_image = first_section_item.__typename === "Album" ? first_section_item.coverArt.sources[0]?.url : first_section_item.images.items[0]?.sources[0]?.url; if (first_playlist_image === undefined) { throw new ScriptException("missing playlist image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name, thumbnail: first_playlist_image, url: channel_url }); } case "genre": { if (channel_uri_id === "recently-played") { if (!bridge.isLoggedIn()) { throw new LoginRequiredException("login to open recently-played"); } // Spotify just load the first 50 const { url: uri_url, headers: uri_headers } = recently_played_ids_args(0, 50); const recently_played_ids = JSON.parse(throw_if_not_ok(local_http.GET(uri_url, uri_headers, false)).body); const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) { return uri_obj.uri; })); const recently_played_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const section_items = recently_played_response.data.lookup.flatMap(function (section_item) { if (section_item.__typename === "UnknownTypeWrapper") { return [{ image: { sources: [{ "height": 640, "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png", }] }, name: "Liked Songs", __typename: "PseudoPlaylist", uri: "spotify:collection:tracks" }]; } const section_item_content = section_item.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") { return [section_item_content]; } return []; }); const first_section_item = section_items?.[0]; if (first_section_item === undefined) { throw new ScriptException("unreachable"); } const first_section_first_playlist_image = function (section_item) { switch (section_item.__typename) { case "Album": return section_item.coverArt.sources[0]?.url; case "Playlist": return section_item.images.items[0]?.sources[0]?.url; case "PseudoPlaylist": return section_item.image.sources[0]?.url; default: throw assert_exhaustive(section_item); } }(first_section_item); if (first_section_first_playlist_image === undefined) { throw new ScriptException("missing playlist image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name: "Recently played", thumbnail: first_section_first_playlist_image, url: "https://open.spotify.com/genre/recently-played" }); } // use limit of 4 to load minimal data but try to guarantee that we can get a cover photo const limit = 4; const { url, headers } = browse_page_args(channel_uri_id, { offset: 0, limit }, { offset: 0, limit }); const browse_page_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); if (browse_page_response.data.browse.__typename === "GenericError") { throw new ScriptException("error loading genre page"); } const name = browse_page_response.data.browse.header.title.transformedLabel; const sections = browse_page_response.data.browse.sections.items.flatMap(function (item) { if (is_playlist_section(item)) { return [item]; } return []; }); const channel_url = `${PAGE_URL_PREFIX}${channel_uri_id}`; const section_items = sections[0]?.sectionItems.items.flatMap(function (section_item) { if (section_item.content.__typename === "UnknownType") { return []; } const section_item_content = section_item.content.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") { return [section_item_content]; } return []; }); const first_section_item = section_items?.[0]; if (first_section_item === undefined) { throw new LoginRequiredException("login to view custom genres"); } const first_section_first_playlist_image = first_section_item.__typename === "Album" ? first_section_item.coverArt.sources[0]?.url : first_section_item.images.items[0]?.sources[0]?.url; if (first_section_first_playlist_image === undefined) { throw new ScriptException("missing playlist image"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name, thumbnail: first_section_first_playlist_image, url: channel_url }); } case "show": { const { url, headers } = show_metadata_args(channel_uri_id); const show_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const sources = show_response.data.podcastUnionV2.coverArt.sources; const thumbnail = sources[sources.length - 1]?.url; if (thumbnail === undefined) { throw new ScriptException("missing cover art"); } return new PlatformChannel({ id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name: show_response.data.podcastUnionV2.name, thumbnail, url: `${SHOW_URL_PREFIX}${channel_uri_id}`, description: show_response.data.podcastUnionV2.htmlDescription }); } case "user": { const url = `https://spclient.wg.spotify.com/user-profile-view/v3/profile/${channel_uri_id}?playlist_limit=0&artist_limit=0&episode_limit=0`; const user_response = JSON.parse(throw_if_not_ok(local_http.GET(url, { Authorization: `Bearer ${local_state.bearer_token}` }, false)).body); return new PlatformChannel({ id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name: user_response.name, thumbnail: user_response.image_url, url: `${USER_URL_PREFIX}${channel_uri_id}`, subscribers: user_response.followers_count }); } case "artist": { const { url, headers } = artist_metadata_args(channel_uri_id); const artist_metadata_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const thumbnail = artist_metadata_response.data.artistUnion.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING; const banner = artist_metadata_response.data.artistUnion.visuals.headerImage?.sources[0]?.url; const channel = { id: new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), name: artist_metadata_response.data.artistUnion.profile.name, thumbnail, url: `${ARTIST_URL_PREFIX}${channel_uri_id}`, subscribers: artist_metadata_response.data.artistUnion.stats.monthlyListeners, description: artist_metadata_response.data.artistUnion.profile.biography.text }; if (banner === undefined) { return new PlatformChannel(channel); } return new PlatformChannel({ ...channel, banner }); } case "content-feed": throw new ScriptException("not implemented"); default: throw assert_exhaustive(channel_type, "unreachable"); } } function is_playlist_section(item) { return item.data.__typename === "BrowseGenericSectionData" || item.data.__typename === "HomeGenericSectionData" || item.data.__typename === "WhatsNewSectionData" || item.data.__typename === "CustomRecentlyPlayedSectionData"; } function browse_page_args(page_uri_id, pagePagination, sectionPagination) { const variables = JSON.stringify({ uri: `spotify:page:${page_uri_id}`, pagePagination, sectionPagination }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "177a4ae12a90e35d335f060216ce5df7864a228c6ca262bd5ed90b37c2419dd9" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "browsePage"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function recently_played_ids_args(offset, limit) { const url = `https://spclient.wg.spotify.com/recently-played/v3/user/${local_state.username}/recently-played?format=json&offset=${offset}&limit=${limit}&filter=default,collection-new-episodes`; return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function recently_played_details_args(uris) { const variables = JSON.stringify({ uris }); const extensions = JSON.stringify({ persistedQuery: { version: 1, sha256Hash: "8e4eb5eafa2837eca337dc11321ac285a01f9a056a7ac83f77a66f9998b06a73" } }); const url = new URL(QUERY_URL); url.searchParams.set("operationName", "fetchEntitiesForRecentlyPlayed"); url.searchParams.set("variables", variables); url.searchParams.set("extensions", extensions); return { url: url.toString(), headers: { Authorization: `Bearer ${local_state.bearer_token}` } }; } function parse_channel_url(url) { const match_result = url.match(CHANNEL_REGEX); if (match_result === null) { throw new ScriptException("regex error"); } const maybe_channel_type = match_result[1]; if (maybe_channel_type === undefined) { throw new ScriptException("regex error"); } const is_section = match_result[2] === "section"; let channel_type = maybe_channel_type; if (is_section) { channel_type = "section"; } const channel_uri_id = match_result[3]; if (channel_uri_id === undefined) { throw new ScriptException("regex error"); } return { channel_type, channel_uri_id: channel_uri_id === "recently-played" ? "recently-played" : channel_uri_id }; } //#endregion //#region channel content function getChannelCapabilities() { return new ResultCapabilities([ Type.Feed.Playlists, Type.Feed.Albums, Type.Feed.Videos ], [ Type.Order.Chronological ], []); } function getChannelContents(url, type, order, filters) { if (filters !== null) { throw new ScriptException("unreachable"); } if (order !== "CHRONOLOGICAL") { throw new ScriptException("unreachable"); } if (type !== Type.Feed.Videos) { throw new ScriptException("unreachable"); } check_and_update_token(); const { channel_type, channel_uri_id } = parse_channel_url(url); switch (channel_type) { case "section": { const initial_limit = 20; const { url, headers } = browse_section_args(channel_uri_id, 0, initial_limit); const browse_section_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const name = browse_section_response.data.browseSection.data.title.transformedLabel; const section = browse_section_response.data.browseSection; const section_uri_id = channel_uri_id; const section_items = section.sectionItems.items.flatMap(function (section_item) { const section_item_content = section_item.content.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") { return [section_item_content]; } return []; }); if (section_items.length === 0) { return new ContentPager([], false); } const first_section_item = section_items[0]; if (first_section_item === undefined) { throw new ScriptException("no section items"); } const author = new PlatformAuthorLink(new PlatformID(PLATFORM, section_uri_id, plugin.config.id), name, `${SECTION_URL_PREFIX}${section_uri_id}`, first_section_item.__typename === "Album" ? first_section_item.coverArt.sources[0]?.url : first_section_item.images.items[0]?.sources[0]?.url); return new SectionPager(channel_uri_id, section_items, 0, initial_limit, author, section.sectionItems.totalCount > initial_limit); } case "genre": { if (channel_uri_id === "recently-played") { if (!bridge.isLoggedIn()) { throw new LoginRequiredException("login to open recently-played"); } // Spotify just load the first 50 const { url: uri_url, headers: uri_headers } = recently_played_ids_args(0, 50); const recently_played_ids = JSON.parse(throw_if_not_ok(local_http.GET(uri_url, uri_headers, false)).body); const { url, headers } = recently_played_details_args(recently_played_ids.playContexts.map(function (uri_obj) { return uri_obj.uri; })); const recently_played_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const section_items = recently_played_response.data.lookup.flatMap(function (section_item) { if (section_item.__typename === "UnknownTypeWrapper") { return [{ image: { sources: [{ "height": 640, "url": "https://misc.scdn.co/liked-songs/liked-songs-640.png", }] }, name: "Liked Songs", __typename: "PseudoPlaylist", uri: "spotify:collection:tracks" }]; } const section_item_content = section_item.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album") { return [section_item_content]; } return []; }); const first_section_item = section_items?.[0]; if (first_section_item === undefined) { throw new ScriptException("unreachable"); } const first_section_first_playlist_image = function (section_item) { switch (section_item.__typename) { case "Album": return section_item.coverArt.sources[0]?.url; case "Playlist": return section_item.images.items[0]?.sources[0]?.url; case "PseudoPlaylist": return section_item.image.sources[0]?.url; default: throw assert_exhaustive(section_item); } }(first_section_item); if (first_section_first_playlist_image === undefined) { throw new ScriptException("missing playlist image"); } const author = new PlatformAuthorLink(new PlatformID(PLATFORM, "recently-played", plugin.config.id), "Recently played", `${PAGE_URL_PREFIX}recently-played`, first_section_first_playlist_image); const playlists = section_items.map(function (section_item) { return format_section_item(section_item, author); }); return new ContentPager(playlists, false); } const limit = 4; const { url, headers } = browse_page_args(channel_uri_id, { offset: 0, limit: 50 }, { offset: 0, limit: limit }); const browse_page_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); if (browse_page_response.data.browse.__typename === "GenericError") { throw new ScriptException("error loading genre page"); } const playlists = format_page(browse_page_response.data.browse.sections.items, limit, browse_page_response.data.browse.header.title.transformedLabel); return new ContentPager(playlists, false); } 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"); } const show_metadata_response = JSON.parse(throw_if_not_ok(responses[0]).body); const author = new PlatformAuthorLink(new PlatformID(PLATFORM, channel_uri_id, plugin.config.id), show_metadata_response.data.podcastUnionV2.name, `${SHOW_URL_PREFIX}${channel_uri_id}`, show_metadata_response.data.podcastUnionV2.coverArt.sources[0]?.url); switch (show_metadata_response.data.podcastUnionV2.__typename) { case "Audiobook": { const chapters_response = JSON.parse(throw_if_not_ok(responses[1]).body); const publish_date_time = new Date(show_metadata_response.data.podcastUnionV2.publishDate.isoString).getTime() / 1000; return new ChapterPager(channel_uri_id, chapters_response, 0, chapters_limit, author, publish_date_time); } case "Podcast": { const episodes_response = JSON.parse(throw_if_not_ok(responses[2]).body); return new EpisodePager(channel_uri_id, episodes_response, 0, episodes_limit, author); } default: throw assert_exhaustive(show_metadata_response.data.podcastUnionV2, "unreachable"); } } case "artist": return new FlattenedArtistDiscographyPager(channel_uri_id, 0, 2); case "user": return new ContentPager([], false); case "content-feed": throw new ScriptException("not implemented"); default: throw assert_exhaustive(channel_type, "unreachable"); } } function getChannelPlaylists(url) { check_and_update_token(); const { channel_type, channel_uri_id } = parse_channel_url(url); switch (channel_type) { case "section": return new PlaylistPager([], false); case "genre": return new PlaylistPager([], false); case "show": return new PlaylistPager([], false); case "artist": return new ArtistDiscographyPager(channel_uri_id, 0, 50); case "user": return new UserPlaylistPager(channel_uri_id, 0, 50); case "content-feed": throw new ScriptException("not implemented"); default: throw assert_exhaustive(channel_type, "unreachable"); } } /** * * @param sections * @param display_limit maximum number of items to display per section * @returns */ function format_page(sections, display_limit, page_title) { const filtered_sections = sections.flatMap(function (item) { if (is_playlist_section(item)) { return [item]; } return []; }); const content = filtered_sections.flatMap(function (section) { const section_title = section.data.title; const section_name = section_title === null ? page_title : "text" in section_title ? section_title.text : section_title.transformedLabel; const section_items = section.sectionItems.items.flatMap(function (section_item) { if (section_item.content.__typename === "UnknownType") { return []; } const section_item_content = section_item.content.data; if (section_item_content.__typename === "Playlist" || section_item_content.__typename === "Album" || section_item_content.__typename === "Episode" || section_item_content.__typename === "PseudoPlaylist") { return [section_item_content]; } return []; }); if (section_items.length === 0) { return []; } const first_section_item = section_items[0]; if (first_section_item === undefined) { throw new ScriptException("no sections"); } const author = function () { if ("section_url" in section) { return new PlatformAuthorLink(new PlatformID(PLATFORM, section.section_url, plugin.config.id), section_name, section.section_url); } return new PlatformAuthorLink(new PlatformID(PLATFORM, id_from_uri(section.uri), plugin.config.id), section_name, `${SECTION_URL_PREFIX}${id_from_uri(section.uri)}`); }(); return section_items.map(function (playlist) { return format_section_item(playlist, author); }).slice(0, display_limit); }); return content; } class ArtistDiscographyPager extends PlaylistPager { artist_uri_id; limit; offset; artist; total_albums; constructor(artist_uri_id, offset, limit) { const { url: metadata_url, headers: metadata_headers } = artist_metadata_args(artist_uri_id); const { url: discography_url, headers: discography_headers } = discography_args(artist_uri_id, offset, limit); const responses = local_http .batch() .GET(metadata_url, metadata_headers, false) .GET(discography_url, discography_headers, false) .execute(); if (responses[0] === undefined || responses[1] === undefined) { throw new ScriptException("unreachable"); } const metadata_response = JSON.parse(throw_if_not_ok(responses[0]).body); const discography_response = JSON.parse(throw_if_not_ok(responses[1]).body); const avatar_url = metadata_response.data.artistUnion.visuals.avatarImage?.sources[0]?.url ?? HARDCODED_EMPTY_STRING; const author = new PlatformAuthorLink(new PlatformID(PLATFORM, artist_uri_id, plugin.config.id), metadata_response.data.artistUnion.profile.name, `${ARTIST_URL_PREFIX}${artist_uri_id}`, avatar_url, metadata_response.data.artistUnion.stats.monthlyListeners); const total_albums = discography_response.data.artistUnion.discography.all.totalCount; super(format_discography(discography_response, author), total_albums > offset + limit); this.artist_uri_id = artist_uri_id; this.limit = limit; this.artist = author; this.offset = offset + limit; this.total_albums = total_albums; } nextPage() { const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit); const discography_response = JSON.parse(local_http.GET(url, headers, false).body); this.results = format_discography(discography_response, this.artist); this.hasMore = this.total_albums > this.offset + this.limit; this.offset = this.offset + this.limit; return this; } hasMorePagers() { return this.hasMore; } } class FlattenedArtistDiscographyPager extends VideoPager { artist_uri_id; limit; offset; total_albums; constructor(artist_uri_id, offset, limit) { const { url: discography_url, headers: discography_headers } = discography_args(artist_uri_id, offset, limit); const discography_response = JSON.parse(throw_if_not_ok(local_http.GET(discography_url, discography_headers, false)).body); const total_albums = discography_response.data.artistUnion.discography.all.totalCount; super(load_album_tracks_and_flatten(discography_response), total_albums > offset + limit); this.artist_uri_id = artist_uri_id; this.limit = limit; this.offset = offset + limit; this.total_albums = total_albums; } nextPage() { const { url, headers } = discography_args(this.artist_uri_id, this.offset, this.limit); const discography_response = JSON.parse(local_http.GET(url, headers, false).body); this.results = load_album_tracks_and_flatten(discography_response); this.hasMore = this.total_albums > this.offset + this.limit; this.offset = this.offset + this.limit; return this; } hasMorePagers() { return this.hasMore; } } //TODO parallelize all of this album track loading code function load_album_tracks_and_flatten(discography_response) { const songs = []; for (const album of discography_response.data.artistUnion.discography.all.items) { const first_release = album.releases.items[0]; if (first_release === undefined) { throw new ScriptException("unreachable"); } const pagination_limit = 50; const offset = 0; const { url, headers } = album_metadata_args(first_release.id, offset, pagination_limit); const album_metadata_response = 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; 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); } } return songs; } function format_discography(discography_response, artist) { 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, offset, limit) { 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, section_as_author) { 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 for: ${section.uri}`); } 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, 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": { 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_URL_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 { section_uri_id; section_as_author; limit; offset; constructor(section_uri_id, section_items, offset, limit, section_as_author, has_more) { const playlists = section_items.map(function (section_item) { return format_section_item(section_item, section_as_author); }); super(playlists, has_more); this.section_uri_id = section_uri_id; this.section_as_author = section_as_author; this.offset = offset + limit; this.limit = limit; } nextPage() { const { url, headers } = browse_section_args(this.section_uri_id, this.offset, this.limit); const browse_section_response = JSON.parse(throw_if_not_ok(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 (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; } hasMorePagers() { return this.hasMore; } } function browse_section_args(page_uri_id, offset, limit) { 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, offset, limit) { 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, offset, limit) { 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 { audiobook_uri_id; limit; author; publish_date_time; offset; constructor(audiobook_uri_id, chapters_response, offset, limit, author, publish_date_time) { 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.audiobook_uri_id = audiobook_uri_id; this.limit = limit; this.author = author; this.publish_date_time = publish_date_time; this.offset = next_offset === null ? offset : next_offset; } nextPage() { const { url, headers } = book_chapters_args(this.audiobook_uri_id, this.offset, this.limit); const chapters_response = JSON.parse(throw_if_not_ok(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; } hasMorePagers() { return this.hasMore; } } function format_chapters(chapters_response, author, publish_date_time) { 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 { podcast_uri_id; limit; author; offset; constructor(podcast_uri_id, episodes_response, offset, limit, author) { const chapters = format_episodes(episodes_response, author); const next_offset = episodes_response.data.podcastUnionV2.episodesV2.pagingInfo.nextOffset; super(chapters, next_offset !== null); this.podcast_uri_id = podcast_uri_id; this.limit = limit; this.author = author; this.offset = next_offset === null ? offset : next_offset; } nextPage() { const { url, headers } = podcast_episodes_args(this.podcast_uri_id, this.offset, this.limit); const chapters_response = JSON.parse(throw_if_not_ok(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; } hasMorePagers() { return this.hasMore; } } function format_episodes(episodes_response, author) { 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 { username; limit; offset; total_playlists; constructor(username, offset, limit) { const { url, headers } = user_playlists_args(username, offset, limit); const playlists_response = JSON.parse(throw_if_not_ok(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.username = username; this.limit = limit; this.offset = offset + limit; this.total_playlists = total_playlists; } nextPage() { const { url, headers } = user_playlists_args(this.username, this.offset, this.limit); const playlists_response = JSON.parse(throw_if_not_ok(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; } hasMorePagers() { return this.hasMore; } } function user_playlists_args(username, offset, limit) { 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) { 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 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 = []; let more = true; let offset = 0; const limit = 50; while (more) { const { url, headers } = library_args(offset, limit); const library_response = JSON.parse(throw_if_not_ok(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; // to avoid the never type const type = item.__typename; 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_URL_PREFIX}${id_from_uri(item.uri)}`; case "Audiobook": return []; case "Podcast": return []; case "Artist": return []; case "Folder": return []; case "NotFound": return []; default: throw assert_exhaustive(item, `unknown item type: ${type}`); } }) ]; if (library_response.data.me.libraryV3.totalCount <= offset + limit) { more = false; } offset += limit; } return playlists; } function library_args(offset, limit) { const variables = JSON.stringify({ filters: [], order: null, textFilter: "", features: ["LIKED_SONGS", "YOUR_EPISODES", "PRERELEASES"], limit, offset, flatten: true, 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, limit) { 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, limit) { 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() { const { url, headers } = following_args(); const following_response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); let following = 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); const library_response = JSON.parse(throw_if_not_ok(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; // to avoid the never type const type = item.__typename; 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)}`; case "Folder": return []; case "NotFound": return []; default: throw assert_exhaustive(item, `unknown item type: ${type}`); } }) ]; if (library_response.data.me.libraryV3.totalCount <= offset + limit) { more = false; } offset += limit; } return following; } function getPlaybackTracker(url) { if (!local_settings.spotifyActivity) { return null; } const { content_uri_id, content_type } = parse_content_url(url); check_and_update_token(); return new SpotifyPlaybackTracker(content_uri_id, content_type); } class SpotifyPlaybackTracker extends PlaybackTracker { recording_play = false; play_recorded = false; total_seconds_played = 0; feature_identifier; device_id; context_url; context_uri; skip_to_data; duration; interval_seconds; constructor(uri_id, content_type) { const interval_seconds = 2; super(interval_seconds * 1000); this.interval_seconds = interval_seconds; // generate device id // from spotify player js code const ht = "undefined" != typeof crypto && "function" == typeof crypto.getRandomValues; const gt = (e) => ht ? function (e) { return crypto.getRandomValues(new Uint8Array(e)); }(e) : function (e) { const t = []; for (; t.length < e;) t.push(Math.floor(256 * Math.random())); return t; }(e); const ft = (e) => { const t = Math.ceil(e / 2); return function (e) { let t = ""; for (let n = 0; n < e.length; n++) { const i = e[n]; if (i === undefined) { throw new ScriptException("issue generating device id"); } if (i < 16) { (t += "0"); } t += i.toString(16); } return t; }(gt(t)); }; const vt = () => ft(40); this.device_id = vt(); // load track info switch (content_type) { case "episode": { const { url, headers } = episode_metadata_args(uri_id); const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); switch (response.data.episodeUnionV2.__typename) { case "Chapter": this.context_uri = response.data.episodeUnionV2.audiobookV2.data.uri; this.feature_identifier = "audiobook"; break; case "Episode": this.context_uri = response.data.episodeUnionV2.podcastV2.data.uri; this.feature_identifier = "show"; break; default: throw assert_exhaustive(response.data.episodeUnionV2, "unreachable"); } this.context_url = `context://${this.context_uri}`; this.skip_to_data = { content_type: "episode", track_uri: response.data.episodeUnionV2.uri }; this.duration = response.data.episodeUnionV2.duration.totalMilliseconds; break; } case "track": { const { url, headers } = track_metadata_args(uri_id); const response = JSON.parse(throw_if_not_ok(local_http.GET(url, headers, false)).body); const track_album_index = response.data.trackUnion.trackNumber - 1; const { url: tracks_url, headers: tracks_headers } = album_tracks_args(id_from_uri(response.data.trackUnion.albumOfTrack.uri), track_album_index, 1); const tracks_response = JSON.parse(throw_if_not_ok(local_http.GET(tracks_url, tracks_headers, false)).body); this.feature_identifier = "album"; this.context_uri = response.data.trackUnion.albumOfTrack.uri; this.context_url = `context://${this.context_uri}`; this.duration = response.data.trackUnion.duration.totalMilliseconds; const uid = tracks_response.data.albumUnion.tracks.items[0]?.uid; if (uid === undefined) { throw new ScriptException("can't find song uid"); } this.skip_to_data = { content_type: "track", uid, track_uri: response.data.trackUnion.uri, track_album_index }; break; } default: throw assert_exhaustive(content_type, "unreachable"); } } onProgress(_seconds, is_playing) { if (is_playing) { // this ends up lagging behind. this.total_seconds_played += this.interval_seconds; } if (is_playing && !this.recording_play && this.total_seconds_played > 30) { this.recording_play = true; log("creating WebSocket connection"); // setup WebSocket connection const url = `wss://gue1-dealer.spotify.com/?access_token=${local_state.bearer_token}`; const socket = http.socket(url, {}, false); socket.connect({ open: () => { }, closed: (code, reason) => { console.log(code.toString()); console.log(reason); }, closing: (code, reason) => { console.log(code.toString()); console.log(reason); }, message: (msg) => { // ignore queued messages if (this.play_recorded) { log("ignoring queued message"); return; } const message = JSON.parse(msg); // this is the initial connection message if ("method" in message) { const connection_id = message.headers["Spotify-Connection-Id"]; const track_playback_url = "https://gue1-spclient.spotify.com/track-playback/v1/devices"; throw_if_not_ok(local_http.POST(track_playback_url, JSON.stringify({ device: { brand: "spotify", capabilities: { change_volume: true, enable_play_token: true, supports_file_media_type: true, play_token_lost_behavior: "pause", disable_connect: false, audio_podcasts: true, video_playback: true, manifest_formats: ["file_ids_mp3", "file_urls_mp3", "manifest_urls_audio_ad", "manifest_ids_video", "file_urls_external", "file_ids_mp4", "file_ids_mp4_dual", "manifest_urls_audio_ad"] }, device_id: this.device_id, device_type: "computer", metadata: {}, model: "web_player", name: SPOTIFY_CONNECT_NAME, platform_identifier: PLATFORM_IDENTIFIER, is_group: false }, outro_endcontent_snooping: false, connection_id: connection_id, client_version: CLIENT_VERSION, volume: 65535 }), { Authorization: `Bearer ${local_state.bearer_token}` }, false)); const connect_state_url = `https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_${this.device_id.slice(0, 35)}`; throw_if_not_ok(local_http.requestWithBody("PUT", connect_state_url, JSON.stringify({ member_type: "CONNECT_STATE", device: { device_info: { capabilities: { can_be_player: false, hidden: true, needs_full_player_state: true } } } }), { Authorization: `Bearer ${local_state.bearer_token}`, "X-Spotify-Connection-Id": connection_id }, false)); const transfer_url = `https://gue1-spclient.spotify.com/connect-state/v1/player/command/from/${this.device_id}/to/${this.device_id}`; throw_if_not_ok(local_http.POST(transfer_url, JSON.stringify({ command: { context: { uri: this.context_uri, url: this.context_url, metadata: {} }, play_origin: { feature_identifier: this.feature_identifier, feature_version: local_state.feature_version, referrer_identifier: "your_library" }, options: { license: "on-demand", skip_to: this.skip_to_data.content_type === "track" ? { track_index: this.skip_to_data.track_album_index, track_uid: this.skip_to_data.uid, track_uri: this.skip_to_data.track_uri } : { track_uri: this.skip_to_data.track_uri }, player_options_override: {} }, logging_params: { page_instance_ids: [ "54d854fb-fcb4-4e1f-a600-4fd9cbfaac2e" ], interaction_ids: [ "d3697919-e8be-425d-98bc-1ea70e28963a" ], command_id: "46b1903536f6eda76783840368982c5e" }, endpoint: "play" } }), { Authorization: `Bearer ${local_state.bearer_token}` }, false)); return; } if (message.uri === "hm://track-playback/v1/command") { if (message.payloads[0]?.state_machine.states.length === 0) { log("ignored WS message that was informing of the active device"); log(msg); return; } const state_machine = message.payloads[0]?.state_machine; const playback_id = (() => { const data = this.skip_to_data; switch (data.content_type) { case "episode": { return state_machine?.states.find((state) => { return state_machine.tracks[state.track]?.metadata.uri === data.track_uri; })?.state_id; } case "track": { return message.payloads[0]?.state_machine.states.find((state) => { return state.track_uid === data.uid; })?.state_id; } default: throw assert_exhaustive(data); } })(); if (playback_id === undefined) { log("error missing playback_id"); log(msg); return; } let state_machine_id = state_machine?.state_machine_id; if (state_machine_id === undefined) { log("error missing state_machine_id"); log(msg); return; } let seq_num = 3; const initial_state_machine_id = state_machine_id; const state_update_url = `https://gue1-spclient.spotify.com/track-playback/v1/devices/${this.device_id}/state`; const logged_in = bridge.isLoggedIn(); const bitrate = logged_in ? 256000 : 128000; const format = logged_in ? 11 : 10; const audio_quality = logged_in ? "VERY_HIGH" : "HIGH"; // simulate song play const before_track_load = JSON.parse(local_http.requestWithBody("PUT", state_update_url, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 1, position: 0, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, debug_source: "before_track_load" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false).body); state_machine_id = before_track_load.state_machine.state_machine_id; seq_num += 1; local_http.requestWithBody("PUT", state_update_url, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: initial_state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 0, position: 0, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, debug_source: "speed_changed" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false); seq_num += 1; const speed_change = JSON.parse(local_http.requestWithBody("PUT", state_update_url, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 1, position: 0, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, previous_position: 0, debug_source: "speed_changed" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false).body); state_machine_id = speed_change.state_machine.state_machine_id; seq_num += 1; const started_playing = JSON.parse(local_http.requestWithBody("PUT", state_update_url, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 1, position: 1360, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, previous_position: 1360, debug_source: "started_playing" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false).body); state_machine_id = started_playing.state_machine.state_machine_id; seq_num += 1; const played_threshold_reached = JSON.parse(local_http.requestWithBody("PUT", state_update_url, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 1, position: 30786, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, previous_position: 30786, debug_source: "played_threshold_reached" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false).body); state_machine_id = played_threshold_reached.state_machine.state_machine_id; seq_num += 1; // delete the device const url = `https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_${this.device_id.slice(0, 35)}`; local_http.request("DELETE", url, { Authorization: `Bearer ${local_state.bearer_token}` }, false); const deregister = `https://gue1-spclient.spotify.com/track-playback/v1/devices/${this.device_id}`; local_http.requestWithBody("DELETE", deregister, JSON.stringify({ seq_num: seq_num, state_ref: { state_machine_id: state_machine_id, state_id: playback_id, paused: false }, sub_state: { playback_speed: 1, position: 40786, duration: this.duration, media_type: "AUDIO", bitrate, audio_quality, format }, previous_position: 40786, debug_source: "deregister" }), { Authorization: `Bearer ${local_state.bearer_token}` }, false); socket.close(); this.play_recorded = true; log("closing WebSocket connection"); return; } log("ignored WS message"); log(msg); return; }, failure: (exception) => { log("failure"); console.log(exception); } }); } } } //#endregion //#region utilities function url_from_image_uri(image_uri) { const match_result = image_uri.match(/^spotify:(image|mosaic):([0-9a-zA-Z:]*)$/); if (match_result === null) { if (/^https:\/\//.test(image_uri)) { return image_uri; } throw new ScriptException("regex error"); } const image_type = match_result[1]; 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) { return parse_uri(uri).uri_id; } function parse_uri(uri) { 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) { log(uri); throw new ScriptException(`regex error processing: ${uri}`); } const maybe_type = match_result[1]; if (maybe_type === undefined) { throw new ScriptException(`regex error processing: ${uri}`); } const uri_type = maybe_type; const uri_id = match_result[2]; if (uri_id === undefined) { throw new ScriptException(`regex error processing: ${uri}`); } return { uri_id, uri_type }; } /** * Converts seconds to the timestamp format used in WebVTT * @param seconds * @returns */ function milliseconds_to_WebVTT_timestamp(milliseconds) { return new Date(milliseconds).toISOString().substring(11, 23); } function assert_never(value) { log(value); } function log_passthrough(value) { log(value); return value; } function throw_if_not_ok(response) { if (!response.isOk) { throw new ScriptException(`Request failed [${response.code}] for ${response.url}`); } return response; } function assert_exhaustive(value, exception_message) { 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 = []; ee.length = 256; for (let ke = 0; ke < 256; ke++) // @ts-expect-error ignore JavaScript error ee[ke] = Z[ke >> 4] + Z[15 & ke]; const te = []; te.length = 128; for (let ke = 0; ke < Q.length; ++ke) te[Q.charCodeAt(ke)] = ke; function get_gid(song_uri_id) { 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 ignore JavaScript 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 ignore JavaScript 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 ignore JavaScript 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 ignore JavaScript 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 ignore JavaScript 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 ignore JavaScript 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 ignore JavaScript 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 console.log(assert_never, log_passthrough); // export statements are removed during build step // used for unit testing in SpotifyScript.test.ts // export { get_gid, assert_never, log_passthrough }; //# sourceMappingURL=SpotifyScript.js.map