From 55cec9a16fbe5bb74fef43534222b3c790cd4561 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 3 Feb 2026 16:04:30 +0000 Subject: [PATCH 1/3] fix(deezer): Fix error list access #344 --- src/backend/sources/DeezerInternalSource.ts | 54 ++++++++++++--------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 4ae2d32b..7fc24930 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -130,31 +130,41 @@ export default class DeezerInternalSource extends MemorySource { getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { - 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; - for(const e of resp.results.error) { - 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__); + 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(',')}`); } - 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 => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate); + } catch (e) { + throw new Error('Failed to get recently played tracks', {cause: e}); } - return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate); } callApi = async (req: request.SuperAgentRequest, retries = 0) => { -- 2.51.2 From 3c99adc01ccba50e41e78ebb2b37010de097b280 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 3 Feb 2026 17:26:05 +0000 Subject: [PATCH 2/3] feat(deezer): Add family account monitoring #344 --- docsite/docs/configuration/sources/deezer.mdx | 41 ++++++ .../infrastructure/config/source/deezer.ts | 3 + src/backend/sources/DeezerInternalSource.ts | 117 +++++++++++++++++- src/backend/sources/ScrobbleSources.ts | 1 + 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/docsite/docs/configuration/sources/deezer.mdx b/docsite/docs/configuration/sources/deezer.mdx index 4e9a7ea0..eb9ad9a6 100644 --- a/docsite/docs/configuration/sources/deezer.mdx +++ b/docsite/docs/configuration/sources/deezer.mdx @@ -91,6 +91,47 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat This option comes with some trade-offs: MS will aggressively detect repeated tracks within a window of time that should eliminate all duplicates. However, this will also prevent *intentionally* repeated tracks from being scrobbled. See [this thread](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2970417070) for more information on how this works. + ### Family account + + Multi-scrobbler can monitor listening history for accounts linked to a [Deezer Family Account](https://www.deezer.com/en/offers/family). + +
+ + Instructions + + Start multi-scrobbler using the [ARL](#retrieve-arl) for the **main account**. + + In the logs look for **Linked Accounts** associated with the Deezer Source. It will look something like this: + + ``` + VERBOSE: [App] [Sources] [Deezer - MyDeezer] Linked Accounts: + Name: Joe | ID: 166276334 | Private?: Yes + Name: Mary | ID: 48475231 | Private?: Yes + Name: FoxxMD | ID: 896225281 | Private?: No + Name: Cool Guy | ID: 128522478 | Private?: Yes + ``` + + The Account **ID** you want to monitor should be set: + + * ENV Config => `DEEZER_ACCOUNT_ID=896225281` + * File Config => `"accountId": "896225281"` in the `data` section of your config + + Restart multi-scrobbler to start monitoring that account. + + :::warning[Restrictions] + + Deezer may not return the correct listening history under these circumstances: + + * The ARL used is **not** for the main account + * If you can get an ARL specifically for the linked account (login as the linked account), use that **instead** of family `accountId` + * The ARL account is private + * The linked account to be monitored ( using `accountId`) is private + + ::: +
+ + + diff --git a/src/backend/common/infrastructure/config/source/deezer.ts b/src/backend/common/infrastructure/config/source/deezer.ts index 15c347d6..02c8f110 100644 --- a/src/backend/common/infrastructure/config/source/deezer.ts +++ b/src/backend/common/infrastructure/config/source/deezer.ts @@ -39,6 +39,9 @@ export interface DeezerInternalData extends CommonSourceData, PollingOptions { * @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0" */ userAgent?: string + + /** The ID (USER_ID) of the linked account to monitor. If not set, monitors the main ARL account */ + accountId?: string } export interface DeezerInternalSourceConfig extends CommonSourceConfig { diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 7fc24930..71627a93 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -14,6 +14,7 @@ import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js"; import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { UpstreamError } from "../common/errors/UpstreamError.js"; interface DeezerHistoryResponse { errors: [] @@ -23,9 +24,50 @@ interface DeezerHistoryResponse { } } +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 { requiresAuth = true; requiresAuthInteraction = false; + isSubAccount: boolean = false; + + authedAccount: DeezerAuthedUserData; + + accounts?: DeezerAccountData[] = [] csrfToken?: string; @@ -119,7 +161,42 @@ export default class DeezerInternalSource extends MemorySource { .query({ method: 'deezer.getUserData' }) - const resp = await this.callApi(req); + 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; @@ -167,6 +244,44 @@ export default class DeezerInternalSource extends MemorySource { } } + enumerateChildAccounts = async (): Promise => { + 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, diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 45d38a6c..fdf64dfa 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -376,6 +376,7 @@ export default class ScrobbleSources { redirectUri: process.env.DEEZER_REDIRECT_URI, accessToken: process.env.DEEZER_ACCESS_TOKEN, arl: process.env.DEEZER_ARL, + accountId: process.env.DEEZER_ACCOUNT_ID }; if (!Object.values(d).every(x => x === undefined)) { configs.push({ -- 2.51.2 From 1110b6b047ef40d5f8d11e2e020866cca972d364 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 3 Feb 2026 17:41:18 +0000 Subject: [PATCH 3/3] docs(deezer): Use config tabs --- docsite/docs/configuration/sources/deezer.mdx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docsite/docs/configuration/sources/deezer.mdx b/docsite/docs/configuration/sources/deezer.mdx index eb9ad9a6..87a88e66 100644 --- a/docsite/docs/configuration/sources/deezer.mdx +++ b/docsite/docs/configuration/sources/deezer.mdx @@ -113,8 +113,31 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat The Account **ID** you want to monitor should be set: - * ENV Config => `DEEZER_ACCOUNT_ID=896225281` - * File Config => `"accountId": "896225281"` in the `data` section of your config + + + Set in your docker compose `environment` section: + + ```yaml + - DEEZER_ACCOUNT_ID=896225281 + ``` + + + Add an `accountId` property to the `data` section + ```json + [ + { + "name": "DeezerARL", + "enable": true, + "clients": [], + "data": { + "arl": "UOsRPjT3U5Dhaaup3xQ30D...", + "accountId": "896225281" + } + } + ] + ``` + + Restart multi-scrobbler to start monitoring that account. -- 2.51.2