Merge pull request #466 from FoxxMD/deezerImprovements

feat: Deezer family account and other improvements
This commit is contained in:
Matt Foxx
2026-02-11 08:17:55 -05:00
committed by GitHub
4 changed files with 217 additions and 24 deletions
@@ -91,6 +91,70 @@ 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).
<details>
<summary>Instructions</summary>
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:
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
Set in your docker compose `environment` section:
```yaml
- DEEZER_ACCOUNT_ID=896225281
```
</TabItem>
<TabItem value="file" label="File or AIO">
Add an `accountId` property to the `data` section
```json
[
{
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"arl": "UOsRPjT3U5Dhaaup3xQ30D...",
"accountId": "896225281"
}
}
]
```
</TabItem>
</Tabs>
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
:::
</details>
</TabItem>
<TabItem value="official" label="Official API">
@@ -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 {
+149 -24
View File
@@ -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;
@@ -130,31 +207,79 @@ 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;
}
this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
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 => DeezerInternalSource.formatPlayObj(x)).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});
}
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
+1
View File
@@ -377,6 +377,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({