mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4e625824 | ||
|
|
b98756a262 | ||
|
|
41aaaca530 | ||
|
|
ed42da6843 | ||
|
|
34044401d4 | ||
|
|
f61e50fd45 | ||
|
|
f5ad2ad29b | ||
|
|
2e4acd0f00 | ||
|
|
b9517bd066 | ||
|
|
335880249f | ||
|
|
5c2917f906 | ||
|
|
124e857d9f | ||
|
|
5dee499381 | ||
|
|
10325fd4e6 | ||
|
|
045890e243 | ||
|
|
685e48a7a6 | ||
|
|
7eb5104f0b | ||
|
|
4272e8ea58 | ||
|
|
8e6d35a85b | ||
|
|
917d05f295 | ||
|
|
287c994643 | ||
|
|
7980a0c856 | ||
|
|
9d2557cf6a | ||
|
|
6f8debc304 | ||
|
|
b53ae78d55 | ||
|
|
8723bc8890 | ||
|
|
eca8ad2ce1 | ||
|
|
b6b8d3423f | ||
|
|
fc027e8554 | ||
|
|
707da834bb | ||
|
|
002520cd8a | ||
|
|
7ca84ce27f |
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -209,3 +209,4 @@ export type CompareDateSingle<D extends DateLike = Dayjs> = {
|
||||
date: D;
|
||||
};
|
||||
|
||||
export type CacheClearType = 'external-api' | 'transforms';
|
||||
+6
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user