Compare commits

...
Author SHA1 Message Date
FoxxMD fe4e625824 docs: Improve scenario guidance for lfm/lz endpoints 2026-08-10 16:21:39 +00:00
FoxxMD b98756a262 fix: Fix incorrect prefix not being used for env key collection
#668
2026-08-10 14:21:59 +00:00
FoxxMD 41aaaca530 docs: In 0.14.0 upgrade guide add note about ID requirement in 0.16.0+ 2026-08-08 21:06:31 -04:00
FoxxMD ed42da6843 chore(release): Bump version for release 2026-08-08 20:56:01 -04:00
Matt Foxx 34044401d4 Merge pull request #664 from FoxxMD/fixNewPlayRecent
fix(source): Fix missing id/uid on cached recent plays
2026-08-08 20:53:51 -04:00
FoxxMD f61e50fd45 fix(source): Fix missing id/uid on cached recent plays
#662
2026-08-08 17:12:13 -04:00
FoxxMD f5ad2ad29b chore(release): Bump version for release 2026-08-08 19:29:38 +00:00
FoxxMD 2e4acd0f00 fix: Add missing configureAs for ENV parsed components
#661
2026-08-08 19:24:45 +00:00
FoxxMD b9517bd066 docs: Add more type guidance for aio/file config 2026-08-07 17:22:20 +00:00
FoxxMD 335880249f chore(release): Bump version for release 2026-08-07 16:49:36 +00:00
Matt Foxx 5c2917f906 Merge pull request #660 from FoxxMD/ingressSot
Ingress sot
2026-08-07 12:34:15 -04:00
FoxxMD 124e857d9f fix: now playing playerUpdate event data 2026-08-07 14:53:02 +00:00
FoxxMD 5dee499381 test: fix failing tests from np data structure change 2026-08-07 14:53:02 +00:00
FoxxMD 10325fd4e6 refactor(now playing): Use more efficient solution for ingress now playing
Re-queue valid ingress NP update on client rather than spamming all clients with duplicate updates from memory source

This should have the same functional affect with way less log noise
2026-08-07 14:53:02 +00:00
FoxxMD 045890e243 feat(sourc): Add Ingress SOT type and force-push ingress player updates
#659
2026-08-07 14:53:02 +00:00
FoxxMD 685e48a7a6 fear(ui): Implement cache clearing mechanism #634 2026-08-06 20:20:11 +00:00
FoxxMD 7eb5104f0b feat(ui): Implement basic settings page 2026-08-06 19:34:02 +00:00
FoxxMD 4272e8ea58 feat(ui): Improve UX for theme switcher
* Remove three-state toggle
* Revert to system theme only when *user* initiated toggle results in same theme as system
2026-08-06 18:24:57 +00:00
FoxxMD 8e6d35a85b fix(lz endpoint): Fix missing early return when no sources for playing now response 2026-08-05 19:08:08 +00:00
FoxxMD 917d05f295 docs: Update configuration types language 2026-08-05 15:02:21 +00:00
FoxxMD 287c994643 docs: Some corrections and updated language 2026-08-05 14:15:25 +00:00
Matt Foxx 7980a0c856 Merge pull request #657 from FoxxMD/lastfmEndpointResponses
feat(lfm endpoint): Implement more api emulation
2026-08-05 09:28:43 -04:00
FoxxMD 9d2557cf6a feat(lfm endpoint): Handle scrobble payload variants 2026-08-04 22:14:03 +00:00
FoxxMD 6f8debc304 docs: Add lastfm endpoint alternative guidance 2026-08-04 18:50:59 +00:00
FoxxMD b53ae78d55 fix(lfm endpoint): Fix no response on no slug matched 2026-08-04 18:30:16 +00:00
FoxxMD 8723bc8890 fix(lfm endpoint): Improve regex slug matching to handle more variants 2026-08-04 18:29:41 +00:00
FoxxMD eca8ad2ce1 fix(lfm endpoint): Fix slug regex match 2026-08-04 18:16:39 +00:00
FoxxMD b6b8d3423f fix(lfm endpoint): Detect desired json response from accept header 2026-08-04 18:06:45 +00:00
FoxxMD fc027e8554 fix(lfm endpoint): remove duplicated method restrictions 2026-08-04 16:07:11 +00:00
FoxxMD 707da834bb docs: regenerate tealfm source env 2026-08-04 15:10:17 +00:00
FoxxMD 002520cd8a docs: Fix config.json example 2026-08-04 15:10:06 +00:00
FoxxMD 7ca84ce27f feat(lfm endpoint): Implement more api emulation #656
* Add optional username/apiKey config fields
* Identify Source by optional username/sk/api_key
* Emulate auth.getMobileSession response with sk derived from Source uid
* Update docs with guidance for multiple sources and improved setup language
2026-08-04 14:59:05 +00:00
51 changed files with 944 additions and 251 deletions
+4 -2
View File
@@ -22,7 +22,8 @@
"type": "spotify",
"enable": true,
"clients": ["myConfig"],
"name": "mySpotifySource",
"name": "Cool Spotify Name",
"id": "mySpotifyId",
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
@@ -34,7 +35,8 @@
{
"type": "maloja",
"enable": true,
"name": "myConfig",
"name": "Foxx Maloja",
"id": "myMalojaId",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
+110 -52
View File
@@ -13,7 +13,7 @@ import FileExample from "../../src/components/FileExample";
import ScrobbleThreshold from "@site/src/components/snippets/_scrobble-threshold.mdx"
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import SpotifyConfig from '!!raw-loader!../../../config/spotify.json.example';
import SingleConfig from '!!raw-loader!../../../config/jellyfin.json.example';
:::tip
@@ -29,40 +29,35 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
* client/source specific json config files
* an all-in-one json config file
**MS will parse configuration from all configuration types.** You can mix and match configurations but it is generally better to stick to one or the other.
**MS will parse configuration from all configuration types.** You can mix and match configurations but it is generally better to stick to one type.
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
MS will parse environmental variables present in the OS/container when it is run. **This method means MS does not require files to run.**
MS will parse environmental variables present in the OS/container when it is run.
This method means MS **does not require config files** but it will still create and use data files in the [local](/installation/#config-and-data-directories) or [docker](/installation/?dockerSetting=storage#recommended-settings) data directory.
<details>
<summary>Use ENV-based configuration if...</summary>
* You are the only person for whom MS is scrobbling for
* You have a very simple setup for MS such as one scrobble [Client](/configuration/clients) and one [Source](/configuration/sources) IE Plex -> Maloja
* You are the only person for whom MS is scrobbling for or
* You have a very simple setup for MS such as one scrobble [Client](/configuration/clients) and one [Source](/configuration/sources) IE Jellyfin -> Koito, or
* You are not comfortable editing JSON files
</details>
<details>
<summary>Config Example</summary>
For Docker container...
```shell
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" ...
```
For Docker Compose
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
environment:
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
- JELLYFIN_URL=192.168.0.110:8096
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b
- JELLYFIN_USER=MyUser
- JELLYFIN_ID=myJellyin
# ...
# ...
```
@@ -77,77 +72,114 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
<TabItem value="file" label="File">
MS will parse configuration files located in the directory specified by the `CONFIG_DIR` environmental variable. This variable defaults to:
* Local installation -> `PROJECT_DIR/config`
* Docker -> `/config` (in the container) -- see the [install docs](../installation/installation.mdx#docker) for how to configure this correctly
* Local installation -> See [Config and Data Directories](/installation/#config-and-data-directories)
* Docker -> `/config` (in the container) -- see the [**Storage** tab in the Docker installation docs](/installation/?dockerSetting=storage#recommended-settings) for how to configure this correctly
<details>
<summary>Use File-based configuration if...</summary>
* You have many [Sources](/configuration/sources)
* You have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
Funkwhale...
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers
* You want only to scrobble to specific **Clients**
* You are comfortable editing JSON files
* You have more than one of the same type of **Source/Client** you want to use EX 2x Last.fm accounts, 3x Jellyfin accounts
* You want activity from specific **Sources** to scrobble to specific **Clients**
* You need to setup more advanced configuration for a Source/Client
* Most Source/Clients only support basic configuration through ENV, all configuration is possible using File/AIO
* Most Source/Clients only support basic configuration through ENV, more configuration is possible using File. AIO enables complete configuration.
</details>
:::tip
* There are **example configurations** for all Source/Client types and AIO config located in the [`/config`](https://github.com/FoxxMD/multi-scrobbler/tree/master/config) directory of this project. These can be used as-is by renaming them to `.json`.
* For docker installations these examples are copied to your configuration directory on first-time use.
* These are the same examples you will find in the **Configuration** section of each Source/Client
* There is also a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration.
:::
Each file is named by the **type** of the Client/Source found in below sections. Each file as an **array** of that type of Client/Source.
Each config file is named after the **type** of the Client/Source. Within each file, a JSON **array** defines all of the instances for that type of Client/Source.
Example directory structure:
```
/CONFIG_DIR
plex.json
spotify.json
maloja.json
jellyfin.json
lastfm.json
koito.json
```
<details>
<summary>Config Example</summary>
<CodeBlock title="CONFIG_DIR/spotify.json" language="json5">{SpotifyConfig}</CodeBlock>
```yaml title="jellyfin.json"
[
{
"name": "Foxx JF Server",
"id": "foxxJf",
"data": {
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c"
}
}
]
```
</details>
<details>
<summary>Multiple of Same Config Example</summary>
```yaml title="jellyfin.json"
[
{
"name": "Foxx JF Server",
"id": "foxxJf",
"data": {
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c"
}
},
// highlight-start
{
"name": "Foo's JF Server",
"id": "fooJf",
"data": {
"url": "http://192.168.0.150:8096",
"user": "foo",
"apiKey": "9c5bb56bd6540c756fbf481ebd9c5c40"
}
}
// highlight-end
]
```
</details>
You can find an example of the file name that should be used under the **File** Configuration for each Source/Client. Note: this is the same value that is used for `type` in AIO configs.
</TabItem>
<TabItem value="aio" label="File AIO">
MS will parse an **all-in-one** configuration file located in the directory specified by the `CONFIG_DIR` environmental variable. This variable defaults to:
MS will parse an **all-in-one** configuration file (`config.json`) located in the directory specified by the `CONFIG_DIR` environmental variable. The file's location defaults to:
* Local installation -> `PROJECT_DIR/config/config.json`
* Docker -> `/config/config.json` (in the container) -- see the [install docs](../installation/installation.mdx#docker) for how to configure this correctly
* Local installation -> See [Config and Data Directories](/installation/#config-and-data-directories)
* Docker -> `/config/config.json` (in the container) -- see the [**Storage** tab in the Docker installation docs](/installation/?dockerSetting=storage#recommended-settings) for how to configure this correctly
<details>
<summary>Use AIO-based configuration if...</summary>
* You have many [Sources](/configuration/sources)
* You have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
Funkwhale...
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers
* You want only to scrobble to specific **Clients**
* You need to setup [monitoring/webhooks](#monitoring)
* You want to setup defaults for all Sources/Clients
* ...All of the reasons listed in [File](./?configType=file#configuration-types)
* You want to setup [monitoring/webhooks](#monitoring), default settings for all Sources/Clients, or global config options
* You want to use [Transformers](/configuration/transforms) (other than what is provided for [Musicbrainz ENV](/configuration/transforms/musicbrainz/#env-configuration))
* You want complete control over all potential MS configuration
</details>
**The AIO config also enables setting default options for sources/clients as well as global options for MS itself.**
:::tip
* An example AIO config files can be found in the project directory at [`/config/config.json.example`](https://github.com/FoxxMD/multi-scrobbler/tree/master/config/config.json.example)
* For docker installations this example is copied to your configuration directory on first-time use.
* There is also a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration.
* **AIO config is the "complete" way to configure MS.**
* All settings available in File/ENV are available in AIO and AIO has *more* exclusive settings.
* AIO enables configuring **default settings** for all Sources/Clients
* There is a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration.
* Use the [**Config Playground**](/playground) to explore the entire config schema with an editable example
* The example used can also be found in the repo directory at [`/config/config.json.example`](https://github.com/FoxxMD/multi-scrobbler/tree/master/config/config.json.example) in the example below
:::
[**Explore the schema for this configuration, along with an example generator and validator, here**](/playground)
<details>
<summary>Config Example</summary>
@@ -155,6 +187,32 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
<CodeBlock title="CONFIG_DIR/config.json" language="json5">{AIOConfig}</CodeBlock>
</details>
<DetailsAdmo type="important" summary="Source/Client AIO Configs require a 'type' property">
Compared to their [File](./?configType=file#configuration-types) equivalent, Sources/Clients configured using AIO require an additional `type` property.
```yaml title="config.json"
{
"sources": [
{
"name": "Foxx JF Server",
// highlight-start
"type: "jellyfin",
// highlight-end
"id": "foxxJf",
"data": {
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c"
}
}
]
}
```
You can find an example of the required `type` value by referring to the **AIO** tab in each Source/Client's Configuration section example. This is the same value that is used as the name of individual [File](./?configType=file#configuration-types) config files.
</DetailsAdmo>
</TabItem>
</Tabs>
@@ -248,7 +306,7 @@ WARN : [App] [Sources] [spotify Secrets] Matched: None | Unmatched: SPOTIFY_SECR
## Base URL
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](/configuration/sources/spotify) and [lastfm](/configuration/clients/lastfm) -- as well as some logging hints.
Defines the URL that is used to generate default redirect URLs for authentication for services like [spotify](/configuration/sources/spotify) and [lastfm](/configuration/clients/lastfm) -- as well as some logging hints.
* Default => `http://localhost:9078`
* Set with [ENV](./?configType=env#configuration-types) `BASE_URL` or `baseUrl` [all-in-one configuration](./?configType=aio#configuration-types)
@@ -279,7 +337,7 @@ Useful when running with [docker](../installation/installation.mdx#docker) so th
## Caching
Multi-scrobbler implements caching to persist important data across restarts, reduce external API calls, and make some actions faster.
Multi-scrobbler implements caching to reduce external API calls and make some actions faster.
A default **in-memory** cache store is used so that you always benefit from some caching. An optional, [**secondary** store](#secondary-caching) can be configured for greater caching capabilities.
@@ -301,7 +359,7 @@ API Calls to external (metadata) services used to [Enhance Scrobbles](/configura
<DetailsAdmo type="important" summary="Auth Cache Configuration">
Auth caching defaults to a **file** that is stored in the `CONFIG_DIR` directory using the pre-defined file name `ms-auth.cache`.
Auth caching defaults to a **file** that is stored in the `DATA_DIR` directory using the pre-defined file name `ms-auth.cache`.
This provides automatic persistence across restarts for long-lived auth data/credentials if you have configured a [persisted volume/bind mount](/installation?dockerSetting=storage#recommended-settings) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)).
@@ -448,7 +506,7 @@ Multi-scrobbler depends on a SQLite database (`ms.db`) that is created on first
The database stores *all* Plays for your Sources/Clients as well as metadata and debugging information to help troubleshoot issues. Each Play is associated with a Source/Config in the database based on your configuration.
You **should set IDs for each Source/Client** so that the database can identify these even when the configuration is changed.
You **must set IDs for each Source/Client** so that the database can identify these even when the configuration is changed.
### Retention
@@ -1,6 +1,8 @@
| Environmental Variable | Type | Default | Description |
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------ |
| _**`LFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `LFM_NAME` | string | Value of `LFM_ID` | A vanity name EX `My Cool Component` |
| `LFM_ENABLE` | boolean | true | Should this component be used? |
| `LFM_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source |
| Environmental Variable | Type | Default | Description |
| ---------------------- | ------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _**`LFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `LFM_NAME` | string | Value of `LFM_ID` | A vanity name EX `My Cool Component` |
| `LFM_ENABLE` | boolean | true | Should this component be used? |
| `LFM_SLUG` | string | | When using **multiple sources without apikey/user**, or **not** using the standard base URL, this is the URL base path that should be used to identify scrobbles for this source |
| `LFM_USERNAME` | string | | A fake username to differentiate LFM Endpoint Sources |
| `LFM_API_KEY` | string | | A fake api key to differentiate LFM Endpoint Sources |
@@ -1,8 +1,8 @@
| Environmental Variable | Type | Default | Description |
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| _**`LZE_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `LZE_NAME` | string | Value of `LZE_ID` | A vanity name EX `My Cool Component` |
| `LZE_ENABLE` | boolean | true | Should this component be used? |
| `LZE_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source |
| `LZE_TOKEN` | string | | If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source |
| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source |
| Environmental Variable | Type | Default | Description |
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _**`LZE_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `LZE_NAME` | string | Value of `LZE_ID` | A vanity name EX `My Cool Component` |
| `LZE_ENABLE` | boolean | true | Should this component be used? |
| `LZE_SLUG` | string | | When using **multiple sources without tokens**, or **not** using a standard base URL, this is the URL base path that should be used to identify scrobbles for this source |
| `LZE_TOKEN` | string | | If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source |
| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source |
@@ -1,7 +1,7 @@
| Environmental Variable | Type | Default | Description |
| -------------------------------- | ------- | --------------------------- | --------------------------------------- |
| _**`SOURCE_TEALFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `SOURCE_TEALFM_NAME` | string | Value of `SOURCE_TEALFM_ID` | A vanity name EX `My Cool Component` |
| `SOURCE_TEALFM_ENABLE` | boolean | true | Should this component be used? |
| _**`SOURCE_TEALFM_IDENTIFIER`**_ | string | | Identify the account to login as |
| `SOURCE_TEALFM_APP_PW` | string | | |
| Environmental Variable | Type | Default | Description |
| -------------------------------- | ------- | --------------------------- | --------------------------------------------------------------------------------------------- |
| _**`SOURCE_TEALFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `SOURCE_TEALFM_NAME` | string | Value of `SOURCE_TEALFM_ID` | A vanity name EX `My Cool Component` |
| `SOURCE_TEALFM_ENABLE` | boolean | true | Should this component be used? |
| _**`SOURCE_TEALFM_IDENTIFIER`**_ | string | | Identify the account to login as |
| _**`SOURCE_TEALFM_APP_PW`**_ | string | | The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account |
@@ -19,23 +19,144 @@ This Source enables multi-scrobbler to accept scrobbles from outside application
:::
### URL
<DetailsAdmo type="tip" summary="Recommended Alternative">
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
This Source has the same data limitations as the [Last.fm (Source)](/configuration/sources/lastfm-source) and [Last.fm (Client)](/configuration/clients/lastfm) have: Last.fm does not support separating artists/album artists fields which means any multi-artist tracks will have all artists combined into one string. This makes scrobbling to other services, and using metadata corrections like [Musicbrainz](/configuration/transforms/musicbrainz), more difficult.
If your application has the option to scrobble using Listenbrainz then use the [Listenbrainz (Endpoint)](/configuration/sources/listenbrainz-endpoint) Source instead.
</DetailsAdmo>
## Setup
:::tip[Scenario Picker]
Use **one** of the scenarios below based on how on your service(s) (things "scrobbling" to Multi-Scrobbler using this Source) will need to interact with MS.
<Tabs groupId="scenario" queryString>
<TabItem value="single" label="One Service">
> You have **one** service, like Panoscrobbler, that will scrobble to Multi-Scrobbler.
Configure **one** Last.fm Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and [apikey/user](#authentication), if necessary. Use the same apikey/user in MS as you do in your service.
</TabItem>
<TabItem value="multiple" label="Many Services, Same User">
> You may have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
>
> All of these services will scrobble for the same user (you) and all of the scrobbles should go to the same [Clients](/configuration/clients).
Configure **one** Lastfm Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and configure [apikey/user](#authentication) in MS, if necessary. Use the same apikey/user for all your services as you configure in MS.
</TabItem>
<TabItem value="multiple-tokens" label="Many Services, Many Users, Can use LFM Account">
> You have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
>
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
>
> **All** of these services can accept a [**apikey/user**](#authentication) to authenticate a Last.fm user/account.
Configure one Last.fm Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
For each Lastfm Endpoint Source, configure a different [apikey/user](#authentication). Use that same apikey/user when configuring Last.fm for the associated service.
All Last.fm Endpoint Sources should use the same [standard base URL](#standard-base-url).
</TabItem>
<TabItem value="multiple-slug" label="Many Services, Many Users, Cannot use LFM Account">
> You have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
>
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
>
> All, or some, of these services **cannot** accept an [apikey/user](#authentication) to authenticate a Last.fm user/account.
**This is not a common scenario** but if it fits your usecase then use these guidelines to differentiate your services:
Configure one Last.fm Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
* For any groups of services that **can use [apikey/user](#authentication)**, use the instructions for [Many Services, Many Users, Can use LFM Account](./?scenario=multiple-tokens#setup)
* For any groups of services that **cannot accept** an apikey/user...
* You can still configure a Source **without an [apikey/user](#authentication)**. Any services not using an account will use this "default" Source.
* If multiple groups cannot use an apikey/user, or the groups cannot use the [standard base URL](#standard-base-url), then use different [base url **Slugs**](#multiple-sources).
</TabItem>
<TabItem value="slug" label="Service cannot use standard URL">
> You have **one** service, like PanoScrobbler, that will scrobble to Multi-Scrobbler.
>
> You have already tried the [**One Service**](./?scenario=single#setup) scenario with the [standard base URL](#standard-base-url) but it is not working.
**This is not a common scenario.** Check the [**Troubleshooting URL**](#troubleshooting-url) section before trying this.
At this point, you can try to use a different [base url **Slug**](#multiple-sources). Your service must be able to set a "fully custom" URL for Last.fm IE it must allow you to set more than just a domain/host.
</TabItem>
</Tabs>
:::
### Standard Base URL
When setting up your Last.fm client to communicate with Multi-Scrobbler replace the Last.fm domain with your Multi-Scrobbler domain:
https://**last.fm** => https://**yourMSDomain**
MS accepts Last.fm API calls with the same **url base** and structure as the [last.fm api](https://www.last.fm/api/intro), IE `http://yourMSDomain/2.0/`
### Authentication
If you are only setting up **one** Lastfm Endpoint Source then you do not need to configure any explicit username/apiKey/password for MS. If your Last.fm Client requires these credentials use any fake values you want.
<DetailsAdmo type="important" summary="Supported Auth Types">
Currently, Multi-Scrobbler only supports the [**Mobile Application**](https://www.last.fm/api/mobileauth) auth flow. If your client requires one of the other authentication flows please [open an issue](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=02-feature-request.yml).
**Note:** Your client does not **need** to implement any auth in order to use a Lastfm Endpoint Source. You can directly make [`track.scrobble`](https://www.last.fm/api/show/track.scrobble) or [`track.updateNowPlaying`](https://www.last.fm/api/show/track.updateNowPlaying) api calls to `http://yourMSDomain/2.0/` with any fake auth data you want.
</DetailsAdmo>
### Multiple Sources
If you need configure **more than one** Lastfm Endpoint Source then you should configure username/apiKey for MS, per Source. This enables MS to differentiate scrobbles for each Source.
Use the same **username** and/or **API Key** you configure with MS when setting up your Last.fm Client. These values can be anything you want, as long as they match between MS and your client. Password can be anything and is not checked.
However, if you cannot configure username/apiKeys for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user. **This is not a common scenario** and should only be used if username/apiKey configuration is not sufficient or not possible.
<DetailsAdmo type="tip" summary="Different URL Base (Slug)">
If you cannot use different username/apiKey per Source (or Last.fm Client) you can still differentiate Sources by using a different **url base (slug)** for Last.fm communication.
Setting a **slug** in config will change the **url base** for MS like this:
```
http://localhost:9078/2.0/
slug: "mySlug"
```
```
http://yourMsDomain/api/lastfm/mySlug
```
which is the "standard" Last.fm server path for scrobbling
The above url base is equivalent to making calls to `http://yourMSDomain/2.0/`
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
</DetailsAdmo>
If a slug is used then the URL will be:
## Troubleshooting URL
```
http://localhost:9078/api/lastfm/mySlug
```
If you think your service should be able to use the [standard base URL](#standard-base-url) but it is not working follow these steps before trying the [the base URL Slug](#multiple-sources).
#### Verify URL Format
Some service may require `http/https` in the URL format (`http://myMSDomainOrIp:9078`) while others may not (`myDomainOrIp:9078`). Check the docs for your service to see if they specify this or have an example. Try both versions, if possible, to see if one works.
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
Some services that can scrobble to a custom Last.fm URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality.
</DetailsAdmo>
#### Is MS is Reachable?
Verify that MS can be accessed by your service, or from the same host as the service is running. See the [**FAQ**](/FAQ/#ingress-connection) for guidance on troubleshooting ingress connection issues.
#### Service Logging
If you service has accessible logging, check to see if it outputs the URL it is trying to reach when authenticating the Last.fm apiKey/user, or trying to make a scrobble request.
If you decide to report this issue, please include this logging in your issue.
## Configuration
@@ -23,16 +23,76 @@ If your service/player has a "Scrobble to Listenbrainz" feature and there is an
## Setup
### URL
:::tip[Scenario Picker]
In general, you should use one of the following as the **base URL** when configuring your application to scrobble to multi-scrobbler:
Use **one** of the scenarios below based on how on your service(s) (things "scrobbling" to Multi-Scrobbler using this Source) will need to interact with MS.
<Tabs groupId="scenario" queryString>
<TabItem value="single" label="One Service">
> You have **one** service, like Navidrome, that will scrobble to Multi-Scrobbler.
Configure **one** Listenbrainz Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and set a [Token](#token), if necessary. Use the same **Token** in MS as you do in your service.
</TabItem>
<TabItem value="multiple" label="Many Services, Same User">
> You may have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
>
> All of these services will scrobble for the same user (you) and all of the scrobbles should go to the same [Clients](/configuration/clients).
Configure **one** Listenbrainz Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and configure a [Token](#token) in MS, if necessary. Use the same token for all your services as you configure in MS.
</TabItem>
<TabItem value="multiple-tokens" label="Many Services, Many Users, Can use Tokens">
> You have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
>
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
>
> **All** of these services can accept a **Token** to authenticate a Listenbrainz user/account.
Configure one Listenbrainz Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
For each Listenbrainz Endpoint Source, configure a different [**Token**](#token). Use that same token when configuring Listenbrainz for the associated service.
All Listenbrainz Endpoint Sources should use the same [standard base URL](#standard-base-url).
</TabItem>
<TabItem value="multiple-slug" label="Many Services, Many Users, Cannot use Tokens">
> You have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
>
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
>
> All, or some, of these services **cannot** accept a **Token** to authenticate a Listenbrainz user/account.
**This is not a common scenario** but if it fits your usecase then use these guidelines to differentiate your services:
Configure one Listenbrainz Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
* For any groups of services that **can accept** a token, use the instructions for [Many Services, Many Users, Can use Tokens](./?scenario=multiple-tokens#setup)
* For any groups of services that **cannot accept** a token...
* You can still configure a Source **without a [Token](#token)**. Any services not using a token will use this "default" Source.
* If multiple groups cannot use a token, or the groups cannot use the [standard base URL](#standard-base-url), then use different [base url **Slugs**](#multiple-sources).
</TabItem>
<TabItem value="slug" label="Service cannot use standard URL">
> You have **one** service, like Navidrome, that will scrobble to Multi-Scrobbler.
>
> You have already tried the [**One Service**](./?scenario=single#setup) scenario with both [standard base URL](#standard-base-url) and neither is working.
**This is not a common scenario.** Check the [**Troubleshooting URL**](#troubleshooting-url) section before trying this.
At this point, you can try to use a different [base url **Slug**](#multiple-sources). Your service must be able to set a "fully custom" URL for Listenbrainz IE it must allow you to set more than just a domain/host.
</TabItem>
</Tabs>
:::
### Standard Base URL
In most scenarios you should use one of the following as the **base URL** when configuring your service to scrobble to multi-scrobbler:
* `http://myMultiScrobblerIP:9078`
* `http://myMultiScrobblerIP:9078/1/`
<DetailsAdmo type="tip" summary="Default Scrobble Submit URL">
If you are not using a [slug for multiple endpoints](#multiple-endpoints) then multi-scrobbler will accept Listenbrainz scrobbles at
Multi-scrobbler will accept Listenbrainz scrobbles at
```
http://myMultiScrobblerIP:9078/1/submit-listens
@@ -42,41 +102,34 @@ which is the **standard** Listenbrainz server path for scrobbling.
</DetailsAdmo>
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
Some services that can scrobble to a custom Listenbrainz URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
</DetailsAdmo>
### Token
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
Most Listenbrainz clients require an Authentication Token to be provided during setup. This value can be anything you want but ensure you use the same value for `token` (ENV `LZE_TOKEN`) in your configuration for this Listenbrainz Endpoint Source.
### Multiple Endpoints
If your service does not require a token, or does not let you set one, then do not configure it for this Source either.
If you are setting up multiple [Listenbrainz (Endpoint) Sources](/configuration/sources/listenbrainz-endpoint) MS can differentiate scrobbles based on [Token](#token) so that you can use the same endpoint for many users.
### Multiple Sources
However, if you cannot configure multiple tokens for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user.
If you need configure **more than one** Listenbrainz Endpoint Source then MS can differentiate scrobbles based on [Token](#token) so that you can use the [**same, standard base URL**](#standard-base-url) for many users.
<details>
However, if you cannot configure multiple tokens for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user. **This is not a common scenario** and should only be used if **Token** configuration is not sufficient or not possible.
<summary>Using a Slug</summary>
<DetailsAdmo type="tip" summary="Different URL Base (Slug)">
When a **slug** is defined in ENV/File config MS will listen for listenbrainz routes using the slug as a the route path. Example:
When a **slug** is defined in ENV/File config MS will listen for listenbrainz `submit-listens` requests using the slug as the base route path. Example:
```
LZE_SLUG=foobar
```
MS will accept calls to
MS will accept requests for `submit-listens` (scrobble submissions and now playing) at
```
http://myMultiScrobblerIP:9078/api/listenbrainz/foobar
http://myMultiScrobblerIP:9078/api/listenbrainz/foobar/submit-listens
```
for scrobble submission.
</details>
</DetailsAdmo>
## Setup for Popular Apps
@@ -100,13 +153,37 @@ Set the [**ListenBrainz.BaseURL**](https://www.navidrome.org/docs/usage/configur
* In the ListenBrainz Scrobbler configuration screen:
* User Token is the [Token](#token) you configured
* Toggle **Show advanced settings**
* Base URL is the [URL](#url) you can configured, or the equivalent of `http://myMultiScrobblerIP:9078` for your MS instance
* Base URL is the [URL](#standard-base-url) you can configured, or the equivalent of `http://myMultiScrobblerIP:9078` for your MS instance
* Save the configuration and you are ready to scrobble
To troubleshoot any errors, and assuming you are using Home Assistant, view the Logs in the Music Assistant Add-ons. Please include these logs in any reported issue.
</details>
## Troubleshooting URL
If you think your service should be able to use the [standard base URL](#standard-base-url) but it is not working follow these steps before trying the [the base URL Slug](#multiple-sources).
#### Verify URL Format
Some service may require `http/https` in the URL format (`http://myMSDomainOrIp:9078`) while others may not (`myDomainOrIp:9078`). Check the docs for your service to see if they specify this or have an example. Try both versions, if possible, to see if one works.
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
Some services that can scrobble to a custom Listenbrainz URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality.
</DetailsAdmo>
#### Is MS is Reachable?
Verify that MS can be accessed by your service, or from the same host as the service is running. See the [**FAQ**](/FAQ/#ingress-connection) for guidance on troubleshooting ingress connection issues.
#### Service Logging
If you service has accessible logging, check to see if it outputs the URL it is trying to reach when authenticating the Listenbrainz account/user, or trying to make a scrobble request.
If you decide to report this issue, please include this logging in your issue.
## Configuration
<Config config="ListenbrainzEndpointSourceConfig" fileContent={ListenbrainzEndpointConfig} name="endpointlz">
+3 -4
View File
@@ -125,7 +125,7 @@ services:
</TabItem>
<TabItem value="linuxHost" label="Linux Host">
If you are running this container with **Docker** on a **Linux Host** you **should** specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
If you are running this container with **Docker** on a **Linux Host** you **should** specify `user:group` permissions of the user who owns the **configuration and data directories** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
@@ -150,7 +150,7 @@ services:
</TabItem>
<TabItem value="caching" label="Caching">
**Optionally**, add a [Valkey](https://valkey.io/) service to your stack for [secondary caching](/configuration#secondary-caching) to take advantage of faster performance and reduced memory usage.
**Optionally**, add a [Valkey](https://valkey.io/) service to your stack for [secondary caching](/configuration#secondary-caching) to take advantage of faster performance, reduced memory usage, and cached api calls to external services.
```yaml title="docker-compose.yml"
services:
@@ -215,8 +215,7 @@ services:
- PUID=1000
- PGID=1000
# uncomment along with valkey service/volume below for better caching
#- CACHE_METADATA=valkey
#- CACHE_METADATA_CONN=redis://valkey:6379
#- CACHE_VALKEY=redis://valkey:6379
volumes:
- ./config:/config
ports:
+3 -1
View File
@@ -100,10 +100,12 @@ Add an `id` to the top-level for each Source/Client configuration, next to `data
:::important[Default ID]
**If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
**In 0.14.0, If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
If you decide to add an ID later, you must use the Source/Client name as the ID in order to keep Plays/Scobbles associated with the same config.
**In [0.16.0+](https://github.com/FoxxMD/multi-scrobbler/releases/tag/0.16.0) you are required to have an ID or a configuration error is thrown.**
:::
## Cached Scrobble Migration
+16 -4
View File
@@ -1,6 +1,15 @@
{
"debugMode": false,
"disableWeb": false,
"sourceDefaults": {
"maxPollRetries": 0,
"logPayload": false,
"logFilterFailure": "warn",
"logPlayerState": false,
"scrobbleThresholds": {
"duration": 30,
"percent": 50
},
"maxPollRetries": 1,
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
@@ -8,12 +17,13 @@
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
"baseUrl": "http://localhost",
"sources": [
{
"type": "spotify",
"enable": true,
"clients": ["myConfig"],
"name": "mySpotifySource",
"name": "Cool Spotify Name",
"id": "mySpotifyId",
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
@@ -24,7 +34,9 @@
"clients": [
{
"type": "maloja",
"name": "myConfig",
"enable": true,
"name": "Foxx Maloja",
"id": "myMalojaId",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.15.0",
"version": "0.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.15.0",
"version": "0.16.2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.15.0",
"version": "0.16.2",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -88,6 +88,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, KoitoClientConfig
env: envDataSchema,
prefix: 'KOITO',
toConfig: (partial) => ({
configureAs: 'client',
data: {
url: partial.KOITO_URL,
token: partial.KOITO_TOKEN,
@@ -55,6 +55,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, LastfmClientConfi
env: envDataSchema,
prefix: 'LASTFM',
toConfig: (partial) => ({
configureAs: 'client',
data: {
apiKey: partial.LASTFM_API_KEY,
secret: partial.LASTFM_SECRET,
@@ -74,6 +74,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, LibrefmClientConf
env: envDataSchema,
prefix: 'LIBREFM',
toConfig: (partial) => ({
configureAs: 'client',
data: {
apiKey: partial.LIBREFM_API_KEY,
secret: partial.LIBREFM_SECRET,
@@ -46,6 +46,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, ListenBrainzClien
env: envDataSchema,
prefix: 'LZ',
toConfig: (partial) => ({
configureAs: 'client',
data: {
url: partial.LZ_URL,
token: partial.LZ_TOKEN,
@@ -40,6 +40,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, MalojaClientConfi
env: envDataSchema,
prefix: 'MALOJA',
toConfig: (partial) => ({
configureAs: 'client',
data: {
url: partial.MALOJA_URL,
apiKey: partial.MALOJA_API_KEY
@@ -50,6 +50,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, RockSkyClientConf
env: envDataSchema,
prefix: 'ROCKSKY',
toConfig: (partial) => ({
configureAs: 'client',
data: {
key: partial.ROCKSKY_KEY,
token: partial.ROCKSKY_TOKEN,
@@ -29,6 +29,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, TealClientConfig>
env: envDataSchema,
prefix: 'TEALFM',
toConfig: (partial) => ({
configureAs: 'client',
data: {
identifier: partial.TEALFM_IDENTIFIER,
appPassword: partial.TEALFM_APP_PW
@@ -16,7 +16,13 @@ export const lastFmEndpointDataSchema = z.object({
* If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used
* */
slug: z.string().optional().meta({
description: "The URL ending that should be used to identify scrobbles for this source"
description: "When using **multiple sources without apikey/user**, or **not** using the standard base URL, this is the URL base path that should be used to identify scrobbles for this source"
}),
username: z.string().optional().meta({
description: 'A fake username to differentiate LFM Endpoint Sources'
}),
apiKey: z.string().optional().meta({
description: 'A fake api key to differentiate LFM Endpoint Sources'
}),
});
@@ -24,6 +30,8 @@ export type LastFMEndpointData = z.infer<typeof lastFmEndpointDataSchema>;
const envDataSchema = z.object({
LFM_SLUG: lastFmEndpointDataSchema.shape.slug,
LFM_USERNAME: lastFmEndpointDataSchema.shape.username,
LFM_API_KEY: lastFmEndpointDataSchema.shape.apiKey
});
export const envSchemas: EnvSourceSchema<typeof envDataSchema, LastFMEndpointSourceConfig> = {
@@ -31,7 +39,9 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, LastFMEndpointSou
prefix: 'LFM',
toConfig: (partial) => ({
data: {
slug: partial.LFM_SLUG
slug: partial.LFM_SLUG,
username: partial.LFM_USERNAME,
apiKey: partial.LFM_API_KEY
}
})
};
@@ -16,7 +16,7 @@ export const listenbrainzEndpointDataSchema = z.object({
* If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used
* */
slug: z.string().optional().meta({
description: "The URL ending that should be used to identify scrobbles for this source"
description: "When using **multiple sources without tokens**, or **not** using a standard base URL, this is the URL base path that should be used to identify scrobbles for this source"
}),
/**
@@ -21,6 +21,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, KoitoSourceConfig
env: envDataSchema,
prefix: 'SOURCE_KOITO',
toConfig: (partial) => ({
configureAs: 'source',
data: {
url: partial.SOURCE_KOITO_URL,
token: partial.SOURCE_KOITO_TOKEN,
@@ -22,6 +22,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, LastfmSourceConfi
env: envDataSchema,
prefix: 'SOURCE_LASTFM',
toConfig: (partial) => ({
configureAs: 'source',
data: {
apiKey: partial.SOURCE_LASTFM_API_KEY,
secret: partial.SOURCE_LASTFM_SECRET,
@@ -23,6 +23,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, LibrefmSourceConf
env: envDataSchema,
prefix: 'SOURCE_LIBREFM',
toConfig: (partial) => ({
configureAs: 'source',
data: {
apiKey: partial.SOURCE_LIBREFM_API_KEY,
secret: partial.SOURCE_LIBREFM_SECRET,
@@ -21,6 +21,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, ListenBrainzSourc
env: envDataSchema,
prefix: 'SOURCE_LZ',
toConfig: (partial) => ({
configureAs: 'source',
data: {
url: partial.SOURCE_LZ_URL,
token: partial.SOURCE_LZ_TOKEN,
@@ -20,6 +20,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, MalojaSourceConfi
env: envDataSchema,
prefix: 'SOURCE_MALOJA',
toConfig: (partial) => ({
configureAs: 'source',
data: {
url: partial.SOURCE_MALOJA_URL,
apiKey: partial.SOURCE_MALOJA_API_KEY
@@ -20,6 +20,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, RockskySourceConf
env: envDataSchema,
prefix: 'SOURCE_ROCKSKY',
toConfig: (partial) => ({
configureAs: 'source',
data: {
key: partial.SOURCE_ROCKSKY_KEY,
handle: partial.SOURCE_ROCKSKY_HANDLE
@@ -23,6 +23,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, TealSourceConfig>
env: envDataSchema,
prefix: 'SOURCE_TEALFM',
toConfig: (partial) => ({
configureAs: 'source',
data: {
identifier: partial.SOURCE_TEALFM_IDENTIFIER,
appPassword: partial.SOURCE_TEALFM_APP_PW
+190 -1
View File
@@ -20,6 +20,7 @@ import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
import { redactString } from "@foxxmd/redact-string";
import dns from 'node:dns/promises';
import xml2js from 'xml2js';
const badErrors = [
'api key suspended',
@@ -514,7 +515,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn({payload: rest}), `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`;
this.logger.warn({payload: rest}, `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`);
}
return response;
} catch (e) {
@@ -813,4 +814,192 @@ export interface LastFMScrobblePayload {
export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload {
method: string
}
export type LastFMPayloadkey = keyof LastFMScrobbleRequestPayload;
const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist'];
//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid'];
//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional];
export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMScrobbleRequestPayload[] => {
const keys = Object.keys(obj);
let allObject = true;
for(const k of lfmPayloadKeysRequired) {
if(!keys.includes(k)) {
throw new Error(`Missing required key '${k}'`);
}
if(Array.isArray(obj[k])) {
allObject = false;
} else if(allObject === false) {
throw new Error('Payload is an unexpected mix of arrays and objects');
}
}
const payloads: LastFMScrobbleRequestPayload[] = [];
if(allObject) {
payloads.push(obj as LastFMScrobbleRequestPayload);
} else {
let index = 0;
for(const t of (obj.track as string[])) {
payloads.push({
track: t,
artist: obj.artist[index],
timestamp: obj.timestamp !== undefined ? obj.timestamp[index] : dayjs().unix(),
album: obj.album !== undefined ? obj.album[index] : undefined,
mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined,
duration: obj.duration !== undefined ? obj.duration[index] : undefined,
albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined,
method: obj.method as string
})
index++;
}
}
return payloads.map(x => {
const cleaned: LastFMScrobbleRequestPayload = x;
if(typeof cleaned.duration === 'string') {
cleaned.duration = Number.parseInt(cleaned.duration);
}
if(isNaN(cleaned.duration) || cleaned.duration <= 0) {
cleaned.duration = undefined;
}
if(typeof cleaned.timestamp === 'string') {
cleaned.timestamp = Number.parseInt(cleaned.timestamp);
}
if(isNaN(cleaned.timestamp)) {
cleaned.timestamp = dayjs().unix();
}
return cleaned;
})
}
export const playToScrobbleApiResponseJson = (play: PlayObject) => {
const jsonPayload: LastFMTrackScrobbleResponse = {
scrobbles: {
'@attr': {
accepted: 1,
ignored: 0
},
scrobble: {
track: {
corrected: 0,
'#text': play.data.track
},
artist: {
corrected: 0,
'#text': play.data.artists?.join(',')
},
album: {
corrected: 0,
'#text': play.data.album
},
albumArtist: {
corrected: 0,
'#text': play.data.albumArtists?.join(',')
},
timestamp: dayjs().unix(),
ignoredMessage: {
code: 0,
'#text': ''
}
}
}
}
return jsonPayload;
}
export const playToNowPlayingApiResponseJson = (play: PlayObject) => {
const jsonPayload = {
nowplaying: {
track: {
corrected: 0,
'#text': play.data.track
},
artist: {
corrected: 0,
'#text': play.data.artists?.join(',')
},
album: {
corrected: 0,
'#text': play.data.album
},
albumArtist: {
corrected: 0,
'#text': play.data.albumArtists?.join(',')
},
ignoredMessage: {
code: 0,
'#text': ''
}
}
}
return jsonPayload;
}
export const playToScrobbleApiResponseXml = (play: PlayObject) => {
const builder = new xml2js.Builder();
const xml = builder.buildObject({
lfm: {
$: { status: "ok" },
scrobbles: {
$: {accepted: 1, ignored: 0},
scrobble: {
track: {
$: {corrected: 0},
_: play.data.track
},
artist: {
$: {corrected: 0},
_: play.data.artists?.join(',')
},
album: {
$: {corrected: 0},
_: play.data.album
},
albumArtist: {
$: {corrected: 0},
_: play.data.albumArtists?.join(',')
},
timestamp: {
_: dayjs().unix(),
},
ignoredMessage: {
$: {code: 0}
}
}
}
}
});
return xml;
}
export const playToNowPlayingApiResponseXml = (play: PlayObject) => {
const builder = new xml2js.Builder();
const xml = builder.buildObject({
lfm: {
$: { status: "ok" },
nowplaying: {
track: {
$: { corrected: 0 },
_: play.data.track
},
artist: {
$: { corrected: 0 },
_: play.data.artists?.join(',')
},
album: {
$: { corrected: 0 },
_: play.data.album
},
albumArtist: {
$: { corrected: 0 },
_: play.data.albumArtists?.join(',')
},
ignoredMessage: {
$: { code: 0 }
}
}
}
});
return xml;
}
@@ -15,7 +15,8 @@ import {
type PlayOriginal,
type PlayLifecycle,
type SourcePlayerJson,
QUEUE_STATUS_COMPLETED
QUEUE_STATUS_COMPLETED,
SOURCE_SOT
} from "../../core/Atomic.ts";
import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts";
import AbstractComponent from "../common/AbstractComponent.ts";
@@ -73,11 +74,18 @@ import assert from "node:assert";
import { COMPONENT_STATE, type ComponentClientApiJson, type PlayApiCommonDetailed } from "../../core/Api.ts";
import type {ComponentState} from "react";
type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>;
type SourceMappedPlayer = {player: SourcePlayerObj, source: SourceIdentifier};
type PlatformMappedPlays = Map<string, SourceMappedPlayer>;
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
const platformTruncate = truncateStringToLength(10);
const bufferNPUpdateReasonFragments: string[] = [
'previous update play data does not match current',
'player in valid update state',
'less than min threshold'
];
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
declare type: ClientType;
@@ -116,7 +124,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
nowPlayingIsRealtime: boolean = false;
nowPlayingInit: boolean = false;
nowPlayingEnabled: boolean;
nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined;
nowPlayingFilter: (queue: NowPlayingQueue) => SourceMappedPlayer | undefined;
nowPlayingMinThreshold: NowPlayingUpdateThreshold = (_) => 10;
nowPlayingMaxThreshold: NowPlayingUpdateThreshold = (_) => 30;
nowPlayingLastUpdated?: Dayjs;
@@ -542,7 +550,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
}
this.nowPlayingFilter = (queue: NowPlayingQueue): SourcePlayerObj => {
this.nowPlayingFilter = (queue: NowPlayingQueue): SourceMappedPlayer => {
if (queue.size === 0) {
return undefined;
}
@@ -555,7 +563,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
// if only one player then return it
const plays = Array.from(platformPlays);
if (plays.length === 1) {
return plays[0][1].player;
return plays[0][1];
}
// else we need to sort players to determine which to report
@@ -568,7 +576,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
if (platform === this.nowPlayingLastPlay.platformId
// only keep using sticky platform if it hasn't gone stale/orphaned
&& (!(data.player.status?.stale ?? false) && !(data.player.status?.orphaned ?? false))) {
return data.player;
return data;
}
}
}
@@ -582,7 +590,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
// otherwise sort platform alphabetically and take first
preferredPlays.sort((a, b) => a[0].localeCompare(b[0]));
return preferredPlays[0][1].player;
return preferredPlays[0][1];
}
}
}
@@ -1638,14 +1646,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
return;
}
// eslint-disable-next-line prefer-const
let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData);
let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData.player);
let clientReason: string | undefined;
if(!shouldUpdate) {
this.npLogger.trace(`Not updating, ${npUpdateTopReason}`);
}
if(shouldUpdate) {
const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData);
const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData.player);
clientReason = clientUpdateReason;
shouldUpdate = clientUpdate;
if(!clientUpdate) {
@@ -1653,29 +1661,45 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
}
const cleanNowPlayingQueue = new Map();
// finally, do the update
if(shouldUpdate) {
this.npLogger.verbose(`Updating because ${npUpdateTopReason}${clientReason !== undefined ? ` --AND-- ${clientReason}` : ''}`);
const isClearing = this.nowPlayingIsRealtime && shouldClearNPStatus(sourcePlayerData);
const isClearing = this.nowPlayingIsRealtime && shouldClearNPStatus(sourcePlayerData.player);
try {
await this.doPlayingNow(sourcePlayerData);
await this.doPlayingNow(sourcePlayerData.player);
this.npLogger.trace(`Now Playing updated.`);
this.setStatus('Now Playing updated');
if(!isClearing) {
this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData));
this.emitEvent('playerUpdate', {...sourcePlayerData, expiration: this.nowPlayingExpirationDate});
this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData.player));
this.emitEvent('playerUpdate', {...sourcePlayerData.player, expiration: this.nowPlayingExpirationDate});
} else {
this.nowPlayingExpirationDate = undefined;
this.emitEvent('playerDelete', {platformId: sourcePlayerData.platformId});
this.emitEvent('playerDelete', {platformId: sourcePlayerData.player.platformId});
}
this.emitEvent('nowPlayingUpdated', sourcePlayerData);
} catch (e) {
this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e}));
}
this.nowPlayingLastPlay = sourcePlayerData;
this.nowPlayingLastPlay = sourcePlayerData.player;
this.nowPlayingLastUpdated = dayjs();
} else {
if(sourcePlayerData.player.play?.meta?.sourceSOT === SOURCE_SOT.INGRESS && bufferNPUpdateReasonFragments.every((x) => npUpdateTopReason.includes(x))) {
// update is for an ingress Source and is valid
// but time since last update was less than client threshold interval
//
// Ingress Sources may not send any additional updates to MS until a scrobble event
// so, otherwise, NP would never be updated until that happens
//
// to prevent that we want client NP to update with this *valid* update after min threshold is met
// so we will re-queue the update so that it gets used in a subsequent NP processing run
this.npLogger.debug('Re-queuing valid NP update from ingress Source that did not meet min threshold');
const sourceId = `${sourcePlayerData.source.name}-${sourcePlayerData.source.type}`;
cleanNowPlayingQueue.set(sourceId, this.nowPlayingQueue.get(sourceId));
}
}
this.nowPlayingQueue = new Map();
this.nowPlayingQueue = cleanNowPlayingQueue;
}
}
+9 -5
View File
@@ -181,7 +181,10 @@ export default class ScrobbleClients {
}
}
const clientKeys = envKeys.filter(x => x.includes(clientUpper));
const envSchema = await getClientEnvSchema(clientType);
const configTypeUpper = envSchema.prefix.toUpperCase();
const clientKeys = envKeys.filter(x => x.includes(configTypeUpper));
if (clientKeys.length > 0) {
clientUnparsedConfigs.push({
config: pick(process.env, ...clientKeys),
@@ -198,16 +201,17 @@ export default class ScrobbleClients {
switch (entry.source) {
case 'env': {
const envSchema = await getClientEnvSchema(clientType);
const primitiveSchema = generateCommonComponentEnvConfigSchema(envSchema.prefix.toUpperCase());
const primitiveSchema = generateCommonComponentEnvConfigSchema(configTypeUpper);
const parsed = primitiveSchema.parse(entry.config);
const primitives: CommonConfigPrimitives = commonComponentEnvConfigToConfigPrimitives(envSchema.prefix.toUpperCase(), parsed);
const primitives: CommonConfigPrimitives = commonComponentEnvConfigToConfigPrimitives(configTypeUpper, parsed);
const parsedEnvConfigValues = envSchema.env.parse(entry.config);
const { data = {}, options = {} } = envSchema.toConfig(parsedEnvConfigValues);
const transformOptions = transformPresetEnv(envSchema.prefix.toUpperCase());
const { data = {}, options = {}, ...rest } = envSchema.toConfig(parsedEnvConfigValues);
const transformOptions = transformPresetEnv(configTypeUpper);
parsedConfig = {
name: `${clientType} - ${entry.source}${entry.pos !== '' ? ` - ${entry.pos}` : ''} `,
...primitives,
data,
...rest,
source: generateConfigLocation('client', entry),
options: {
...options,
+17
View File
@@ -346,6 +346,23 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json(asSerializablePlaySelect(playRes));
});
app.delete('/api/cache/:cacheType', async (req, res) => {
const cache = await getRoot().items.cache();
logger.verbose(`User request cache deletion for ${req.params.cacheType}`);
switch(req.params.cacheType) {
case 'external-api':
await cache.cacheApi.clear();
break;
case 'transforms':
await cache.cacheTransform.clear();
break;
default:
return res.sendStatus(404);
}
logger.verbose('Cache cleared!');
return res.sendStatus(204);
});
/**
*
*
+83 -9
View File
@@ -7,7 +7,11 @@ import { nonEmptyBody } from "./middleware.ts";
import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.ts";
import type { EndpointLastfmSource} from "../sources/EndpointLastfmSource.ts";
import { playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.ts";
import type {LastFMScrobbleRequestPayload} from "../common/vendor/LastfmApiClient.ts";
import {playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml, type LastFMScrobbleRequestPayload} from "../common/vendor/LastfmApiClient.ts";
import xml2js from 'xml2js';
import crypto from 'node:crypto';
const unmatchIdentifierWarn: string[] = [];
export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
@@ -39,23 +43,93 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
if (validSources.length === 0) {
const [slug] = parseDisplayIdentifiersFromRequest(req);
logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`);
return res.sendStatus(409);
}
if(!('method' in req.body)) {
return res.status(400).json({error: `Missing 'method' param`});
}
const method = (req.body as LastFMScrobbleRequestPayload).method;
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
return res.status(400).json({error: `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`});
let wantsJson: boolean = false;
if(req.query.format === 'json') {
wantsJson = true;
} else {
// some players, like ArchiveTune, use the accept header to signal they want json
// rather than using the official format=json qs lastfm wants
const a = req.header('accept');
if(a !== undefined && a.includes('json')) {
wantsJson = true;
}
}
res.sendStatus(200);
let source: EndpointLastfmSource;
// try to find by username or api_key or sk
if(req.body.api_key !== undefined) {
source = validSources.find(x => x.config.data?.apiKey === req.body.api_key);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.api_key) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has the apiKey '${req.body.api_key}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.api_key);
}
} else if(req.body.username !== undefined) {
source = validSources.find(x => x.config.data?.username === req.body.username);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.username) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has the username '${req.body.username}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.username);
}
} else if(req.body.sk !== undefined) {
source = validSources.find(x => crypto.createHash('md5').update(x.getUid()).digest('hex') === req.body.sk);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.sk) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has an ID md5 that matches the provided session key (sk) '${req.body.sk}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.sk);
}
}
const playerState = playStateFromRequest(req.body);
if(source === undefined) {
source = validSources[0];
}
switch (method) {
case 'auth.getMobileSession': {
const resp = {
session: {
name: req.body.name ?? source.getUid(),
key: crypto.createHash('md5').update(source.getUid()).digest('hex'),
subscriber: 0
}
};
source.logger.info(`Authenticating with username ${resp.session.name}`);
if (wantsJson) {
return res.status(200).json(resp);
}
const builder = new xml2js.Builder();
const xml = builder.buildObject({ lfm: { $: { status: "ok" }, ...resp } });
return res.status(200).setHeader('Content-Type', 'application/xml').send(xml);
}
case 'track.updateNowPlaying':
case 'track.scrobble': {
const playerState = playStateFromRequest(req.body);
if (method === 'track.scrobble') {
if (wantsJson) {
res.status(200).json(playToScrobbleApiResponseJson(playerState[0].play))
} else {
res.status(200).setHeader('Content-Type', 'application/xml').send(playToScrobbleApiResponseXml(playerState[0].play));
}
} else {
if (wantsJson) {
res.status(200).json(playToNowPlayingApiResponseJson(playerState[0].play))
} else {
res.status(200).setHeader('Content-Type', 'application/xml').send(playToNowPlayingApiResponseXml(playerState[0].play));
}
}
await source.handle(playerState)
} break;
default:
return res.status(400).json({ error: `Unexpected 'method' param value '${method}', expected one of: track.updateNowPlaying | track.scrobble | auth.getMobileSession` });
for (const source of validSources) {
await source.handle(playerState);
}
});
}
}
@@ -92,10 +92,13 @@ export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobb
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
return res.status(409).json({error: `Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured`, code: 409});
}
const matchedSource = sources.find(x => x.config.data?.username === user || x.name === user);
let matchedSource = sources.find(x => x.config.data?.username === user || x.name === user);
if(matchedSource === undefined) {
matchedSource = sources[0];
}
const playObjs = scrobbleClients.getPlayingNow(matchedSource.name, matchedSource.clients);
listens = playObjs.map(x => ({playing_now: true, track_metadata: playToListenPayload(x).track_metadata}));
+1 -1
View File
@@ -325,7 +325,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
// only need to update if its already in memory,
// and better to update in-memory than clear cache so we aren't refetching from db on every discover
if(recentPlays !== undefined) {
recentPlays.push(play);
recentPlays.push({...play, id: playRow[0].id, uid: playRow[0].uid});
recentPlays.sort(sortByOldestPlayDate);
this.cache.cacheDb.set(this.recentDiscoveredCacheKey(), recentPlays, '2m');
}
+31 -32
View File
@@ -11,14 +11,14 @@ import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {PlayPlatformId} from '../../core/Atomic.ts';
import MemorySource from "./MemorySource.ts";
import type {LastFMEndpointSourceConfig} from "../common/infrastructure/config/source/endpointlfm.ts";
import { type LastFMScrobbleRequestPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts";
import { ingressPayloads, type LastFMPayloadkey, type LastFMScrobbleRequestPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts";
import type {Logger} from "@foxxmd/logging";
import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i);
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i);
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:^\/1\/?|^\/2.0\/?)$/i);
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)(\/|\/2.0\/)?$/i);
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
@@ -29,7 +29,7 @@ export class EndpointLastfmSource extends MemorySource {
constructor(name: any, config: LastFMEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('endpointlfm', name, config, internal, emitter);
this.multiPlatform = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.playerSourceOfTruth = SOURCE_SOT.INGRESS;
const {
data = {},
@@ -44,15 +44,12 @@ export class EndpointLastfmSource extends MemorySource {
}
matchRequest(req: ExpressRequest): boolean {
let matchesPath = false;
const slug = parseSlugFromRequest(req);
if (slug === false) {
return false;
} else {
matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
}
return matchesPath;
return (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
}
static formatPlayObj(obj: LastFMScrobbleRequestPayload, options: FormatPlayObjectOptions = {}): PlayObject {
@@ -67,20 +64,23 @@ export class EndpointLastfmSource extends MemorySource {
return true;
}
handle = async (stateData: PlayerStateData) => {
handle = async (stateData: PlayerStateData[]) => {
if(stateData[0].play.meta.nowPlaying === true) {
this.setStatus('Received Now Playing');
} else {
this.setStatus('Received Play');
}
await this.processRecentPlays([stateData]);
if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) {
const discovered = await this.discover([stateData.play]);
if (discovered.length > 0) {
await this.scrobble(discovered);
if(stateData.length === 1) {
if(stateData[0].play.meta.nowPlaying === true) {
this.setStatus('Received Now Playing');
} else {
this.setStatus('Received Play');
}
await this.processRecentPlays(stateData);
} else {
this.setStatus(`Received ${stateData.length} batch Plays`);
}
const discoverable = stateData.filter(x => x.play.meta.nowPlaying === false);
const discovered = await this.discover(discoverable.map(x => x.play));
if (discovered.length > 0) {
await this.scrobble(discovered);
}
this.componentRepo.updateById(this.dbComponent.id, {lastActiveAt: dayjs()});
this.setStatus('Waiting for Plays');
@@ -93,17 +93,16 @@ export class EndpointLastfmSource extends MemorySource {
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
export const playStateFromRequest = (obj: LastFMScrobbleRequestPayload): PlayerStateData => {
const play = scrobblePayloadToPlay(obj);
play.meta.sourceSOT = SOURCE_SOT.HISTORY;
return {
platformId: [play.meta.deviceId, NO_USER],
play,
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
stateUpdatedAt: dayjs()
}
}
export const playStateFromRequest = (obj: Record<LastFMPayloadkey, unknown>): PlayerStateData[] => ingressPayloads(obj).map(x => {
const play = scrobblePayloadToPlay(x);
play.meta.sourceSOT = SOURCE_SOT.INGRESS;
return {
platformId: [play.meta.deviceId, NO_USER],
play,
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
stateUpdatedAt: dayjs()
}
})
export const parseSlugFromString = (path: string): string | false | undefined => {
const noSlug = parseRegexSingle(noSlugMatch, path);
@@ -117,7 +116,7 @@ export const parseSlugFromString = (path: string): string | false | undefined =>
return false;
}
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.originalUrl);
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => {
const slug = parseSlugFromRequest(req);
@@ -32,7 +32,7 @@ export class EndpointListenbrainzSource extends MemorySource {
constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('endpointlz', name, config, internal, emitter);
this.multiPlatform = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.playerSourceOfTruth = SOURCE_SOT.INGRESS;
const {
data = {},
@@ -127,7 +127,7 @@ export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData[] => {
const playStates: PlayerStateData[] = payload.map((x) => {
const play = listenPayloadToPlay(x, listen_type === 'playing_now');
play.meta.sourceSOT = SOURCE_SOT.HISTORY;
play.meta.sourceSOT = SOURCE_SOT.INGRESS;
return {
platformId: [play.meta.deviceId, NO_USER],
play,
+1 -2
View File
@@ -60,7 +60,7 @@ export default class MemorySource extends AbstractSource {
// player cleanup on *schedule* is needed when the Source is non-polling (ingress)
// because if the source stops sending updates then processRecentPlays() was never called so we never remove old players
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 15 }, new AsyncTask('Player Cleanup', (): Promise<any> => {
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 10 }, new AsyncTask('Player Cleanup', (): Promise<any> => {
if (this.canPoll) {
return Promise.resolve();
}
@@ -68,7 +68,6 @@ export default class MemorySource extends AbstractSource {
.withConcurrency(1)
.for(this.players.keys())
.process(async (key) => {
await this.cleanupPlayer(key);
});
})));
+8 -7
View File
@@ -178,8 +178,6 @@ export default class ScrobbleSources {
let sourceUnparsedConfigs = unparsedConfigs.filter(x => x.type === configType);
const configTypeUpper = configType.toUpperCase();
let rawConfigs;
try {
rawConfigs = await readJson(`${this.internalConfig.configDir}/${configType}.json`, { throwOnNotFound: false, logger: childLogger(this.logger, `${configType} Secrets`) });
@@ -202,6 +200,9 @@ export default class ScrobbleSources {
}
}
const envSchema = await getSourceEnvSchema(configType);
const configTypeUpper = envSchema.prefix.toUpperCase();
const configKeys = envKeys.filter(x => x.includes(configTypeUpper));
if (configKeys.length > 0) {
sourceUnparsedConfigs.push({
@@ -218,17 +219,17 @@ export default class ScrobbleSources {
try {
switch (entry.source) {
case 'env': {
const envSchema = await getSourceEnvSchema(configType);
const primitiveSchema = generateCommonComponentEnvConfigSchema(envSchema.prefix.toUpperCase());
const primitiveSchema = generateCommonComponentEnvConfigSchema(configTypeUpper);
const parsed = primitiveSchema.parse(entry.config);
const primitives: CommonConfigPrimitives = commonComponentEnvConfigToConfigPrimitives(envSchema.prefix.toUpperCase(), parsed);
const primitives: CommonConfigPrimitives = commonComponentEnvConfigToConfigPrimitives(configTypeUpper, parsed);
const parsedEnvConfigValues = envSchema.env.parse(entry.config);
const { data = {}, options = {} } = envSchema.toConfig(parsedEnvConfigValues);
const transformOptions = transformPresetEnv(envSchema.prefix.toUpperCase());
const { data = {}, options = {}, ...rest } = envSchema.toConfig(parsedEnvConfigValues);
const transformOptions = transformPresetEnv(configTypeUpper);
parsedConfig = {
name: `${configType} - ${entry.source}${entry.pos !== '' ? ` - ${entry.pos}` : ''} `,
...primitives,
data,
...rest,
source: generateConfigLocation('source', entry),
options: {
...options,
+2 -2
View File
@@ -34,7 +34,7 @@ export class WebScrobblerSource extends MemorySource {
constructor(name: any, config: WebScrobblerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('webscrobbler', name, config, internal, emitter);
this.multiPlatform = true;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.playerSourceOfTruth = SOURCE_SOT.INGRESS;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
const {
@@ -88,7 +88,7 @@ export class WebScrobblerSource extends MemorySource {
} = obj;
const play = WebScrobblerSource.formatPlayObj(obj.data.song, {nowPlaying: eventName !== 'scrobble'});
play.meta.sourceSOT = SOURCE_SOT.HISTORY;
play.meta.sourceSOT = SOURCE_SOT.INGRESS;
return {
platformId: [play.meta.deviceId, NO_USER],
play,
@@ -2,7 +2,6 @@ import type {Logger} from "@foxxmd/logging";
import type {Request} from "express";
import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.ts";
import { IngressNotifier } from "./IngressNotifier.ts";
import type {LastFMScrobbleRequestPayload} from "../../common/vendor/LastfmApiClient.ts";
export class LFMEndpointNotifier extends IngressNotifier {
@@ -34,16 +33,12 @@ export class LFMEndpointNotifier extends IngressNotifier {
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
if(req.method !== 'POST') {
return `Expected POST request (track.scrobble payload) but received ${req.method}`;
return `Expected POST request but received ${req.method}`;
}
if(!isRaw) {
if(!('method' in req.body)) {
return `Body is missing 'method' param`
}
const method = (req.body as LastFMScrobbleRequestPayload).method;
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
return `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`
}
}
return;
}
@@ -865,7 +865,7 @@ describe('Now Playing', function() {
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
@@ -886,7 +886,7 @@ describe('Now Playing', function() {
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
@@ -910,7 +910,7 @@ describe('Now Playing', function() {
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(secondPlatform));
expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(secondPlatform));
});
@@ -927,7 +927,7 @@ describe('Now Playing', function() {
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.play.meta.deviceId).eq(a.play.meta.deviceId);
expect(toReport.player.play.meta.deviceId).eq(a.play.meta.deviceId);
});
@@ -944,7 +944,7 @@ describe('Now Playing', function() {
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.play.meta.deviceId).eq(b.play.meta.deviceId);
expect(toReport.player.play.meta.deviceId).eq(b.play.meta.deviceId);
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { getListDiff, type ListDiff } from "@donedeal0/superdiff";
import { type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts";
import { type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, type SOURCE_SOT_TYPES, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts";
import { comparingMultipleArtists, playObjDataMatch, setIntersection } from "../utils.ts";
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, temporalAccuracyToString, type TemporalPlayComparisonOptions, temporalPlayComparisonSummary } from "./TimeUtils.ts";
@@ -496,7 +496,7 @@ export const existingScrobble = async (playObjPre: PlayObject, existingScrobbles
//
// OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources)
// then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble
const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING];
const looseTimeAccuracy = playObj.data.repeat || ([SOURCE_SOT.HISTORY, SOURCE_SOT.INGRESS] as SOURCE_SOT_TYPES[]).includes(playObj.meta.sourceSOT) ? [TA_DURING] : [TA_FUZZY, TA_DURING];
existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => {
+7 -1
View File
@@ -15,6 +15,7 @@ import { MSErrorBoundary } from './components/ErrorBoundary';
import { ComponentDetailedRoutable } from './components/msComponent/MSComponentDetailed';
import { MSComponentListFetchable } from './components/msComponent/MSComponentList';
import { Provider } from './components/Provider';
import { SettingsContainer } from './components/settings/settings';
function NoMatch() {
const location = useLocation();
@@ -70,7 +71,8 @@ const routesNested: RouteObject[] = [
{
path: "/next",
Component: Layout,
children: [ {
children: [
{
index: true,
element: <MSErrorBoundary><MSComponentListFetchable/></MSErrorBoundary>,
},
@@ -78,6 +80,10 @@ const routesNested: RouteObject[] = [
path: "components/:componentId",
element: <Container boxSize="full" p="0" maxWidth="8xl"><MSErrorBoundary><ComponentDetailedRoutable/></MSErrorBoundary></Container>
},
{
path: "settings",
element: <Container p="0" boxSize="full" maxWidth="4xl"><MSErrorBoundary><SettingsContainer/></MSErrorBoundary></Container>
},
{
path: "*",
element: <NoMatch/>
+19 -29
View File
@@ -6,8 +6,6 @@ import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
import { BsCircleHalf } from "react-icons/bs";
import { Tooltip } from "./ChakraTooltip";
export interface ColorModeProviderProps extends ThemeProviderProps {}
@@ -26,12 +24,27 @@ export interface UseColorModeReturn {
export const useColorMode = (): UseColorModeReturn => {
const { resolvedTheme, setTheme, forcedTheme, systemTheme, theme } = useTheme()
const colorMode = forcedTheme || resolvedTheme
const toggleColorMode = (remove?: boolean) => {
if(remove) {
//console.log(`Use Color Mode -- system theme: ${systemTheme} | Used Theme ${theme} | Color mode ${colorMode}`);
const toggleColorMode = () => {
// https://lea.verou.me/blog/2026/dark-mode-toggles/#good-two-state-ux
// only change override (or remove) if *user* initiated
// dont do anything if system theme changes
const inverseTheme = resolvedTheme === 'dark' ? 'light' : 'dark';
// if user-initiated toggle
//
// and is going back to system theme
if(systemTheme === inverseTheme) {
// then remove override
setTheme('system');
localStorage.removeItem('theme');
console.debug('removed theme override');
} else {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
// otherwise, going to non-system theme
// add override
setTheme(inverseTheme);
console.debug(`setting theme override to ${inverseTheme}`);
}
}
return {
@@ -59,8 +72,7 @@ export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>((props, ref) => {
const { toggleColorMode, systemTheme, colorMode, theme } = useColorMode();
//console.log(`System theme: ${systemTheme} | Theme ${theme} | Color mode ${colorMode}`);
const { toggleColorMode } = useColorMode();
const toggleButton = (
<IconButton
onClick={() => toggleColorMode()}
@@ -80,31 +92,9 @@ export const ColorModeButton = React.forwardRef<
</IconButton>
);
const systemButton = (
<Tooltip content="Reset theme to system">
<IconButton
onClick={() => toggleColorMode(true)}
variant="ghost"
aria-label="Use system color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "4",
height: "4",
},
}}
>
<BsCircleHalf />
</IconButton>
</Tooltip>
);
return (
<ButtonGroup variant="outline" attached>
{toggleButton}
{theme !== 'system' ? systemButton : undefined}
</ButtonGroup>
)
});
+1 -1
View File
@@ -51,7 +51,7 @@ export const MobileSidebarNav = (props: { hideFrom?: BreakpointName | false } =
</IconButton>
</Drawer.CloseTrigger>
<Drawer.Body display="flex" flexDir="column" gap="6" py="5" flex="1">
<SideNavItems items={NAV_LINKS} />
<SideNavItems items={NAV_LINKS} currentUrl={location.pathname} />
</Drawer.Body>
</Drawer.Content>
</Drawer.Positioner>
+6 -2
View File
@@ -73,8 +73,8 @@ export const SideNav = (props: SideNavProps) => {
</Link>
) : (
<RouterLink
href={item.url!}
aria-current={item.url === currentUrl ? "page" : undefined}
to={{pathname: item.url!}}
aria-current={(currentUrl.length <= item.url.length ? currentUrl.startsWith(item.url) : currentUrl === item.url) ? "page" : undefined}
>
{item.title}
{item.status && <StatusBadge>{item.status}</StatusBadge>}
@@ -134,6 +134,10 @@ export const NAV_LINKS: SideNavProps[] = [
{
title: 'Dashboard',
url: '/next/'
},
{
title: 'Settings',
url: '/next/settings'
}
]
},
+1 -1
View File
@@ -83,7 +83,7 @@ art = {},
return (
<article className={["player", "mb-2"].join(' ')}>
<div className="player__wrapper">
{sot === SOURCE_SOT.HISTORY ? <span className="player-tooltip"><Tooltip
{sot !== SOURCE_SOT.PLAYER ? <span className="player-tooltip"><Tooltip
classNames={['justify-end', 'mr-4']}
message="This player is for DISPLAY ONLY and likely represents a 'Now Playing' status exposed by the Source. For scrobbling Multi Scrobbler uses the 'recently played' or 'history' information provided by this source.">
<FontAwesomeIcon width={9} color="black" icon={faQuestion}/>
@@ -0,0 +1,89 @@
import { useCallback } from "react"
import {
VStack,
Heading,
SegmentGroup,
Group,
Box,
Button
} from "@chakra-ui/react"
import { useTheme } from "next-themes"
import { BsCircleHalf } from "react-icons/bs";
import { LuMoon, LuSun } from "react-icons/lu"
import { CheckIcon, makeChakraIcon } from "../icons/ChakraIcons";
import { useMutation } from "@tanstack/react-query";
import ky from "ky";
import type { CacheClearType } from "../../../core/Api";
import { EphemeralElement } from "../Badges";
const DarkIcon = makeChakraIcon(LuMoon);
const LightIcon = makeChakraIcon(LuSun);
const SystemIcon = makeChakraIcon(BsCircleHalf);
export const SettingsContainer = () => {
const { resolvedTheme, setTheme, theme } = useTheme();
const setThemeCB = useCallback((val: string) => {
switch(val) {
case 'system':
setTheme('system');
localStorage.removeItem('theme');
break;
case 'dark':
setTheme('dark');
break;
case 'light':
setTheme('light');
break;
}
},[setTheme]);
const {mutate, isPending, variables, isSuccess} = useMutation({
mutationFn: (clearType: CacheClearType) => ky.delete(`/api/cache/${clearType}`)
});
return (
<VStack alignItems="flex-start">
<Heading my="3">Appearance</Heading>
<VStack>
<SegmentGroup.Root size="md" value={theme === 'system' ? 'system' : resolvedTheme} onValueChange={(val) => setThemeCB(val.value)}>
<SegmentGroup.Indicator />
<SegmentGroup.Item key="dark" value="dark">
<SegmentGroup.ItemText>Dark <DarkIcon/></SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
<SegmentGroup.Indicator />
<SegmentGroup.Item key="light" value="light">
<SegmentGroup.ItemText>Light <LightIcon/></SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
<SegmentGroup.Indicator />
<SegmentGroup.Item key="system" value="system">
<SegmentGroup.ItemText>System <SystemIcon/></SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
</SegmentGroup.Root>
</VStack>
<Heading my="3">Cache</Heading>
<Box>
<Group attached>
<Button colorPalette="red"
loading={isPending && variables === 'external-api'}
loadingText="Clearing..."
disabled={isPending}
onClick={() => mutate('external-api')}
variant="outline">
Clear External API Cache {isSuccess && variables === 'external-api' && <EphemeralElement expires={2000}><CheckIcon/></EphemeralElement>}
</Button>
<Button colorPalette="red"
loading={isPending && variables === 'transforms'}
loadingText="Clearing..."
disabled={isPending}
onClick={() => mutate('transforms')}
variant="outline">
Clear Transform Cache {isSuccess && variables === 'transforms' && <EphemeralElement expires={2000}><CheckIcon/></EphemeralElement>}
</Button>
</Group>
</Box>
</VStack>
)
}
+1
View File
@@ -209,3 +209,4 @@ export type CompareDateSingle<D extends DateLike = Dayjs> = {
date: D;
};
export type CacheClearType = 'external-api' | 'transforms';
+6 -5
View File
@@ -469,12 +469,13 @@ export interface TemporalPlayComparison {
} | { type: 'none' }
}
export type SOURCE_SOT_TYPES = 'player' | 'history';
export type SOURCE_SOT_TYPES = 'player' | 'history' | 'ingress';
export const SOURCE_SOT = {
PLAYER : 'player' as SOURCE_SOT_TYPES,
HISTORY: 'history' as SOURCE_SOT_TYPES
}
export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history'];
PLAYER : 'player',
HISTORY: 'history',
INGRESS: 'ingress'
} as const satisfies Record<string, SOURCE_SOT_TYPES>
export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history','ingress'];
export interface URLData {
url: URL