UNPKG

cody-music

Version:

mac osx spotify and itunes music player controller, spotify audio features, itunes and spotify genre, and playlist control

999 lines (883 loc) 34.5 kB
import { MusicUtil } from "./util"; import { MusicController } from "./controller"; import { MusicStore } from "./store"; import { MusicClient } from "./client"; import { PlayerDevice, Track, PlayerType, PlayerContext, TrackStatus, Artist, PlayerName, CodyResponse, } from "./models"; import { AudioStat } from "./audiostat"; import { getUnixTime } from "date-fns"; const musicStore = MusicStore.getInstance(); const musicClient = MusicClient.getInstance(); const audioStat = AudioStat.getInstance(); const musicController = MusicController.getInstance(); const musicUtil = new MusicUtil(); export const SPOTIFY_LIKED_SONGS_PLAYLIST_NAME = "Liked Songs"; export class MusicPlayerState { private static instance: MusicPlayerState; private constructor() { // } static getInstance() { if (!MusicPlayerState.instance) { MusicPlayerState.instance = new MusicPlayerState(); } return MusicPlayerState.instance; } async isWindowsSpotifyRunning(): Promise<boolean> { /** * /tasklist /fi "imagename eq Spotify.exe" /fo list /v |find " - " * Window Title: Dexys Midnight Runners - Come On Eileen */ let result = await musicUtil .execCmd(MusicController.WINDOWS_SPOTIFY_TRACK_FIND) .catch((e) => { return null; }); if (result && result.toLowerCase().includes("title")) { return true; } return false; } async isSpotifyWebRunning(): Promise<boolean> { let accessToken = musicStore.spotifyAccessToken; if (accessToken) { let spotifyDevices: PlayerDevice[] = await this.getSpotifyDevices(); if (spotifyDevices.length > 0) { return true; } } return false; } /** * returns... * { "devices" : [ { "id" : "5fbb3ba6aa454b5534c4ba43a8c7e8e45a63ad0e", "is_active" : false, "is_private_session": true, "is_restricted" : false, "name" : "My fridge", "type" : "Computer", "volume_percent" : 100 } ] } */ async getSpotifyDevices(): Promise<PlayerDevice[]> { let devices = []; const accessToken = musicStore.spotifyAccessToken; if (!accessToken) { return []; } const api = "/v1/me/player/devices"; let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if (response.data && response.data.devices) { devices = response.data.devices; } return devices || []; } /** * returns i.e. * track = { artist: 'Bob Dylan', album: 'Highway 61 Revisited', disc_number: 1, duration: 370, played count: 0, track_number: 1, starred: false, popularity: 71, id: 'spotify:track:3AhXZa8sUQht0UEdBJgpGc', name: 'Like A Rolling Stone', album_artist: 'Bob Dylan', artwork_url: 'http://images.spotify.com/image/e3d720410b4a0770c1fc84bc8eb0f0b76758a358', spotify_url: 'spotify:track:3AhXZa8sUQht0UEdBJgpGc' } } */ async getWindowsSpotifyTrackInfo() { let windowTitleStr = "Window Title:"; // get the artist - song name from the command result, then get the rest of the info from spotify let songInfo = await musicUtil .execCmd(MusicController.WINDOWS_SPOTIFY_TRACK_FIND) .catch((e) => { return null; }); if (!songInfo || !songInfo.includes(windowTitleStr)) { // it must have paused, or an ad, or it was closed return null; } // fetch it from spotify // result will be something like: "Window Title: Dexys Midnight Runners - Come On Eileen" songInfo = songInfo.substring(windowTitleStr.length); let artistSong = songInfo.split("-"); let artist = artistSong[0].trim(); let song = artistSong[1].trim(); const qParam = encodeURIComponent(`artist:${artist} track:${song}`); const qryStr = `q=${qParam}&type=track&limit=2&offset=0`; let api = `/v1/search?${qryStr}`; let resp = await musicClient.spotifyApiGet(api); let trackInfo = null; if ( musicUtil.isResponseOk(resp) && resp.data && resp.data.tracks && resp.data.tracks.items ) { trackInfo = resp.data.tracks.items[0]; // set the other attributes like start and type trackInfo["type"] = "spotify"; trackInfo["state"] = "playing"; trackInfo["start"] = 0; trackInfo["end"] = 0; trackInfo["genre"] = ""; } return trackInfo; } async getSpotifyTracks( ids: string[], includeArtistData: boolean = false, includeAudioFeaturesData: boolean = false, includeGenre: boolean = false ): Promise<Track[]> { const finalIds: string[] = []; ids.forEach((id) => { id = musicUtil.createSpotifyIdFromUri(id); if (id) { finalIds.push(id); } }); const tracksToReturn: Track[] = []; const api = `/v1/tracks`; const qsOptions = { ids: finalIds.join(",") }; let response = await musicClient.spotifyApiGet(api, qsOptions); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api, qsOptions); } // get the features if the flag is set to true let spotifyAudioFeaturesP = null; if (includeAudioFeaturesData) { // async - call the spotify api to fetch the audio features spotifyAudioFeaturesP = audioStat .getSpotifyAudioFeatures(ids) .catch((e) => { return null; }); } if (response && response.status === 200 && response.data) { // get the tracks let artistIdMap: any = {}; const tracks: any[] = response.data.tracks || []; tracks.forEach((trackData: Track) => { const track: Track = musicUtil.copySpotifyTrackToCodyTrack( trackData ); track.progress_ms = response.data.progress_ms ? response.data.progress_ms : 0; if (track.artists) { track.artists.forEach((artist: any) => { artistIdMap[artist.id] = artist.id; }); } tracksToReturn.push(track); }); // fetch the artists from spotify if this flag is set to true if (includeArtistData) { let artistIds = Object.keys(artistIdMap).map(key => key); // fetch the artists all at once or in batches let artists: any[] = []; if (artistIds) { // spotify's limit is 50, so batch if it's greater than 50 if (artistIds.length > 50) { while (artistIds.length) { // keep removing from the artistIds 50 at a time let splicedArtistIds = artistIds.splice(0, 50); const batchedArtists = await this.getSpotifyArtistsByIds( splicedArtistIds ); if (batchedArtists && batchedArtists.length) { artists.push(...batchedArtists); } } } else { artists = await this.getSpotifyArtistsByIds(artistIds); } } // populate the artists into the existing tracks if (artists && artists.length) { tracksToReturn.forEach((t: Track) => { // get the artist IDs from the tracks to return shallow artists array const trackArtistIds: string[] = t.artists.map((artist: any) => artist.id); // filter out the full artists found in the artists response // based on the artist IDs that were in the tracksToReturn const artistsForTrack: any[] = artists.filter( (n: any) => trackArtistIds.includes(n.id) ); if (artistsForTrack && artistsForTrack.length) { // replace the shallow artists with the full artists t.artists = artistsForTrack; } if (!t.genre && includeGenre) { // first check if we have an artist in artists let genre = ""; if (t.artists && t.artists.length) { for (let artistCandidate of t.artists) { if ( artistCandidate.genres && artistCandidate.genres.length ) { try { genre = musicClient.getHighestFrequencySpotifyGenre( artistCandidate.genres ); } catch (e) { // } break; } } } if (genre) { t.genre = genre; } } }); } } // get the features if the flag is set to true if (includeAudioFeaturesData) { // await for the call we made earlier const spotifyAudioFeatures = await spotifyAudioFeaturesP; if (spotifyAudioFeatures && spotifyAudioFeatures.length) { // "id": "4JpKVNYnVcJ8tuMKjAj50A", // "uri": "spotify:track:4JpKVNYnVcJ8tuMKjAj50A", // track.features = spotifyAudioFeatures[0]; spotifyAudioFeatures.forEach((feature: any) => { const uri: string = feature.uri; const foundTrack = tracksToReturn.find( (t: Track) => t.uri === uri ); if (foundTrack) { foundTrack.features = feature; } }); } } } return tracksToReturn; } async getSpotifyTrackById( id: string, includeArtistData: boolean = false, includeAudioFeaturesData: boolean = false, includeGenre: boolean = false ): Promise<Track> { id = musicUtil.createSpotifyIdFromUri(id); let track: Track; let api = `/v1/tracks/${id}`; let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if (response && response.status === 200 && response.data) { track = musicUtil.copySpotifyTrackToCodyTrack(response.data); track.progress_ms = response.data.progress_ms ? response.data.progress_ms : 0; // get the arist data if (includeArtistData && track.artists) { let artists: Artist[] = []; for (let i = 0; i < track.artists.length; i++) { const artist = track.artists[i]; const artistData: Artist = await this.getSpotifyArtistById( artist.id ); artists.push(artistData); } if (artists.length > 0) { track.artists = artists; } else { track.artists = []; } } if (!track.genre && includeGenre) { // first check if we have an artist in artists // artists[0].genres[0] let genre = ""; if ( track.artists && track.artists.length > 0 && track.artists[0].genres ) { // make sure we use the highest frequency genre genre = musicClient.getHighestFrequencySpotifyGenre( track.artists[0].genres ); } if (!genre) { // get the genre genre = await musicController.getGenre( track.artist, track.name ); } if (genre) { track.genre = genre; } } // get the features if (includeAudioFeaturesData) { const spotifyAudioFeatures = await audioStat.getSpotifyAudioFeatures( [id] ); if (spotifyAudioFeatures && spotifyAudioFeatures.length > 0) { track.features = spotifyAudioFeatures[0]; } } } else { track = new Track(); } return track; } async getSpotifyArtistsByIds(ids: string[]): Promise<Artist[]> { let artists: Artist[] = []; ids = musicUtil.createSpotifyIdsFromUris(ids); let api = `/v1/artists`; // const qParam = { ids }; // just create a comma separated list of these if (ids && ids.length) { api = `${api}?ids=${ids.join(",")}`; } let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if (response && response.status === 200 && response.data) { artists = response.data.artists || []; } return artists; } async getSpotifyArtistById(id: string): Promise<Artist> { let artist: Artist = new Artist(); id = musicUtil.createSpotifyIdFromUri(id); let api = `/v1/artists/${id}`; let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if (response && response.status === 200 && response.data) { const artistData = response.data; // delete external_urls delete artistData.external_urls; artist = artistData; } return artist; } async getSpotifyWebCurrentTrack(): Promise<Track> { let track: Track; let api = "/v1/me/player/currently-playing"; let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if ( response && response.status === 200 && response.data && response.data.item ) { const data = response.data; track = musicUtil.copySpotifyTrackToCodyTrack(data.item); track.progress_ms = data.progress_ms ? data.progress_ms : 0; // set the actions ("actions": {"disallows": {"resuming": true}}) track.actions = data.actions; // set whether this track is playing or not /** * data: { context:null currently_playing_type:"track" is_playing:true item:Object {album: Object, artists: Array(1), available_markets: Array(79), …} progress_ms:153583 timestamp:1583797755729 } */ const isPlaying = data.is_playing !== undefined && data.is_playing !== null ? data.is_playing : false; if (track.uri && track.uri.includes("spotify:ad:")) { track.state = TrackStatus.Advertisement; } else { track.state = isPlaying ? TrackStatus.Playing : TrackStatus.Paused; } } else { track = new Track(); track.state = TrackStatus.NotAssigned; track.httpStatus = response.status; } return track; } async getSpotifyRecentlyPlayedTracksBefore( limit: number = 50, before: number = 0 ): Promise<CodyResponse> { return this.fetchSpotifyReentlyPlayedTracksData(limit, 0, before); } async getSpotifyRecentlyPlayedTracksAfter( limit: number = 50, after: number = 0 ): Promise<CodyResponse> { return this.fetchSpotifyReentlyPlayedTracksData(limit, after, 0); } async getSpotifyRecentlyPlayedTracks( limit: number = 50, after: number = 0, before: number = 0 ): Promise<Track[]> { const resp: CodyResponse = await this.fetchSpotifyReentlyPlayedTracksData( limit, after, before ); if (resp && resp.data && resp.data.tracks) { return resp.data.tracks; } return []; } /** * Fetch the recently played tracks data * @param limit (max of 1000) * @param after * @param before */ async fetchSpotifyReentlyPlayedTracksData( limit: number = 50, after: number = 0, before: number = 0 ): Promise<CodyResponse> { let api = "/v1/me/player/recently-played"; const qsOptions: any = {}; // max # of tracks for pagination let trackLimit = limit; if (trackLimit <= 0 || trackLimit > 1000) { trackLimit = 1000; } // spotify api limit is 50 let apiLimit = limit; if (apiLimit <= 0 || apiLimit > 50) { apiLimit = 50; } // set the spotify per api limit qsOptions["limit"] = apiLimit; if (after && after > 0) { qsOptions["after"] = after; } else if (before && before > 0) { qsOptions["before"] = before; } let resp: CodyResponse = await musicClient.spotifyApiGet( api, qsOptions ); // check if the token needs to be refreshed if (resp.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again resp = await musicClient.spotifyApiGet(api, qsOptions); } let tracks: Track[] = []; if (musicUtil.isItemsResponseOk(resp)) { resp.data.items.forEach((item: any) => { const track: Track = musicUtil.copySpotifyTrackToCodyTrack( item.track ); // set the context info if (item.context) { track.context_type = item.context.type; track.context_uri = item.context.uri; } track.played_at = item.played_at; track.played_at_utc_seconds = getUnixTime(item.played_at); tracks.push(track); }); let cursors = resp.data.cursors; if (cursors) { cursors.before = parseInt(cursors.before, 10); cursors.after = parseInt(cursors.after, 10); } else { cursors = { before: 0, after: 0, }; } if (resp.data.next && tracks.length < trackLimit) { // continue fetching until we've reached the limit let reachedLimit = false; let nextApi = resp.data.next; while (!reachedLimit) { let nextCursors = resp.data.cursors; if (nextCursors && cursors) { // update the before and after const prevBefore = cursors.before; const before = parseInt(nextCursors.before, 10); if (before < prevBefore || prevBefore === 0) { cursors.before = before; } const prevAfter = cursors.after; const after = parseInt(nextCursors.after, 10); if (after > prevAfter || prevAfter === 0) { cursors.after = after; } } if (!nextApi) { reachedLimit = true; break; } // get the next api api = nextApi.substring( nextApi.indexOf("/v1"), nextApi.length ); const nextResults = await musicClient.spotifyApiGet(api); if (musicUtil.isItemsResponseOk(nextResults)) { nextApi = nextResults.data.next; if (nextResults.data.items.length > 0) { nextResults.data.items.forEach((item: any) => { let spotifyTrack = item.track; const track: Track = musicUtil.copySpotifyTrackToCodyTrack( spotifyTrack ); track.played_at = item.played_at; track.played_at_utc_seconds = getUnixTime(item.played_at); tracks.push(track); }); } else { reachedLimit = true; } } else { reachedLimit = true; } if (tracks.length >= trackLimit) { reachedLimit = true; } } } // update the cursors resp.data.cursors = cursors; } if (resp.data) { delete resp.data.items; delete resp.data.href; delete resp.data.next; delete resp.data.limit; // add tracks to the response resp.data["tracks"] = tracks; } return resp; } async getRecommendationsForTracks( seed_tracks: string[] = [], limit: number = 40, market: string = "", min_popularity: number = 20, target_popularity: number = 90, seed_genres: string[] = [], seed_artists: string[] = [], features: any = {} ) { let tracks: Track[] = []; // change the trackIds to non-uri ids seed_tracks = musicUtil.createTrackIdsFromUris(seed_tracks); // the create trackIds will create normal artist ids as well seed_artists = musicUtil.createTrackIdsFromUris(seed_artists); // it can only take up to 5, remove the rest if (seed_tracks.length > 5) { seed_tracks.length = 5; } if (seed_genres.length > 5) { seed_genres.length = 5; } if (seed_artists.length > 5) { seed_artists.length = 5; } const qsOptions: any = { limit, min_popularity, target_popularity, }; if (seed_genres.length) { qsOptions["seed_genres"] = seed_genres.join(","); } if (seed_tracks.length) { qsOptions["seed_tracks"] = seed_tracks.join(","); } if (seed_artists.length) { qsOptions["seed_artists"] = seed_artists.join(","); } if (market) { qsOptions["market"] = market; } const featureKeys = Object.keys(features); if (featureKeys.length) { featureKeys.forEach((key) => { qsOptions[key] = features[key]; }); } const api = `/v1/recommendations`; // add to the api to prevent the querystring from escaping the comma let response = await musicClient.spotifyApiGet(api, qsOptions); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api, qsOptions); } if (musicUtil.isResponseOk(response)) { tracks = response.data.tracks; } return tracks; } async setMute(mute: boolean, device_id = ""): Promise<CodyResponse> { const api = `/v1/me/player/volume`; if (musicStore.prevVolumePercent === 0) { // get the previous volume const devices: PlayerDevice[] = await this.getSpotifyDevices(); const playerContext: PlayerContext = await this.getSpotifyPlayerContext(); if (playerContext && playerContext.device) { musicStore.prevVolumePercent = playerContext.device.volume_percent; } if (playerContext.device.volume_percent === 0) { musicStore.prevVolumePercent = 45; } } let qsOptions: any = { volume_percent: mute ? 0 : musicStore.prevVolumePercent, }; if (device_id) { qsOptions["device_id"] = device_id; } let codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); // check if the token needs to be refreshed if (codyResp.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); } return codyResp; } async setShuffle(shuffle: boolean, device_id = ""): Promise<CodyResponse> { const api = `/v1/me/player/shuffle`; let qsOptions: any = { state: shuffle, }; if (device_id) { qsOptions["device_id"] = device_id; } let codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); // check if the token needs to be refreshed if (codyResp.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); } return codyResp; } async setRepeatOff(device_id: string = ""): Promise<CodyResponse> { return await this.setRepeat("off", device_id); } async setTrackRepeat(device_id: string = ""): Promise<CodyResponse> { return await this.setRepeat("track", device_id); } async setPlaylistRepeat(device_id: string = ""): Promise<CodyResponse> { return await this.setRepeat("context", device_id); } async updateRepeatMode( setToOn: boolean, device_id: string = "" ): Promise<CodyResponse> { const state = setToOn ? "track" : "off"; return await this.setRepeat(state, device_id); } async setRepeat( state: string, device_id: string = "" ): Promise<CodyResponse> { const api = `/v1/me/player/repeat`; let qsOptions: any = { state, }; if (device_id) { qsOptions["device_id"] = device_id; } let codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); // check if the token needs to be refreshed if (codyResp.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again codyResp = await musicClient.spotifyApiPut(api, qsOptions, {}); } return codyResp; } async getSpotifyPlayerContext(): Promise<PlayerContext> { let playerContext: PlayerContext = new PlayerContext(); let api = "/v1/me/player"; let response = await musicClient.spotifyApiGet(api); // check if the token needs to be refreshed if (response.status === 401) { // refresh the token await musicClient.refreshSpotifyToken(); // try again response = await musicClient.spotifyApiGet(api); } if ( response && response.status === 200 && response.data && response.data.item ) { // override "type" with "spotify" response.data.item["type"] = "spotify"; response.data.item["playerType"] = PlayerType.WebSpotify; musicUtil.extractAristFromSpotifyTrack(response.data.item); playerContext = response.data; } return playerContext; } async launchAndPlaySpotifyTrack( trackId: string = "", playlistId: string = "", playerName: PlayerName = PlayerName.SpotifyWeb ) { // check if there's any spotify devices const spotifyDevices: PlayerDevice[] = await this.getSpotifyDevices(); if (!spotifyDevices || spotifyDevices.length === 0) { // no spotify devices found, lets launch the web player with the track // launch it await this.launchWebPlayer(playerName); // now select it from within the playlist within 2 seconds await setTimeout(() => { this.playSpotifyTrackFromPlaylist( trackId, musicStore.spotifyUserId, playlistId ); }, 5000); } else { // a device is found, play using the device await this.playSpotifyTrackFromPlaylist( trackId, musicStore.spotifyUserId, playlistId ); } } async playSpotifyTrackFromPlaylist( trackId: string, spotifyUserId: string, playlistId: string = "" ) { const spotifyUserUri = musicUtil.createSpotifyUserUriFromId( spotifyUserId ); if (playlistId === SPOTIFY_LIKED_SONGS_PLAYLIST_NAME) { playlistId = ""; } const spotifyDevices: PlayerDevice[] = await this.getSpotifyDevices(); const deviceId = spotifyDevices.length > 0 ? spotifyDevices[0].id : ""; let options: any = {}; if (deviceId) { options["device_id"] = deviceId; } if (trackId) { options["track_ids"] = [trackId]; } else { options["offset"] = { position: 0 }; } if (playlistId) { const playlistUri = `${spotifyUserUri}:playlist:${playlistId}`; options["context_uri"] = playlistUri; } /** * to play a track without the play list id * curl -X "PUT" "https://api.spotify.com/v1/me/player/play?device_id=4f38ae14f61b3a2e4ed97d537a5cb3d09cf34ea1" * --data "{\"uris\":[\"spotify:track:2j5hsQvApottzvTn4pFJWF\"]}" */ if (!playlistId) { // just play by track id await musicController.spotifyWebPlayTrack(trackId, deviceId); } else { // we have playlist id within the options, use that await musicController.spotifyWebPlayPlaylist( playlistId, trackId, deviceId ); } } launchWebPlayer(options: any) { if (options.album_id) { const albumId = musicUtil.createSpotifyIdFromUri(options.album_id); return musicUtil.launchWebUrl( `https://open.spotify.com/album/${albumId}` ); } else if (options.track_id) { const trackId = musicUtil.createSpotifyIdFromUri(options.track_id); return musicUtil.launchWebUrl( `https://open.spotify.com/track/${trackId}` ); } else if (options.playlist_id) { const playlistId = musicUtil.createSpotifyIdFromUri( options.playlist_id ); return musicUtil.launchWebUrl( `https://open.spotify.com/playlist/${playlistId}` ); } return musicUtil.launchWebUrl("https://open.spotify.com"); } updateSpotifyLoved(loved: boolean) { // } }