[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
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #466 from FoxxMD/deezerImprovements

feat: Deezer family account and other improvements

authored by

Matt Foxx and committed by
GitHub
(Feb 11, 2026, 8:17 AM EST) 0b8dfaa5 3e25be1e

+217 -24
+149 -24
src/backend/sources/DeezerInternalSource.ts
··· 14 14 import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; 15 15 import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; 16 16 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 17 + import { UpstreamError } from "../common/errors/UpstreamError.js"; 17 18 18 19 interface DeezerHistoryResponse { 19 20 errors: [] ··· 23 24 } 24 25 } 25 26 27 + interface DeezerAccountData { 28 + USER_ID: string, 29 + /** account name */ 30 + BLOG_NAME: string, 31 + /** https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3347915743 */ 32 + EXTRA_FAMILY?: { 33 + /** if false then this account is private */ 34 + IS_LOGGABLE_AS: boolean 35 + /** true if private? */ 36 + IS_DELINKABLE: boolean 37 + } 38 + } 39 + 40 + interface DeezerAccountResponse { 41 + error?: {PERMISSION_ERROR: "No Permission"} 42 + results: DeezerAccountData[] 43 + } 44 + 45 + interface DeezerUserDataResponse { 46 + results: DeezerAuthedUserData & { 47 + checkForm: string 48 + } 49 + } 50 + 51 + interface DeezerAuthedUserData { 52 + USER: { 53 + USER_ID: string 54 + /** account name */ 55 + BLOG_NAME: string 56 + MULTI_ACCOUNT: { 57 + /** true if its a sub account */ 58 + IS_SUB_ACCOUNT: boolean 59 + } 60 + } 61 + } 62 + 26 63 export default class DeezerInternalSource extends MemorySource { 27 64 requiresAuth = true; 28 65 requiresAuthInteraction = false; 66 + isSubAccount: boolean = false; 67 + 68 + authedAccount: DeezerAuthedUserData; 69 + 70 + accounts?: DeezerAccountData[] = [] 29 71 30 72 csrfToken?: string; 31 73 ··· 119 161 .query({ 120 162 method: 'deezer.getUserData' 121 163 }) 122 - const resp = await this.callApi(req); 164 + const resp = (await this.callApi(req)) as DeezerUserDataResponse; 165 + this.authedAccount = resp.results; 166 + this.logger.verbose(`Authenticated for User ${resp.results.USER.BLOG_NAME}`); 167 + const enumerated = await this.enumerateChildAccounts(); 168 + 169 + // still a bit unsure about this 170 + // https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3357187332 171 + // but it seems like if the authed account is not the *main* account in the family then it is always considered private? 172 + if(resp.results.USER.MULTI_ACCOUNT.IS_SUB_ACCOUNT) { 173 + this.logger.verbose('This account is a child account, will not enumerate other accounts'); 174 + this.isSubAccount = true; 175 + if(this.config.data.accountId !== undefined) { 176 + this.logger.warn('Cannot use accountId when authenticated account is a child account!'); 177 + } 178 + } else { 179 + const enumerated = await this.enumerateChildAccounts(); 180 + if(this.config.data.accountId !== undefined) { 181 + if(!enumerated) { 182 + this.logger.warn('Unable to verify if account history is available for accountId due to enumeration issue.'); 183 + } else { 184 + const requestedAccount = this.accounts.find(x => x.USER_ID === this.config.data.accountId); 185 + if(requestedAccount === undefined) { 186 + this.logger.warn(`Could not find a linked account matching ${this.config.data.accountId}. History fetching may fail.`); 187 + } else { 188 + const authedAccount = this.accounts.find(x => x.USER_ID === this.authedAccount.USER.USER_ID); 189 + if(!authedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS && this.config.data.accountId !== this.authedAccount.USER.USER_ID) { 190 + 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.`); 191 + } else if(!requestedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS) { 192 + this.logger.warn('Account specified by accountId is private, likely returned will not be correct!'); 193 + } 194 + } 195 + } 196 + this.jar.setCookie(`account_id=${this.config.data.accountId}`, 'https://www.deezer.com'); 197 + this.logger.verbose(`Set account_id=${this.config.data.accountId}`); 198 + } 199 + } 123 200 return true; 124 201 } catch (e) { 125 202 throw e; ··· 130 207 131 208 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 132 209 133 - const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') 134 - .query({ 135 - method: 'user.getSongsHistory' 136 - }) 137 - .set('Content-Type', 'application/json') 138 - .send({ 139 - nb: 30, 140 - start: 0 141 - }); 142 - // returns listening history in descending order (newest to oldest) 143 - const resp = (await this.callApi(req)) as DeezerHistoryResponse; 144 - for(const e of resp.results.error) { 145 - this.logger.warn(`Error returned in history response: ${e}`); 146 - } 147 - const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song'); 148 - if(nonSong.length > 0) { 149 - const nonSongTypes = []; 150 - for(const n of nonSong) { 151 - if(!nonSongTypes.includes(n.__TYPE__)) { 152 - nonSongTypes.push(n.__TYPE__); 153 - } 210 + try { 211 + const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') 212 + .query({ 213 + method: 'user.getSongsHistory' 214 + }) 215 + .set('Content-Type', 'application/json') 216 + .send({ 217 + nb: 30, 218 + start: 0 219 + }); 220 + // returns listening history in descending order (newest to oldest) 221 + const resp = (await this.callApi(req)) as DeezerHistoryResponse; 222 + let errList: string[] = []; 223 + if('error' in resp.results) { 224 + errList = resp.results.error; 225 + } else if('errors' in resp) { 226 + errList = resp.errors; 154 227 } 155 - this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`); 228 + for (const e of errList) { 229 + this.logger.warn(`Error returned in history response: ${e}`); 230 + } 231 + const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song'); 232 + if (nonSong.length > 0) { 233 + const nonSongTypes = []; 234 + for (const n of nonSong) { 235 + if (!nonSongTypes.includes(n.__TYPE__)) { 236 + nonSongTypes.push(n.__TYPE__); 237 + } 238 + } 239 + this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`); 240 + } 241 + return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate); 242 + } catch (e) { 243 + throw new Error('Failed to get recently played tracks', {cause: e}); 156 244 } 157 - return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate); 245 + } 246 + 247 + enumerateChildAccounts = async (): Promise<boolean> => { 248 + try { 249 + const resp = await this.getChildAccounts(); 250 + this.accounts = resp; 251 + const accountSummaries: string[] = []; 252 + for(const a of this.accounts) { 253 + accountSummaries.push(`Name: ${a.BLOG_NAME} | ID: ${a.USER_ID} | Private?: ${a.EXTRA_FAMILY.IS_LOGGABLE_AS ? 'No' : 'Yes'}`); 254 + } 255 + this.logger.verbose(`Linked Accounts:\n${accountSummaries.join('\n')}`) 256 + return true; 257 + } catch (e) { 258 + if(this.config.data.accountId !== undefined) { 259 + this.logger.warn(new Error(`Could not fetch child accounts, likely using 'accountId' will not work!`)); 260 + } else { 261 + 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})); 262 + } 263 + return false; 264 + } 265 + } 266 + 267 + getChildAccounts = async () => { 268 + try { 269 + const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php') 270 + .query({ 271 + method: 'deezer.getChildAccounts' 272 + }) 273 + .set('Content-Type', 'application/json') 274 + .send({ 275 + nb: 30, 276 + start: 0 277 + }); 278 + const resp = (await this.callApi(req)) as DeezerAccountResponse; 279 + return resp.results; 280 + } catch (e) { 281 + throw new UpstreamError('Unable to get child accounts', {cause: e}); 282 + } 158 283 } 159 284 160 285 callApi = async (req: request.SuperAgentRequest, retries = 0) => {
+1
src/backend/sources/ScrobbleSources.ts
··· 377 377 redirectUri: process.env.DEEZER_REDIRECT_URI, 378 378 accessToken: process.env.DEEZER_ACCESS_TOKEN, 379 379 arl: process.env.DEEZER_ARL, 380 + accountId: process.env.DEEZER_ACCOUNT_ID 380 381 }; 381 382 if (!Object.values(d).every(x => x === undefined)) { 382 383 configs.push({
+64
docsite/docs/configuration/sources/deezer.mdx
··· 91 91 92 92 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. 93 93 94 + ### Family account 95 + 96 + Multi-scrobbler can monitor listening history for accounts linked to a [Deezer Family Account](https://www.deezer.com/en/offers/family). 97 + 98 + <details> 99 + 100 + <summary>Instructions</summary> 101 + 102 + Start multi-scrobbler using the [ARL](#retrieve-arl) for the **main account**. 103 + 104 + In the logs look for **Linked Accounts** associated with the Deezer Source. It will look something like this: 105 + 106 + ``` 107 + VERBOSE: [App] [Sources] [Deezer - MyDeezer] Linked Accounts: 108 + Name: Joe | ID: 166276334 | Private?: Yes 109 + Name: Mary | ID: 48475231 | Private?: Yes 110 + Name: FoxxMD | ID: 896225281 | Private?: No 111 + Name: Cool Guy | ID: 128522478 | Private?: Yes 112 + ``` 113 + 114 + The Account **ID** you want to monitor should be set: 115 + 116 + <Tabs groupId="configType" queryString> 117 + <TabItem value="env" label="ENV"> 118 + Set in your docker compose `environment` section: 119 + 120 + ```yaml 121 + - DEEZER_ACCOUNT_ID=896225281 122 + ``` 123 + </TabItem> 124 + <TabItem value="file" label="File or AIO"> 125 + Add an `accountId` property to the `data` section 126 + ```json 127 + [ 128 + { 129 + "name": "DeezerARL", 130 + "enable": true, 131 + "clients": [], 132 + "data": { 133 + "arl": "UOsRPjT3U5Dhaaup3xQ30D...", 134 + "accountId": "896225281" 135 + } 136 + } 137 + ] 138 + ``` 139 + </TabItem> 140 + </Tabs> 141 + 142 + Restart multi-scrobbler to start monitoring that account. 143 + 144 + :::warning[Restrictions] 145 + 146 + Deezer may not return the correct listening history under these circumstances: 147 + 148 + * The ARL used is **not** for the main account 149 + * If you can get an ARL specifically for the linked account (login as the linked account), use that **instead** of family `accountId` 150 + * The ARL account is private 151 + * The linked account to be monitored ( using `accountId`) is private 152 + 153 + ::: 154 + </details> 155 + 156 + 157 + 94 158 </TabItem> 95 159 <TabItem value="official" label="Official API"> 96 160
+3
src/backend/common/infrastructure/config/source/deezer.ts
··· 39 39 * @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0" 40 40 */ 41 41 userAgent?: string 42 + 43 + /** The ID (USER_ID) of the linked account to monitor. If not set, monitors the main ARL account */ 44 + accountId?: string 42 45 } 43 46 44 47 export interface DeezerInternalSourceConfig extends CommonSourceConfig {