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 | ||
|
|
a68dea5193 | ||
|
|
b5d56b75d7 | ||
|
|
c8daffb935 | ||
|
|
18a7f26c54 | ||
|
|
e7395e79a5 | ||
|
|
08ee6d379f | ||
|
|
9c692539ce | ||
|
|
5cd5061f20 | ||
|
|
46bcf6d6a6 | ||
|
|
4da931269c | ||
|
|
d19d40d479 | ||
|
|
b3e1c7f660 | ||
|
|
1242a710df | ||
|
|
b724eca9be | ||
|
|
376d4936cc | ||
|
|
a5da0573c5 | ||
|
|
4694ce45a7 | ||
|
|
7c7d710cd3 |
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
|
||||
DOCS_BASE: '/multi-scrobbler'
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
@@ -21,8 +21,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [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)
|
||||
@@ -38,6 +39,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
@@ -45,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -16,5 +16,15 @@
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "koitoClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD",
|
||||
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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';
|
||||
@@ -765,17 +766,18 @@ http://localhost:9078/api/lastfm/mySlug
|
||||
|
||||
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
You will need to run your own Listenbrainz server, [Koito](https://koito.io) server, or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
|
||||
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the **File** or AOI config to setup Listenbrainz as a Source.
|
||||
:::
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
@@ -871,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
|
||||
@@ -2267,10 +2367,12 @@ or replace `localhost:9078` with your own base URL
|
||||
|
||||
### [Listenbrainz](https://listenbrainz.org)
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
You will need to run your own Listenbrainz server, [Koito](https://koito.io) or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
|
||||
@@ -6,12 +6,6 @@ title: Flatpak
|
||||
description: Building Flatpak App locally
|
||||
---
|
||||
|
||||
:::note
|
||||
|
||||
These steps are for building the flatpak from source. If you want to install the application normally then [get it through flathub](../installation/installation.mdx#flatpak)
|
||||
|
||||
:::
|
||||
|
||||
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
|
||||
|
||||
## Install Requirements
|
||||
|
||||
@@ -105,12 +105,6 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
|
||||
|
||||
### Flatpak
|
||||
|
||||
You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
|
||||
```shell
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
||||
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
|
||||
@@ -118,7 +112,9 @@ Flatpak users have experienced issues when using multi-scrobbler as a long-runni
|
||||
* to use a [Docker](#docker) installation if possible or
|
||||
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
|
||||
|
||||
::::
|
||||
:::
|
||||
|
||||
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
|
||||
@@ -31,27 +31,14 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=flatpak run io.github.multiscrobbler
|
||||
WorkingDirectory=/path/to/multi-scrobbler/directory
|
||||
ExecStart=npm run start
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
The above assumes you [installed multi-scrobbler using flatpak](installation.mdx#flatpak)
|
||||
|
||||
### Node.js Installs
|
||||
|
||||
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/path/to/multi-scrobbler/directory
|
||||
ExecStart=node src/index.js
|
||||
Restart=no
|
||||
```
|
||||
|
||||
## Start the Service
|
||||
|
||||
Save the file then run:
|
||||
|
||||
@@ -173,6 +173,15 @@ const config: Config = {
|
||||
runmeLinkLabel: 'Checkout via Runme'
|
||||
}
|
||||
} satisfies Preset.ThemeConfig,
|
||||
headTags: [
|
||||
{
|
||||
tagName: 'meta',
|
||||
attributes: {
|
||||
name: 'google-site-verification',
|
||||
content: process.env.GSITEVERIFICATION ?? 'none'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
|
||||
@@ -22,8 +22,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [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)
|
||||
@@ -39,6 +40,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
@@ -46,7 +48,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker) or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](docs/quickstart)
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
<li>MPRIS (linux desktop)</li>
|
||||
<li>Google Cast (Chromecast)</li>
|
||||
<li>Musikcube</li>
|
||||
<li>MPDF (Music Player Daemon)</li>
|
||||
<li>MPD (Music Player Daemon)</li>
|
||||
<li>VLC</li>
|
||||
<li>Azuracast</li>
|
||||
<li>Icecast</li>
|
||||
<li>Yamaha MusicCast</li>
|
||||
<li>Listenbrainz and Last.fm Endpoints</li>
|
||||
</ul>
|
||||
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
|
||||
<ul>
|
||||
|
||||
Generated
+118
-39
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.2",
|
||||
"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.2",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
total_record_count: number
|
||||
items_per_page: number
|
||||
has_next_page: boolean
|
||||
current_page: number
|
||||
}
|
||||
|
||||
export interface ListenObjectResponse {
|
||||
/** ISO8601 timestamp */
|
||||
time: string
|
||||
track: TrackResponse
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
id: number
|
||||
title: string
|
||||
artists: ArtistResponse[]
|
||||
musicbrainz_id: string | null
|
||||
listen_count: number
|
||||
duration: number
|
||||
image: string | null
|
||||
album_id: number
|
||||
time_listened: number
|
||||
}
|
||||
|
||||
export interface ArtistResponse {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+68
-8
@@ -1,7 +1,7 @@
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { PlayObject, URLData } from "../../../core/Atomic.js";
|
||||
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
@@ -16,6 +16,10 @@ import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
|
||||
import { parseRegexSingleOrFail } from '../../utils.js';
|
||||
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
|
||||
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -112,17 +116,27 @@ export interface ListenResponse {
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
|
||||
|
||||
export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: string;
|
||||
url: URLData;
|
||||
isKoito: boolean = false;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
const {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
this.url = url;
|
||||
let cleanUrl = url;
|
||||
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
cleanUrl = url.replace(LZ_VERSION_PATH, '');
|
||||
}
|
||||
this.url = normalizeWebAddress(cleanUrl);
|
||||
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +192,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(this.url))
|
||||
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
|
||||
this.isKoito = await this.checkKoito();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
|
||||
@@ -190,18 +205,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async checkKoito(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
|
||||
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
|
||||
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -217,11 +244,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
|
||||
.query({
|
||||
period: 'all_time',
|
||||
page: 0,
|
||||
limit: maxTracks
|
||||
})
|
||||
);
|
||||
const { body } = resp as any;
|
||||
return body as KoitoListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -238,6 +283,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
if(this.isKoito) {
|
||||
return this.getRecentlyPlayedKoito(maxTracks)
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
@@ -247,6 +296,17 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListensKoito(maxTracks);
|
||||
return resp.items.map(x => listenObjectResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
submitListen = async (play: PlayObject, log: boolean = false) => {
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
@@ -257,7 +317,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
// so no useful information
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
|
||||
// TODO may we should make a call to recent-listens to get the parsed scrobble?
|
||||
const resp = await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
|
||||
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Response: ${resp.text}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import dayjs from "dayjs";
|
||||
import { PlayObject } from "../../../../core/Atomic.js";
|
||||
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
|
||||
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
|
||||
|
||||
export class KoitoApiClient extends AbstractApiClient {
|
||||
|
||||
constructor(name: any, config: any, options: AbstractApiOptions) {
|
||||
super('Koito', name, config, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track: obj.track.title,
|
||||
artists: (obj.track.artists ?? []).map(x => x.name),
|
||||
duration: obj.track.duration,
|
||||
playDate: dayjs(obj.time)
|
||||
},
|
||||
meta: {
|
||||
source: 'Koito',
|
||||
newFromSource: options.newFromSource ?? false,
|
||||
trackId: obj.track.id.toString()
|
||||
}
|
||||
}
|
||||
if(obj.track.musicbrainz_id !== null) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: obj.track.musicbrainz_id
|
||||
}
|
||||
}
|
||||
}
|
||||
return play;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzClientConfig;
|
||||
|
||||
@@ -44,6 +45,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
this.isKoito = this.api.isKoito;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
|
||||
if (payload !== undefined) {
|
||||
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
|
||||
|
||||
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
|
||||
const pSources = scrobbleSources.getByType('plex').filter(x => x instanceof PlexSource) as PlexSource[];
|
||||
if (pSources.length === 0) {
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex Webhook sources are configured');
|
||||
}
|
||||
|
||||
for (const source of pSources) {
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -46,7 +46,7 @@ import EventEmitter from "events";
|
||||
import { nanoid } from "nanoid";
|
||||
import pEvent from "p-event";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
RunTimeTicks,
|
||||
Type, // should be BaseItemKind.Audio
|
||||
UserData,
|
||||
ProviderIds = {}
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
const meta: BrainzMeta = {};
|
||||
|
||||
if(ProviderIds.MusicBrainzAlbum !== undefined) {
|
||||
meta.album = ProviderIds.MusicBrainzAlbum;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzArtist !== undefined) {
|
||||
meta.artist = [ProviderIds.MusicBrainzArtist];
|
||||
}
|
||||
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
|
||||
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
|
||||
}
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
artists: Artists,
|
||||
album: Album,
|
||||
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
source: 'Jellyfin',
|
||||
}
|
||||
}
|
||||
if(Object.keys(meta).length > 0) {
|
||||
play.data.meta = { brainz: meta };
|
||||
}
|
||||
return play;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
|
||||
|
||||
export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzSourceConfig;
|
||||
|
||||
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await request.get(this.api.url);
|
||||
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
|
||||
const isKoito = await this.api.checkKoito();
|
||||
this.api.isKoito = isKoito;
|
||||
this.isKoito = isKoito;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(isNodeNetworkException(e)) {
|
||||
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
if(this.isKoito) {
|
||||
return await this.api.getRecentlyPlayedKoito(limit);
|
||||
}
|
||||
const now = await this.api.getPlayingNow();
|
||||
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
|
||||
@@ -2,7 +2,12 @@ import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
|
||||
const RT_TICK = 500;
|
||||
export const RT_TICK_DEFAULT = 500;
|
||||
let RT_TICK = 500;
|
||||
|
||||
export const setRtTick = (tick: number) => {
|
||||
RT_TICK = tick;
|
||||
}
|
||||
|
||||
export abstract class RealtimePlayer {
|
||||
|
||||
@@ -13,7 +18,7 @@ export abstract class RealtimePlayer {
|
||||
private clockTS: Dayjs = dayjs();
|
||||
|
||||
protected constructor(/* logger: Logger */) {
|
||||
//this.logger = childLogger(logger, `RT`);
|
||||
this.position = 0;
|
||||
const job = new SimpleIntervalJob({
|
||||
milliseconds: RT_TICK,
|
||||
runImmediately: true
|
||||
@@ -27,7 +32,6 @@ export abstract class RealtimePlayer {
|
||||
}), { id: 'rt' });
|
||||
this.scheduler.addSimpleIntervalJob(job);
|
||||
this.scheduler.stop();
|
||||
this.position = 0;
|
||||
}
|
||||
|
||||
public play(position?: number) {
|
||||
@@ -52,14 +56,18 @@ export abstract class RealtimePlayer {
|
||||
}
|
||||
|
||||
public getPosition(asSeconds: boolean = false) {
|
||||
if(this.position === 0 || this.position === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return !asSeconds ? this.position : this.position / 1000;
|
||||
}
|
||||
|
||||
public setPosition(time?: number) {
|
||||
if(time === undefined) {
|
||||
this.position += Math.abs(dayjs().diff(this.clockTS, 'ms'));
|
||||
} else {
|
||||
this.position = time;
|
||||
}
|
||||
this.position = time;
|
||||
this.clockTS = dayjs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('Sample Configs', function () {
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
|
||||
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
|
||||
expect(clients.clients).length(1);
|
||||
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
|
||||
expect(clients.clients).length(expectedCount);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,8 +9,7 @@ export class TestSource extends AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
export class TestMemorySource extends MemoryPositionalSource {
|
||||
|
||||
export class TestMemorySource extends MemorySource {
|
||||
}
|
||||
|
||||
export class TestMemoryPositionalSource extends MemoryPositionalSource {
|
||||
|
||||
@@ -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";
|
||||
@@ -17,6 +17,10 @@ import { SourceConfig } from "../../common/infrastructure/config/source/sources.
|
||||
import MemorySource from "../../sources/MemorySource.js";
|
||||
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);
|
||||
|
||||
@@ -186,9 +190,13 @@ describe('Player Cleanup', function () {
|
||||
|
||||
this.afterEach(() => {
|
||||
MockDate.reset();
|
||||
setRtTick(RT_TICK_DEFAULT);
|
||||
});
|
||||
this.beforeEach(() => {
|
||||
setRtTick(1);
|
||||
});
|
||||
|
||||
const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const cleanedUpDuration = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
@@ -202,6 +210,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -210,25 +219,27 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
|
||||
}
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemorySource);
|
||||
it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () {
|
||||
await cleanedUpDuration(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemoryPositionalSource);
|
||||
it('Discovers cleaned up Play with correct duration (Positional Source)', async function () {
|
||||
await cleanedUpDuration(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const noScrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -243,6 +254,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -251,12 +263,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
@@ -269,12 +283,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
|
||||
@@ -283,15 +299,15 @@ describe('Player Cleanup', function () {
|
||||
}
|
||||
|
||||
|
||||
it('Does not discover same Play after becoming active again (Non Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemorySource);
|
||||
it('Does not discover same Play after becoming active again (Non Positional Source)', async function () {
|
||||
await noScrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover same Play after becoming active again (Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
it('Does not discover same Play after becoming active again (Positional Source)', async function () {
|
||||
await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const noScrobbleStale = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -308,6 +324,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -316,6 +333,7 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -326,15 +344,15 @@ describe('Player Cleanup', function () {
|
||||
|
||||
}
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () {
|
||||
noScrobbleStale(generateMemorySource);
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () {
|
||||
await noScrobbleStale(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () {
|
||||
noScrobbleStale(generateMemoryPositionalSource);
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () {
|
||||
await noScrobbleStale(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const scrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -351,6 +369,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -359,12 +378,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should not discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(0);
|
||||
@@ -378,12 +399,14 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should return discovered play with ~90 seconds of duration
|
||||
@@ -393,12 +416,12 @@ describe('Player Cleanup', function () {
|
||||
|
||||
}
|
||||
|
||||
it('Does discover Play after becoming active again (Non Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemorySource);
|
||||
it('Does discover Play after becoming active again (Non Positional Source)', async function () {
|
||||
await scrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does discover Play after becoming active again (Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
it('Does discover Play after becoming active again (Positional Source)', async function () {
|
||||
await scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,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;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
|
||||
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
|
||||
|
||||
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
|
||||
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
||||
let v4: string,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ art = {},
|
||||
} = {}
|
||||
} = {},
|
||||
play,
|
||||
listenedDuration,
|
||||
listenedDuration = 0,
|
||||
status: {
|
||||
calculated = '???',
|
||||
reported,
|
||||
@@ -52,7 +52,7 @@ art = {},
|
||||
|
||||
let durPer = null;
|
||||
if(duration !== undefined && duration !== null && duration !== 0) {
|
||||
if(listenedDuration === 0) {
|
||||
if(listenedDuration === 0 || listenedDuration === null) {
|
||||
durPer = ' (0%)';
|
||||
} else {
|
||||
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
|
||||
@@ -97,7 +97,7 @@ art = {},
|
||||
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
|
||||
<div className="flex">
|
||||
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
</div>
|
||||
</section>
|
||||
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
|
||||
|
||||
+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