Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
29 kB · 691 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692import 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<string> {
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<true | string | undefined> { await this.buildSpotifyApi(); return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { 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<ReturnType<typeof this.spotifyApi.getMe>>(((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<string>): Promise<PaginatedTimeRangeListensResult<string>> => {
// 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<typeof this.spotifyApi.getMyRecentlyPlayedTracks>[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<ReturnType<typeof this.spotifyApi.getMyRecentlyPlayedTracks>>((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<PlayObject[]> => { try { return await this.getPlayHistory(options); } catch (e) { throw e; } }
getNowPlaying = async () => { const func = (api: SpotifyWebApi) => api.getMyCurrentPlayingTrack(); const playingRes = await this.callApi<ReturnType<typeof this.spotifyApi.getMyCurrentPlayingTrack>>(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<PlayObject> => { 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<string | null>(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<ReturnType<typeof this.spotifyApi.getMyCurrentPlaybackState>>(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<ReturnType<typeof this.spotifyApi.getMyDevices>>(funcDevice); }*/
callApi = async <T>(func: (api: SpotifyWebApi) => Promise<any>, retries = 0): Promise<T> => { 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