├── .yarnrc.yml ├── src ├── util │ ├── debug.ts │ ├── spotify-player-state.ts │ ├── caching.ts │ ├── const.ts │ ├── spotify-api.ts │ ├── diff.ts │ ├── spotify-audio-analysis.ts │ └── spotify-ws-api.ts ├── types │ ├── Observer.ts │ ├── Cache.ts │ ├── internal │ │ └── TypedEventEmitter.ts │ ├── SpotifyMedia.ts │ ├── SpotifyAnalysis.ts │ └── SpotifyCluster.ts ├── structs │ ├── internal │ │ └── ObserverWrapper.ts │ ├── PlayerStateEvents.ts │ ├── PlayerStateObserver.ts │ ├── ClusterObserver.ts │ ├── PlayerTrackResolver.ts │ ├── AudioAnalysisObserver.ts │ ├── CoordinatedSpotifySocket.ts │ ├── AudioAnalysisEvents.ts │ └── SpotifySocket.ts ├── index.ts └── test.ts ├── package.json ├── .gitignore ├── README.md ├── yarn.lock ├── tsconfig.json └── LICENSE /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.1.1.cjs 4 | -------------------------------------------------------------------------------- /src/util/debug.ts: -------------------------------------------------------------------------------- 1 | import createDebug from "debug"; 2 | 3 | export const debug = createDebug("sactivity"); -------------------------------------------------------------------------------- /src/types/Observer.ts: -------------------------------------------------------------------------------- 1 | export interface Observer { 2 | observe(target: T): void; 3 | unobserve(target: T): void; 4 | disconnect(): void; 5 | } -------------------------------------------------------------------------------- /src/types/Cache.ts: -------------------------------------------------------------------------------- 1 | export interface Cache { 2 | resolve(ids: string[]): Promise>; 3 | store(objects: Record): Promise; 4 | } -------------------------------------------------------------------------------- /src/types/internal/TypedEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import StrictEventEmitter from "strict-event-emitter-types/types/src"; 3 | 4 | export type TypedEventEmitter = { 5 | new(): StrictEventEmitter; 6 | }; -------------------------------------------------------------------------------- /src/util/spotify-player-state.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyPlayerState } from "../types/SpotifyCluster"; 2 | 3 | /** 4 | * Computes the current position of a player state 5 | * @param playerState player state 6 | * @returns current position relative to the start of the song, in milliseconds 7 | */ 8 | export function playerStatePosition(playerState: SpotifyPlayerState): number { 9 | if (playerState.is_paused) return +playerState.position_as_of_timestamp; 10 | 11 | return +playerState.position_as_of_timestamp + (Date.now() - +playerState.timestamp); 12 | } 13 | -------------------------------------------------------------------------------- /src/util/caching.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "../types/Cache"; 2 | 3 | export async function tryCached(ids: string[], refresh: (ids: string[]) => Promise>, cache?: Cache): Promise> { 4 | if (!ids.length) return {}; 5 | 6 | const tracks = cache ? await cache.resolve(ids) : {}; 7 | const missing = cache ? ids.filter(id => !tracks[id]) : ids; 8 | 9 | const resolved = missing.length ? await refresh(missing) : {}; 10 | if (cache && Object.keys(resolved).length) await cache.store(resolved); 11 | 12 | return Object.assign(resolved, tracks); 13 | } -------------------------------------------------------------------------------- /src/util/const.ts: -------------------------------------------------------------------------------- 1 | export const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"; 2 | export const SPOTIFY_APP_VERSION = "1.1.56.182.ga73ec2f9"; 3 | export const APP_PLATFORM = "WebPlayer"; 4 | 5 | export const CORE_HEADERS = { 6 | // "app-platform": APP_PLATFORM, 7 | referer: "https://open.spotify.com/", 8 | "sec-fetch-dest": "empty", 9 | "sec-fetch-mode": "cors", 10 | "sec-fetch-site": "same-origin", 11 | // "spotify-app-version": SPOTIFY_APP_VERSION, 12 | "user-agent": USER_AGENT 13 | } 14 | -------------------------------------------------------------------------------- /src/structs/internal/ObserverWrapper.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from "../../types/Observer"; 2 | 3 | export class ObserverWrapper implements Observer { 4 | public constructor(observer: Observer) { 5 | this.#observer = observer; 6 | } 7 | 8 | #observer: Observer; 9 | 10 | public observe(target: Target) { 11 | this.#observer.observe(target); 12 | } 13 | 14 | public unobserve(target: Target) { 15 | this.#observer.unobserve(target); 16 | } 17 | 18 | public disconnect() { 19 | this.#observer.disconnect(); 20 | } 21 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./structs/SpotifySocket"; 2 | export * from "./structs/CoordinatedSpotifySocket"; 3 | export * from "./structs/ClusterObserver"; 4 | export * from "./structs/PlayerStateObserver"; 5 | export * from "./structs/PlayerStateEvents"; 6 | export * from "./structs/PlayerTrackResolver"; 7 | export * from "./structs/AudioAnalysisObserver"; 8 | export * from "./structs/AudioAnalysisEvents"; 9 | 10 | export * from "./util/spotify-audio-analysis"; 11 | export * from "./util/spotify-ws-api"; 12 | export * from "./util/spotify-api"; 13 | export * from "./util/diff"; 14 | 15 | export * from "./types/SpotifyMedia"; 16 | export * from "./types/SpotifyCluster"; 17 | export * from "./types/Observer"; 18 | export * from "./types/SpotifyAnalysis"; 19 | export * from "./types/Cache"; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sactivity", 3 | "version": "2.0.0", 4 | "description": "Spotify WebSocket Activity API", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": "https://github.com/EricRabil/sactivity.git", 8 | "author": "Eric Rabil ", 9 | "license": "MIT", 10 | "files": [ 11 | "dist/**" 12 | ], 13 | "devDependencies": { 14 | "@types/debug": "^4.1.5", 15 | "@types/ws": "^7.4.0", 16 | "dotenv": "^8.2.0", 17 | "typescript": "^4.2.3" 18 | }, 19 | "dependencies": { 20 | "axios": "^0.21.1", 21 | "debug": "^4.3.1", 22 | "eventemitter3": "^4.0.7", 23 | "isomorphic-ws": "^4.0.1", 24 | "strict-event-emitter-types": "^2.0.0", 25 | "ws": "^7.4.4" 26 | }, 27 | "packageManager": "yarn@3.1.1" 28 | } 29 | -------------------------------------------------------------------------------- /src/types/SpotifyMedia.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyEntity { 2 | href: string; 3 | external_urls: { 4 | spotify: string; 5 | }; 6 | id: string; 7 | name: string; 8 | type: Type; 9 | uri: string; 10 | } 11 | 12 | export type SpotifyArtist = SpotifyEntity<"artist">; 13 | 14 | export interface SpotifyImage { 15 | height: number; 16 | url: string; 17 | width: number; 18 | } 19 | 20 | export interface SpotifyAlbum extends SpotifyEntity<"album"> { 21 | artists: SpotifyArtist[]; 22 | images: SpotifyImage[]; 23 | release_date: string; 24 | release_date_precision: string; 25 | total_tracks: number; 26 | } 27 | 28 | export interface SpotifyTrack extends SpotifyEntity<"track"> { 29 | album: SpotifyAlbum; 30 | artists: SpotifyArtist[]; 31 | disc_number: number; 32 | duration_ms: number; 33 | explicit: boolean; 34 | external_ids: { 35 | isrc: string; 36 | }; 37 | is_local: boolean; 38 | is_playable: boolean; 39 | popularity: number; 40 | preview_url: string; 41 | track_number: number; 42 | } -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { AudioAnalysisEvents, Cache, CoordinatedSpotifySocket, SpotifyAnalysisResult } from "."; 3 | 4 | dotenv.config(); 5 | 6 | const cookies = process.env.SPOTIFY_COOKIES as string; 7 | 8 | function mockCache(): Cache { 9 | const cache: Map = new Map(); 10 | 11 | return { 12 | async resolve(ids: string[]): Promise> { 13 | return ids.reduce((acc, id) => cache.has(id) ? Object.assign(acc, { 14 | [id]: cache.get(id) as T 15 | }) : acc, {}); 16 | }, 17 | async store(tracks: Record) { 18 | for (const id in tracks) { 19 | cache.set(id, tracks[id]); 20 | } 21 | } 22 | } 23 | } 24 | 25 | CoordinatedSpotifySocket.create(cookies).then(({ socket, accessToken }) => { 26 | const analysisCache: Cache = mockCache(); 27 | 28 | const resolver = new AudioAnalysisEvents({ 29 | cache: analysisCache, 30 | cookie: cookies 31 | }); 32 | 33 | resolver.on("tatum", () => console.log("tatum")); 34 | 35 | resolver.observe(socket); 36 | }); 37 | -------------------------------------------------------------------------------- /src/util/spotify-api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { SpotifyTrack } from "../types/SpotifyMedia"; 3 | import { CORE_HEADERS } from "./const"; 4 | 5 | interface TracksResult { 6 | tracks: SpotifyTrack[]; 7 | } 8 | 9 | async function _resolveTracks(ids: string[], accessToken: string): Promise> { 10 | const { data: { tracks } } = await axios.get(`https://api.spotify.com/v1/tracks?ids=${ids.join("\n")}&market=from_token`, { 11 | headers: { 12 | ...CORE_HEADERS, 13 | origin: "https://open.spotify.com", 14 | authorization: `Bearer ${accessToken}` 15 | } 16 | }); 17 | 18 | return tracks.reduce((acc, track) => Object.assign(acc, { [track.id]: track }), {}); 19 | } 20 | 21 | export async function resolveTracks(ids: string[], accessToken: string, regenerate?: () => Promise): Promise> { 22 | if (ids.length === 0) return {}; 23 | 24 | try { 25 | return await _resolveTracks(ids, accessToken); 26 | } catch (e) { 27 | if (regenerate && axios.isAxiosError(e) && [401, 403].includes(e.response?.status || 0)) { 28 | return await _resolveTracks(ids, await regenerate()); 29 | } 30 | throw e; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/util/diff.ts: -------------------------------------------------------------------------------- 1 | export type Diffed = T extends object ? { 2 | [K in keyof T]: T[K] extends object ? Diffed : { 3 | old: T[K]; 4 | new: T[K]; 5 | }; 6 | } : Diff; 7 | 8 | export interface Diff { 9 | old: T | typeof None; 10 | new: T; 11 | }; 12 | 13 | export const None = Symbol("None"); 14 | 15 | function isObject(object: unknown): object is object { 16 | return typeof object === "object" && object !== null; 17 | } 18 | 19 | export function isDifferent(diff: Diff): boolean { 20 | if (diff.old === None) return true; 21 | else return diff.old !== diff.new; 22 | } 23 | 24 | export function diff(oldObject: T | null, newObject: T): Diffed { 25 | const diffed: Partial> = {}; 26 | 27 | for (const key in newObject) { 28 | const value = newObject[key]; 29 | const oldValue = (typeof oldObject === "object" && oldObject !== null) ? oldObject[key] : None; 30 | 31 | if (isObject(value)) { 32 | (diffed as unknown as Record)[key] = diff(isObject(oldValue) ? oldValue : null, value) as Diffed; 33 | } else { 34 | (diffed as unknown as Record)[key] = { 35 | old: oldValue, 36 | new: value 37 | }; 38 | } 39 | } 40 | 41 | return diffed as Diffed; 42 | } -------------------------------------------------------------------------------- /src/structs/PlayerStateEvents.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { TypedEventEmitter } from "../types/internal/TypedEventEmitter"; 3 | import { Observer } from "../types/Observer"; 4 | import { SpotifyPlayerState } from "../types/SpotifyCluster"; 5 | import { isDifferent } from "../util/diff"; 6 | import { PlayerStateObserver } from "./PlayerStateObserver"; 7 | import { SpotifySocket } from "./SpotifySocket"; 8 | 9 | type PlayerStateTrigger = "paused" | "resumed" | "track" | "position" | "duration" | "stopped" | "started"; 10 | 11 | type Events = { 12 | [K in PlayerStateTrigger]: (state: SpotifyPlayerState) => void; 13 | } 14 | 15 | /** 16 | * Emits events when various attributes of a state changes 17 | */ 18 | export class PlayerStateEvents extends (EventEmitter as TypedEventEmitter) implements Observer { 19 | public constructor() { 20 | super(); 21 | 22 | this.#observer = new PlayerStateObserver(states => { 23 | for (const [diff, state] of states) { 24 | if (isDifferent(diff.is_paused)) this.emit(state.is_paused ? "paused" : "resumed", state); 25 | if (isDifferent(diff.track.uri)) this.emit("track", state); 26 | if (isDifferent(diff.position_as_of_timestamp)) this.emit("position", state); 27 | if (isDifferent(diff.duration)) this.emit("duration", state); 28 | if (isDifferent(diff.is_playing)) this.emit(state.is_playing ? "started" : "stopped", state); 29 | } 30 | }); 31 | } 32 | 33 | #observer: PlayerStateObserver; 34 | 35 | public observe(target: SpotifySocket) { 36 | this.#observer.observe(target); 37 | } 38 | 39 | public unobserve(target: SpotifySocket) { 40 | this.#observer.unobserve(target); 41 | } 42 | 43 | public disconnect() { 44 | this.#observer.disconnect(); 45 | } 46 | } -------------------------------------------------------------------------------- /src/structs/PlayerStateObserver.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyPlayerState } from "../types/SpotifyCluster"; 2 | import { debug } from "../util/debug"; 3 | import { diff, Diffed } from "../util/diff"; 4 | import { ClusterObserver } from "./ClusterObserver"; 5 | import { ObserverWrapper } from "./internal/ObserverWrapper"; 6 | import { SpotifySocket } from "./SpotifySocket"; 7 | 8 | export type DiffedPlayerState = Diffed; 9 | 10 | export interface PlayerStateCallback { 11 | (states: [DiffedPlayerState, SpotifyPlayerState][]): any; 12 | } 13 | 14 | function isSpotifyPlayerState(object: unknown): object is SpotifyPlayerState { 15 | if (typeof object !== "object" || object === null) return false; 16 | for (const key of ["duration", "playback_id", "playback_quality", "timestamp", "track"]) { 17 | if (!(key in object)) return false; 18 | } 19 | return true; 20 | } 21 | 22 | /** 23 | * Observes player states, firing a callback with a diffed representation and the updated state 24 | */ 25 | export class PlayerStateObserver extends ObserverWrapper { 26 | public constructor(callback: PlayerStateCallback) { 27 | super(new ClusterObserver(clusters => { 28 | const diffedStates: [DiffedPlayerState, SpotifyPlayerState][] = []; 29 | 30 | for (const cluster of clusters) { 31 | if (!isSpotifyPlayerState(cluster.player_state)) { 32 | // Sometimes only a partial player state is included, e.g. no more tracks are left to play. 33 | continue; 34 | } 35 | 36 | const sessionID = cluster.player_state.session_id; 37 | const oldState = this.#states.get(sessionID) || null; 38 | this.#states.set(sessionID, cluster.player_state); 39 | 40 | diffedStates.push([ 41 | diff(oldState, cluster.player_state), 42 | cluster.player_state 43 | ]); 44 | } 45 | 46 | if (!diffedStates.length) return; 47 | 48 | this.#callback(diffedStates); 49 | })); 50 | 51 | this.#callback = callback; 52 | } 53 | 54 | #callback: PlayerStateCallback; 55 | #states: Map = new Map(); 56 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/plugins 12 | !.yarn/releases 13 | !.yarn/sdks 14 | !.yarn/versions 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | .env.test 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | 85 | # Next.js build output 86 | .next 87 | 88 | # Nuxt.js build / generate output 89 | .nuxt 90 | dist 91 | 92 | # Gatsby files 93 | .cache/ 94 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 95 | # https://nextjs.org/blog/next-9-1#public-directory-support 96 | # public 97 | 98 | # vuepress build output 99 | .vuepress/dist 100 | 101 | # Serverless directories 102 | .serverless/ 103 | 104 | # FuseBox cache 105 | .fusebox/ 106 | 107 | # DynamoDB Local files 108 | .dynamodb/ 109 | 110 | # TernJS port file 111 | .tern-port 112 | -------------------------------------------------------------------------------- /src/structs/ClusterObserver.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyCluster, SpotifyPlayerState } from "../types/SpotifyCluster"; 2 | import { SpotifySocket, SpotifyMessageHandler } from "./SpotifySocket"; 3 | 4 | export interface SpotifyClusterUpdatePayload { 5 | ack_id: string; 6 | cluster: SpotifyCluster; 7 | devices_that_changed: string[]; 8 | update_reason: string; 9 | } 10 | 11 | /** 12 | * Ensures a value has the same top-level shape as a SpotifyClusterUpdatePayload 13 | * @param object the value to inspect 14 | * @returns whether the object appears to be a SpotifyClusterUpdatePayload 15 | */ 16 | function isClusterUpdatePayload(object: unknown): object is SpotifyClusterUpdatePayload { 17 | if (typeof object !== "object" || object === null) return false; 18 | for (const key of ["cluster", "devices_that_changed", "update_reason"]) { 19 | if (!(key in object)) return false; 20 | } 21 | 22 | return true; 23 | } 24 | 25 | export interface ClusterCallback { 26 | (clusters: SpotifyCluster[]): any; 27 | } 28 | 29 | /** 30 | * Observes the cluster state on one or more SpotifySockets 31 | */ 32 | export class ClusterObserver { 33 | public constructor(callback: ClusterCallback) { 34 | this.#callback = callback; 35 | } 36 | 37 | /** 38 | * The callback function for this observer 39 | */ 40 | #callback: ClusterCallback; 41 | 42 | /** 43 | * A set of observed sockets, used when disconnecting 44 | */ 45 | #observed: Set = new Set(); 46 | 47 | /** 48 | * Memoized handler for connect-state messages, so that it can be unregistered 49 | * @param message the connect-state message 50 | */ 51 | #connectStateHandler: SpotifyMessageHandler = ({ path, payloads }) => { 52 | switch (path) { 53 | case "/v1/cluster": 54 | const clusters = payloads.filter(isClusterUpdatePayload).map(update => update.cluster); 55 | if (!clusters.length) break; 56 | this.#callback(clusters); 57 | } 58 | }; 59 | 60 | /** 61 | * Observes a SpotifySocket's connect-state API for cluster updates 62 | * @param target target to observe 63 | */ 64 | public observe(target: SpotifySocket): void { 65 | target.registerHandler("connect-state", this.#connectStateHandler); 66 | this.#observed.add(target); 67 | } 68 | 69 | /** 70 | * Unobserve's a SpotifySocket's connect-state API 71 | * @param target target to unobserve 72 | */ 73 | public unobserve(target: SpotifySocket): void { 74 | target.unregisterHandler("connect-state", this.#connectStateHandler); 75 | this.#observed.delete(target); 76 | } 77 | 78 | /** 79 | * Unobserve's from all SpotifySockets 80 | */ 81 | public disconnect(): void { 82 | this.#observed.forEach(target => this.unobserve(target)); 83 | } 84 | } -------------------------------------------------------------------------------- /src/types/SpotifyAnalysis.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyTrack } from "./SpotifyMedia"; 2 | 3 | export interface SpotifyAnalysisTimeInterval { 4 | start: number; 5 | duration: number; 6 | confidence: number; 7 | } 8 | 9 | export interface AsyncSpotifyAnalysisCache { 10 | resolve(id: string): Promise; 11 | resolveMetadata(id: string): Promise; 12 | resolveMany(id: string[]): Promise>; 13 | resolveManyMetadatas(id: string[]): Promise>; 14 | store(id: string, result: SpotifyAnalysisResult): Promise; 15 | storeMetadata(id: string, result: SpotifyTrack): Promise; 16 | storeManyMetadatas(metadatas: Record): Promise; 17 | } 18 | 19 | export namespace SpotifyAnalysisTimeInterval { 20 | export function isInterval(obj: unknown): obj is SpotifyAnalysisTimeInterval { 21 | return typeof obj === "object" 22 | && obj !== null 23 | && typeof (obj as Record).start === "number" 24 | && typeof (obj as Record).duration === "number" 25 | && typeof (obj as Record).confidence === "number"; 26 | } 27 | } 28 | 29 | export interface SpotifyAnalysisSection extends SpotifyAnalysisTimeInterval { 30 | loudness: number; 31 | tempo: number; 32 | tempo_confidence: number; 33 | key: number; 34 | key_confidence: number; 35 | mode: number; 36 | mode_confidence: number; 37 | time_signature: number; 38 | time_signature_confidence: number; 39 | } 40 | 41 | export interface SpotifyAnalysisSegment extends SpotifyAnalysisTimeInterval { 42 | loudness_start: number; 43 | loudness_max_time: number; 44 | loudness_max: number; 45 | loudness_end: number; 46 | pitches: number[]; 47 | timbre: number[]; 48 | } 49 | 50 | export interface SpotifyAnalysisTrack { 51 | duration: number; 52 | sample_md5: string; 53 | offset_seconds: number; 54 | window_seconds: number; 55 | analysis_sample_rate: number; 56 | analysis_channels: number; 57 | end_of_fade_in: number; 58 | start_of_fade_out: number; 59 | loudness: number; 60 | tempo: number; 61 | tempo_confidence: number; 62 | time_signature: number; 63 | time_signature_confidence: number; 64 | key: number; 65 | key_confidence: number; 66 | mode: number; 67 | mode_confidence: number; 68 | codestring: string; 69 | code_version: number; 70 | echoprintstring: string; 71 | echoprint_version: number; 72 | synchstring: string; 73 | synch_version: number; 74 | rhyhtmstring: string; 75 | rhyhtm_version: number; 76 | } 77 | 78 | export interface SpotifyAnalysisResult { 79 | bars: SpotifyAnalysisTimeInterval[]; 80 | beats: SpotifyAnalysisTimeInterval[]; 81 | sections: SpotifyAnalysisSection[]; 82 | segments: SpotifyAnalysisSegment[]; 83 | tatums: SpotifyAnalysisTimeInterval[]; 84 | track: SpotifyAnalysisTrack; 85 | } -------------------------------------------------------------------------------- /src/structs/PlayerTrackResolver.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyTrack } from "../types/SpotifyMedia"; 2 | import { Observer } from "../types/Observer"; 3 | import { SpotifyPlayerState } from "../types/SpotifyCluster"; 4 | import { isDifferent } from "../util/diff"; 5 | import { resolveTracks } from "../util/spotify-api"; 6 | import { PlayerStateObserver } from "./PlayerStateObserver"; 7 | import { SpotifyAccessTokenRegnerator, SpotifySocket } from "./SpotifySocket"; 8 | import { CoordinatedSpotifySocket } from "./CoordinatedSpotifySocket"; 9 | import { Cache } from "../types/Cache"; 10 | import { tryCached } from "../util/caching"; 11 | import { ObserverWrapper } from "./internal/ObserverWrapper"; 12 | 13 | export interface PlayerTrackResolverCallback { 14 | (states: { 15 | state: SpotifyPlayerState; 16 | track: SpotifyTrack; 17 | }[]): any; 18 | } 19 | 20 | export type SpotifyTrackCache = Cache; 21 | 22 | export interface PlayerTrackResolverOptions { 23 | cache?: SpotifyTrackCache; 24 | accessTokenRegenerator?: SpotifyAccessTokenRegnerator; 25 | dontInheritAccessTokenRegnerator?: boolean; 26 | accessToken: string; 27 | } 28 | 29 | /** 30 | * Fires a callback with a fully resolved track whenever the current track has changed 31 | */ 32 | export class PlayerTrackResolver extends ObserverWrapper { 33 | /** 34 | * @param callback the callback to fire when a new track plays 35 | * @param options options for the resolver 36 | */ 37 | constructor(callback: PlayerTrackResolverCallback, options: PlayerTrackResolverOptions) { 38 | super(new PlayerStateObserver(async states => { 39 | const updatedStates = states.map(([ _, newState ]) => newState); 40 | 41 | const tracks = await this.tracks(updatedStates.map(state => state.track.uri.slice(14))); 42 | 43 | this.#callback(updatedStates.filter(state => tracks[state.track.uri.slice(14)]).map(state => ({ 44 | state, 45 | track: tracks[state.track.uri.slice(14)] 46 | }))); 47 | })); 48 | 49 | this.#callback = callback; 50 | this.#accessToken = options.accessToken; 51 | this.cache = options.cache; 52 | this.#accessTokenRegenerator = options.accessTokenRegenerator; 53 | this.dontInheritAccessTokenRegnerator = options.dontInheritAccessTokenRegnerator || false; 54 | } 55 | 56 | #accessToken: string; 57 | #callback: PlayerTrackResolverCallback; 58 | #accessTokenRegenerator?: SpotifyAccessTokenRegnerator; 59 | 60 | public dontInheritAccessTokenRegnerator: boolean; 61 | public cache: SpotifyTrackCache | undefined; 62 | 63 | public observe(socket: SpotifySocket): void { 64 | super.observe(socket); 65 | 66 | if (!this.dontInheritAccessTokenRegnerator && !this.#accessTokenRegenerator && socket instanceof CoordinatedSpotifySocket) { 67 | this.#accessTokenRegenerator = socket.accessTokenRegenerator; 68 | } 69 | } 70 | 71 | /** 72 | * Resolves tracks for the given IDs, deferring to the cache when possible 73 | * @param ids IDs to resolve 74 | * @returns 75 | */ 76 | public async tracks(ids: string[]): Promise> { 77 | return tryCached(ids, ids => resolveTracks(ids, this.#accessToken, this.#accessTokenRegenerator), this.cache); 78 | } 79 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sactivity 2 | Spotify WebSocket Activity API Library 3 | 4 | ## How it works 5 | Sactivity has two exports, **Sactivity** and **SpotifyClient**. 6 | 7 | - **Sactivity** is a class that connects to Spotify for you. 8 | 1. It takes your Spotify authoirzation and uses it to generate a token for connecting to Spotify. 9 | 2. Then, it connects to one of multiple Spotify "dealers" that push notifications over a WebSocket. 10 | 3. It passes this socket to **SpotifyClient**, but you can also connect to Spotify on your own and pass it to **SpotifyClient**. 11 | 12 | - **SpotifyClient** handles everything after connecting to the WebSocket, which can still involve some required REST requests. 13 | 1. Waits to receive an initialization payload from Spotify, which includes a connection ID 14 | 2. Calls `PUT https://api.spotify.com/v1/me/notifications/user?connection_id=${connectionID}` to subscribe to activity on the account associated with the connection ID, and by relation, the authorization you provided. 15 | 3. Calls `POST https://guc-spclient.spotify.com/track-playback/v1/devices` and temporarily registers a **fake** Spotify Web Client that will receive notifications from Spotify. 16 | 4. Calls `PUT https://guc-spclient.spotify.com/connect-state/v1/devices/hobs_${clientID}` and subscribes to media player events. 17 | 5. **SpotifyClient** will now emit various events as the media presence changes. 18 | 19 | | Event Name | Description | Data Type | 20 | |------------|---------------------------------------------------------------------------------------------------|-----------------| 21 | | volume | Emitted whenever the volume has changed | number | 22 | | playing | Emitted whenever music is playing again | void | 23 | | stopped | Emitted whenever music is stopped | void | 24 | | paused | Emitted whenever music is paused | void | 25 | | resumed | Emitted whenever music is resumed | void | 26 | | track | Emitted whenever a new track is playing | SpotifyTrack | 27 | | options | Emitted whenever playback options have changed (shuffle, repeat, repeat-one) | PlaybackOptions | 28 | | position | Emitted whenever the position in a song has changed. This includes at the start of a new track. | string | 29 | | device | Emitted whenever the device that is playing music has changed. | SpotifyDevice | 30 | | close | Emitted whenever the WebSocket has closed. This is a cue to reconnect after a set amount of time. | void | 31 | 32 | In the [tests folder](https://github.com/EricRabil/sactivity/blob/master/test/index.js), you can find a working example. 33 | 34 | Data types are declared [here](https://github.com/EricRabil/sactivity/blob/master/ts/SpotifyClient.ts) 35 | 36 | ## Before starting 37 | Sactivity works off of cookies issued by Spotify upon login, which seem to persist for quite a while. Here's how to obtain the cookies needed: 38 | 1. Open Chrome 39 | 2. Open devtools 40 | 3. Go to the network inspector, and in the filter type "get_access_token" 41 | 4. Navigate to https://open.spotify.com in that tab 42 | 5. Click on the network request that shows up, then copy the entirety of the `cookie` header in the Request headers. 43 | -------------------------------------------------------------------------------- /src/structs/AudioAnalysisObserver.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyPlayerState } from "../types/SpotifyCluster"; 2 | import { PlayerStateObserver } from "./PlayerStateObserver"; 3 | import { SpotifySocket } from "./SpotifySocket"; 4 | import { ObserverWrapper } from "./internal/ObserverWrapper"; 5 | import { isDifferent } from "../util/diff"; 6 | import { Cache } from "../types/Cache"; 7 | import { analyzeTracks, createAnalysisToken } from "../util/spotify-audio-analysis"; 8 | import { SpotifyAnalysisResult } from "../types/SpotifyAnalysis"; 9 | import { tryCached } from "../util/caching"; 10 | import { debug } from "../util/debug"; 11 | 12 | export interface AudioAnalysisCallback { 13 | (states: [SpotifyAnalysisResult, SpotifyPlayerState][]): any; 14 | } 15 | 16 | export type SpotifyAudioAnalysisCache = Cache; 17 | 18 | export interface AudioAnalysisObserverOptions { 19 | cache?: SpotifyAudioAnalysisCache; 20 | cookie: string; 21 | } 22 | 23 | /** 24 | * Observes the current track, and fires a callback whenever the track changes. 25 | * 26 | * The callback includes the Spotify audio analysis result, and the updated state. 27 | */ 28 | export class AudioAnalysisObserver extends ObserverWrapper { 29 | public constructor(callback: AudioAnalysisCallback, options: AudioAnalysisObserverOptions) { 30 | super(new PlayerStateObserver(async states => { 31 | const changedStates = states.filter(state => isDifferent(state[0].track.uri) || isDifferent(state[0].position_as_of_timestamp) || isDifferent(state[0].is_playing) || isDifferent(state[0].is_paused)).map(state => state[1]); 32 | 33 | if (!changedStates.length) return; 34 | 35 | const analyzed = await this.analyzeTracks(changedStates.map(state => state.track.uri.slice(14))); 36 | 37 | const updates: [SpotifyAnalysisResult, SpotifyPlayerState][] = []; 38 | 39 | for (const state of changedStates) { 40 | updates.push([analyzed[state.track.uri.slice(14)], state]); 41 | } 42 | 43 | this.#callback(updates); 44 | 45 | await this.analyzeTracks(changedStates.filter(state => state.next_tracks).flatMap(state => state.next_tracks.filter(track => !track.uri.includes("delimiter")).map(track => track.uri.slice(14))).slice(0, 5)); 46 | })); 47 | 48 | this.#callback = callback; 49 | this.#cookie = options.cookie; 50 | this.cache = options.cache; 51 | } 52 | 53 | #callback: AudioAnalysisCallback; 54 | #cookie: string; 55 | #token: string | null; 56 | 57 | public cache: SpotifyAudioAnalysisCache | undefined; 58 | 59 | /** 60 | * Regnerates the token used to analyze tracks 61 | * @returns promise of the analysis token 62 | */ 63 | public async regenerateAnalysisToken(): Promise { 64 | debug("regenerating analysis token"); 65 | 66 | const token = await createAnalysisToken(this.#cookie); 67 | if (!token) throw new Error("Failed to regenerate analysis token."); 68 | 69 | return this.#token = token; 70 | } 71 | 72 | /** 73 | * Analyzes an array of track IDs 74 | * @param ids IDs to analyze 75 | * @returns promise of record mapping from track ID to analysis result 76 | */ 77 | public async analyzeTracks(ids: string[]): Promise> { 78 | return tryCached(ids, async ids => analyzeTracks(ids, await this.ensureAnalysisToken(), () => this.regenerateAnalysisToken()), this.cache); 79 | } 80 | 81 | /** 82 | * Resolves the analysis token if it does not exist 83 | * @returns new analysis token 84 | */ 85 | public async ensureAnalysisToken(): Promise { 86 | if (this.#token) return this.#token; 87 | else return this.regenerateAnalysisToken(); 88 | } 89 | } -------------------------------------------------------------------------------- /src/types/SpotifyCluster.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyClusterDevice { 2 | can_play: boolean; 3 | capabilities: { 4 | can_be_player: boolean; 5 | command_acks: boolean; 6 | gaia_eq_connect_id: boolean; 7 | is_controllable: boolean; 8 | is_observable: boolean; 9 | supported_types: string[]; 10 | supports_command_request: boolean; 11 | supports_external_episodes: boolean; 12 | supports_gzip_pushes: boolean; 13 | supports_logout: boolean; 14 | supports_playlist_v2: boolean; 15 | supports_rename: boolean; 16 | supports_set_options_command: boolean; 17 | supports_transfer_command: boolean; 18 | volume_steps: number; 19 | }; 20 | client_id: string; 21 | device_id: string; 22 | device_software_version: string; 23 | device_type: string; 24 | metadata_map: Record; 25 | name: string; 26 | public_ip: string; 27 | spirc_version: string; 28 | volume: number; 29 | } 30 | 31 | export interface SpotifyPlayerContext { 32 | context_description: string; 33 | context_owner: string; 34 | "filtering.predicate": string; 35 | image_url: string; 36 | "zelda.context_uri": string; 37 | } 38 | 39 | export interface SpotifyPlayerIndex { 40 | page: number; 41 | track: number; 42 | } 43 | 44 | export interface SpotifyPlayerTrack { 45 | metadata: { 46 | "actions.skipping_next_past_track": string; 47 | "actions.skipping_prev_past_track": string; 48 | album_title: string; 49 | album_uri: string; 50 | artist_uri: string; 51 | "collection.artist.is_banned": string; 52 | "collection.is_banned": string; 53 | context_uri: string; 54 | entity_uri: string; 55 | image_large_url: string; 56 | image_small_url: string; 57 | image_url: string; 58 | image_xlarge_url: string; 59 | interaction_id: string; 60 | iteration: string; 61 | page_instance_id: string; 62 | track_player: string; 63 | }; 64 | provider: string; 65 | uid: string; 66 | uri: string; 67 | } 68 | 69 | export interface SpotifyPlayerOptions { 70 | repeating_context: boolean; 71 | repeating_track: boolean; 72 | shuffling_context: boolean; 73 | } 74 | 75 | export interface SpotifyPlayOrigin { 76 | feature_classes: string[]; 77 | feature_identifier: string; 78 | feature_version: string; 79 | referrer_identifier: string; 80 | view_uri: string; 81 | } 82 | 83 | export interface SpotifyPlaybackQuality { 84 | bitrate_level: string; 85 | } 86 | 87 | export interface SpotifyPlayerRestrictions { 88 | disallow_resuming_reasons: string[]; 89 | } 90 | 91 | export interface SpotifyPlayerStateFragment { 92 | context_restrictions: Record; 93 | context_uri: string; 94 | context_url: string; 95 | is_paused: boolean; 96 | is_playing: boolean; 97 | is_system_initiated: boolean; 98 | next_tracks: SpotifyPlayerTrack[]; 99 | options: SpotifyPlayerOptions; 100 | page_metadata: Record; 101 | play_origin: SpotifyPlayOrigin; 102 | playback_speed: number; 103 | position_as_of_timestamp: string; 104 | prev_tracks: SpotifyPlayerTrack[]; 105 | queue_revision: string; 106 | restrictions: SpotifyPlayerRestrictions; 107 | session_id: string; 108 | suppressions: Record; 109 | } 110 | 111 | export interface SpotifyPlayerState extends SpotifyPlayerStateFragment { 112 | context_metadata: SpotifyPlayerContext; 113 | duration: string; 114 | index: SpotifyPlayerIndex; 115 | playback_id: string; 116 | playback_quality: SpotifyPlaybackQuality; 117 | timestamp: string; 118 | track: SpotifyPlayerTrack; 119 | } 120 | 121 | export interface SpotifyCluster { 122 | active_device_id: string; 123 | devices: Record; 124 | need_full_player_state: boolean; 125 | not_playing_since_timestamp?: string; 126 | player_state: SpotifyPlayerState | SpotifyPlayerStateFragment; 127 | server_timestamp_ms: string; 128 | timestamp: string; 129 | transfer_data_timestamp: string; 130 | } 131 | -------------------------------------------------------------------------------- /src/util/spotify-audio-analysis.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { SpotifyAnalysisResult } from "../types/SpotifyAnalysis"; 3 | import { CORE_HEADERS, USER_AGENT } from "./const"; 4 | 5 | const SPOTIFY_ANALYSIS_TOKEN = (clientID: string) => `https://accounts.spotify.com/authorize?response_type=token&redirect_uri=https%3A%2F%2Fdeveloper.spotify.com%2Fcallback&client_id=${clientID}&state=${Math.random().toString(36).substring(7)}`; 6 | const SPOTIFY_AUDIO_ANALYSIS = (trackID: string) => `https://api.spotify.com/v1/audio-analysis/${trackID}`; 7 | 8 | /** 9 | * Generates an audio analysis token from the developer portal. This is highly ephemeral and should be expected to expire very quickly. 10 | * @param cookie open.spotify.com cookies 11 | * @returns promise of an audio analysis token, or null if it failed 12 | */ 13 | export async function createAnalysisToken(cookie: string): Promise { 14 | const headers = { 15 | accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 16 | "accept-encoding": "gzip, deflate, br", 17 | "accept-language": "en", 18 | "cache-control": "max-age=0", 19 | cookie, 20 | "referer": "https://developer.spotify.com/callback/", 21 | "sec-fetch-dest": "document", 22 | "sec-fetch-mode": "navigate", 23 | "sec-fetch-site": "same-origin", 24 | "sec-fetch-user": "?1", 25 | "upgrade-insecure-requests": "1", 26 | "user-agent": USER_AGENT 27 | } 28 | 29 | const page = await axios.get("https://developer.spotify.com/console/get-audio-analysis-track/", { 30 | headers 31 | }); 32 | 33 | const bits = /&client_id=(.*)`/g.exec(page.data); 34 | if(!bits) return null; 35 | 36 | const [, clientID] = bits; 37 | 38 | const result = await axios.get(SPOTIFY_ANALYSIS_TOKEN(clientID), { 39 | headers: { 40 | ...headers, 41 | referer: "https://developer.spotify.com/" 42 | }, 43 | maxRedirects: 0, 44 | validateStatus: status => status === 302 45 | }); 46 | 47 | const location = result.headers.location; 48 | if(!location) return null; 49 | 50 | const tokenBits = /access_token=(.*)&token_/g.exec(location); 51 | if(!tokenBits) return null; 52 | 53 | return tokenBits[1] || null; 54 | } 55 | 56 | /** 57 | * Analyzes a track 58 | * @param id ID of the track to analyze 59 | * @param token analysis token 60 | * @param regnerateToken callback to regenerate the analysis token 61 | * @returns promise of an analyzed track 62 | */ 63 | export async function analyzeTrack(id: string, token: string, regnerateToken?: () => Promise): Promise { 64 | try { 65 | const { data } = await axios.get(SPOTIFY_AUDIO_ANALYSIS(id), { 66 | headers: { 67 | authorization: `Bearer ${token}`, 68 | ...CORE_HEADERS 69 | }, 70 | responseType: 'json' 71 | }); 72 | 73 | return data; 74 | } catch (e) { 75 | if (!axios.isAxiosError(e) || !e.response || e.response.status !== 401 || e.response.data.error.message !== "The access token expired" || !regnerateToken) throw e; 76 | 77 | return await analyzeTrack(id, await regnerateToken()); 78 | } 79 | } 80 | 81 | /** 82 | * Analyzes an array of track IDs, returning a dictionary mapping their ID to the analysis result 83 | * @param ids IDs to analyze 84 | * @param token analysis token 85 | * @param regenerateToken callback to regenerate the analysis token 86 | * @returns promise of a dictionary mapping track ID to analysis result 87 | */ 88 | export async function analyzeTracks(ids: string[], token: string, regenerateToken?: () => Promise): Promise> { 89 | if (!ids.length) return {}; 90 | 91 | const innerRegenerateToken = regenerateToken ? () => regenerateToken().then(newToken => token = newToken) : undefined; 92 | 93 | const tracks: Record = {}; 94 | 95 | for (const id of ids) { 96 | tracks[id] = await analyzeTrack(id, token, innerRegenerateToken); 97 | } 98 | 99 | return tracks; 100 | } -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # This file is generated by running "yarn install" inside your project. 2 | # Manual changes might be lost - proceed with caution! 3 | 4 | __metadata: 5 | version: 5 6 | cacheKey: 8 7 | 8 | "@types/debug@npm:^4.1.5": 9 | version: 4.1.5 10 | resolution: "@types/debug@npm:4.1.5" 11 | checksum: 36bdb74909be193aeeb8c9bb64ef45d691f35181dcf75285728ec1e07103cb91042be2e8294f0624fc5922d9b4f68482faf5ea3068288577ebdccee76cd7870c 12 | languageName: node 13 | linkType: hard 14 | 15 | "@types/node@npm:*": 16 | version: 14.14.35 17 | resolution: "@types/node@npm:14.14.35" 18 | checksum: 6d6e428b6c504ede62e63dddf83439d3f10b3b283132172f7c3b1ef49b4f499afcfd2e4601e3019074b2dd140ee109a9b63bbd6a18a089c89e45108fa8d5b1ee 19 | languageName: node 20 | linkType: hard 21 | 22 | "@types/ws@npm:^7.4.0": 23 | version: 7.4.0 24 | resolution: "@types/ws@npm:7.4.0" 25 | dependencies: 26 | "@types/node": "*" 27 | checksum: afc0060614ccc9382e0e2900220088bedbbdf9cf828b03990bcca9c9c0167bd2aa8b59cd001419ea804e5cc3b1668cc0c6c4aeb60f63c2a36814bed3f0d20528 28 | languageName: node 29 | linkType: hard 30 | 31 | "axios@npm:^0.21.1": 32 | version: 0.21.1 33 | resolution: "axios@npm:0.21.1" 34 | dependencies: 35 | follow-redirects: ^1.10.0 36 | checksum: c87915fa0b18c15c63350112b6b3563a3e2ae524d7707de0a73d2e065e0d30c5d3da8563037bc29d4cc1b7424b5a350cb7274fa52525c6c04a615fe561c6ab11 37 | languageName: node 38 | linkType: hard 39 | 40 | "debug@npm:^4.3.1": 41 | version: 4.3.1 42 | resolution: "debug@npm:4.3.1" 43 | dependencies: 44 | ms: 2.1.2 45 | peerDependenciesMeta: 46 | supports-color: 47 | optional: true 48 | checksum: 2c3352e37d5c46b0d203317cd45ea0e26b2c99f2d9dfec8b128e6ceba90dfb65425f5331bf3020fe9929d7da8c16758e737f4f3bfc0fce6b8b3d503bae03298b 49 | languageName: node 50 | linkType: hard 51 | 52 | "dotenv@npm:^8.2.0": 53 | version: 8.2.0 54 | resolution: "dotenv@npm:8.2.0" 55 | checksum: ad4c8e0df3e24b4811c8e93377d048a10a9b213dcd9f062483b4a2d3168f08f10ec9c618c23f5639060d230ccdb174c08761479e9baa29610aa978e1ee66df76 56 | languageName: node 57 | linkType: hard 58 | 59 | "eventemitter3@npm:^4.0.7": 60 | version: 4.0.7 61 | resolution: "eventemitter3@npm:4.0.7" 62 | checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374 63 | languageName: node 64 | linkType: hard 65 | 66 | "follow-redirects@npm:^1.10.0": 67 | version: 1.13.3 68 | resolution: "follow-redirects@npm:1.13.3" 69 | peerDependenciesMeta: 70 | debug: 71 | optional: true 72 | checksum: 4b5aaa91a0f938547a083d001a572da4d6b7586b699f330da8794ae2cf8b0d8628bcde794f9b205fbe6c6dcab30272454e723aed8f5540bf47a41fa8c7e36441 73 | languageName: node 74 | linkType: hard 75 | 76 | "isomorphic-ws@npm:^4.0.1": 77 | version: 4.0.1 78 | resolution: "isomorphic-ws@npm:4.0.1" 79 | peerDependencies: 80 | ws: "*" 81 | checksum: d7190eadefdc28bdb93d67b5f0c603385aaf87724fa2974abb382ac1ec9756ed2cfb27065cbe76122879c2d452e2982bc4314317f3d6c737ddda6c047328771a 82 | languageName: node 83 | linkType: hard 84 | 85 | "ms@npm:2.1.2": 86 | version: 2.1.2 87 | resolution: "ms@npm:2.1.2" 88 | checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f 89 | languageName: node 90 | linkType: hard 91 | 92 | "sactivity@workspace:.": 93 | version: 0.0.0-use.local 94 | resolution: "sactivity@workspace:." 95 | dependencies: 96 | "@types/debug": ^4.1.5 97 | "@types/ws": ^7.4.0 98 | axios: ^0.21.1 99 | debug: ^4.3.1 100 | dotenv: ^8.2.0 101 | eventemitter3: ^4.0.7 102 | isomorphic-ws: ^4.0.1 103 | strict-event-emitter-types: ^2.0.0 104 | typescript: ^4.2.3 105 | ws: ^7.4.4 106 | languageName: unknown 107 | linkType: soft 108 | 109 | "strict-event-emitter-types@npm:^2.0.0": 110 | version: 2.0.0 111 | resolution: "strict-event-emitter-types@npm:2.0.0" 112 | checksum: 91ef62364cad9ece9ab9984e806b1c6d947d0617437a25605fff0cbfae59ac6a8d641257a168c1d5f2909809a467c714f027fdccb70b6155d68eac0dc1535299 113 | languageName: node 114 | linkType: hard 115 | 116 | "typescript@npm:^4.2.3": 117 | version: 4.2.3 118 | resolution: "typescript@npm:4.2.3" 119 | bin: 120 | tsc: bin/tsc 121 | tsserver: bin/tsserver 122 | checksum: b4a2020c021211184ac15caf59936b2089c13e79685f340a31aaa839c9de2f73b44a5e3757292de6cdad2ed967aef80d4592161b814cc29c0570f261850c4bca 123 | languageName: node 124 | linkType: hard 125 | 126 | "typescript@patch:typescript@^4.2.3#~builtin": 127 | version: 4.2.3 128 | resolution: "typescript@patch:typescript@npm%3A4.2.3#~builtin::version=4.2.3&hash=493e53" 129 | bin: 130 | tsc: bin/tsc 131 | tsserver: bin/tsserver 132 | checksum: f97b1f885444f13c340127a0918b17d0c4e5c248f99203a22712b3b43d7129c9c7b95437e4f1de99edf79d3046fa9e15356fb5d27d9d94e47a98158c8b18fda5 133 | languageName: node 134 | linkType: hard 135 | 136 | "ws@npm:^7.4.4": 137 | version: 7.4.4 138 | resolution: "ws@npm:7.4.4" 139 | peerDependencies: 140 | bufferutil: ^4.0.1 141 | utf-8-validate: ^5.0.2 142 | peerDependenciesMeta: 143 | bufferutil: 144 | optional: true 145 | utf-8-validate: 146 | optional: true 147 | checksum: a0dd15af5270652d18e6fe866d393d43d4378f7c883dbbd01789a664abd6b763c5be7151ba987973eca4a93a249afbe4a2b53458cd7200e04bde457b7a3a4e1a 148 | languageName: node 149 | linkType: hard 150 | -------------------------------------------------------------------------------- /src/structs/CoordinatedSpotifySocket.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyAccessTokenRegnerator, SpotifySocket } from "./SpotifySocket"; 2 | import WebSocket from "isomorphic-ws"; 3 | import { automatedCreateSpotifyClient, connectState, getAccessToken, sendCommand, SpotifyDevice, trackPlayback } from "../util/spotify-ws-api"; 4 | import { debug } from "../util/debug"; 5 | import { SpotifyCluster } from "../types/SpotifyCluster"; 6 | import { ClusterObserver } from "./ClusterObserver"; 7 | 8 | /** 9 | * Default coordinator for the initialization of a Spotify socket following a successful connection 10 | */ 11 | export class CoordinatedSpotifySocket extends SpotifySocket { 12 | /** 13 | * Creates a coordinated socket from the given cookies and SpotifyDevice 14 | * @param cookie cookies from open.spotify.com 15 | * @param device device metadata to use when initializing – omit to use default 16 | * @returns promise of a CoordinatedSpotifySocket and the accessToken used to create it 17 | */ 18 | public static async create(cookie: string, device?: SpotifyDevice): Promise<{ 19 | accessToken: string; 20 | socket: CoordinatedSpotifySocket; 21 | }> { 22 | const { accessToken, socket } = await automatedCreateSpotifyClient(cookie); 23 | 24 | return { 25 | accessToken, 26 | socket: new CoordinatedSpotifySocket(socket, accessToken, cookie, device) 27 | }; 28 | } 29 | 30 | /** 31 | * Default device metadata. The device_id is randomized at runtime. 32 | * 33 | * This object is sealed. If you need to mutate it, make your own. 34 | */ 35 | public static readonly DEFAULT_DEVICE: SpotifyDevice = Object.seal({ 36 | brand: "spotify", 37 | capabilities: { 38 | audio_podcasts: true, 39 | change_volume: true, 40 | disable_connect: false, 41 | enable_play_token: true, 42 | manifest_formats: [ 43 | "file_urls_mp3", 44 | "manifest_ids_video", 45 | "file_urls_external", 46 | "file_ids_mp4", 47 | "file_ids_mp4_dual" 48 | ], 49 | play_token_lost_behavior: "pause", 50 | supports_file_media_type: true, 51 | video_playback: true 52 | }, 53 | device_id: Array(40).fill(0).map(x => Math.random().toString(36).charAt(2)).join(''), 54 | device_type: "computer", 55 | metadata: {}, 56 | model: "web_player", 57 | name: "Web Player (Microsoft Edge)", 58 | platform_identifier: "web_player osx 11.3.0;microsoft edge 89.0.774.54;desktop" 59 | }); 60 | 61 | #lastCluster: SpotifyCluster; 62 | 63 | private constructor(socket: WebSocket, accessToken: string, cookie: string, public readonly device: SpotifyDevice = CoordinatedSpotifySocket.DEFAULT_DEVICE) { 64 | super(socket); 65 | 66 | this.#accessToken = accessToken; 67 | this.#cookie = cookie; 68 | 69 | debug(`create CoordinatedSpotifySocket: ${socket.readyState}`) 70 | 71 | socket.onclose = async () => { 72 | debug("disconnected from spotify."); 73 | 74 | if (this.#forceClosed) return; 75 | 76 | debug("scheduling reconnect"); 77 | await new Promise(resolve => setTimeout(resolve, 5000)); 78 | 79 | debug("reconnecting to spotify"); 80 | const { accessToken, socket } = await automatedCreateSpotifyClient(cookie); 81 | 82 | this.#accessToken = accessToken; 83 | this.attach(socket); 84 | }; 85 | 86 | new ClusterObserver(clusters => { 87 | this.#lastCluster = clusters[0]; 88 | }).observe(this); 89 | 90 | this.observeConnectionID(async connectionID => { 91 | if (connectionID) { 92 | // await subscribeToNotifications(connectionID, this.#accessToken); 93 | await trackPlayback(connectionID, this.#accessToken, this.device); 94 | const cluster = this.#lastCluster = await connectState(connectionID, this.#accessToken, device); 95 | 96 | debug("we are connected to spotify."); 97 | 98 | this.handlePayload({ 99 | headers: { 100 | "content-type": "application/json" 101 | }, 102 | payloads: [ 103 | { 104 | ack_id: "none", 105 | cluster, 106 | devices_that_changed: [cluster.active_device_id], 107 | update_reason: "INITIAL_STATE" 108 | } 109 | ], 110 | type: "message", 111 | uri: "hm://connect-state/v1/cluster" 112 | }); 113 | } 114 | }); 115 | } 116 | 117 | #accessToken: string; 118 | #cookie: string; 119 | #forceClosed: boolean = false; 120 | 121 | public close() { 122 | this.#forceClosed = true; 123 | this.socket.close(); 124 | this.halt(); 125 | } 126 | 127 | public async generateAccessToken(): Promise { 128 | const { accessToken } = await getAccessToken(this.#cookie); 129 | return this.#accessToken = accessToken; 130 | } 131 | 132 | public get accessTokenRegenerator(): SpotifyAccessTokenRegnerator { 133 | return () => this.generateAccessToken(); 134 | } 135 | 136 | public async sendCommand(endpoint: string, opts: any = {}): Promise { 137 | const activeDevice = this.#lastCluster?.active_device_id; 138 | 139 | if (!activeDevice) return; 140 | 141 | await sendCommand({ 142 | from: this.device.device_id, 143 | to: activeDevice, 144 | endpoint, 145 | accessToken: this.#accessToken, 146 | opts 147 | }); 148 | } 149 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | "declarationMap": false, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": false, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | "strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 68 | "resolveJsonModule": true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/util/spotify-ws-api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import WebSocket from "isomorphic-ws"; 3 | import { SpotifyCluster } from "../types/SpotifyCluster"; 4 | import { CORE_HEADERS } from "./const"; 5 | import { debug } from "./debug"; 6 | 7 | export interface AccessToken { 8 | accessToken: string; 9 | accessTokenExpirationTimestampMs: number; 10 | clientId: string; 11 | isAnonymous: boolean; 12 | } 13 | 14 | async function getAccessToken_asScrape(cookie: string): Promise { 15 | // /]id="config"[^>]*>(.*)<\/script>