mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede2d9f71a | ||
|
|
acccc32813 | ||
|
|
69f73ff09f | ||
|
|
305f3c42ae | ||
|
|
d6ac4c34c5 | ||
|
|
9c6eeaafdb | ||
|
|
6bd841fea2 | ||
|
|
e05c46e9ce | ||
|
|
e94109dc75 |
@@ -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)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "DeezerOfficalDeprecated",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+118
-39
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user