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.
47 kB · 1166 lines
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166import dayjs, { type Dayjs, type ManipulateType } from "dayjs";import type {PlayObject, PlayObjectMinimal, ScrobbleActionResult, UnixTimestamp, URLData, Writeable} from "../../../core/Atomic.ts";import { nonEmptyStringOrDefault, splitByFirstFound, truncateStringToLength } from "../../../core/StringUtils.ts";import { creditsToNames, nameToCredit, withImage } from "../../../core/MusicMetadata.ts";import { namesToCredits } from "../../../core/MusicMetadata.ts";import { removeUndefinedKeys } from '../../../core/DataUtils.ts';import { writeFile } from '../../utils/FSUtils.ts';import { readJson } from '../../utils/DataUtils.ts';import { creditMbid, creditsWithIds, mbMeta } from '../../../core/MusicMetadata.ts';import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.ts";import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.ts";import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.ts";import { UpstreamError } from "../errors/UpstreamError.ts";import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfigOptional, type PaginatedListensTimeRangeOptions, type PaginatedTimeRangeListens, type PaginatedTimeRangeListensResult } from "../infrastructure/Atomic.ts";import type {LastfmData} from "../infrastructure/config/client/lastfm.ts";import AbstractApiClient from "./AbstractApiClient.ts";import { parseArtistCredits } from "../../utils/StringUtils.ts";import { normalizeStr } from "../../../core/StringUtils.ts";import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams, LastFMResponseError } from 'lastfm-ts-api';import clone from 'clone';import type { IncomingMessage } from "http";import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";import { AuthError, mergeSimpleError, ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";import { redactString } from "@foxxmd/redact-string";import dns from 'node:dns/promises';import xml2js from 'xml2js';import { findCauseByFunc } from "../../utils/ErrorUtils.ts";import * as z from 'zod';import { RateLimiterMemory, RateLimiterQueue, type IRateLimiterOptions } from "rate-limiter-flexible";import pRetry from 'p-retry';
const badErrors = [ 'api key suspended', 'invalid session key', 'invalid api key', 'authentication failed', 'invalid parameters'];
const unrecoverableAuthErrorCodes = [ 10, // invalid api key 13, // invalid method signature 26, // api key suspended]
const retryErrors = [ 'operation failed', 'service offline', 'temporarily unavailable', 'rate limit']
export const LIBREFM_HOST = 'libre.fm';export const LIBREFM_PATH = '/2.0/';export const LASTFM_HOST = 'ws.audioscrobbler.com';export const LASTFM_PATH = '/2.0';
export interface LastfmApiData { urlBase?: string, rateLimit?: Pick<IRateLimiterOptions, 'points' | 'duration'>}
export default class LastfmApiClient extends AbstractApiClient implements PaginatedTimeRangeListens<number> {
user?: string; declare config: LastfmData; sessionKey?: string; lastRefreshed?: number // always set in constructor declare redirectUri: string;
path!: string; upstreamName: string = 'Last.fm';
url: URLData;
userApi!: LastFMUser; trackApi!: LastFMTrack;
reqQueue?: RateLimiterQueue;
constructor(name: any, config: Partial<LastfmData> & LastfmApiData, options: AbstractApiOptions & InternalConfigOptional & {type?: string}) { const {type = 'lastfm', configDir, localUrl} = options ?? {}; super(type, name, config, options); const { redirectUri, apiKey, secret, session, urlBase = `https://${LASTFM_HOST}${LASTFM_PATH}` } = config;
this.sessionKey = session;
this.url = normalizeWebAddress(urlBase, {removeTrailingSlash: false}); let cbPrefix: string;
if(this.url.url.host === LASTFM_HOST) { this.logger.info('Using official Last.fm instance host/path'); cbPrefix = 'lastfm'; if (apiKey === undefined) { this.logger.warn(`'apiKey' not found in config!`); } } else { this.upstreamName = 'Libre.fm'; cbPrefix = 'librefm'; if(this.url.url.host === LIBREFM_HOST) { this.logger.info('Using official Libre.fm instance host/path'); } else { this.logger.info('Assuming custom Libre.fm instance'); } }
if(config.rateLimit !== undefined) { this.logger.verbose(`Rate limiting requests => ${config.rateLimit.points}req/${config.rateLimit.duration}s`); this.reqQueue = new RateLimiterQueue(new RateLimiterMemory({points: config.rateLimit.points, duration: config.rateLimit.duration}), {maxQueueSize: 20}); }
this.redirectUri = `${redirectUri ?? joinedUrl(localUrl, `${cbPrefix}/callback`).href}?state=${normalizeStr(this.name, {keepSingleWhitespace: false})}`;
this.logger.info(`Using ${this.url.normal} for API calls`); this.logger.info(`Redirect Uri: ${this.redirectUri}`); this.workingCredsPath = `${configDir}/currentCreds-${this.url.url.host === LASTFM_HOST ? 'lastfm' : 'librefm'}-${name}.json`; }
callApi = async <T>(func: any, retries = 0): Promise<T> => { const { maxRequestRetries = 2, retryMultiplier = DEFAULT_RETRY_MULTIPLIER } = this.config;
const limitedCallFunc = async () => { if (this.reqQueue !== undefined) { await this.reqQueue.removeTokens(1); this.logger.trace('Post-limiter api call start'); } return await func() as T; }
try { return await pRetry(() => limitedCallFunc(), { retries: maxRequestRetries, factor: retryMultiplier, minTimeout: 300, maxRetryTime: 30000, onFailedAttempt: (context) => { const { error, attemptNumber, retriesLeft } = context; const parts: string[] = []; let shouldThrow = false;
if (error instanceof LastFMResponseError) { const status = error.response?.statusCode; parts.push(`HTTP ${status}`); parts.push(error.message); if (status === 429) { parts.push('Api call failed due to too many requests'); } else if (error.message.includes('Expected JSON response')) { parts.push('Api call failed due unexpected non-json response'); if (error.content !== undefined) { // add some truncated response content parts.push(`Contents (truncated): ${truncateStringToLength(150)(error.content)}`); } } else { const retryError = retryErrors.find(x => error.message.toLocaleLowerCase().includes(x)); let networkError = undefined; if (retryError === undefined) { const nError = getNodeNetworkException(error); if (nError !== undefined) { networkError = nError.message; } else if (error.message.includes('ETIMEDOUT')) { networkError = 'request timed out after 3 seconds' } if(networkError !== undefined) { parts.push(`Api call failed due to a network issue: ${retryError}`); } else { parts.push(`Api call failed for a non-retryable reason`); shouldThrow = true; } } else { parts.push(`Api call failed due to a retryable error: ${retryError}`); } } if(shouldThrow) { throw new SimpleError(`Request attempt ${attemptNumber} failed with non-retryable reason: ${parts.join(' | ')}`, {cause: error, shortStack: false}); } this.logger.warn(new SimpleError(`Request attempt ${attemptNumber} failed. ${retriesLeft} retries left: ${parts.join(' | ')}`, {cause: error, shortStack: true})); } else { let networkError: string | undefined; const nError = getNodeNetworkException(error); if(nError !== undefined) { networkError = nError.message; } else if(error.message.includes('ETIMEDOUT')) { networkError = 'request timed out after 3 seconds' } if(networkError === undefined) { throw new SimpleError(`Request attempt ${attemptNumber} failed with non-retryable reason`, {cause: error, shortStack: true}); } this.logger.warn(mergeSimpleError(new SimpleError(`Request attempt ${attemptNumber} failed due to network error: ${networkError}`, {cause: error, shortStack: true}))); } } }); } catch (e) { if(e instanceof SimpleError) { throw e; } throw new Error('API call failed unexpectedly', {cause: e}); } }
getAuthUrl = () => {
const isLastFm = this.url.url.host === LASTFM_HOST; let authUrl: string; if(isLastFm) { this.logger.debug('Detected last.fm host'); authUrl = `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`; } else { this.logger.debug('Detected non-last.fm host (probably libre.fm)'); const aUrl = new URL(this.url.url.toString()); aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`; aUrl.pathname = '/api/auth'; authUrl = aUrl.toString(); } const redactedUrl = authUrl.replace(this.config.apiKey, redactString(this.config.apiKey, 6)); this.logger.debug(`Generated auth url: ${redactedUrl}`); return authUrl; }
authenticate = async (token: any) => { this.logger.debug(`Recieved token from auth callback: ${redactString(token, 10)}`); const auth = new LastFMAuth(this.config.apiKey, this.config.secret, undefined, { hostname: this.url.url.host, path: this.url.url.pathname });
this.logger.debug('Trying to get session key from last.fm...'); const sessionRes = await auth.getSession({token}); const { session: { key: sessionKey, name, // username } = {} } = sessionRes; if(sessionKey === undefined) { throw new Error(`${this.upstreamName} did not return a session key`); } this.lastRefreshed = dayjs().unix(); this.logger.debug(`Created session for user ${name}! Session key: ${redactString(sessionKey, 6)} | Refreshed At ${this.lastRefreshed}`); this.sessionKey = sessionKey; this.user = name;
this.initApi();
await writeFile(this.workingCredsPath, JSON.stringify({ sessionKey, name, lastRefreshed: this.lastRefreshed })); this.logger.trace(`Wrote session data to ${this.workingCredsPath}`); }
protected initApi = () => { this.logger.debug(`Creating new API instances with: API Key ${redactString(this.config.apiKey, 6)} | Secret: ${redactString(this.config.secret, 6)} | Session Key ${this.sessionKey !== undefined ? redactString(this.sessionKey, 6) : 'N/A'} | Host ${this.url.url.host} | Path ${this.url.url.pathname}`); this.userApi = new LastFMUser(this.config.apiKey, this.config.secret, this.sessionKey, { hostname: this.url.url.host, path: this.url.url.pathname }); this.trackApi = new LastFMTrack(this.config.apiKey, this.config.secret, this.sessionKey, { hostname: this.url.url.host, path: this.url.url.pathname }); }
initialize = async (opts: {sessionKey?: string, name?: string} = {}): Promise<true> => {
try { this.logger.debug(`Trying to load credentials from file path: ${this.workingCredsPath}`); const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false}); const {sessionKey, name, lastRefreshed} = creds || {}; this.sessionKey = sessionKey ?? opts.sessionKey; this.user = name ?? opts.name; if(lastRefreshed !== undefined) { this.lastRefreshed = lastRefreshed; } if(this.sessionKey !== undefined) { this.logger.trace('Found session key during initialization, initing api instances'); this.initApi(); } else { this.logger.trace('No session key found during initialization, not initing api instances'); } return true; } catch (e) { throw new Error(`Current ${this.upstreamName} credentials file exists but could not be parsed`, {cause: e}); } }
testConnection = async() => { try { this.logger.trace(`Looking up IP for ${this.url.url.hostname}`); const resolved = await dns.lookup(this.url.url.hostname); this.logger.trace(`${this.url.url.hostname} resolved to ${resolved.address}`); this.logger.trace(`Checking if ${this.url.url.hostname}:${this.url.port} is reachable...`); await isPortReachableConnect(this.url.port, { host: this.url.url.hostname, timeout: 2000 }); this.logger.verbose(`${this.url.url.hostname}:${this.url.port} is reachable.`); return true; } catch (e: any) { const hint = e.error?.cause?.message ?? undefined; // eslint-disable-next-line preserve-caught-error throw new Error(`Could not connect to ${this.upstreamName} API server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e }); } }
testAuth = async () => { if (this.sessionKey === undefined) { this.logger.warn('No session key found. User interaction for authentication required.'); this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`); throw new AuthError('No session key found. User interaction for authentication required.', {unrecoverable: false}); } try { // existing lastfm clients are ok with getting user from getInfo // but libre throws an error // so, if not libre allow empty user and set user from response to comply with new librefm implementation/refactor const infoPayload: Record<string, any> = { sk: this.sessionKey, }; if(this.url.url.host !== LASTFM_HOST) { this.logger.debug(`Adding ${this.user} to getInfo payload due to non-last.fm host`); infoPayload.user = this.user; } this.logger.debug(`Using sk:${redactString(this.sessionKey, 6)} in getInfo payload for auth test...`); const resp = await this.callApi<LastFMUserGetInfoResponse>(() => this.userApi.getInfo(infoPayload)); if(this.user === undefined) { this.user = resp.user.name; } this.initialized = true; this.logger.info(`Client authorized for user ${this.user}`) return true; } catch (e) { if(e instanceof AuthError) { throw e; } this.logger.error('Testing auth failed'); if(isNodeNetworkException(e)) { this.logger.error(`Could not communicate with ${this.upstreamName} API`); throw new AuthError('Testing auth failed', {cause: e, unrecoverable: false}); } let unrecoverable: boolean | undefined; if(e instanceof LastFMResponseError) { try { const errorContent = e.content !== undefined ? JSON.parse(e.content) : {}; if(`error` in errorContent) { unrecoverable = unrecoverableAuthErrorCodes.includes(errorContent.error); } } catch (e) { // swallow } } if(unrecoverable === undefined) { const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined; if(errorWithMessage !== undefined) { const statusCode = errorWithMessage.response?.statusCode; unrecoverable = statusCode !== undefined && [401,403].includes(statusCode); } } throw new AuthError('Testing auth failed', {cause: e, unrecoverable}); } }
getRecentTracksWithPagination = async (options: TracksFetchOptions = {}) => { const { page = 1, limit = 200, from, to } = options;
return await this.getRecentTracks({limit, from, to, page, extended: 1}); }
getPaginatedUnitOfTime(): ManipulateType { return 'second'; }
getPaginatedTimeRangeListens = async (fetchOptions: PaginatedListensTimeRangeOptions<number>, options: { includeNowPlaying?: boolean } = {}): Promise<PaginatedTimeRangeListensResult<number>> => {
const resp = await this.getRecentTracksWithPagination(fetchOptions);
const { includeNowPlaying = false } = options;
const { recenttracks: { track: list = [], '@attr': { total, totalPages, perPage, page } } = {} as LastFMUserGetRecentTracksResponse['recenttracks'], } = resp;
const plays = list.reduce((acc: any, x: any) => { try { const formatted = formatPlayObj(x); const { data: { track, playDate, }, meta: { nowPlaying, } } = formatted; if (nowPlaying === true && !includeNowPlaying) { // if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing // and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp // so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway this.logger.trace({ track }, `Ignoring 'now playing' track returned from ${this.upstreamName} client`); return acc; } else if (playDate === undefined) { if(nowPlaying === true) { formatted.data.playDate = dayjs(); } else { this.logger.warn({ track }, `${this.upstreamName} recently scrobbled track did not contain a timestamp, omitting from time frame check`); return acc; } } return acc.concat(formatted); } catch (e) { this.logger.warn(new Error(`Failed to format ${this.upstreamName} recently scrobbled track, omitting from time frame check`, { cause: e })); this.logger.debug({ data: x }, 'Full api response object:'); return acc; } }, []);
let totalNum: number | undefined; // libre.fm does not return total, only perPage and totalPages if(total !== undefined) { if(typeof total === 'string') { totalNum = parseInt(total, 10); } else { totalNum = total; } } else if(perPage !== undefined && totalPages !== undefined) { const pp = typeof perPage === 'string' ? parseInt(perPage, 10) : perPage; const tp = typeof totalPages === 'string' ? parseInt(totalPages, 10): totalPages; totalNum = pp * tp; }
return { data: plays, meta: { ...fetchOptions, total: totalNum, more: fetchOptions.cursor < parseInt(totalPages, 10) } };
}
getRecentTracks = async (options: TracksFetchOptions = {}): Promise<LastFMUserGetRecentTracksResponse> => {
const { to, from, extended = 1, ...rest } = options;
const requestOpts: Writeable<LastFMUserGetRecentTracksParams> = { ...rest, // sk is required if user has "Hide recent listening" enabled on their profile sk: this.sessionKey, user: this.user, extended };
if (to !== undefined) { requestOpts.to = to.toString(); } if (from !== undefined) { requestOpts.from = from.toString(); }
try { const resp = await this.callApi<LastFMUserGetRecentTracksResponse>(() => this.userApi.getRecentTracks(requestOpts)); const { recenttracks: { track = [], ...rest } = {}, ...restResp } = resp; const correctedResp: Writeable<LastFMUserGetRecentTracksResponse> = { ...restResp, // @ts-expect-error this is fine recenttracks: { ...rest, track: [], } } // when the lfm api response only returns one scrobble it formats `track` as a singular scrobble object INSTEAD of an array // and in all other cases it should return an array of scrobble objects if (!Array.isArray(track)) { if (typeof track === 'object' && track !== null) { correctedResp.recenttracks.track = [track]; this.logger.debug('Converted `track` property to an array'); } else { this.logger.warn({ track }, `Expected track to be an array or object but it was ${track === null ? 'null' : typeof track}`); } } else { correctedResp.recenttracks.track = track; } return correctedResp; } catch (e: any) { if (e.message.includes('Invalid resource specified')) { // likely the user does not have any scrobbles on their profile yet // https://github.com/FoxxMD/multi-scrobbler/issues/401#issuecomment-3749489057 // https://github.com/libre-fm/libre-fm/discussions/91#discussioncomment-15456070 // so we log as a warning and return empty array instead this.logger.warn(new Error('This error occurs when a librefm (and lastfm?) account has no existing scrobbles yet. If you are seeing this warning and this is not the case, please create an issue', { cause: e })); return { recenttracks: { track: [], '@attr': { user: this.user ?? '', totalPages: '0', total: '0', page: '1', perPage: '50' } } } } throw e; } }
scrobble = async (playObj: PlayObject): Promise<ScrobbleActionResult> => { const { meta: { source, newFromSource = false, } = {} } = playObj;
const scrobblePayload = playToClientPayload(playObj); if(scrobblePayload.duration !== undefined && scrobblePayload.duration < 30) { this.logger.info(`Removing duration of ${scrobblePayload.duration} from payload so that LFM doesn't ignore it.`); delete scrobblePayload.duration; }
try { const response = await this.callApi<LastFMTrackScrobbleResponse>(() => this.trackApi.scrobble({...scrobblePayload}) as unknown as LastFMTrackScrobbleResponse); const { scrobbles: { '@attr': { accepted = 0, ignored = 0, code = undefined, } = {}, scrobble: { track: { '#text': trackName, } = {}, album: { '#text': albumName } = {}, timestamp, ignoredMessage: { code: ignoreCode, '#text': ignoreMsg, } = {}, ...rest } = {} } = {}, } = response; if(code === 5) { throw new UpstreamError(`${this.upstreamName} API reported daily scrobble limit exceeded!`, {showStopper: true}); } if(ignored > 0) { throw new LastFMIgnoredScrobble(`${this.upstreamName} ignored scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/errorcodes for more information`, {showStopper: false}); }
const modifiedPlay = clone(playObj); delete modifiedPlay.data.playDateCompleted; if(timestamp !== undefined) { modifiedPlay.data.playDate = dayjs.unix(timestamp); } if(trackName !== undefined) { modifiedPlay.data.track = {...modifiedPlay.data.track, name: trackName}; } if(albumName !== undefined) { modifiedPlay.data.album = {...modifiedPlay.data.album, name: albumName}; }
return {payload: scrobblePayload, response, mergedScrobble: modifiedPlay, createdAt: dayjs().toISOString()}; // last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe //await sleep(1000); } catch (e) { let apiError: Error; if(!(e instanceof UpstreamError)) { apiError = new UpstreamError(`Error received from ${this.upstreamName} API`, {cause: e, showStopper: true}); } else { apiError = e; } throw new ScrobbleSubmitError('Failed to submit scrobble to Last.fm', {cause: apiError, payload: scrobblePayload}); } finally { this.logger.debug({payload: scrobblePayload}, 'Raw Payload'); } }
playingNow = async (data: PlayObject) => { try { const {timestamp, mbid, ...rest} = playToClientPayload(data); const response = await this.callApi<LastFMUpdateNowPlayingResponse>(() => this.trackApi.updateNowPlaying(rest)); const { nowplaying: { ignoredMessage: { code: ignoreCode, '#text': ignoreMsg, } = {}, } = {} } = response; if (ignoreCode !== undefined && ignoreCode > 0) { this.logger.warn({payload: rest}, `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`); } return response; } catch (e) { if (!(e instanceof UpstreamError)) { throw new UpstreamError(`Error received from ${this.upstreamName} API`, {cause: e, showStopper: false}); } else { throw e; } } }}
export interface TracksFetchOptions { to?: UnixTimestamp from?: UnixTimestamp extended?: 0 | 1 page?: number /** Max 200. Default is 50 */ limit?: number}
export class LastFMIgnoredScrobble extends UpstreamError {
}
export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObject => { const { artist, track, duration, album, albumArtist, } = obj;
let ts: Dayjs | undefined; if('timestamp' in obj) { ts = dayjs.unix(obj.timestamp); } const mbid = 'mbid' in obj ? obj.mbid : undefined;
let artists: string[] = [];
const credits = parseArtistCredits(artist); if(credits !== undefined) { artists.push(credits.primary); if(credits.secondary !== undefined) { const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== ''); if(nonEmptyArtists.length > 0) { artists = [...artists, ...nonEmptyArtists]; } } } else { artists = [artist]; }
const play: PlayObjectMinimal = { data: { track: nameToCredit(track, mbMeta(nonEmptyStringOrDefault(mbid), 'recording')), album: nameToCredit(nonEmptyStringOrDefault(album)), albumArtists: typeof albumArtist === 'string' && nonEmptyStringOrDefault(albumArtist) !== undefined ? [nameToCredit(albumArtist)] : undefined, duration: typeof duration === 'string' ? parseInt(duration, 10) : duration, playDate: ts, artists: namesToCredits(artists) }, meta: { source: 'lastfm', nowPlaying: obj.method === 'track.updateNowPlaying' } };
return baseFormatPlayObj(obj, play);}
export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload => { const { data: { artists = [], album, albumArtists = [], track, duration, playDate, } = {} } = playObj; const mbid = creditMbid(track, 'recording');
// LFM does not support multiple artists in scrobble payload // https://www.last.fm/api/show/track.scrobble let artist: string; if (artists.length === 0) { artist = ""; } else { artist = artists[0].name; }
const additionalRichPayload: Partial<LastFMScrobblePayload> = {}; if(duration !== 0) { additionalRichPayload.duration = duration; }
const rawPayload: LastFMScrobblePayload = { artist: artist, // track is required by LFM, an empty value will be rejected upstream // but we don't throw here since this is also used to build payloads for logging failed scrobbles track: track?.name ?? '', album: album?.name, timestamp: getScrobbleTsSOCDate(playObj).unix(), mbid, ...additionalRichPayload };
// LFM ignores scrobbles where album artist is VA // https://github.com/FoxxMD/multi-scrobbler/issues/340#issuecomment-3220774257 const nonVaAlbumArtists = albumArtists.filter(x => x.name.trim().toLocaleLowerCase() !== 'va'); // LFM does not support multiple artists in scrobble payload // https://www.last.fm/api/show/track.scrobble if (nonVaAlbumArtists.length > 0) { rawPayload.albumArtist = nonVaAlbumArtists[0].name; }
// I don't know if its lastfm-node-client building the request params incorrectly // or the last.fm api not handling the params correctly... // // ...but in either case if any of the below properties is undefined (possibly also null??) // then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful) // so remove all undefined keys from the object before passing to the api client return removeUndefinedKeys(rawPayload, false); }
export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectOptions & {source?: string} = {}): PlayObject => { const { artist: { '#text': artists, name: artistName, mbid: artistMbid, }, name: title, image, album: { '#text': album, mbid: albumMbid, }, duration, date: { uts: time, } = {}, '@attr': { nowplaying = 'false', } = {}, url, mbid, } = obj; const { source = 'Lastfm' } = options; // arbitrary decision yikes const artistStrings = splitByFirstFound(artists, [','], artistName === undefined || artistName.trim() === '' ? [] : [artistName]); let al: string | undefined = album; if(al !== undefined) { if(al === null) { al = undefined; } else if (al.trim() === '') { // lastfm may provide empty string when album data is not defined al = undefined; } }
const i = image ?? []; let imageSizeNum: number = 0; let imageUrl: string | undefined = undefined; if(i !== undefined && i !== null && i.length > 0) { for(const im of i) { const res = lastfmImageSize.safeParse(im.size); if(res.success && lastfmImageSizeMap[res.data] > imageSizeNum) { imageSizeNum = lastfmImageSizeMap[res.data]; imageUrl = im["#text"]; } } }
const play: PlayObjectMinimal = { data: { artists: creditsWithIds(namesToCredits([...new Set(artistStrings)] as string[]), splitByFirstFound<undefined>(artistMbid, [',',';'], undefined), 'musicbrainz', 'artist'), track: nameToCredit(title, mbMeta(mbid, 'recording')), album: withImage(nameToCredit(al, mbMeta(albumMbid, 'release')), imageUrl), duration, playDate: time !== undefined ? dayjs.unix(typeof time === 'string' ? Number.parseInt(time, 10) : time) : undefined }, meta: { nowPlaying: nowplaying === 'true', source, url: { web: url, } } }
return baseFormatPlayObj(obj, play);}
type LastFMTrackScrobbleResponse = Readonly<{ scrobbles: { scrobble: { track: { corrected: LastFMBooleanNumber; '#text': string; }; artist: { corrected: LastFMBooleanNumber; '#text': string; }; album: { corrected: LastFMBooleanNumber; '#text': string; }; albumArtist: { corrected: LastFMBooleanNumber; '#text': string; }; timestamp: number; ignoredMessage: { code: number; '#text': string; }; } '@attr': { accepted: number; ignored: number; code?: number; }; };}>;
const lastfmImageSize = z.enum(['small','medium','large','extralarge']);type LastFmImageSizes = z.infer<typeof lastfmImageSize>;const lastfmImageSizeMap: Record<LastFmImageSizes, number> = { small: 1, medium: 2, large: 3, extralarge: 4} as const;
export type LastFMTrackObject = { artist: { mbid?: string; name?: string '#text': string; }; streamable?: LastFMBooleanNumber; image?: Array<{ '#text': string; size: string; }>; mbid?: string; album: { mbid?: string; '#text': string; }; duration?: number; name: string; url?: string; date: { uts: string; '#text'?: string; }; '@attr'?: { nowplaying: 'true' | 'false'; };}
export interface LastFMScrobblePayload { /** * join multiple artists with ', ' * */ artist: string
/** * track title * */ track: string
/** * Unix timestamp of time track should be scrobbled at * */ timestamp: number /** * length of track in seconds * */ duration?: number
album?: string
albumArtist?: string
/** MusicBrainz track ID */ mbid?: string}
// bodies arrive as urlencoded form data, so numeric fields are always strings (or string[] for batch keys) on the wireconst lastfmNumericStringSchema = z.coerce.number().int().positive();
export const lastfmSubmitPayloadSchema = z.object({ artist: z.string(), track: z.string(), album: z.string().optional(), albumArtist: z.string().optional(), mbid: z.string().optional(), duration: lastfmNumericStringSchema.optional(), sk: z.string().optional(), api_key: z.string().optional()});
export const lastfmSubmitMultiPayloadSchema = z.object({ artist: z.array(z.string()), track: z.array(z.string()), album: z.array(z.string()).optional(), albumArtist: z.array(z.string()).optional(), mbid: z.array(z.string()).optional(), duration: z.array(lastfmNumericStringSchema).optional(), sk: z.string().optional(), api_key: z.string().optional()});
export const lastfmScrobblePayloadSchema = z.object({ method: z.literal('track.scrobble'), ...lastfmSubmitPayloadSchema.shape, timestamp: lastfmNumericStringSchema,}).meta({title: 'Single Scrobble'});export type LastfmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;export const lastfmScrobbleMultiPayloadSchema = z.object({ method: z.literal('track.scrobble'), ...lastfmSubmitMultiPayloadSchema.shape, timestamp: z.array(lastfmNumericStringSchema),}).meta({title: 'Bulk Scrobbles'});export const lastfmScrobbleMaybeMultiPayloadSchema = z.union([lastfmScrobblePayloadSchema,lastfmScrobbleMultiPayloadSchema]).meta({title: 'Single or Bulk Scrobble'});export type LastfmScrobbleMaybeMultiPayload = z.infer<typeof lastfmScrobbleMaybeMultiPayloadSchema>;
export type LastFmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
export const lastfmNowPlayingPayloadSchema = z.object({ method: z.literal('track.updateNowPlaying'), ...lastfmSubmitPayloadSchema.shape,}).meta({title: 'Update Now Playing'});export type LastFmNowPlayingPayload = z.infer<typeof lastfmNowPlayingPayloadSchema>;
export type LastFmSubmitPayload = LastfmScrobbleMaybeMultiPayload | LastFmNowPlayingPayload;export type LastFmSingleSubmitPayload = LastfmScrobblePayload | LastFmNowPlayingPayload;
export const lastfmAuthRequestPayloadSchema = z.object({ method: z.literal('auth.getMobileSession'), username: z.string().optional(), password: z.string().optional(), api_key: z.string().optional(),}).meta({title: 'Get Mobile Auth Session'});
export const lastfmRequestSchema = z.union([ lastfmScrobbleMaybeMultiPayloadSchema, z.discriminatedUnion("method", [ lastfmNowPlayingPayloadSchema, lastfmAuthRequestPayloadSchema ]).meta({title: 'Update Now Playing or Mobile Auth Session'})]);export type LastFmRequest = z.infer<typeof lastfmRequestSchema>;
export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload { method: string}
export type LastFMPayloadkey = keyof LastFMScrobbleRequestPayload;const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist'];//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid'];//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional];
export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScrobblePayload[] => { const keys = Object.keys(obj); let allObject = true; for(const k of lfmPayloadKeysRequired) { if(!keys.includes(k)) { throw new Error(`Missing required key '${k}'`); } if(Array.isArray(obj[k])) { allObject = false; } else if(allObject === false) { throw new Error('Payload is an unexpected mix of arrays and objects'); } } const payloads: LastfmScrobblePayload[] = [];
if(allObject) { payloads.push(obj as LastfmScrobblePayload); } else { let index = 0; for(const t of (obj.track as string[])) { payloads.push({ track: t, artist: obj.artist[index], timestamp: obj.timestamp !== undefined ? (obj.timestamp as number[])[index] : dayjs().unix(), album: obj.album !== undefined ? obj.album[index] : undefined, mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined, duration: obj.duration !== undefined ? (obj.duration as number[])[index] : undefined, albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined, method: obj.method }) index++; } }
return payloads.map(x => { const cleaned: LastfmScrobblePayload = x; if(typeof cleaned.duration === 'string') { cleaned.duration = Number.parseInt(cleaned.duration); } if(cleaned.duration === undefined || isNaN(cleaned.duration) || cleaned.duration <= 0) { cleaned.duration = undefined; } if(typeof cleaned.timestamp === 'string') { cleaned.timestamp = Number.parseInt(cleaned.timestamp); } if(isNaN(cleaned.timestamp)) { cleaned.timestamp = dayjs().unix(); } return cleaned; })}
export const playToScrobbleApiResponseJson = (play: PlayObject) => { const jsonPayload: LastFMTrackScrobbleResponse = { scrobbles: { '@attr': { accepted: 1, ignored: 0 }, scrobble: { track: { corrected: 0, '#text': play.data.track?.name ?? '' }, artist: { corrected: 0, '#text': creditsToNames(play.data.artists).join(',') }, album: { corrected: 0, '#text': play.data.album?.name ?? '' }, albumArtist: { corrected: 0, '#text': creditsToNames(play.data.albumArtists).join(',') }, timestamp: dayjs().unix(), ignoredMessage: { code: 0, '#text': '' } } } } return jsonPayload;}
export const playToNowPlayingApiResponseJson = (play: PlayObject) => { const jsonPayload = { nowplaying: { track: { corrected: 0, '#text': play.data.track?.name ?? '' }, artist: { corrected: 0, '#text': creditsToNames(play.data.artists).join(',') }, album: { corrected: 0, '#text': play.data.album?.name ?? '' }, albumArtist: { corrected: 0, '#text': creditsToNames(play.data.albumArtists).join(',') }, ignoredMessage: { code: 0, '#text': '' } } } return jsonPayload;}
export const playToScrobbleApiResponseXml = (play: PlayObject) => { const builder = new xml2js.Builder(); const xml = builder.buildObject({ lfm: { $: { status: "ok" }, scrobbles: { $: {accepted: 1, ignored: 0}, scrobble: { track: { $: {corrected: 0}, _: play.data.track?.name ?? '' }, artist: { $: {corrected: 0}, _: creditsToNames(play.data.artists).join(',') }, album: { $: {corrected: 0}, _: play.data.album?.name ?? '' }, albumArtist: { $: {corrected: 0}, _: creditsToNames(play.data.albumArtists).join(',') }, timestamp: { _: dayjs().unix(), }, ignoredMessage: { $: {code: 0} } } } } }); return xml;}
export const playToNowPlayingApiResponseXml = (play: PlayObject) => { const builder = new xml2js.Builder(); const xml = builder.buildObject({ lfm: { $: { status: "ok" }, nowplaying: { track: { $: { corrected: 0 }, _: play.data.track?.name ?? '' }, artist: { $: { corrected: 0 }, _: creditsToNames(play.data.artists).join(',') }, album: { $: { corrected: 0 }, _: play.data.album?.name ?? '' }, albumArtist: { $: { corrected: 0 }, _: creditsToNames(play.data.albumArtists).join(',') }, ignoredMessage: { $: { code: 0 } } } } }); return xml;}