Compare commits

...
33 Commits
Author SHA1 Message Date
FoxxMD 8be08a5c29 chore: Bump for release 2025-06-20 20:09:21 +00:00
Matt Foxx 9fe5b9b671 Merge pull request #312 from FoxxMD/lzEnrich
feat: Enrich Listenbrainz submit payload
2025-06-20 16:07:43 -04:00
FoxxMD 720c07b008 feat(listenbrainz): Include more ids/fields in submit additional_info #311 2025-06-20 16:43:01 +00:00
FoxxMD 7fdf93c5d3 feat(spotify): Add spotify meta ids to play 2025-06-20 16:42:04 +00:00
FoxxMD a0fd466bcf dev: Add ENV only launch config 2025-06-19 18:15:45 +00:00
FoxxMD 35d62c913a fix: Guard against undefined type in config.json #310 2025-06-19 18:12:57 +00:00
FoxxMD ede2d9f71a chore: Bump version for release 2025-06-19 16:22:39 +00:00
Matt Foxx acccc32813 Merge pull request #296 from FoxxMD/deezer-internal
feat: Implement Deezer Internal (ARL) Source
2025-06-17 20:41:08 -04:00
FoxxMD 69f73ff09f docs(deezer): Rewrite Deezer Source with ARL implementation
* Add ARL implementation docs and warnings
* Update json examples to include both types of deezer configs
2025-06-17 15:16:13 +00:00
FoxxMD 305f3c42ae fix(deezer): options are optional 2025-06-17 15:14:01 +00:00
FoxxMD d6ac4c34c5 feat: Implement better during/range temporal comparisons
* Refactor temporal comparisons to provide granular "during" thresholds
* Use duration for ignoring discovery with aggressive mode in Deezer #296
2025-06-17 10:10:34 -04:00
FoxxMD 9c6eeaafdb feat: Fuzzy diff threshold configurable
* Refactor fuzzy diff theshold to be configurable
* Configure aggressive fuzzy discovery for Deezer to be 40 seconds #296
2025-06-17 10:10:34 -04:00
FoxxMD 6bd841fea2 feat(deezer internal): Add fuzzy discovery ignore option
Allows user-configurable decision on whether to ignore fuzzy-matched discovered plays
2025-06-17 10:10:34 -04:00
FoxxMD e05c46e9ce fix(deezer official): Fix missing interval interface property 2025-06-17 10:10:34 -04:00
FoxxMD e94109dc75 feat: Implement Deezer Internal (ARL) Source 2025-06-17 10:10:34 -04:00
FoxxMD a68dea5193 chore: Bump version for release 2025-06-16 19:22:43 +00:00
FoxxMD b5d56b75d7 test(listenbrainz): Fix expected client count 2025-06-16 16:06:57 +00:00
FoxxMD c8daffb935 docs(listenbrainz): Add Koito compatibility and config example #300 2025-06-16 16:02:13 +00:00
FoxxMD 18a7f26c54 feat(listenbrainz): Implement limited support for Koito as Listenbrainz Source/Client #300
* Support Koito server detection via public route
  * Warns users of limited support on detection
* Implement play history retrival for Koito server
2025-06-16 15:49:39 +00:00
FoxxMD e7395e79a5 feat(listenbrainz): Detect /1/ version prefix on base URL
Remove /1/ from base URL to further normalize user URL and make config easier #300
2025-06-16 14:15:48 +00:00
FoxxMD 08ee6d379f fix(listenbrainz): Normalize URL and join parts correctly
* Normalize base URL to make user URL configuration easier/more robust
* Use joinPath for concetenating URL to avoid missing slashes #300
2025-06-16 14:04:15 +00:00
Matt Foxx 9c692539ce Merge pull request #298 from FoxxMD/jellyfin-musicbrainz
feat(jellyfin): Add musicbrainz metadata #297
2025-06-04 14:15:09 -04:00
FoxxMD 5cd5061f20 feat(jellyfin): Add musicbrainz metadata #297 2025-06-04 13:44:15 +00:00
FoxxMD 46bcf6d6a6 chore: Bump version for release 2025-05-29 13:20:34 +00:00
FoxxMD 4da931269c fix(plex): On Plex webhook routes filter plex sources to get webhook source only
#291
2025-05-27 17:31:34 +00:00
FoxxMD d19d40d479 test: Fix real time player not triggered during event loop
Running tests synchronously preventing rt player from advancing which cause each player update to be considered an overdrift
2025-05-27 17:07:30 +00:00
FoxxMD b3e1c7f660 chore: Update devcontainer to node 20 to match docker image 2025-05-27 17:05:50 +00:00
FoxxMD 1242a710df fix: Fix NaN and undefined initial states for rt player #292 2025-05-27 15:29:06 +00:00
FoxxMD b724eca9be docs: Add missing var for docs deploy 2025-04-23 15:04:20 +00:00
FoxxMD 376d4936cc docs: Add google site verification 2025-04-23 15:01:42 +00:00
FoxxMD a5da0573c5 docs: Remove flatpak installation method
See #287
2025-03-31 18:31:46 +00:00
FoxxMD 4694ce45a7 chore: Update flatpak description 2025-03-31 12:12:28 -04:00
FoxxMD 7c7d710cd3 fix(ui): Guard against null listenedDuration
Fixes #285
2025-03-28 08:21:04 -04:00
43 changed files with 1300 additions and 250 deletions
+1 -1
View File
@@ -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": {},
+1
View File
@@ -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
+19
View File
@@ -16,6 +16,25 @@
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "dev (ENV Only)",
"type": "node",
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
"CONFIG_DIR": "./not-real"
},
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**",
// Ignore all dependencies (optional)
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "tsx",
"type": "node",
+4 -2
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
[
{
"name": "DeezerOfficalDeprecated",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
}
}
]
+2 -5
View File
@@ -1,13 +1,10 @@
[
{
"name": "FoxxMDeezer",
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
"arl": "UOsRPjT3U5Dhaaup3xQ30DWFoSgqugdSYzL3QE743waaVy2eur2ckyJcZ4RV123Q3PsbKXkLwA6ahc9XmEfev6BtfSP8LujE3JswWRQogZCDUOwVzWO5sAig",
}
}
]
+11 -1
View File
@@ -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"
}
},
]
+145 -43
View File
@@ -12,7 +12,8 @@ import AIOExample from "../../src/components/AIOExample";
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/deezer.json.example';
import DeezerDeprecatedConfig from '!!raw-loader!../../../config/deezer-deprecated.json.example';
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
@@ -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
View File
@@ -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
+3 -7
View File
@@ -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
+2 -15
View File
@@ -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:
+9
View File
@@ -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 !== '') {
+4 -2
View File
@@ -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>
+118 -39
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.6",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -53,6 +53,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
@@ -79,6 +80,7 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
@@ -490,6 +492,32 @@
"tough-cookie": "^4.1.4"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@curvenote/ansi-to-react": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@curvenote/ansi-to-react/-/ansi-to-react-7.0.0.tgz",
@@ -1025,6 +1053,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
@@ -3254,12 +3283,10 @@
}
},
"node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"dependencies": {
"debug": "^4.3.4"
},
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
@@ -6015,6 +6042,30 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-cookie-agent": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.1.tgz",
"integrity": "sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0",
"undici": "^7.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -9095,10 +9146,17 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -9142,7 +9200,8 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -9481,7 +9540,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "4.1.8",
@@ -10538,6 +10598,24 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -10631,27 +10709,15 @@
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
"node": ">=16"
}
},
"node_modules/tr46": {
@@ -10947,14 +11013,14 @@
"dev": true
},
"node_modules/undici": {
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
"integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=14.0"
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
@@ -11032,6 +11098,7 @@
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -11984,6 +12051,18 @@
"undici": "^5.19.1"
}
},
"node_modules/youtubei.js/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.6",
"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
+112 -12
View File
@@ -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 {
@@ -70,8 +74,21 @@ export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: string
}
// using submit-listens example from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
export interface SubmitListenAdditionalTrackInfo extends AdditionalTrackInfo {
artist_names?: string[]
release_artist_name?: string
release_artist_names?: string[]
spotify_album_id?: string
spotify_album_artist_ids?: string[]
spotify_artist_ids?: string[]
albumartist?: string
}
export interface TrackPayload extends MinimumTrack {
additional_info?: AdditionalTrackInfo
additional_info?: SubmitListenAdditionalTrackInfo
}
export interface ListenPayload {
@@ -112,17 +129,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 +205,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 +218,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 +257,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 +296,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 +309,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 +330,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}`)
}
@@ -279,14 +352,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
track,
duration,
meta: {
brainz = {}
brainz = {},
spotify = {}
} = {}
}
} = play;
// using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
const addInfo: SubmitListenAdditionalTrackInfo = {
// all artists
artist_names: Array.from(new Set([...artists, ...albumArtists])),
// primary artist
release_artist_name: artists[0],
release_artist_names: [artists[0]],
};
if(spotify.track !== undefined) {
addInfo.spotify_id = spotify.track;
}
if(spotify.album !== undefined) {
addInfo.spotify_album_id = spotify.album;
}
if(spotify.albumArtist !== undefined && spotify.albumArtist.length > 0) {
addInfo.spotify_album_artist_ids = spotify.albumArtist;
}
if(spotify.artist !== undefined) {
addInfo.spotify_artist_ids = spotify.artist;
}
return {
listened_at: getScrobbleTsSOCDate(play).unix(),
track_metadata: {
artist_name: artists[0],
artist_name: Array.from(new Set([...artists, ...albumArtists])).join(', '),
track_name: track,
release_name: album,
additional_info: {
@@ -294,7 +393,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
track_mbid: brainz.track,
artist_mbids: brainz.artist,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup
release_group_mbid: brainz.releaseGroup,
...addInfo
}
}
}
+38
View File
@@ -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;
}
@@ -121,6 +121,11 @@ export default class ScrobbleClients {
clientDefaults = cd;
for (const [index, c] of mainConfigClientConfigs.entries()) {
const {name = 'unnamed'} = c;
if(c.type === undefined) {
const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`;
this.logger.error(invalidMsgType);
continue;
}
if(!isClientType(c.type.toLocaleLowerCase())) {
const invalidTypeMsg = `Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`;
//this.emitter.emit('error', new Error(invalidTypeMsg));
+2 -2
View File
@@ -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) {
+12 -8
View File
@@ -2,7 +2,7 @@ import { childLogger, LogDataPretty } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import { FixedSizeList } from "fixed-size-list";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY } from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
import {
@@ -33,11 +33,12 @@ import {
sortByNewestPlayDate,
sortByOldestPlayDate,
} from "../utils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
export interface RecentlyPlayedOptions {
limit?: number
@@ -80,7 +81,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
emitter: EventEmitter;
protected SCROBBLE_BACKLOG_COUNT: number = 20;
protected SCROBBLE_BACKLOG_COUNT: number = 30;
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
@@ -130,7 +131,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
protected addPlayToDiscovered = (play: PlayObject) => {
const platformId = this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID;
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(200);
list.add(play);
this.recentDiscoveredPlays.set(platformId, list);
this.tracksDiscovered++;
@@ -152,7 +153,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
return [];
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
protected getExistingDiscoveredLists = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject[][] => {
const lists: PlayObject[][] = [];
if(opts.checkAll !== true) {
lists.push(this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID));
@@ -168,19 +169,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
});
}
return lists;
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
}
return undefined;
//const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID);
//return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
}
alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => {
+279
View File
@@ -0,0 +1,279 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import request, { Request, Response, SuperAgent } from 'superagent';
import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js";
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { CookieJar, Cookie } from 'tough-cookie';
import { MixedCookieAgent } from 'http-cookie-agent/http';
import MemorySource from "./MemorySource.js";
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
interface DeezerHistoryResponse {
errors: []
results: {
data: DeezerInternalTrackData[]
error: string[]
}
}
export default class DeezerInternalSource extends MemorySource {
requiresAuth = true;
requiresAuthInteraction = false;
csrfToken?: string;
agent: request.SuperAgentStatic & request.Request
jar: CookieJar
declare config: DeezerInternalSourceConfig;
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('deezer', name, config, internal, emitter);
const {
data: {
interval = 60,
...rest
} = {},
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
// @ts-expect-error not correct structure
this.config.data = {
...rest,
interval,
};
this.canPoll = true;
this.canBacklog = true;
this.supportsUpstreamRecentlyPlayed = true;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
// https://developers.deezer.com/api/user/history
// https://stackoverflow.com/a/19497151/1469797
this.SCROBBLE_BACKLOG_COUNT = 50;
this.jar = new CookieJar();
const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
this.agent = request.agent().use((req) => req.agent(mixedAgent));
}
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const play: PlayObject = {
data: {
artists: [obj.ART_NAME],
album: obj.ALB_TITLE,
track: obj.SNG_TITLE,
duration: obj.DURATION,
playDate: dayjs(obj.TS * 1000),
},
meta: {
source: 'Deezer',
trackId: obj.SNG_ID,
newFromSource,
url: {
web: `https://www.deezer.com/track/${obj.SNG_ID}`
}
}
};
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
play.meta.art = {
album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
}
}
return play;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
if (this.config.data.arl === undefined) {
throw new Error('arl must be defined in configuration');
}
this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get('https://deezer.com');
return true;
} catch (e) {
throw e;
}
}
doAuthentication = async () => {
try {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'deezer.getUserData'
})
const resp = await this.callApi(req);
return true;
} catch (e) {
throw e;
}
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'user.getSongsHistory'
})
.set('Content-Type', 'application/json')
.send({
nb: 30,
start: 0
});
// returns listening history in descending order (newest to oldest)
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
for(const e of resp.results.error) {
this.logger.warn(`Error returned in history response: ${e}`);
}
const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song');
if(nonSong.length > 0) {
const nonSongTypes = [];
for(const n of nonSong) {
if(!nonSongTypes.includes(n.__TYPE__)) {
nonSongTypes.push(n.__TYPE__);
}
}
this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
}
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.options;
req.query({
input: 3,
api_version: '1.0',
api_token: this.csrfToken ?? ''
});
setRequestHeaders(req);
try {
const resp = await req;
const {
body: {
error,
results
} = {}
} = resp;
if('checkForm' in results) {
this.csrfToken = results.checkForm;
}
if (error !== undefined && error.length > 0) {
const err = new Error((error as string[]).join(' | '));
throw err;
}
return resp.body;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response,
} = e;
const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
throw new Error(msg, {cause: e});
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
const fuzzyIndex = list.findIndex(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
let temporalOptions: TemporalPlayComparisonOptions = {};
const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
temporalOptions = {
fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
duringReferences: ['duration', 'listenedFor', 'range']
}
temporalAccuracy.push(TA_DURING);
}
return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
});
if(fuzzyIndex !== -1) {
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
// always return fuzzy match as existing
// likely will make MS miss scrobbles for repeated plays
return list[fuzzyIndex];
}
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
// last discovered play was this one, or next played play was also this one
// so we'll assume this means the play is on repeat, don't count as existing
return undefined;
}
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
// so this is likely a duplicate deezer should not have reported
return list[fuzzyIndex];
}
}
}
return undefined;
}
}
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
req
.set('Pragma', 'no-cache')
.set('Origin', 'https://www.deezer.com')
.set('Accept-Encoding', 'gzip, deflate, br')
.set('Accept-Language', 'en-US,en;q=0.9')
.set('User-Agent', userAgent)
.set('Accept', '*/*')
.set('Cache-Control', 'no-cache')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Connection', 'keep-alive')
.set('Referer', 'https://www.deezer.com/login')
.set('DNT', '1')
const ct = req.get('Content-Type');
if(ct === '' || ct === null || ct === undefined) {
req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
}
const buildInternalUrl = (method: string, token: string = ''): URL => {
const params = new URLSearchParams([
['api_version', '1.0'],
['input', '3']
]);
params.append('method', method);
params.append('api_token', token);
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
return u;
}
+1 -1
View File
@@ -91,7 +91,7 @@ export default class DeezerSource extends AbstractSource {
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. Refer to the MS documentation for a new Deezer Source implementation.');
try {
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
+25 -2
View File
@@ -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 = {}) => {
+3 -3
View File
@@ -1,7 +1,7 @@
import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT } from "../../core/Atomic.js";
import {
buildTrackString,
combinePartsToString,
@@ -19,7 +19,7 @@ import {
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import MemorySource from "./MemorySource.js";
@@ -313,7 +313,7 @@ export default class JellyfinSource extends MemorySource {
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
}
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
if(hasAcceptableTemporalAccuracy(temporalResult.match)) {
existingTracked = currPlay;
}
break;
+9 -1
View File
@@ -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();
}
}
+17 -4
View File
@@ -5,7 +5,7 @@ import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } fro
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
import {
@@ -64,6 +64,7 @@ import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
import PlexApiSource from './PlexApiSource.js';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
import { IcecastSource } from './IcecastSource.js';
import DeezerInternalSource from './DeezerInternalSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -139,7 +140,7 @@ export default class ScrobbleSources {
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig");
break;
case 'deezer':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerCompatConfig");
break;
case 'endpointlz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
@@ -264,8 +265,14 @@ export default class ScrobbleSources {
sourceDefaults = this.buildSourceDefaults(sd);
for (const [index, c] of mainConfigSourcesConfigs.entries()) {
const {name = 'unnamed'} = c;
if(c.type === undefined) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[sourceType]" must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
}
if(!isSourceType(c.type.toLocaleLowerCase())) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source type of '${c.type}'. Must be one of ${sourceTypes.join(' | ')}`;
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source "type" of "${c.type}". Must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
@@ -411,6 +418,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 +810,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);
+11 -2
View File
@@ -2,7 +2,7 @@ import dayjs, { Dayjs } from "dayjs";
import EventEmitter from "events";
import SpotifyWebApi from "spotify-web-api-node";
import request from 'superagent';
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js";
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
@@ -155,6 +155,7 @@ export default class SpotifySource extends MemoryPositionalSource {
}
const {
id: albumId,
name: albumName,
artists: albumArtists = [],
images = []
@@ -183,7 +184,15 @@ export default class SpotifySource extends MemoryPositionalSource {
track: name,
duration: duration_ms / 1000,
playDate: played_at,
playDateCompleted
playDateCompleted,
meta: {
spotify: {
track: id,
artist: artists.map(x => x.id),
albumArtist: actualAlbumArtists.map(x => x.id),
album: albumId
}
}
},
meta: {
deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`,
+2 -1
View File
@@ -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);
});
}
});
+1 -2
View File
@@ -9,8 +9,7 @@ export class TestSource extends AbstractSource {
}
}
export class TestMemorySource extends MemoryPositionalSource {
export class TestMemorySource extends MemorySource {
}
export class TestMemoryPositionalSource extends MemoryPositionalSource {
+184 -21
View File
@@ -6,7 +6,7 @@ import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
@@ -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;
});
});
});
});
+3
View File
@@ -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,
+7 -1
View File
@@ -1,6 +1,8 @@
import { getListDiff, ListDiff } from "@donedeal0/superdiff";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { playObjDataMatch } from "../utils.js";
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
export const metaInvariantTransform = (play: PlayObject): PlayObject => {
@@ -221,3 +223,7 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu
return `${a} => ${b}`;
}).join('\n');
}
export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean =>
playObjDataMatch(a, b)
&& hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t);
+73 -41
View File
@@ -1,11 +1,14 @@
import dayjs, { Dayjs } from "dayjs";
import isToday from 'dayjs/plugin/isToday.js';
import {
AcceptableTemporalDuringReference,
PlayObject,
SCROBBLE_TS_SOC_END,
SCROBBLE_TS_SOC_START,
ScrobbleTsSOC,
TA_CLOSE,
TA_DEFAULT_ACCURACY,
TA_DURING,
TA_EXACT,
TA_FUZZY,
TA_NONE,
@@ -38,31 +41,31 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
}
if (data.date.fuzzyDurationDiff !== undefined) {
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.date.fuzzyListenedDiff !== undefined) {
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.range !== undefined) {
if (data.range === false) {
parts.push('Candidate not played during Existing tracked listening');
} else {
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
}
} else {
if(data.range === undefined) {
parts.push('Range Comparison N/A');
} else if(data.range.type === 'none') {
parts.push(`Candidate not played during Existing ${data.duringReferences.join(' or ')}`);
} else {
parts.push(`Candidate played during tracked listening range from Existing "${data.range.type}" ${data.range.timestamps[0].format('HH:mm:ssZ')} => ${data.range.timestamps[1].format('HH:mm:ssZ')}`);
}
return parts.join(' | ');
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
export interface TemporalPlayComparisonOptions {
diffThreshold?: number,
fuzzyDuration?: boolean,
useListRanges?: boolean
} = {}): TemporalPlayComparison => {
fuzzyDiffThreshold?: number
duringReferences?: AcceptableTemporalDuringReference
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: TemporalPlayComparisonOptions = {}): TemporalPlayComparison => {
const result: TemporalPlayComparison = {
match: TA_NONE
};
const {
meta: {
@@ -98,9 +101,15 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const {
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
fuzzyDuration = false,
useListRanges = true,
fuzzyDiffThreshold = 10,
duringReferences = ['range']
} = options;
const result: TemporalPlayComparison = {
match: TA_NONE,
duringReferences
};
// cant compare!
if (existingTsSOCDate === undefined || candidateTsSOCDate === undefined) {
return result;
@@ -115,7 +124,8 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const scrobblePlayDiff = Math.abs(existingTsSOCDate.unix() - candidateTsSOCDate.unix());
result.date = {
threshold: diffThreshold,
diff: scrobblePlayDiff
diff: scrobblePlayDiff,
fuzzyDiffThreshold
};
if(scrobblePlayDiff <= 1) {
@@ -124,29 +134,57 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
result.match = TA_CLOSE;
}
if (useListRanges && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = range;
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
result.match = TA_CLOSE;
if(result.match !== TA_NONE) {
return result;
}
if(duringReferences.length > 0) {
if (duringReferences.includes('range') && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = {
type: 'range',
timestamps: [range.start.timestamp, range.end.timestamp]
}
result.match = TA_DURING;
return result;
}
break;
}
}
if (result.range === undefined) {
result.range = false;
if(duringReferences.includes('listenedFor') && existingPlay.data.listenedFor !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'listenedFor',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's')]
}
return result;
}
}
if(duringReferences.includes('duration') && existingPlay.data.duration !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'duration',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's')]
}
return result;
}
}
}
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
// so check if the duration matches the diff between the two play dates
if (result.match === TA_NONE && referenceDuration !== undefined) {
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
if (result.date.fuzzyDurationDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyDurationDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY;
}
}
@@ -155,7 +193,7 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
// so check if there is a close match between candidate play date and source + listened for
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
if (result.date.fuzzyListenedDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyListenedDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY
}
}
@@ -194,15 +232,7 @@ export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, seco
}
}
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
if(typeof expected === 'number') {
if(typeof found === 'number') {
return found <= expected;
}
return false;
}
return found === false;
}
export const hasAcceptableTemporalAccuracy = (found: TemporalAccuracy, expected: TemporalAccuracy[] = TA_DEFAULT_ACCURACY): boolean => expected.includes(found);
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
switch(acc) {
@@ -212,7 +242,9 @@ export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
return 'close';
case 3:
return 'fuzzy';
case false:
case 4:
return 'during';
case 99:
return 'no correlation';
}
}
+3 -3
View File
@@ -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'} />
+33 -3
View File
@@ -74,6 +74,13 @@ export interface BrainzMeta {
releaseGroup?: string
}
export interface SpotifyMeta {
artist?: string[]
albumArtist?: string[]
album?: string
track?: string
}
export interface TrackData {
artists?: string[]
albumArtists?: string[]
@@ -86,6 +93,7 @@ export interface TrackData {
meta?: {
brainz?: BrainzMeta
spotify?: SpotifyMeta
}
}
@@ -244,12 +252,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 +283,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';