From 3c99adc01ccba50e41e78ebb2b37010de097b280 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 3 Feb 2026 17:26:05 +0000 Subject: [PATCH] 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