Skip to content
Snippets Groups Projects
SpotifyScript.ts 160 KiB
Newer Older
    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 {
    private recording_play = false
    private play_recorded = false
    private total_seconds_played = 0
    private readonly feature_identifier: string
    private readonly device_id: string
    private readonly context_url: string
    private readonly context_uri: string
    private readonly skip_to_data: {
        readonly content_type: Exclude<ContentType, "track">
        readonly track_uri: string
    } | {
        readonly content_type: Exclude<ContentType, "episode">
        readonly track_uri: string
        readonly uid: string
        readonly track_album_index: number
    }
    private readonly duration: number
    private readonly interval_seconds: number
    constructor(uri_id: string, content_type: ContentType) {
        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: number) => 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: number) => {
            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")
                    }
                    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)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const response: EpisodeMetadataResponse = 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)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const response: TrackMetadataResponse = 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)
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                const tracks_response: AlbumTracksResponse = 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")
        }
    }
    override onInit(_seconds: number): void {
    }
    override onProgress(_seconds: number, is_playing: boolean): void {
        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: {
                        readonly headers: {
                            readonly "Spotify-Connection-Id": string
                        readonly method: "PUT"
                        readonly type: "message"
                    } | {
                        readonly type: "message"
                        readonly uri: string
                        readonly payloads: {
                            readonly state_machine: {
                                readonly state_machine_id: string
                                readonly states: {
                                    readonly state_id: string
                                    readonly track_uid: string
                                    readonly track: number
                                }[]
                                readonly tracks: {
                                    readonly metadata: {
                                        readonly uri: string
                                    }
                                }[]
                            }
                        }[]
                    } = 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"
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                        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
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                        ))

                        const connect_state_url = `https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_${this.device_id.slice(0, 35)}`
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                        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
                            },
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                            false))
                        const transfer_url = `https://gue1-spclient.spotify.com/connect-state/v1/player/command/from/${this.device_id}/to/${this.device_id}`
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                        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
Kai DeLorenzo's avatar
Kai DeLorenzo committed
                        ))
                        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")

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

//#region bad

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

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