Compare commits

...
9 Commits
Author SHA1 Message Date
FoxxMD ede2d9f71a chore: Bump version for release 2025-06-19 16:22:39 +00:00
Matt Foxx acccc32813 Merge pull request #296 from FoxxMD/deezer-internal
feat: Implement Deezer Internal (ARL) Source
2025-06-17 20:41:08 -04:00
FoxxMD 69f73ff09f docs(deezer): Rewrite Deezer Source with ARL implementation
* Add ARL implementation docs and warnings
* Update json examples to include both types of deezer configs
2025-06-17 15:16:13 +00:00
FoxxMD 305f3c42ae fix(deezer): options are optional 2025-06-17 15:14:01 +00:00
FoxxMD d6ac4c34c5 feat: Implement better during/range temporal comparisons
* Refactor temporal comparisons to provide granular "during" thresholds
* Use duration for ignoring discovery with aggressive mode in Deezer #296
2025-06-17 10:10:34 -04:00
FoxxMD 9c6eeaafdb feat: Fuzzy diff threshold configurable
* Refactor fuzzy diff theshold to be configurable
* Configure aggressive fuzzy discovery for Deezer to be 40 seconds #296
2025-06-17 10:10:34 -04:00
FoxxMD 6bd841fea2 feat(deezer internal): Add fuzzy discovery ignore option
Allows user-configurable decision on whether to ignore fuzzy-matched discovered plays
2025-06-17 10:10:34 -04:00
FoxxMD e05c46e9ce fix(deezer official): Fix missing interval interface property 2025-06-17 10:10:34 -04:00
FoxxMD e94109dc75 feat: Implement Deezer Internal (ARL) Source 2025-06-17 10:10:34 -04:00
20 changed files with 926 additions and 164 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
+13
View File
@@ -0,0 +1,13 @@
[
{
"name": "DeezerOfficalDeprecated",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
}
}
]
+2 -5
View File
@@ -1,13 +1,10 @@
[
{
"name": "FoxxMDeezer",
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
"arl": "UOsRPjT3U5Dhaaup3xQ30DWFoSgqugdSYzL3QE743waaVy2eur2ckyJcZ4RV123Q3PsbKXkLwA6ahc9XmEfev6BtfSP8LujE3JswWRQogZCDUOwVzWO5sAig",
}
}
]
+139 -40
View File
@@ -12,7 +12,8 @@ import AIOExample from "../../src/components/AIOExample";
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/deezer.json.example';
import DeezerDeprecatedConfig from '!!raw-loader!../../../config/deezer-deprecated.json.example';
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
@@ -872,72 +873,170 @@ Most Listenbrainz clients require a token (Authentication Token) to be provided
</TabItem>
</Tabs>
### [~~Deezer~~](https://deezer.com/)
### [Deezer](https://deezer.com/)
:::warning
<Tabs groupId="deezerSource" queryString>
<TabItem value="arl" label="Unofficial ARL">
:::warning
**This Source is DEPRECATED because Deezer has dropped official API support.** This Source will **not** be removed but no further support or fixes will be given.
This Source uses unofficial methods to access Deezer data that are likely in violation of Deezer's TOS. Deezer may change or remove these methods at any time, breaking functionality. Use this Source at your own risk.
Users cannot create new applications on Deezer Developers and there is no guarantee existing applications will continue to work.
:::
As a workaround consider integrating Deezer with last.fm and then using [last.fm as a Source](#lastfm-source).
:::info
Users with existing Deezer applications in use with multi-scrobbler should consider this change as well to avoid future breaking issues with the unsupported API.
This Source will work only if you have a **Premium** Deezer account.
[See this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
:::
:::
##### Retrieve ARL
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
The `arl` is a browser cookie that Deezer uses to store your login information. Multi-scrobbler uses the `arl` value to make authorized requests to Deezer's API where it can retrieve listening history.
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
* Redirect URL must end in `deezer/callback`
* Default would be `http://localhost:9078/deezer/callback`
**After logging into Deezer**, use one of the methods below to retrieve the `arl` cookie value:
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
<Tabs groupId="arlRetrival">
<TabItem value="chrome" label="Chrome">
1. On deezer.com, press F12 to open the Developer Tools window.
2. Go to the Applications tab. Press the '>>' More tabs icon if it's hidden.
3. Under the Cookies Filter, click deezer.com, then the text bar name `arl`.
4. Copy all the contents shown in the **Cookie Value** pane.
</TabItem>
<TabItem value="firefox" label="Firefox">
1. On deezer.com, hit Ctrl+Shift+I to open Developer Tools
2. Go to the Storage Tab, then expand Cookies in the sidebar and select deezer.com
3. Find the row with `arl` as the name, then double click the Value column and right click -> copy the value
</TabItem>
</Tabs>
* **Application ID**
* **Secret Key**
* **Redirect URL** (if not the default)
#### Configuration
**If no access token is provided...**
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ------------------------------------------------ |
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
</TabItem>
<TabItem value="file" label="File">
<details>
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
<summary>Example</summary>
#### Configuration
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={DeezerConfig} name="deezer"/>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
</Tabs>
##### Duplicate detection
Third party integrations with Deezer (Sonos) may cause Deezer to [report the same track many times in listening history.](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2922374738) If you experience this issue modify your Deezer Source config (file-based only) to include the option `"fuzzyDiscoveryIgnore": "aggressive"`
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-----------------------------------------|-----------------------------------|
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
<summary>deezer.json example</summary>
```json title="deezer.json"
[
{
"name": "MyDeezer",
"data": {
"arl": "UOsRPjT3U5Dhaaup3x...",
},
"options": {
"fuzzyDiscoveryIgnore": "aggressive"
}
}
]
```
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
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.
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<TabItem value="official" label="Official API">
<summary>Example</summary>
:::warning
<AIOExample data={DeezerConfig} name="deezer"/>
**This Source is DEPRECATED because Deezer has dropped official API support.** This Source will **not** be removed but no further support or fixes will be given. [See this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
</details>
Users cannot create new applications on Deezer Developers and there is no guarantee existing applications will continue to work.
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
As a workaround consider:
* Using the alternative [Deezer Source that uses unofficial, internal API via ARL](./?deezerSource=arl#deezer)
* Connect Deezer with Last.fm and then use [Last.fm as a Source](#lastfm-source)
:::
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
* Redirect URL must end in `deezer/callback`
* Default would be `http://localhost:9078/deezer/callback`
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
* **Application ID**
* **Secret Key**
* **Redirect URL** (if not the default)
**If no access token is provided...**
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-----------------------------------------|-----------------------------------|
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerDeprecatedConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={DeezerDeprecatedConfig} name="deezer"/>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
</Tabs>
</TabItem>
</Tabs>
### [Youtube Music](https://music.youtube.com)
:::warning
+1 -1
View File
@@ -24,7 +24,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](docs/configuration#deezer)
* [Deezer](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
+118 -39
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.4",
"version": "0.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.4",
"version": "0.9.5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -53,6 +53,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
@@ -79,6 +80,7 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
@@ -490,6 +492,32 @@
"tough-cookie": "^4.1.4"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@curvenote/ansi-to-react": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@curvenote/ansi-to-react/-/ansi-to-react-7.0.0.tgz",
@@ -1025,6 +1053,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
@@ -3254,12 +3283,10 @@
}
},
"node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"dependencies": {
"debug": "^4.3.4"
},
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
@@ -6015,6 +6042,30 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-cookie-agent": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.1.tgz",
"integrity": "sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0",
"undici": "^7.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -9095,10 +9146,17 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -9142,7 +9200,8 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -9481,7 +9540,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "4.1.8",
@@ -10538,6 +10598,24 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -10631,27 +10709,15 @@
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
"node": ">=16"
}
},
"node_modules/tr46": {
@@ -10947,14 +11013,14 @@
"dev": true
},
"node_modules/undici": {
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
"integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=14.0"
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
@@ -11032,6 +11098,7 @@
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -11984,6 +12051,18 @@
"undici": "^5.19.1"
}
},
"node_modules/youtubei.js/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.4",
"version": "0.9.5",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -85,6 +85,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
@@ -111,6 +112,7 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
@@ -1,6 +1,8 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { Second } from "../../../../../core/Atomic.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface DeezerData extends CommonSourceData {
export interface DeezerData extends CommonSourceData, PollingOptions {
/**
* deezer client id
*
@@ -20,13 +22,6 @@ export interface DeezerData extends CommonSourceData {
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri?: string
/**
* optional, how long to wait before calling spotify for new tracks (in seconds)
*
* @default 60
* @examples [60]
* */
interval?: number
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
@@ -35,3 +30,51 @@ export interface DeezerSourceConfig extends CommonSourceConfig {
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
type: 'deezer'
}
export interface DeezerInternalData extends CommonSourceData, PollingOptions {
/** ARL retrieved from Deezer response header */
arl: string
/** User agent
*
* @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0"
*/
userAgent?: string
}
export interface DeezerInternalSourceConfig extends CommonSourceConfig {
data: DeezerInternalData
options?: DeezerInternalSourceOptions
}
export interface DeezerInternalSourceOptions extends CommonSourceOptions {
fuzzyDiscoveryIgnore?: boolean | 'aggressive'
}
export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
type: 'deezer'
}
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
export interface DeezerInternalTrackData {
/** Song Id */
SNG_ID: string
/** Date listened as unix timestamp in seconds */
TS: Second
/** Album Id */
ALB_ID: string
/** Album Title */
ALB_TITLE: string
/** Album Art Id */
ALB_PICTURE: string
/** Artist Id */
ART_ID: string
/** Artist Name */
ART_NAME: string
/** Song Title */
SNG_TITLE: string
/** Time listened to track in seconds */
DURATION: Second
__TYPE__: 'song' | string
}
@@ -2,7 +2,7 @@ import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
@@ -29,6 +29,7 @@ export type SourceConfig =
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| DeezerInternalSourceConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
@@ -56,6 +57,7 @@ export type SourceAIOConfig =
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| DeezerInternalAIOConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
@@ -10,6 +10,8 @@ import {
PlayObject,
QueuedScrobble,
TA_CLOSE,
TA_DEFAULT_ACCURACY,
TA_DURING,
TA_FUZZY,
TrackStringOptions,
} from "../../core/Atomic.js";
@@ -42,7 +44,7 @@ import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/E
import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js";
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalAccuracyToString,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
@@ -315,7 +317,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
const temporalComparison = comparePlayTemporally(x.play, playObj);
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
return hasAcceptableTemporalAccuracy(temporalComparison.match)
});
return [matchPlayDate, dtInvariantMatches];
@@ -403,9 +405,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const temporalComparison = comparePlayTemporally(x, playObj);
let timeMatch = 0;
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
timeMatch = 1;
} else if(temporalComparison.match === TA_FUZZY) {
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, [TA_FUZZY, TA_DURING])) {
timeMatch = 0.6;
}
+12 -8
View File
@@ -2,7 +2,7 @@ import { childLogger, LogDataPretty } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import { FixedSizeList } from "fixed-size-list";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY } from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
import {
@@ -33,11 +33,12 @@ import {
sortByNewestPlayDate,
sortByOldestPlayDate,
} from "../utils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
export interface RecentlyPlayedOptions {
limit?: number
@@ -80,7 +81,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
emitter: EventEmitter;
protected SCROBBLE_BACKLOG_COUNT: number = 20;
protected SCROBBLE_BACKLOG_COUNT: number = 30;
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
@@ -130,7 +131,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
protected addPlayToDiscovered = (play: PlayObject) => {
const platformId = this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID;
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(200);
list.add(play);
this.recentDiscoveredPlays.set(platformId, list);
this.tracksDiscovered++;
@@ -152,7 +153,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
return [];
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
protected getExistingDiscoveredLists = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject[][] => {
const lists: PlayObject[][] = [];
if(opts.checkAll !== true) {
lists.push(this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID));
@@ -168,19 +169,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
});
}
return lists;
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
}
return undefined;
//const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID);
//return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
}
alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => {
+279
View File
@@ -0,0 +1,279 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import request, { Request, Response, SuperAgent } from 'superagent';
import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js";
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { CookieJar, Cookie } from 'tough-cookie';
import { MixedCookieAgent } from 'http-cookie-agent/http';
import MemorySource from "./MemorySource.js";
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
interface DeezerHistoryResponse {
errors: []
results: {
data: DeezerInternalTrackData[]
error: string[]
}
}
export default class DeezerInternalSource extends MemorySource {
requiresAuth = true;
requiresAuthInteraction = false;
csrfToken?: string;
agent: request.SuperAgentStatic & request.Request
jar: CookieJar
declare config: DeezerInternalSourceConfig;
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('deezer', name, config, internal, emitter);
const {
data: {
interval = 60,
...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,
};
this.canPoll = true;
this.canBacklog = true;
this.supportsUpstreamRecentlyPlayed = true;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
// https://developers.deezer.com/api/user/history
// https://stackoverflow.com/a/19497151/1469797
this.SCROBBLE_BACKLOG_COUNT = 50;
this.jar = new CookieJar();
const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
this.agent = request.agent().use((req) => req.agent(mixedAgent));
}
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const play: PlayObject = {
data: {
artists: [obj.ART_NAME],
album: obj.ALB_TITLE,
track: obj.SNG_TITLE,
duration: obj.DURATION,
playDate: dayjs(obj.TS * 1000),
},
meta: {
source: 'Deezer',
trackId: obj.SNG_ID,
newFromSource,
url: {
web: `https://www.deezer.com/track/${obj.SNG_ID}`
}
}
};
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
play.meta.art = {
album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
}
}
return play;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
if (this.config.data.arl === undefined) {
throw new Error('arl must be defined in configuration');
}
this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get('https://deezer.com');
return true;
} catch (e) {
throw e;
}
}
doAuthentication = async () => {
try {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'deezer.getUserData'
})
const resp = await this.callApi(req);
return true;
} catch (e) {
throw e;
}
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
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__);
}
}
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);
}
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.options;
req.query({
input: 3,
api_version: '1.0',
api_token: this.csrfToken ?? ''
});
setRequestHeaders(req);
try {
const resp = await req;
const {
body: {
error,
results
} = {}
} = resp;
if('checkForm' in results) {
this.csrfToken = results.checkForm;
}
if (error !== undefined && error.length > 0) {
const err = new Error((error as string[]).join(' | '));
throw err;
}
return resp.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,
} = e;
const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
throw new Error(msg, {cause: e});
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
const fuzzyIndex = list.findIndex(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
let temporalOptions: TemporalPlayComparisonOptions = {};
const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
temporalOptions = {
fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
duringReferences: ['duration', 'listenedFor', 'range']
}
temporalAccuracy.push(TA_DURING);
}
return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
});
if(fuzzyIndex !== -1) {
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
// always return fuzzy match as existing
// likely will make MS miss scrobbles for repeated plays
return list[fuzzyIndex];
}
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
// last discovered play was this one, or next played play was also this one
// so we'll assume this means the play is on repeat, don't count as existing
return undefined;
}
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
// so this is likely a duplicate deezer should not have reported
return list[fuzzyIndex];
}
}
}
return undefined;
}
}
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
req
.set('Pragma', 'no-cache')
.set('Origin', 'https://www.deezer.com')
.set('Accept-Encoding', 'gzip, deflate, br')
.set('Accept-Language', 'en-US,en;q=0.9')
.set('User-Agent', userAgent)
.set('Accept', '*/*')
.set('Cache-Control', 'no-cache')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Connection', 'keep-alive')
.set('Referer', 'https://www.deezer.com/login')
.set('DNT', '1')
const ct = req.get('Content-Type');
if(ct === '' || ct === null || ct === undefined) {
req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
}
const buildInternalUrl = (method: string, token: string = ''): URL => {
const params = new URLSearchParams([
['api_version', '1.0'],
['input', '3']
]);
params.append('method', method);
params.append('api_token', token);
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
return u;
}
+1 -1
View File
@@ -91,7 +91,7 @@ export default class DeezerSource extends AbstractSource {
}
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. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
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});
+3 -3
View File
@@ -1,7 +1,7 @@
import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT } from "../../core/Atomic.js";
import {
buildTrackString,
combinePartsToString,
@@ -19,7 +19,7 @@ import {
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import MemorySource from "./MemorySource.js";
@@ -313,7 +313,7 @@ export default class JellyfinSource extends MemorySource {
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
}
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
if(hasAcceptableTemporalAccuracy(temporalResult.match)) {
existingTracked = currPlay;
}
break;
+10 -3
View File
@@ -5,7 +5,7 @@ import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } fro
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
import {
@@ -64,6 +64,7 @@ import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
import PlexApiSource from './PlexApiSource.js';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
import { IcecastSource } from './IcecastSource.js';
import DeezerInternalSource from './DeezerInternalSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -139,7 +140,7 @@ export default class ScrobbleSources {
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig");
break;
case 'deezer':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerCompatConfig");
break;
case 'endpointlz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
@@ -411,6 +412,7 @@ export default class ScrobbleSources {
clientSecret: process.env.DEEZER_CLIENT_SECRET,
redirectUri: process.env.DEEZER_REDIRECT_URI,
accessToken: process.env.DEEZER_ACCESS_TOKEN,
arl: process.env.DEEZER_ARL,
};
if (!Object.values(d).every(x => x === undefined)) {
configs.push({
@@ -802,7 +804,12 @@ export default class ScrobbleSources {
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, this.internalConfig, this.emitter);
break;
case 'deezer':
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
const deezerConfig = compositeConfig as DeezerCompatConfig;
if(deezerConfig.data.arl !== undefined) {
newSource = await new DeezerInternalSource(name, compositeConfig as DeezerInternalSourceConfig, this.internalConfig, this.emitter);
} else {
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
}
break;
case 'ytmusic':
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter);
+143 -1
View File
@@ -6,7 +6,7 @@ import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
@@ -19,6 +19,8 @@ import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
import { sleep } from "../../utils.js";
import DeezerInternalSource from "../../sources/DeezerInternalSource.js";
import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js";
chai.use(asPromised);
@@ -457,4 +459,144 @@ describe('Scrobble Threshold Checks', function() {
expect(results.duration.passes).is.true;
expect(results.passes).is.true;
});
});
const generateDeezerSource = (options: DeezerInternalSourceOptions = {}) => {
return new DeezerInternalSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
}
const firstPlayDate = dayjs().subtract(1, 'hour');
const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate});
const lastPlay = normalizedPlays[normalizePlays.length - 1];
describe('Deezer Internal Source', function() {
describe('When fuzzyDiscoveryIgnore is not defined or false', function () {
it('discovers fuzzy play', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource();
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
});
describe('When fuzzyDiscoveryIgnore is true', function () {
it('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('discovers fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
it('discovers fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length);
});
});
describe('When fuzzyDiscoveryIgnore is aggressive', function () {
it('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover play found during duration of previous', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const duringPlay = clone(targetPlay);
duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([duringPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play with delay of up to 40 seconds', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('it does not discover fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length - 1);
});
});
});
@@ -1,8 +1,8 @@
import { loggerTest } from "@foxxmd/logging";
import { assert } from 'chai';
import { assert, expect } from 'chai';
import clone from "clone";
import { describe, it } from 'mocha';
import { playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { generatePlay, generatePlays } from "./PlayTestUtils.js";
import { PlayObject } from "../../../core/Atomic.js";
@@ -249,4 +249,37 @@ describe('Compare lists by order', function () {
});
});
describe('Source Play Comparisons', function() {
describe('Generic Source Comparison', function() {
it('matches identical plays', function() {
expect(genericSourcePlayMatch(newPlay, newPlay)).to.be.true;
});
it('matches identical plays with close timestamps', function() {
const closePlay = clone(newPlay);
closePlay.data.playDate = closePlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, closePlay)).to.be.true;
});
it('does not match unique plays', function() {
expect(genericSourcePlayMatch(newPlay, generatePlay())).to.be.false;
});
it('does not match unique plays with exact timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate;
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
it('does not match unique plays with close timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
});
});
});
+7 -1
View File
@@ -1,6 +1,8 @@
import { getListDiff, ListDiff } from "@donedeal0/superdiff";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { playObjDataMatch } from "../utils.js";
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
export const metaInvariantTransform = (play: PlayObject): PlayObject => {
@@ -221,3 +223,7 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu
return `${a} => ${b}`;
}).join('\n');
}
export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean =>
playObjDataMatch(a, b)
&& hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t);
+73 -41
View File
@@ -1,11 +1,14 @@
import dayjs, { Dayjs } from "dayjs";
import isToday from 'dayjs/plugin/isToday.js';
import {
AcceptableTemporalDuringReference,
PlayObject,
SCROBBLE_TS_SOC_END,
SCROBBLE_TS_SOC_START,
ScrobbleTsSOC,
TA_CLOSE,
TA_DEFAULT_ACCURACY,
TA_DURING,
TA_EXACT,
TA_FUZZY,
TA_NONE,
@@ -38,31 +41,31 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
}
if (data.date.fuzzyDurationDiff !== undefined) {
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.date.fuzzyListenedDiff !== undefined) {
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.range !== undefined) {
if (data.range === false) {
parts.push('Candidate not played during Existing tracked listening');
} else {
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
}
} else {
if(data.range === undefined) {
parts.push('Range Comparison N/A');
} else if(data.range.type === 'none') {
parts.push(`Candidate not played during Existing ${data.duringReferences.join(' or ')}`);
} else {
parts.push(`Candidate played during tracked listening range from Existing "${data.range.type}" ${data.range.timestamps[0].format('HH:mm:ssZ')} => ${data.range.timestamps[1].format('HH:mm:ssZ')}`);
}
return parts.join(' | ');
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
export interface TemporalPlayComparisonOptions {
diffThreshold?: number,
fuzzyDuration?: boolean,
useListRanges?: boolean
} = {}): TemporalPlayComparison => {
fuzzyDiffThreshold?: number
duringReferences?: AcceptableTemporalDuringReference
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: TemporalPlayComparisonOptions = {}): TemporalPlayComparison => {
const result: TemporalPlayComparison = {
match: TA_NONE
};
const {
meta: {
@@ -98,9 +101,15 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const {
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
fuzzyDuration = false,
useListRanges = true,
fuzzyDiffThreshold = 10,
duringReferences = ['range']
} = options;
const result: TemporalPlayComparison = {
match: TA_NONE,
duringReferences
};
// cant compare!
if (existingTsSOCDate === undefined || candidateTsSOCDate === undefined) {
return result;
@@ -115,7 +124,8 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const scrobblePlayDiff = Math.abs(existingTsSOCDate.unix() - candidateTsSOCDate.unix());
result.date = {
threshold: diffThreshold,
diff: scrobblePlayDiff
diff: scrobblePlayDiff,
fuzzyDiffThreshold
};
if(scrobblePlayDiff <= 1) {
@@ -124,29 +134,57 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
result.match = TA_CLOSE;
}
if (useListRanges && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = range;
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
result.match = TA_CLOSE;
if(result.match !== TA_NONE) {
return result;
}
if(duringReferences.length > 0) {
if (duringReferences.includes('range') && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = {
type: 'range',
timestamps: [range.start.timestamp, range.end.timestamp]
}
result.match = TA_DURING;
return result;
}
break;
}
}
if (result.range === undefined) {
result.range = false;
if(duringReferences.includes('listenedFor') && existingPlay.data.listenedFor !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'listenedFor',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's')]
}
return result;
}
}
if(duringReferences.includes('duration') && existingPlay.data.duration !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'duration',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's')]
}
return result;
}
}
}
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
// so check if the duration matches the diff between the two play dates
if (result.match === TA_NONE && referenceDuration !== undefined) {
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
if (result.date.fuzzyDurationDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyDurationDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY;
}
}
@@ -155,7 +193,7 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
// so check if there is a close match between candidate play date and source + listened for
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
if (result.date.fuzzyListenedDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyListenedDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY
}
}
@@ -194,15 +232,7 @@ export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, seco
}
}
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
if(typeof expected === 'number') {
if(typeof found === 'number') {
return found <= expected;
}
return false;
}
return found === false;
}
export const hasAcceptableTemporalAccuracy = (found: TemporalAccuracy, expected: TemporalAccuracy[] = TA_DEFAULT_ACCURACY): boolean => expected.includes(found);
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
switch(acc) {
@@ -212,7 +242,9 @@ export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
return 'close';
case 3:
return 'fuzzy';
case false:
case 4:
return 'during';
case 99:
return 'no correlation';
}
}
+25 -3
View File
@@ -244,12 +244,29 @@ export interface DeadLetterScrobble<PlayType, RetryType = Dayjs> extends QueuedS
export type Second = number;
export type Millisecond = number;
export type TemporalAccuracy = 1 | 2 | 3 | false;
export type TemporalAccuracy = 1 | 2 | 3 | 4 | 99;
/** Timestamp diffs are close to exact (less than or equal to 1 second difference) */
export const TA_EXACT: TemporalAccuracy = 1;
/** Timestamp diffs are within source reporting granularity margin-of-error (see lowGranularitySources):
* normal granularity is 10 seconds
* low granularity (subsonic usually) is 60 seconds
*/
export const TA_CLOSE: TemporalAccuracy = 2;
/** Timestamp diffs are not CLOSE but Scrobble A's timestamp +/- duration is within fuzzyDiffThreshold seconds of Scrobble B's timestamp */
export const TA_FUZZY: TemporalAccuracy = 3;
export const TA_NONE: TemporalAccuracy = false;
/** Timestamp diffs are not FUZZY and Scrobble B's timestamp is within potential full play of Scrobble A (timestamp +/- duration) */
export const TA_DURING: TemporalAccuracy = 4;
/** No correlation between timestamps */
export const TA_NONE: TemporalAccuracy = 99;
export type AcceptableTemporaryAccuracy = TemporalAccuracy[]
export const TA_DEFAULT_ACCURACY: AcceptableTemporaryAccuracy = [TA_EXACT, TA_CLOSE];
export type TemporalDuringReference = 'range' | 'duration' | 'listenedFor';
export type AcceptableTemporalDuringReference = TemporalDuringReference[];
export interface TemporalPlayComparison {
match: TemporalAccuracy
@@ -258,8 +275,13 @@ export interface TemporalPlayComparison {
diff: number
fuzzyDurationDiff?: number
fuzzyListenedDiff?: number
fuzzyDiffThreshold?: number
}
range?: false | ListenRangeData
duringReferences: AcceptableTemporalDuringReference
range?: {
timestamps: [Dayjs, Dayjs]
type: TemporalDuringReference
} | { type: 'none' }
}
export type SOURCE_SOT_TYPES = 'player' | 'history';