Skip to content
Snippets Groups Projects
SpotifyScript.ts 153 KiB
Newer Older
        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"
                        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: "Web Player (Grayjay)",
                                        platform_identifier: "web_player linux undefined;chrome 125.0.0.0;desktop",
                                        is_group: false
                                    },
                                    outro_endcontent_snooping: false,
                                    connection_id: connection_id,
                                    client_version: "harmony:4.42.0-2780565f",
                                    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)}`
                        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}`
                        local_http.POST(
                            transfer_url,
                            JSON.stringify({
                                "command": {
                                    "context": {
                                        uri: this.context_uri,
                                        url: this.context_url,
                                        "metadata": {}
                                    },
                                    "play_origin": {
                                        "feature_identifier": "album",
                                        //feature_identifier: "show",
                                        //feature_identifier: "audiobook",
                                        "feature_version": "web-player_2024-05-24_1716563359844_29d0a3b",
                                        "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")

                        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`

                        // 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: 128000, audio_quality: "HIGH", format: 10 },
                                    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: 128000, audio_quality: "HIGH", format: 10 },
                                    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: 128000, audio_quality: "HIGH", format: 10 },
                                    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: 128000, audio_quality: "HIGH", format: 10 },
                                    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: 128000, audio_quality: "HIGH", format: 10 },
                                    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: 128000, audio_quality: "HIGH", format: 10 },
                                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
}
function assert_exhaustive(value: never): void
function assert_exhaustive(value: never, exception_message: string): ScriptException
function assert_exhaustive(value: never, exception_message?: string): ScriptException | undefined {
    log(["Spotify log:", value])
    if (exception_message !== undefined) {
        return new ScriptException(exception_message)
    }
    return
}
//#endregion

//#region bad

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

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