import dayjs, { type Dayjs } from "dayjs"; import type EventEmitter from "events"; import SpotifyWebApi from "spotify-web-api-node"; import request from 'superagent'; import { type BrainzMeta, COMPONENT_AUTH_TYPE, type PlayObject, type PlayObjectMinimal, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC } from "../../core/Atomic.ts"; import { artistNameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; import { isNodeNetworkException } from "../common/errors/NodeErrors.ts"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.ts"; import { DEFAULT_POLLING_INTERVAL, type FormatPlayObjectOptions, type InternalConfig, type PaginatedListensTimeRangeOptions, type PaginatedTimeRangeListens, type PaginatedTimeRangeListensResult, type PlayerStateData, type SourceData, type TimeRangeListensFetcher, } from "../common/infrastructure/Atomic.ts"; import { NO_USER } from '../../core/Atomic.ts'; import { NO_DEVICE } from '../../core/Atomic.ts'; import type {ComponentAuthType, ReportedPlayerStatus} from '../../core/Atomic.ts'; import type {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.ts"; import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, } from "../utils.ts"; import { writeFile } from '../utils/FSUtils.ts'; import { readJson } from '../utils/DataUtils.ts'; import { findCauseByFunc } from "../utils/ErrorUtils.ts"; import { joinedUrl } from "../utils/NetworkUtils.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; // import SpotifyApi.AlbumObjectSimplified = SpotifyApi.SpotifyApi.AlbumObjectSimplified; // import SpotifyApi.ArtistObjectSimplified = SpotifyApi.SpotifyApi.ArtistObjectSimplified; // import SpotifyApi.CurrentlyPlayingObject = SpotifyApi.SpotifyApi.CurrentlyPlayingObject; // import SpotifyApi.PlayHistoryObject = SpotifyApi.SpotifyApi.PlayHistoryObject; // import SpotifyApi.TrackObjectFull = SpotifyApi.SpotifyApi.TrackObjectFull; // import SpotifyApi.UserDevice = SpotifyApi.SpotifyApi.UserDevice; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts"; import { AuthError, SimpleError } from "../common/errors/MSErrors.ts"; const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; const shortDeviceId = truncateStringToLength(10, ''); export default class SpotifySource extends MemoryPositionalSource implements PaginatedTimeRangeListens { spotifyApi: SpotifyWebApi; workingCredsPath: string; override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.interactive; requiresAuth = true; requiresAuthInteraction = true; canGetState = false; declare config: SpotifySourceConfig; getScrobblesForTimeRange: TimeRangeListensFetcher constructor(name: any, config: SpotifySourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('spotify', name, config, internal, emitter); const { data: { interval = DEFAULT_POLLING_INTERVAL, } = {} } = config; if (interval < 5) { this.logger.warn('Interval should probably be 5 seconds or above! Spotify may return 429 response (too many requests)'); } this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; this.canBacklog = true; this.supportsUpstreamRecentlyPlayed = true; // https://developer.spotify.com/documentation/web-api/reference/get-recently-played this.SCROBBLE_BACKLOG_COUNT = 50 this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this, this.logger); } static formatPlayObj(obj: SpotifyApi.PlayHistoryObject | SpotifyApi.CurrentlyPlayingObject, options: FormatPlayObjectOptions = {}): PlayObject { const { newFromSource = false } = options; let artists: SpotifyApi.ArtistObjectSimplified[]; let album: SpotifyApi.AlbumObjectSimplified; let name: string; let duration_ms: number; let played_at: Dayjs; let playDateCompleted: Dayjs | undefined; let id: string; let url: string; let playbackPosition: number | undefined; let deviceId: string | undefined; let isrcString: string | undefined; let trackNumber: number | undefined; let scrobbleTsSOC: ScrobbleTsSOC; if (asPlayHistoryObject(obj)) { const { track, played_at: pa } = obj; const { artists: art = [], name: n, id: i, duration_ms: dm, album: a, external_urls: { spotify, } = {}, external_ids: { isrc } = {}, track_number, } = track; // we don't use available markets for anything and it can be 100+ strings // so delete for debugging sake delete obj.track.available_markets; if(obj.track.album !== undefined) { delete obj.track.album?.available_markets; } scrobbleTsSOC = SCROBBLE_TS_SOC_END; played_at = dayjs(pa); playDateCompleted = played_at; artists = art; name = n; id = i; duration_ms = dm; album = a; url = spotify; isrcString = isrc; trackNumber = track_number; } else if (asCurrentlyPlayingObject(obj)) { const { is_playing, progress_ms, timestamp, device: { id: deviceIdentifier, name: deviceName } = {}, item, } = obj; const { artists: art, name: n, id: i, duration_ms: dm, album: a, external_urls: { spotify, } = {}, external_ids: { isrc } = {}, track_number } = item as SpotifyApi.TrackObjectFull; delete (obj.item as SpotifyApi.TrackObjectFull).available_markets; if((obj.item as SpotifyApi.TrackObjectFull).album !== undefined) { delete (obj.item as SpotifyApi.TrackObjectFull).album.available_markets; } scrobbleTsSOC = SCROBBLE_TS_SOC_START; played_at = dayjs(timestamp); artists = art; name = n; id = i; duration_ms = dm; album = a; url = spotify; playbackPosition = progress_ms / 1000; deviceId = combinePartsToString([shortDeviceId(deviceIdentifier), deviceName]); isrcString = isrc; trackNumber = track_number } else { throw new Error('Could not determine format of spotify response data'); } const { id: albumId, name: albumName, artists: albumArtists = [], images = [] } = album || {}; let actualAlbumArtists: SpotifyApi.ArtistObjectSimplified[] = []; if ((artists.length !== albumArtists.length) || !artists.every(artist => albumArtists.some(albumArtist => artist.id === albumArtist.id))) { // only include album artists if they are not the EXACT same as the track artists // ...if they aren't the exact same then include all artists, even if they are duplicates of track artists actualAlbumArtists = albumArtists; } let imageData: {url: string}; if(images.length > 0) { imageData = images.find(x => x.height < 640); if(imageData === undefined) { imageData = images[0]; } } const play: PlayObjectMinimal = { data: { artists: artists.map(x => artistNameToCredit(x.name)), albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)), album: albumName, track: name, duration: duration_ms / 1000, playDate: played_at, playDateCompleted, isrc: isrcString, meta: { spotify: { track: id, artist: artists.map(x => x.id), albumArtist: actualAlbumArtists.map(x => x.id), album: albumId } } }, meta: { deviceId: deviceId ?? `${NO_DEVICE}`, source: 'Spotify', musicService: 'Spotify', trackId: id, trackProgressPosition: playbackPosition, scrobbleTsSOC, newFromSource, url: { web: url } } }; const brainz: BrainzMeta = {}; if(trackNumber !== undefined) { brainz.trackNumber = trackNumber; } if(Object.keys(brainz).length > 0) { play.data.meta.brainz = brainz; } if(imageData !== undefined) { play.meta.art = {album: imageData.url}; } return baseFormatPlayObj(obj, play); } buildSpotifyApi = async () => { let spotifyCreds = {}; try { spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false}) as any; } catch (e) { this.logger.warn({ path: this.workingCredsPath }, 'Current spotify credentials file exists but could not be parsed'); } const {token: accessToken = undefined, refreshToken = undefined} = (spotifyCreds || {}) as any; const { clientId, clientSecret, redirectUri, } = this.config.data || {}; const rdUri: string = redirectUri || joinedUrl(this.localUrl, 'callback').toString(); const apiConfig = { clientId, clientSecret, accessToken, refreshToken, } if (Object.values(apiConfig).every(x => x === undefined)) { this.logger.info('No values found for Spotify configuration, skipping initialization'); return; } // @ts-expect-error TS(2339): Property 'redirectUri' does not exist on type '{ c... Remove this comment to see the full error message apiConfig.redirectUri = rdUri; const validationErrors = []; if (clientId === undefined) { validationErrors.push('clientId must be defined'); } if (clientSecret === undefined) { validationErrors.push('clientSecret must be defined'); } if (rdUri === undefined) { validationErrors.push('redirectUri must be defined'); } if (validationErrors.length !== 0) { this.logger.warn(`Configuration was not valid: *${validationErrors.join('\n')}`); throw new Error('Failed to initialize a Spotify source'); } if(accessToken === undefined || refreshToken === undefined) { this.logger.info(`No access or refresh token is present. User interaction for authentication is required.`); this.logger.info(`Redirect URL that will be used on auth callback: '${rdUri}'`); } this.spotifyApi = new SpotifyWebApi(apiConfig); } protected async doBuildInitData(): Promise { await this.buildSpotifyApi(); return true; } protected async doCheckConnection(): Promise { try { await request.get('https://api.spotify.com/v1'); return true; } catch (e) { if(isNodeNetworkException(e)) { throw new Error('Could not communicate with Spotify API server', {cause: e}); } if(e.status >= 500) { throw new Error('Spotify API server returned an unexpected response', { cause: e}); } return true; } } doAuthentication = async () => { try { if(undefined === this.spotifyApi.getAccessToken()) { throw new AuthError('Cannot use API until an access token has been received from the authorization flow.', {unrecoverable: false}); } await this.callApi>(((api: any) => api.getMe())); return true; } catch (e) { if(e instanceof AuthError) { throw e; } if(isNodeNetworkException(e)) { this.logger.error('Could not communicate with Spotify API'); } throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: 'statusCode' in e && [401,403].includes(e.statusCode)}) } } createAuthUrl = () => this.spotifyApi.createAuthorizeURL(scopes, this.getSafeExternalName()) handleAuthCodeCallback = async ({ error, code }: any) => { try { if (error === undefined) { const tokenResponse = await this.spotifyApi.authorizationCodeGrant(code); this.spotifyApi.setAccessToken(tokenResponse.body['access_token']); this.spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']); await writeFile(this.workingCredsPath, JSON.stringify({ token: tokenResponse.body['access_token'], refreshToken: tokenResponse.body['refresh_token'], expires: Date.now() + (tokenResponse.body['expires_in'] * 1000), expiresIn: tokenResponse.body['expires_in'], grant: tokenResponse.body['token_type'] })); this.logger.info('Got token from code grant authorization!'); return true; } else { this.logger.warn('Callback contained an error! User may have denied access?') this.logger.error(error); return error; } } catch (e) { throw e; } } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const plays: SourceData[] = []; if(this.canGetState) { const state = await this.getCurrentPlaybackState(); if(state.playerState !== undefined) { if(state.device.is_private_session) { this.logger.debug(`Will not track play on Device ${state.device.name} because it is in a private session.`); } else { plays.push(state.playerState); } } } else { const currPlay = await this.getNowPlaying(); if(currPlay !== undefined) { plays.push(currPlay); } } const newPlays = await this.processRecentPlays(plays); // hint that scrobble timestamp source of truth should be when the track ended (player changed tracks) // rather than when we first saw the track // // this is because Spotify play history (getMyRecentlyPlayedTracks) timestamps based on end of play // and when we backlog we want timestamps to be as accurate as possible return newPlays.map(x => ({...x, meta: {...x.meta, scrobbleTsSOC: SCROBBLE_TS_SOC_END}})) } getPaginatedUnitOfTime(): dayjs.ManipulateType { return 'second'; } getPaginatedTimeRangeListens = async (params: PaginatedListensTimeRangeOptions): Promise> => { // spotify expects before/after to be unix timestamps WITH millseconds, as number // https://developer.spotify.com/documentation/web-api/reference/get-recently-played const options: Parameters[0] = { limit: params.limit, after: undefined }; const { from, to, cursor } = params; if(cursor !== undefined) { options.after = parseInt(cursor, 10); } else if(from !== undefined) { options.after = from * 1000; } try { const result = await this.callApi>((api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks(options)); let more = true; let plays = result.body.items.map((x: SpotifyApi.PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); if(to !== undefined) { const toDate = dayjs.unix(to); plays = plays.filter(x => x.data.playDate.isBefore(toDate)); } // if no plays returned // or if filtered plays are less than results then we've hit the to date if(plays.length === 0 || plays.length < result.body.items.length) { more = false; } // failsafe, check the body for next values if(more && result.body.next === null || result.body.cursors === null) { more = false; } return { data: result.body.items.map((x: SpotifyApi.PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate), meta: { ...params, total: result.body.total, limit: result.body.limit, more, cursorNext: more && result.body.cursors !== null ? result.body.cursors.after : undefined } } } catch (e) { throw new Error('Error occurred while getting Spotify paginated listens', { cause: e }); } } getPlayHistory = async (options: RecentlyPlayedOptions = {}) => { const {limit = 20} = options; return await this.getScrobblesForTimeRange({limit}); } getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { try { return await this.getPlayHistory(options); } catch (e) { throw e; } } getNowPlaying = async () => { const func = (api: SpotifyWebApi) => api.getMyCurrentPlayingTrack(); const playingRes = await this.callApi>(func); const {body: {item}} = playingRes; if(item !== undefined && item !== null) { const play = SpotifySource.formatPlayObj(playingRes.body, {newFromSource: true}); return await this.enrichIsrc(play, item.id); } return undefined; } /** * Backfill ISRC if it is not present in Play * * The `currently-playing` and `playback-state` endpoints MS polls for real-time data *may* not return * `external_ids.isrc` on the track object. When the primary response is missing an ISRC * this makes one extra call to `tracks/{id}` to backfill and cache it */ protected enrichIsrc = async (play: PlayObject, trackId: string | undefined): Promise => { if (this.config.options?.enrichIsrc === false || play.data.isrc !== undefined || trackId === undefined) { return play; } const cacheKey = `spotify-isrc-${trackId}`; try { let isrc = await this.cache.cacheApi.get(cacheKey); if (isrc === undefined) { // called directly, bypassing callApi's retry logic -- this is a best-effort enrichment // and should never delay or block scrobbling of the primary play data const res = await this.spotifyApi.getTrack(trackId); isrc = res.body.external_ids?.isrc ?? null; await this.cache.cacheApi.set(cacheKey, isrc, '10m'); } if (isrc !== null) { play.data.isrc = isrc; } } catch (e) { this.logger.debug(new Error(`Failed to backfill ISRC for track ${trackId} from Spotify tracks endpoint`, {cause: e})); // on enrich call failure, or in the event something in the above code block causes an exception unrelated to api // set to null on failure so we don't make consecutive calls that result in failure on every poll attempt await this.cache.cacheApi.set(cacheKey, null, '10m'); } return play; } getCurrentPlaybackState = async (logError = true): Promise<{device?: SpotifyApi.UserDevice, playerState?: PlayerStateData}> => { try { const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); const res = await this.callApi>(funcState); const { body: { device, item, is_playing, timestamp, progress_ms, } = {} } = res; if(device !== undefined && item !== undefined && item !== null) { let status: ReportedPlayerStatus = 'stopped'; if(is_playing) { status = 'playing'; } else if(item !== null && item !== undefined) { status = 'paused'; } let play: PlayObject | undefined; if(item !== null && item !== undefined) { play = SpotifySource.formatPlayObj(res.body, {newFromSource: true}); play = await this.enrichIsrc(play, item.id); } return { device, playerState: { platformId: [combinePartsToString([shortDeviceId(device.id), device.name]), NO_USER], status, play, stateUpdatedAt: dayjs(), position: progress_ms !== null && progress_ms !== undefined ? progress_ms / 1000 : undefined, } } } return {}; } catch (e) { if(hasApiError(e)) { throw new UpstreamError('Error occurred while trying to retrieve current playback state', {cause: e}); } throw new Error('Error occurred while trying to retrieve current playback state', {cause: e}); } } /* getDevices = async () => { const funcDevice = (api: SpotifyWebApi) => api.getMyDevices(); return await this.callApi>(funcDevice); }*/ callApi = async (func: (api: SpotifyWebApi) => Promise, retries = 0): Promise => { const { maxRequestRetries = 1, retryMultiplier = 2, } = this.config.options; try { return await func(this.spotifyApi); } catch (e) { const spotifyError = new UpstreamError('Spotify API call failed', {cause: e}); if (e.statusCode === 401 && !hasApiPermissionError(e)) { if (this.spotifyApi.getRefreshToken() === undefined) { throw new SimpleError('Access token was not valid and no refresh token was present', {cause: e}); } this.logger.debug('Access token was not valid, attempting to refresh'); try { const tokenResponse = await this.spotifyApi.refreshAccessToken(); const { body: { access_token, // spotify may return a new refresh token // if it doesn't then continue to use the last refresh token we received refresh_token = this.spotifyApi.getRefreshToken(), expires_in, token_type } = {} } = tokenResponse; this.spotifyApi.setAccessToken(access_token); await writeFile(this.workingCredsPath, JSON.stringify({ token: access_token, refreshToken: refresh_token, expires: Date.now() + (expires_in * 1000), expiresIn: expires_in, grant: token_type })); } catch (refreshError) { const error = new UpstreamError('Refreshing access token encountered an error', {cause: refreshError}); this.logger.error(error); this.logger.error(spotifyError); throw error; } try { return await func(this.spotifyApi); } catch (ee) { const secondSpotifyError = new UpstreamError('Spotify API call failed even after refreshing token', {cause: ee}); this.logger.error(secondSpotifyError); this.logger.error(spotifyError); throw secondSpotifyError; } } else if(maxRequestRetries > retries) { const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); await sleep(retryAfter * 1000); return this.callApi(func, retries + 1); } else { const error = new UpstreamError(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`, {cause: e}); this.logger.error(error); throw error; } } } onPollPreAuthCheck = async () => { if (this.spotifyApi === undefined) { this.logger.warn('Cannot poll spotify without valid credentials configuration') return false; } return true; } onPollPostAuthCheck = async () => { // test capabilities try { await this.getCurrentPlaybackState(false); this.canGetState = true; } catch (e) { if(hasApiPermissionError(e)) { this.logger.warn('multi-scrobbler does not have sufficient permissions to access Spotify API "Get Playback State". MS will continue to work but accuracy for determining if/when a track played from a Spotify Connect device (smart device controlled through Spotify app) may be degraded. To fix this re-authenticate MS with Spotify and restart polling.'); this.canGetState = false; return false; } else { if(!hasUpstreamError(e)) { this.logger.error(e); } return false; } } return true; } protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getPlayHistory({formatted: true, ...options}) } const asPlayHistoryObject = (obj: object): obj is SpotifyApi.PlayHistoryObject => 'played_at' in obj const asCurrentlyPlayingObject = (obj: object): obj is SpotifyApi.CurrentlyPlayingObject => 'is_playing' in obj const hasApiPermissionError = (e: Error): boolean => findCauseByFunc(e, (err) => err.message.includes('Permissions missing')) !== undefined const hasApiAuthError = (e: Error): boolean => findCauseByFunc(e, (err) => err.message.includes('An authentication error occurred')) !== undefined const hasApiTimeoutError = (e: Error): boolean => findCauseByFunc(e, (err) => err.message.includes('A timeout occurred')) !== undefined const hasApiError = (e: Error): boolean => findCauseByFunc(e, (err) => err.message.includes('while communicating with Spotify\'s Web API.')) !== undefined