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.
10.0 kB · 254 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255import dayjs from "dayjs";import type EventEmitter from "events";import passport from "passport";import { Strategy as DeezerStrategy } from 'passport-deezer';import request from 'superagent';import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts";import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig } from "../common/infrastructure/Atomic.ts";import type {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.ts";import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, } from "../utils.ts";import { writeFile } from '../utils/FSUtils.ts';import { readJson } from '../utils/DataUtils.ts';import { joinedUrl } from "../utils/NetworkUtils.ts";import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { SimpleError } from "../common/errors/MSErrors.ts";
export default class DeezerSource extends AbstractSource { workingCredsPath;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; requiresAuth = true; requiresAuthInteraction = true;
baseUrl = 'https://api.deezer.com'; redirectUri: string;
declare config: DeezerSourceConfig;
constructor(name: any, config: DeezerSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('deezer', name, config, internal, emitter); const { data: { interval = 60, redirectUri, ...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, redirectUri, };
this.redirectUri = redirectUri || joinedUrl(this.localUrl, 'deezer/callback').toString();
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; this.canBacklog = true; this.supportsUpstreamRecentlyPlayed = true; // https://developers.deezer.com/api/user/history // https://stackoverflow.com/a/19497151/1469797 this.SCROBBLE_BACKLOG_COUNT = 50; }
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { const {newFromSource = false} = options; const { title: name, artist: { name: artistName, } = {}, duration, timestamp, id, link, album: { title: albumName, } = {}, } = obj; const play: PlayObjectMinimal = { data: { artists: [artistName], album: albumName, track: name, duration, playDate: dayjs(timestamp * 1000), }, meta: { source: 'Deezer', musicService: 'Deezer', trackId: id, newFromSource, url: { web: link } } } return baseFormatPlayObj(obj, play); }
protected async doBuildInitData(): Promise<true | string | undefined> { this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. Refer to the MS documentation for a new Deezer Source implementation.');
try { const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false}); if(credFile !== undefined) { this.config.data.accessToken = credFile.accessToken; } else { this.logger.warn(`No Deezer credentials file found at ${this.workingCredsPath}`); } } catch (e) { throw new Error('Current deezer credentials file exists but could not be parsed', {cause: e}); } if (this.config.data.accessToken === undefined) { if (this.config.data.clientId === undefined) { throw new Error('clientId must be defined when accessToken is not present'); } else if (this.config.data.clientSecret === undefined) { throw new Error('clientSecret must be defined when accessToken is not present'); } } this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`); passport.use(`deezer-${this.name}`, this.generatePassportStrategy()); return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { try { await request.get('https://api.deezer.com/infos'); return true; } catch (e) { throw e; } }
doAuthentication = async () => { if(this.config.data.accessToken === undefined) { throw new Error(`No access token is present. User interaction for authentication is required.`); } try { await this.callApi(request.get(`${this.baseUrl}/user/me`)); return true; } catch (e) { throw e; } }
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history?limit=${options.limit || 20}`)); return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByOldestPlayDate); }
callApi = async (req: any, retries = 0) => { const { maxRequestRetries = 1, retryMultiplier = DEFAULT_RETRY_MULTIPLIER } = this.config.options;
req.query({ access_token: this.config.data.accessToken, output: 'json' }); try { const resp = await req; const { body = {}, body: { error, } = {} } = resp; if (error !== undefined) { const err = new Error(error.message); // @ts-expect-error TS(2339): Property 'type' does not exist on type 'Error'. err.type = error.type; // @ts-expect-error TS(2339): Property 'code' does not exist on type 'Error'. err.code = error.code; // @ts-expect-error TS(2339): Property 'response' does not exist on type 'Error'... Remove this comment to see the full error message err.response = resp; throw err; } return 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: { status, body: { "subsonic-response": { status: ssStatus, error: { code, message: ssMessage, } = {}, } = {}, "subsonic-response": ssResp } = {}, text, } = {}, response, } = e; const msg = response !== undefined ? `API Call failed: Server Response => ${ssMessage}` : `API Call failed: ${message}`; const responseMeta = ssResp ?? text; this.logger.error({status, response: responseMeta}, msg); throw e; } }
generatePassportStrategy = () => new DeezerStrategy({ clientID: this.config.data.clientId, clientSecret: this.config.data.clientSecret, callbackURL: this.redirectUri, scope: ['listening_history','offline_access'], }, (accessToken: any, refreshToken: any, profile: any, done: any) => { // return done(null, { // accessToken, // refreshToken, // ...profile, // }); this.handleAuthCodeCallback({ accessToken, refreshToken, ...profile, }).then((r) => { if(r === true) { return done(null, {}); } return done(r); }); })
handleAuthCodeCallback = async (res: any) => { const {error, accessToken, id, displayName} = res; if (error === undefined) { await writeFile(this.workingCredsPath, JSON.stringify({ accessToken, id, displayName, })); this.config.data.accessToken = accessToken; this.logger.info('Got token Deezer SDK callback!'); return true; } else { this.logger.warn('Callback contained an error! User may have denied access?') this.errors.push(error); this.logger.error(error); return error; } }
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})}