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.
19 kB · 449 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450import dayjs from "dayjs";import type EventEmitter from "events";import type { Request } from 'superagent';import request from 'superagent';import { COMPONENT_AUTH_TYPE, type ComponentAuthType, PARSED_FROM, type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy } from "../../core/Atomic.ts";import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig } from "../common/infrastructure/Atomic.ts";import type {DeezerInternalSourceConfig, DeezerInternalTrackData} from "../common/infrastructure/config/source/deezer.ts";import { TRANSFORM_HOOK } from "../../core/Transform.ts";import { parseRetryAfterSecsFromObj, playObjDataMatch, sleep, sortByOldestPlayDate} from "../utils.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import { CookieJar } from 'tough-cookie';import { MixedCookieAgent } from 'http-cookie-agent/http';import MemorySource from "./MemorySource.ts";import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.ts";import type {TemporalPlayComparisonOptions} from "../utils/TimeUtils.ts";import { findAsync, findIndexAsync } from "../utils/AsyncUtils.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { UpstreamError } from "../common/errors/UpstreamError.ts";import { artistNamesToCredits } from "../../core/StringUtils.ts";
interface DeezerHistoryResponse { errors: [] results: { data: DeezerInternalTrackData[] error: string[] }}
interface DeezerAccountData { USER_ID: string, /** account name */ BLOG_NAME: string, /** https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3347915743 */ EXTRA_FAMILY?: { /** if false then this account is private */ IS_LOGGABLE_AS: boolean /** true if private? */ IS_DELINKABLE: boolean }}
interface DeezerAccountResponse { error?: {PERMISSION_ERROR: "No Permission"} results: DeezerAccountData[]}
interface DeezerUserDataResponse { results: DeezerAuthedUserData & { checkForm: string }}
interface DeezerAuthedUserData { USER: { USER_ID: string /** account name */ BLOG_NAME: string MULTI_ACCOUNT: { /** true if its a sub account */ IS_SUB_ACCOUNT: boolean } }}
export default class DeezerInternalSource extends MemorySource { override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; requiresAuth = true; requiresAuthInteraction = false; isSubAccount: boolean = false;
authedAccount: DeezerAuthedUserData;
accounts?: DeezerAccountData[] = []
csrfToken?: string;
agent: request.SuperAgentStatic & request.Request jar: CookieJar
declare config: DeezerInternalSourceConfig;
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('deezer', name, config, internal, emitter); const { data: { interval = 60, ...rest } = {}, } = config;
if (interval < 15) { this.logger.warn('Interval should be above 30 seconds...😬'); }
// @ts-expect-error not correct structure this.config.data = { ...rest, interval, };
this.canPoll = true; this.canBacklog = true; this.supportsUpstreamRecentlyPlayed = true; this.playerSourceOfTruth = SOURCE_SOT.HISTORY; // https://developers.deezer.com/api/user/history // https://stackoverflow.com/a/19497151/1469797 this.SCROBBLE_BACKLOG_COUNT = 50;
this.jar = new CookieJar(); const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } }); // @ts-expect-error not correct structure this.agent = request.agent().use((req) => req.agent(mixedAgent)); }
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject { const {newFromSource = false} = options; const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits([obj.ART_NAME]), album: obj.ALB_TITLE, track: obj.SNG_TITLE, duration: obj.DURATION, playDate: dayjs(obj.TS * 1000), }, meta: { source: 'Deezer', trackId: obj.SNG_ID, newFromSource, url: { web: `https://www.deezer.com/track/${obj.SNG_ID}` }, musicService: 'Deezer', } }; if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') { play.meta.art = { album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg` } } return baseFormatPlayObj(obj, play); }
protected async doBuildInitData(): Promise<true | string | undefined> { this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.'); if (this.config.data.arl === undefined) { throw new Error('arl must be defined in configuration'); } this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com'); return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { try { await request.get('https://deezer.com'); return true; } catch (e) { throw e; } }
doAuthentication = async () => { try { const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') .query({ method: 'deezer.getUserData' }) const resp = (await this.callApi(req)) as DeezerUserDataResponse; this.authedAccount = resp.results; this.logger.verbose(`Authenticated for User ${resp.results.USER.BLOG_NAME}`); const enumerated = await this.enumerateChildAccounts();
// still a bit unsure about this // https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3357187332 // but it seems like if the authed account is not the *main* account in the family then it is always considered private? if(resp.results.USER.MULTI_ACCOUNT.IS_SUB_ACCOUNT) { this.logger.verbose('This account is a child account, will not enumerate other accounts'); this.isSubAccount = true; if(this.config.data.accountId !== undefined) { this.logger.warn('Cannot use accountId when authenticated account is a child account!'); } } else { const enumerated = await this.enumerateChildAccounts(); if(this.config.data.accountId !== undefined) { if(!enumerated) { this.logger.warn('Unable to verify if account history is available for accountId due to enumeration issue.'); } else { const requestedAccount = this.accounts.find(x => x.USER_ID === this.config.data.accountId); if(requestedAccount === undefined) { this.logger.warn(`Could not find a linked account matching ${this.config.data.accountId}. History fetching may fail.`); } else { const authedAccount = this.accounts.find(x => x.USER_ID === this.authedAccount.USER.USER_ID); if(!authedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS && this.config.data.accountId !== this.authedAccount.USER.USER_ID) { this.logger.warn(`Authed Account (${this.authedAccount.USER.USER_ID}) is private and specified accountId is not the same (${this.config.data.accountId}), likely history returned will not be correct.`); } else if(!requestedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS) { this.logger.warn('Account specified by accountId is private, likely returned will not be correct!'); } } } this.jar.setCookie(`account_id=${this.config.data.accountId}`, 'https://www.deezer.com'); this.logger.verbose(`Set account_id=${this.config.data.accountId}`); } } return true; } catch (e) { throw e; } }
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
this.setStatus('Checking for new Plays'); try { const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') .query({ method: 'user.getSongsHistory' }) .set('Content-Type', 'application/json') .send({ nb: 30, start: 0 }); // returns listening history in descending order (newest to oldest) const resp = (await this.callApi(req)) as DeezerHistoryResponse; let errList: string[] = []; if('error' in resp.results) { errList = resp.results.error; } else if('errors' in resp) { errList = resp.errors; } for (const e of errList) { this.logger.warn(`Error returned in history response: ${e}`); } const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song'); if (nonSong.length > 0) { const nonSongTypes = []; for (const n of nonSong) { if (!nonSongTypes.includes(n.__TYPE__)) { nonSongTypes.push(n.__TYPE__); } } this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`); } return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => { const play = DeezerInternalSource.formatPlayObj(x); play.meta.parsedFrom = PARSED_FROM.history; return play; }).sort(sortByOldestPlayDate); } catch (e) { throw new Error('Failed to get recently played tracks', {cause: e}); } }
enumerateChildAccounts = async (): Promise<boolean> => { try { const resp = await this.getChildAccounts(); this.accounts = resp; const accountSummaries: string[] = []; for(const a of this.accounts) { accountSummaries.push(`Name: ${a.BLOG_NAME} | ID: ${a.USER_ID} | Private?: ${a.EXTRA_FAMILY.IS_LOGGABLE_AS ? 'No' : 'Yes'}`); } this.logger.verbose(`Linked Accounts:\n${accountSummaries.join('\n')}`) return true; } catch (e) { if(this.config.data.accountId !== undefined) { this.logger.warn(new Error(`Could not fetch child accounts, likely using 'accountId' will not work!`)); } else { this.logger.warn(new Error('Could not enumerate child accounts. You can ignore this if there is no family account or accountId being used.', {cause: e})); } return false; } }
getChildAccounts = async () => { try { const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') .query({ method: 'deezer.getChildAccounts' }) .set('Content-Type', 'application/json') .send({ nb: 30, start: 0 }); const resp = (await this.callApi(req)) as DeezerAccountResponse; return resp.results; } catch (e) { throw new UpstreamError('Unable to get child accounts', {cause: e}); } }
callApi = async (req: request.SuperAgentRequest, retries = 0) => { const { maxRequestRetries = 1, retryMultiplier = DEFAULT_RETRY_MULTIPLIER } = this.config.options;
req.query({ input: 3, api_version: '1.0', api_token: this.csrfToken ?? '' }); setRequestHeaders(req); try { const resp = await req; const { body: { error, results } = {} } = resp; if('checkForm' in results) { this.csrfToken = results.checkForm; } if (error !== undefined && error.length > 0) { const err = new Error((error as string[]).join(' | ')); throw err; } return resp.body; } catch (e) { if(retries < maxRequestRetries) { 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 await this.callApi(req, retries + 1) } const { message, response, } = e; const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`; throw new Error(msg, {cause: e}); } }
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
async existingDiscovered(play: PlayObject): Promise<PlayMatchResult | undefined> { const list: PlayObject[] = await this.getRecentPlays(); const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate); const existing = await findAsync(list, async x => { const e = await this.transformPlay(x, TRANSFORM_HOOK.existing); return genericSourcePlayMatch(e, candidate); }); if(existing) { return { match: true, score: 1, breakdowns: [], reason: 'Has matching data with very close timestamps', closestMatchedPlay: existing, createdAt: dayjs().toISOString() } } if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { const fuzzyIndex = await findIndexAsync(list, async x => { const e = await this.transformPlay(x, TRANSFORM_HOOK.existing); let temporalOptions: TemporalPlayComparisonOptions = {}; const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY]; if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { temporalOptions = { fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5), duringReferences: ['duration', 'listenedFor', 'range'], logger: this.logger } temporalAccuracy.push(TA_DURING); } return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions); }); if(fuzzyIndex !== -1) { if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { // always return fuzzy match as existing // likely will make MS miss scrobbles for repeated plays return { match: true, score: 1, breakdowns: [], reason: 'Has matching data and timestamp is during the duration of a previous play', closestMatchedPlay: list[fuzzyIndex], createdAt: dayjs().toISOString() } } if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) { // last discovered play was this one, or next played play was also this one // so we'll assume this means the play is on repeat, don't count as existing return { match: false, score: 0.5, breakdowns: [], reason: 'Has matching data for previous play but assuming its on repeat', closestMatchedPlay: list[fuzzyIndex], createdAt: dayjs().toISOString() } } // next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match) // so this is likely a duplicate deezer should not have reported return { match: true, score: 1, breakdowns: [], reason: 'Has matching data and looks like a misreported play', closestMatchedPlay: list[fuzzyIndex], createdAt: dayjs().toISOString() } } } return { match: false, score: 0, breakdowns: [], createdAt: dayjs().toISOString() } }}
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => { req .set('Pragma', 'no-cache') .set('Origin', 'https://www.deezer.com') .set('Accept-Encoding', 'gzip, deflate, br') .set('Accept-Language', 'en-US,en;q=0.9') .set('User-Agent', userAgent) .set('Accept', '*/*') .set('Cache-Control', 'no-cache') .set('X-Requested-With', 'XMLHttpRequest') .set('Connection', 'keep-alive') .set('Referer', 'https://www.deezer.com/login') .set('DNT', '1')
const ct = req.get('Content-Type'); if(ct === '' || ct === null || ct === undefined) { req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); }}
const buildInternalUrl = (method: string, token: string = ''): URL => { const params = new URLSearchParams([ ['api_version', '1.0'], ['input', '3'] ]); params.append('method', method); params.append('api_token', token);
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
return u;}