mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b58c2f89 | ||
|
|
86b8b4ad90 | ||
|
|
29ffc6b804 | ||
|
|
3bdc9447b8 | ||
|
|
4a27806825 | ||
|
|
b8cd53f554 | ||
|
|
e7218074f8 | ||
|
|
a48ada9ac3 | ||
|
|
bc28de66b1 | ||
|
|
bc18993cd3 | ||
|
|
d280ca5dd2 | ||
|
|
155d8a3252 | ||
|
|
68d9417cf7 | ||
|
|
9b7483db91 | ||
|
|
7c0797b496 | ||
|
|
1b3cbe9ab3 | ||
|
|
15e0332ac2 | ||
|
|
d532c85c2a | ||
|
|
eecbea4cab | ||
|
|
823dd5d011 | ||
|
|
0fa9a8df10 | ||
|
|
a76213a7a0 | ||
|
|
a42ab3d8fd | ||
|
|
475a3f1276 | ||
|
|
6dcfd6028b | ||
|
|
54247e89c2 | ||
|
|
433ea532ae | ||
|
|
b615172ed1 | ||
|
|
aa2beb5846 | ||
|
|
06d3c6a3a8 | ||
|
|
c0cfc5fa9f | ||
|
|
2195ac7057 | ||
|
|
3fc67146c7 | ||
|
|
fe4e625824 | ||
|
|
b98756a262 | ||
|
|
2b7521ebac | ||
|
|
ecd48ca234 | ||
|
|
41aaaca530 | ||
|
|
ed42da6843 | ||
|
|
34044401d4 | ||
|
|
f61e50fd45 | ||
|
|
f5ad2ad29b | ||
|
|
2e4acd0f00 | ||
|
|
b9517bd066 |
+1
-1
@@ -159,4 +159,4 @@ storybook-static
|
||||
|
||||
bun.lock
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
| `DISCORD_TOKEN` | string | | |
|
||||
| `DISCORD_ARTWORK` | boolean OR string OR array | | |
|
||||
| `DISCORD_APPLICATION_ID` | string | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string OR array | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string OR array | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string OR array | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | |
|
||||
@@ -5,4 +5,4 @@
|
||||
| `ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
|
||||
| `ROCKSKY_TOKEN` | string | | Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
|
||||
@@ -557,10 +557,12 @@ Remember, this URL need to be accessible on the public internet.
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :----------------------------- | ----------------- | --------------------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_APPLICATION_ID`**_ | string | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
</Config>
|
||||
@@ -323,12 +323,14 @@ The order in which multi-scrobbler determines what image to use is the same as t
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :--------------------------------- | ----------------- | --------------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_TOKEN`**_ | string | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | string | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
</Config>
|
||||
@@ -52,6 +52,6 @@ For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLO
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="KoitoClientConfig" fileContent={JsonConfig} client name="koito">
|
||||
<Config config="KoitoClientConfig" fileContent={JsonConfig} client ambiguous name="koito">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
|
||||
@@ -63,6 +63,6 @@ The Redirect URL can be explicitly specified in the configuration below, or by u
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmClientConfig" fileContent={JsonConfig} client name="lastfm">
|
||||
<Config config="LastfmClientConfig" fileContent={JsonConfig} client ambiguous name="lastfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -54,6 +54,6 @@ https://my.cool.libre.com/2.0/
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client name="librefm">
|
||||
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client ambiguous name="librefm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
|
||||
@@ -23,6 +23,6 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
|
||||
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client ambiguous name="listenbrainz">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -38,6 +38,6 @@ Finally, add the Maloja server URL and API Key to the configuration type you cho
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaClientConfig" fileContent={JsonConfig} client name="maloja">
|
||||
<Config config="MalojaClientConfig" fileContent={JsonConfig} client ambiguous name="maloja">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -76,6 +76,6 @@ Keys are now **deprecated** and functionality for them will be removed in a futu
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
|
||||
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client ambiguous name="rocksky">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -78,6 +78,6 @@ If using a handle:
|
||||
|
||||
If your identifier does not look like a DID and does not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} client name="tealfm">
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} client ambiguous name="tealfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -151,6 +151,8 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
|
||||
```
|
||||
|
||||
</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 (`config.json`) located in the directory specified by the `CONFIG_DIR` environmental variable. The file's location defaults to:
|
||||
@@ -190,24 +192,26 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
|
||||
|
||||
Compared to their [File](./?configType=file#configuration-types) equivalent, Sources/Clients configured using AIO require an additional `type` property.
|
||||
|
||||
```yaml title="jellyfin.json"
|
||||
[
|
||||
{
|
||||
"name": "Foxx JF Server",
|
||||
// highlight-start
|
||||
"type: "jellyfin",
|
||||
// highlight-end
|
||||
"id": "foxxJf",
|
||||
"data": {
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c"
|
||||
```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.
|
||||
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>
|
||||
|
||||
@@ -1,8 +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 |
|
||||
| `LFM_USERNAME` | string | | A fake username to differentiate LFM Endpoint Sources |
|
||||
| `LFM_API_KEY` | string | | A fake api key to differentiate LFM Endpoint Sources |
|
||||
| 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 |
|
||||
@@ -4,4 +4,4 @@
|
||||
| `SOURCE_ROCKSKY_NAME` | string | Value of `SOURCE_ROCKSKY_ID` | A vanity name EX `My Cool Component` |
|
||||
| `SOURCE_ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `SOURCE_ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
|
||||
| _**`SOURCE_ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
|
||||
| _**`SOURCE_ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
|
||||
@@ -25,6 +25,6 @@ See the [Koito (Client)](/configuration/clients/koito) configuration for general
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Config config="KoitoSourceConfig" fileContent={KoitoConfig} name="koito">
|
||||
<Config config="KoitoSourceConfig" fileContent={KoitoConfig} configureAs ambiguous name="koito">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -29,7 +29,67 @@ If your application has the option to scrobble using Listenbrainz then use the [
|
||||
|
||||
## Setup
|
||||
|
||||
### URL
|
||||
:::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:
|
||||
|
||||
@@ -49,12 +109,14 @@ Currently, Multi-Scrobbler only supports the [**Mobile Application**](https://ww
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### Multiple Sources
|
||||
### Multiple Sources
|
||||
|
||||
If you have **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.
|
||||
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.
|
||||
@@ -72,6 +134,30 @@ The above url base is equivalent to making calls to `http://yourMSDomain/2.0/`
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
## 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 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
|
||||
|
||||
<Config config="LastFMEndpointSourceConfig" fileContent={LastfmEndpointConfig} name="endpointlfm">
|
||||
|
||||
@@ -24,6 +24,6 @@ To configure this Source use the same registration instructions and configuratio
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmSourceConfig" fileContent={LastfmConfig} name="lastfm">
|
||||
<Config config="LastfmSourceConfig" fileContent={LastfmConfig} configureAs ambiguous name="lastfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -23,6 +23,6 @@ To configure this Source use the same registration instructions and configuratio
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmSourceConfig" fileContent={JsonConfig} name="librefm">
|
||||
<Config config="LibrefmSourceConfig" fileContent={JsonConfig} configureAs ambiguous name="librefm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -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">
|
||||
|
||||
@@ -26,6 +26,6 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenBrainzSourceConfig" fileContent={ListenbrainzConfig} name="listenbrainz">
|
||||
<Config config="ListenBrainzSourceConfig" fileContent={ListenbrainzConfig} configureAs ambiguous name="listenbrainz">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -22,6 +22,6 @@ See the [Maloja (Client)](/configuration/clients/maloja) configuration for gener
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaSourceConfig" fileContent={MalojaConfig} name="maloja">
|
||||
<Config config="MalojaSourceConfig" fileContent={MalojaConfig} configureAs ambiguous name="maloja">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -33,6 +33,6 @@ See the [Rocksky (Client)](/configuration/clients/rocksky#setup) configuration f
|
||||
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
<Config config="RockskySourceConfig" fileContent={JsonConfig} name="rocksky">
|
||||
<Config config="RockskySourceConfig" fileContent={JsonConfig} configureAs ambiguous name="rocksky">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -104,6 +104,6 @@ Allow scrobbles from plyr.fm and unknown services:
|
||||
|
||||
</details>
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} name="tealfm">
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} configureAs ambiguous name="tealfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -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
|
||||
|
||||
@@ -87,7 +87,7 @@ const config: Config = {
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
}
|
||||
},
|
||||
// blog: {
|
||||
|
||||
Generated
+593
-495
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@
|
||||
"overrides": {
|
||||
"lodash": "4.18.1",
|
||||
"satori": "0.25.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.6"
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,15 @@ import AdmonitionIconImportant from './ImportantIcon';
|
||||
|
||||
export interface DetailsAdmoProps extends DetailProps {
|
||||
type?: 'warning' | 'danger' | 'note' | 'tip' | 'important'
|
||||
render?: boolean
|
||||
}
|
||||
|
||||
const DetailsAdmo = (props: DetailsAdmoProps) => {
|
||||
|
||||
if(props.render === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cn: string = '';
|
||||
let icon: ReactElement;
|
||||
|
||||
|
||||
@@ -37,10 +37,29 @@ import {useWindowSize} from '@docusaurus/theme-common';
|
||||
* **text** mode lets you edit the JSON directly.
|
||||
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
|
||||
|
||||
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
|
||||
Both modes **validate** that the configuration is correct. Any errors show up as squiggly lines.
|
||||
|
||||
<span style={{display: props.ambiguous === true ? 'inherit' : 'none'}}>
|
||||
:::important
|
||||
|
||||
Only <strong children={props.client ? 'client' : 'source'}/> <span children={props.name}/> configs will validate correctly in the editor but *both* **source** and **client** <span children={props.name}/> configs are allowed in the real <code>CONFIG/<span children={props.name}/>.json</code>
|
||||
|
||||
:::
|
||||
</span>
|
||||
|
||||
After you finish editing, switch to **text** and then copy all text to get a completed config.
|
||||
</DetailsAdmo>
|
||||
<DetailsAdmo type="important" summary="configureAs Required" render={props.configureAs === true}>
|
||||
When configuring a <span children={props.name}/> **Source** in a **File** Config you **must** set
|
||||
|
||||
```
|
||||
"configureAs": "source"
|
||||
```
|
||||
|
||||
Without this property, Multi-Scrobbler will assume the config is for a <span children={props.name}/> **Client** config.
|
||||
|
||||
This requirement does not apply to **AIO** <span children={props.name}/> configs.
|
||||
</DetailsAdmo >
|
||||
</div>
|
||||
<div className="fileEditorMobile">
|
||||
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./json-schema-viewer-styles.css";
|
||||
import "./modern-json-react-styles.css";
|
||||
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
|
||||
import f from "ajv-formats"
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import DetailsAdmo from "./AdmonitionDetails";
|
||||
|
||||
const validator = createAjvValidator({
|
||||
@@ -21,9 +22,11 @@ const validator = createAjvValidator({
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useColorMode } from '@docusaurus/theme-common';
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
|
||||
import "../modern-json-react-styles.css";
|
||||
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import f from "ajv-formats"
|
||||
import json5 from 'json5';
|
||||
|
||||
@@ -29,9 +30,11 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
}, [props.schema]);
|
||||
|
||||
Generated
+68
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -210,6 +210,7 @@
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.12",
|
||||
"with-local-tmp-dir": "^7.0.1",
|
||||
"zocker": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -10325,6 +10326,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/drange": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz",
|
||||
"integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "1.0.0-rc.4-ca0f029",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-rc.4-ca0f029.tgz",
|
||||
@@ -17310,6 +17321,20 @@
|
||||
"url": "https://github.com/sindresorhus/quote-js-string?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz",
|
||||
"integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"drange": "^1.0.2",
|
||||
"ret": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
@@ -17840,6 +17865,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz",
|
||||
"integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
@@ -20506,6 +20541,37 @@
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/zocker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zocker/-/zocker-3.0.0.tgz",
|
||||
"integrity": "sha512-xSJz2M9AELZKzgpuuZPbTQdMv9263p/zSHDIj81B58X3334Xl6iJ/C3u6OQiOlsrWAoJSJsveBcIdz1eEPL0EA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"randexp": "^0.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zocker/node_modules/@faker-js/faker": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.5.0.tgz",
|
||||
"integrity": "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fakerjs"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
|
||||
"npm": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -250,6 +250,7 @@
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.12",
|
||||
"with-local-tmp-dir": "^7.0.1",
|
||||
"zocker": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/node_modules/zocker/dist/index.cjs b/node_modules/zocker/dist/index.cjs
|
||||
index 0745620..ab9cba8 100644
|
||||
--- a/node_modules/zocker/dist/index.cjs
|
||||
+++ b/node_modules/zocker/dist/index.cjs
|
||||
@@ -51,7 +51,9 @@ function generate$1(schema, ctx) {
|
||||
}
|
||||
}
|
||||
const generate_value$1 = (schema, generation_context) => {
|
||||
- const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema);
|
||||
+ const reg = zod_v4.globalRegistry.get(schema);
|
||||
+ const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema
|
||||
+ || (reg?.zockerId !== undefined && zod_v4.globalRegistry.get(g.schema)?.zockerId === reg.zockerId));
|
||||
if (reference_generator) return reference_generator.generator(schema, generation_context);
|
||||
const instanceof_generator = generation_context.instanceof_generators.find((g) => schema instanceof g.schema);
|
||||
if (instanceof_generator) return instanceof_generator.generator(schema, generation_context);
|
||||
@@ -1,9 +1,35 @@
|
||||
import EventEmitter from "events";
|
||||
|
||||
export class WildcardEmitter extends EventEmitter {
|
||||
emit(type: string, ...args: any) {
|
||||
const argsWithName = args.length === 0 ? [type] : [...args, type];
|
||||
super.emit('*', ...argsWithName);
|
||||
return super.emit(type, ...argsWithName) || super.emit('', ...argsWithName);
|
||||
type DefaultEventMap = [never];
|
||||
type EventMap<T> = Record<keyof T, any[]> | DefaultEventMap;
|
||||
type Key<K, T> = T extends DefaultEventMap ? string | symbol : K | keyof T;
|
||||
type AnyRest = [...args: any[]];
|
||||
type Args<K, T> = T extends DefaultEventMap ? AnyRest : (
|
||||
K extends keyof T ? T[K] : never
|
||||
);
|
||||
type WildcardKey<T> = T extends DefaultEventMap ? string | symbol : keyof T;
|
||||
type WildcardHandler<T> = (event: WildcardKey<T>, ...args: unknown[]) => void;
|
||||
|
||||
export class WildcardEmitter<T extends EventMap<T> = DefaultEventMap> extends EventEmitter<T> {
|
||||
|
||||
private wildcardHandlers: Array<WildcardHandler<T>> = [];
|
||||
|
||||
emit<K>(eventName: Key<K, T>, ...args: Args<K, T>): boolean {
|
||||
this.wildcardHandlers.forEach((h) => h(eventName as WildcardKey<T>, ...args));
|
||||
return super.emit(eventName, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
onAny(handler: WildcardHandler<T>): () => void {
|
||||
this.wildcardHandlers.push(handler);
|
||||
return () => {
|
||||
this.wildcardHandlers = this.wildcardHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
removeAllListeners(eventName?: unknown): this {
|
||||
if (eventName === undefined) {
|
||||
this.wildcardHandlers = [];
|
||||
}
|
||||
return super.removeAllListeners(eventName as Key<unknown, T>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Dayjs } from "dayjs";
|
||||
import type { EmittedMSEvent, PlayObject, SourcePlayerObj, SourceType } from "../../../core/Atomic.ts";
|
||||
import type { MSCoreEvents } from "../../../core/MSCoreEventMap.ts";
|
||||
import type { WebhookPayload } from "./config/health/webhooks.ts";
|
||||
|
||||
export interface MSBackendEventMap extends Omit<MSCoreEvents, 'playerUpdate'> {
|
||||
notify: [EmittedMSEvent<WebhookPayload>]
|
||||
discoveredToScrobble: [EmittedMSEvent<{
|
||||
data: PlayObject | PlayObject[]
|
||||
options: {
|
||||
forceRefresh?: boolean,
|
||||
[key: string]: any,
|
||||
discoverLocation?: 'backlog' | [key: string]
|
||||
checkTime: Dayjs
|
||||
scrobbleFrom: string
|
||||
scrobbleTo: string[]
|
||||
}
|
||||
}>]
|
||||
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[] } },{},SourceType>]
|
||||
configError: [Error | string]
|
||||
instantiateError: [Error]
|
||||
}
|
||||
@@ -1,21 +1,39 @@
|
||||
import * as z from "zod";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import { transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const statusTypeSchema = z.union([z.literal("online"), z.literal("idle"), z.literal("dnd"), z.literal("invisible")]);
|
||||
|
||||
export type StatusType = z.infer<typeof statusTypeSchema>;
|
||||
|
||||
const ipcLocationTupleSchema = z.custom<[number, string]>((val) => Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'string');
|
||||
const HOST_PORT_LOOSE_REGEX = new RegExp(/.+:\d+/i)
|
||||
|
||||
const ipcTransform = z.transform((val: string, ctx) => {
|
||||
const sp = val.split(':');
|
||||
if(sp.length === 1) {
|
||||
// assume its a path
|
||||
return val;
|
||||
}
|
||||
if(!HOST_PORT_LOOSE_REGEX.test(val)) {
|
||||
ctx.issues.push({
|
||||
code: 'custom',
|
||||
message: "Not shaped like host:port",
|
||||
input: val
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return [parseInt(sp[1]), sp[0]]
|
||||
})
|
||||
|
||||
export const discordDataSchema = z.object({
|
||||
token: z.string().optional(),
|
||||
applicationId: z.string().optional(),
|
||||
artwork: z.union([z.boolean(), z.string(), z.array(z.string())]).optional(),
|
||||
artworkDefaultUrl: z.string().optional(),
|
||||
statusOverrideAllow: z.union([z.string(), z.array(statusTypeSchema)]).optional(),
|
||||
statusOverrideAllow: z.array(statusTypeSchema).optional(),
|
||||
listeningActivityAllow: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
ipcLocations: z.union([z.string(), z.array(z.union([z.string(), ipcLocationTupleSchema]))]).optional()
|
||||
ipcLocations: z.array(z.string().pipe(ipcTransform)).optional()
|
||||
});
|
||||
|
||||
export type DiscordData = z.infer<typeof discordDataSchema>;
|
||||
@@ -24,10 +42,10 @@ const envDataSchema = z.object({
|
||||
DISCORD_TOKEN: discordDataSchema.shape.token,
|
||||
DISCORD_ARTWORK: discordDataSchema.shape.artwork,
|
||||
DISCORD_APPLICATION_ID: discordDataSchema.shape.applicationId,
|
||||
DISCORD_IPC_LOCATIONS: discordDataSchema.shape.ipcLocations,
|
||||
DISCORD_IPC_LOCATIONS: z.string().optional().pipe(transformSplitMaybeString),
|
||||
DISCORD_ARTWORK_DEFAULT_URL: discordDataSchema.shape.artworkDefaultUrl,
|
||||
DISCORD_STATUS_OVERRIDE_ALLOW: discordDataSchema.shape.statusOverrideAllow,
|
||||
DISCORD_LISTENING_ACTIVITY_ALLOW: discordDataSchema.shape.listeningActivityAllow,
|
||||
DISCORD_STATUS_OVERRIDE_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.statusOverrideAllow.meta()),
|
||||
DISCORD_LISTENING_ACTIVITY_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.listeningActivityAllow.meta()),
|
||||
});
|
||||
|
||||
export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConfig> = {
|
||||
@@ -40,7 +58,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConf
|
||||
applicationId: partial.DISCORD_APPLICATION_ID,
|
||||
ipcLocations: partial.DISCORD_IPC_LOCATIONS,
|
||||
artworkDefaultUrl: partial.DISCORD_ARTWORK_DEFAULT_URL,
|
||||
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW,
|
||||
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW as StatusType[],
|
||||
listeningActivityAllow: partial.DISCORD_LISTENING_ACTIVITY_ALLOW
|
||||
}
|
||||
})
|
||||
@@ -85,7 +103,7 @@ export const discordStrongDataSchema = discordDataSchema.extend({
|
||||
artwork: z.union([z.boolean(), z.array(z.string())]).optional(),
|
||||
statusOverrideAllow: z.array(statusTypeSchema).optional(),
|
||||
listeningActivityAllow: z.array(z.string()).optional(),
|
||||
ipcLocations: z.array(z.union([z.string(), ipcLocationTupleSchema])).optional()
|
||||
//ipcLocations: z.array(z.union([z.string(), z.tuple([z.number(),z.string()])])).optional()
|
||||
});
|
||||
|
||||
export type DiscordStrongData = z.infer<typeof discordStrongDataSchema>;
|
||||
@@ -101,7 +119,7 @@ export const discordIPCDataSchema = discordStrongDataSchema.extend({
|
||||
//ipcLocations: (string | [number, string])[]
|
||||
});
|
||||
|
||||
export type DiscordIPCData = z.infer<typeof discordIPCDataSchema>;
|
||||
export type DiscordIPCData = Omit<z.infer<typeof discordIPCDataSchema>, 'ipcLocations'> & {ipcLocations: (string | [number, string])[]};
|
||||
|
||||
export interface ActivityAssets {
|
||||
largeImage?: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {UnixTimestamp} from "../../../../../core/Atomic.ts";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
@@ -54,7 +55,7 @@ export const koitoDataSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:4110"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL for the Koito server",
|
||||
examples: ["http://192.168.0.100:4110"]
|
||||
}),
|
||||
@@ -88,6 +89,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import {lastfmClientOptionsSchema} from "./lastfm.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const librefmDataSchema = z.object({
|
||||
...commonClientDataSchema.shape,
|
||||
@@ -43,7 +44,7 @@ export const librefmDataSchema = z.object({
|
||||
*
|
||||
* @default 'https://libre.fm/2.0/'
|
||||
*/
|
||||
urlBase: z.string().optional().meta({
|
||||
urlBase: httpUrl.optional().meta({
|
||||
description: "(Optional) The host and path prefix for your Libre.fm instance",
|
||||
default: "https://libre.fm/2.0/"
|
||||
}),
|
||||
@@ -74,6 +75,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const listenBrainzDataSchema = z.object({
|
||||
...requestRetryOptionsSchema.shape,
|
||||
@@ -11,7 +12,7 @@ export const listenBrainzDataSchema = z.object({
|
||||
* @examples ["https://api.listenbrainz.org/"]
|
||||
* @default "https://api.listenbrainz.org/"
|
||||
* */
|
||||
url: z.string().optional().meta({
|
||||
url: httpUrl.optional().meta({
|
||||
description: "URL for the ListenBrainz server, if not using the default",
|
||||
default: "https://api.listenbrainz.org/",
|
||||
examples: ["https://api.listenbrainz.org/"]
|
||||
@@ -46,6 +47,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const malojaDataSchema = z.object({
|
||||
...requestRetryOptionsSchema.shape,
|
||||
@@ -10,7 +11,7 @@ export const malojaDataSchema = z.object({
|
||||
*
|
||||
* @examples ["http://localhost:42010"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL for maloja server",
|
||||
examples: ["http://localhost:42010"]
|
||||
}),
|
||||
@@ -40,6 +41,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
|
||||
|
||||
@@ -34,7 +34,7 @@ export const rockSkyDataSchema = z.object({
|
||||
*
|
||||
* */
|
||||
handle: z.string().meta({
|
||||
description: "The **fully-qualified** handle for your ATPRoto/Bluesky account, like:"
|
||||
description: "The **fully-qualified** handle for your ATPRoto/Bluesky account"
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as z from "zod";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface WebhookPayload {
|
||||
title?: string
|
||||
@@ -96,7 +97,7 @@ export const ntfyConfigSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "The URL of the Ntfy server",
|
||||
examples: ["http://192.168.0.100:8078"]
|
||||
}),
|
||||
@@ -149,7 +150,7 @@ export const appriseConfigSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
host: z.string().meta({
|
||||
host: httpUrl.meta({
|
||||
description: "The URL of the apprise-api server",
|
||||
examples: ["http://192.168.0.100:8078"]
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,9 @@ export const appleMusicDataSchema = z.object({
|
||||
*
|
||||
* @examples ["https://music.apple.com"]
|
||||
*/
|
||||
origin: z.string().optional().meta({
|
||||
origin: z.url({
|
||||
protocol: /^https$/,
|
||||
}).optional().meta({
|
||||
description: "Origin header to include in every Apple Music API request.",
|
||||
examples: ["https://music.apple.com"]
|
||||
}),
|
||||
@@ -88,7 +90,7 @@ const envDataSchema = z.object({
|
||||
APPLEMUSIC_TEAM_ID: appleMusicKeySchema.shape.teamId.optional(),
|
||||
APPLEMUSIC_KEY_P8: appleMusicKeySchema.shape.p8.optional(),
|
||||
APPLEMUSIC_MEDIA_USER_TOKEN: appleMusicDataSchema.shape.mediaUserToken,
|
||||
APPLEMUSIC_TOKEN: appleMusicDataSchema.shape.token,
|
||||
APPLEMUSIC_TOKEN: appleMusicDataSchema.shape.token.optional(),
|
||||
APPLEMUSIC_ORIGIN_HEADER: appleMusicDataSchema.shape.origin,
|
||||
APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY: z.stringbool().optional().meta(appleMusicOptions.shape.recoverUnchangedTopHistory.meta()),
|
||||
APPLEMUSIC_NORMALIZE_ALBUM: z.stringbool().optional().meta(appleMusicOptions.shape.normalizeAlbum.meta())
|
||||
@@ -104,7 +106,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, AppleMusicSourceC
|
||||
if(envKeyKeys.some(x => partial[x] !== undefined)) {
|
||||
for(const k of envKeyKeys) {
|
||||
if(partial[k] === undefined) {
|
||||
throw new SimpleError(`ENV ${partial[k]} is not defined but when providing auth via MusicKit Key you must provide all of these: ${envKeyKeys.join(', ')}`);
|
||||
throw new SimpleError(`ENV ${k} is not defined but when providing auth via MusicKit Key you must provide all of these: ${envKeyKeys.join(', ')}`);
|
||||
}
|
||||
appleMusicKey = {
|
||||
id: partial.APPLEMUSIC_KEY_ID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { parseBoolStrict } from "../../../../utils.ts";
|
||||
import { wsUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const azuraStationInfoResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -71,7 +72,7 @@ export const azuracastDataSchema = z.object({
|
||||
*
|
||||
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: wsUrl.meta({
|
||||
description: "Base URL of the Azuracast instance",
|
||||
examples: ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,7 @@ export const chromecastDeviceInfoSchema = z.object({
|
||||
*
|
||||
* @examples ["192.168.0.115"]
|
||||
* */
|
||||
address: z.string().meta({
|
||||
address: z.ipv4().meta({
|
||||
description: "The IP address of the device",
|
||||
examples: ["192.168.0.115"]
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,7 @@ 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'
|
||||
|
||||
@@ -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"
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface IcecastMetadata {
|
||||
icy?: {
|
||||
@@ -37,7 +38,7 @@ export const icecastDataSchema = z.object({
|
||||
/**
|
||||
* The Icecast stream URL
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "The Icecast stream URL"
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema, requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const jRiverDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -24,7 +25,10 @@ export const jRiverDataSchema = z.object({
|
||||
* @examples ["http://localhost:52199/MCWS/v1/"]
|
||||
* @default "http://localhost:52199/MCWS/v1/"
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: z.union([
|
||||
httpUrl,
|
||||
z.ipv4()
|
||||
]).meta({
|
||||
description: "URL of the JRiver HTTP server to connect to",
|
||||
default: "http://localhost:52199/MCWS/v1/",
|
||||
examples: ["http://localhost:52199/MCWS/v1/"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const kodiDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -23,7 +24,7 @@ export const kodiDataSchema = z.object({
|
||||
* @examples ["http://localhost:8080/jsonrpc"]
|
||||
* @default "http://localhost:8080/jsonrpc"
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL of the Kodi HTTP server to connect to",
|
||||
default: "http://localhost:8080/jsonrpc",
|
||||
examples: ["http://localhost:8080/jsonrpc"]
|
||||
|
||||
@@ -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,
|
||||
@@ -49,6 +50,8 @@ export type KoitoSourceConfig = z.infer<typeof koitoSourceConfigSchema>;
|
||||
|
||||
export const koitoSourceAIOConfigSchema = z.object({
|
||||
...koitoSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: koitoSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('koito'),
|
||||
}).meta({title: "Koito"});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -51,6 +52,8 @@ export type LastfmSourceConfig = z.infer<typeof lastfmSourceConfigSchema>;
|
||||
|
||||
export const lastFmSouceAIOConfigSchema = z.object({
|
||||
...lastfmSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: lastfmSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('lastfm'),
|
||||
}).meta({title: "Last.fm"});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -53,6 +54,8 @@ export type LibrefmSourceConfig = z.infer<typeof librefmSourceConfigSchema>;
|
||||
|
||||
export const librefmSouceAIOConfigSchema = z.object({
|
||||
...librefmSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: librefmSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('librefm'),
|
||||
}).meta({title: "Libre.fm"});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -49,6 +50,8 @@ export type ListenBrainzSourceConfig = z.infer<typeof listenBrainzSourceConfigSc
|
||||
|
||||
export const listenBrainzSourceAIOConfigSchema = z.object({
|
||||
...listenBrainzSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: listenBrainzSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('listenbrainz'),
|
||||
}).meta({title: 'Listenbrainz'});
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +48,8 @@ export type MalojaSourceConfig = z.infer<typeof malojaSourceConfigSchema>;
|
||||
|
||||
export const malojaSourceAIOConfigSchema = z.object({
|
||||
...malojaSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: malojaSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('maloja'),
|
||||
}).meta({title: 'Maloja'});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
|
||||
import { transformSplitMaybeString, wsUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const mopidyDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -26,7 +26,7 @@ export const mopidyDataSchema = z.object({
|
||||
* @examples ["ws://localhost:6680/mopidy/ws/"]
|
||||
* @default "ws://localhost:6680/mopidy/ws/"
|
||||
* */
|
||||
url: z.string().optional().meta({
|
||||
url: wsUrl.optional().meta({
|
||||
description: "URL of the Mopidy HTTP server to connect to",
|
||||
default: "ws://localhost:6680/mopidy/ws/",
|
||||
examples: ["ws://localhost:6680/mopidy/ws/"]
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import { REPORTED_PLAYER_STATUSES } from '../../../../../core/Atomic.ts';
|
||||
import type {ReportedPlayerStatus} from '../../../../../core/Atomic.ts';
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
|
||||
|
||||
@@ -78,7 +79,10 @@ export const musicCastDataSchema = z.object({
|
||||
*
|
||||
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: z.union([
|
||||
httpUrl,
|
||||
z.ipv4()
|
||||
]).meta({
|
||||
description: "The host or URL of the YamahaExtendedControl endpoint to use",
|
||||
examples: [["192.168.0.101", "http://192.168.0.101/YamahaExtendedControl"]]
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { wsUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const PLAYBACK_STATUS_PLAYING_MC = 'playing';
|
||||
export const PLAYBACK_STATUS_PAUSED_MC = 'paused';
|
||||
@@ -96,7 +97,7 @@ export const musikcubeDataSchema = z.object({
|
||||
* @examples ["ws://localhost:7905"]
|
||||
* @default "ws://localhost:7905"
|
||||
* */
|
||||
url: z.string().optional().meta({
|
||||
url: wsUrl.optional().meta({
|
||||
description: "URL of the Musikcube Websocket (Metadata) server to connect to",
|
||||
default: "ws://localhost:7905",
|
||||
examples: ["ws://localhost:7905"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { envMetaNormalize, transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
|
||||
import { envMetaNormalize, httpUrl, transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const plexApiDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -10,7 +10,7 @@ export const plexApiDataSchema = z.object({
|
||||
/**
|
||||
* http(s)://HOST:PORT of the Plex server to connect to
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "http(s)://HOST:PORT of the Plex server to connect to"
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -55,6 +56,8 @@ export type RockskySourceConfig = z.infer<typeof rockskySourceConfigSchema>;
|
||||
|
||||
export const rockskySourceAIOConfigSchema = z.object({
|
||||
...rockskySourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: rockskySourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('rocksky'),
|
||||
}).meta({title: 'Rocksky'});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const sonosDataSchema = z.object({
|
||||
*
|
||||
* @examples ["192.168.0.170"]
|
||||
* */
|
||||
host: z.string().meta({
|
||||
host: z.ipv4().meta({
|
||||
description: "IP address of any connected Sonos speaker or device",
|
||||
examples: ["192.168.0.170"]
|
||||
}),
|
||||
|
||||
@@ -29,7 +29,9 @@ export const spotifySourceDataSchema = z.object({
|
||||
* @default "http://localhost:9078/callback"
|
||||
* @examples ["http://localhost:9078/callback"]
|
||||
* */
|
||||
redirectUri: z.string().optional().meta({
|
||||
redirectUri: z.url({
|
||||
protocol: /^https?$/,
|
||||
}).optional().meta({
|
||||
description: "spotify redirect URI -- required only if not the default shown here.",
|
||||
default: "http://localhost:9078/callback",
|
||||
examples: ["http://localhost:9078/callback"]
|
||||
|
||||
@@ -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
|
||||
@@ -58,6 +59,8 @@ export type TealSourceConfig = z.infer<typeof tealSourceConfigSchema>;
|
||||
|
||||
export const tealSourceAIOConfigSchema = z.object({
|
||||
...tealSourceConfigSchema.shape,
|
||||
// can be optional since it always appears in a source array
|
||||
configureAs: tealSourceConfigSchema.shape.configureAs.optional(),
|
||||
type: z.literal('tealfm'),
|
||||
}).meta({title: 'teal.fm'});
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const yandexMusicBridgeDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
...pollingOptionsSchema.shape,
|
||||
/** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL of the local Python bridge, for example http://yandex-music-bridge:9980"
|
||||
}),
|
||||
/** Optional API key sent as X-API-Key to the bridge */
|
||||
|
||||
@@ -29,6 +29,7 @@ import { findCauseByFunc } from '../../utils/ErrorUtils.ts';
|
||||
import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts';
|
||||
import { playToSubmitPayload } from './listenbrainz/lzUtils.ts';
|
||||
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
|
||||
import { getRoot } from '../../ioc.ts';
|
||||
|
||||
|
||||
export interface SubmitOptions {
|
||||
@@ -59,6 +60,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: URLData;
|
||||
userAgent: string
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
@@ -66,6 +68,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
let cleanUrl = url;
|
||||
this.userAgent = `multi-scrobbler/${getRoot().items.version}`
|
||||
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
|
||||
if(pathedUrl !== undefined) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
@@ -84,6 +87,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
|
||||
try {
|
||||
req.set('Authorization', `Token ${this.config.token}`);
|
||||
req.set('User-Agent', this.userAgent);
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
const {
|
||||
|
||||
@@ -21,10 +21,14 @@ import {
|
||||
import assert from "node:assert";
|
||||
import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.ts";
|
||||
import { isNodeNetworkException } from "../../errors/NodeErrors.ts";
|
||||
import * as z from 'zod';
|
||||
|
||||
export const HANDLE_REGEX = new RegExp(/.+\..+/);
|
||||
export const HandleZodType = z.stringFormat('atproto-handle', HANDLE_REGEX)
|
||||
export const ATSIGN_REGEX = new RegExp(/^@(.+)/);
|
||||
export const AtSignZodType = z.stringFormat('atproto-atsign-handle', ATSIGN_REGEX);
|
||||
export const DID_REGEX = new RegExp(/did:(?:plc|web):.+/);
|
||||
export const DidZodType = z.stringFormat('did', DID_REGEX);
|
||||
|
||||
export interface HandleOptions {
|
||||
logger?: Logger
|
||||
|
||||
+2
-1
@@ -185,7 +185,8 @@ export const configToIPCConfig = (data: DiscordStrongData): DiscordIPCData => {
|
||||
const parsedPaths: (string | [number, string])[] = [];
|
||||
for(const p of ipcLocations) {
|
||||
if(Array.isArray(p)) {
|
||||
parsedPaths.push(p)
|
||||
if(typeof p[0] === 'number' && typeof p[1] === 'string')
|
||||
parsedPaths.push([p[0],p[1]]);
|
||||
} else {
|
||||
const sp = p.split(':');
|
||||
if(sp.length > 1) {
|
||||
|
||||
@@ -9,10 +9,8 @@ import { difference, sleep } from "../../../utils.ts";
|
||||
import {SequentialRoundRobin} from 'round-robin-js';
|
||||
import type { Cacheable } from "cacheable";
|
||||
import { getRoot } from "../../../ioc.ts";
|
||||
import { version } from "../../../version.ts";
|
||||
import { hashObject } from "../../../utils/StringUtils.ts";
|
||||
import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.ts";
|
||||
;
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { nanoid } from "nanoid";
|
||||
import { stripIndents } from "common-tags";
|
||||
@@ -79,7 +77,7 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
if(mb === undefined) {
|
||||
const api = new MusicBrainzApi({
|
||||
appName: 'multi-scrobbler',
|
||||
appVersion: version,
|
||||
appVersion: getRoot().items.version,
|
||||
appContactInfo: mbConfig.contact,
|
||||
baseUrl: u.url.toString(),
|
||||
preRequest: (method, url, headers) => {
|
||||
|
||||
+82
-37
@@ -1,5 +1,5 @@
|
||||
import dayjs, { type Dayjs, type ManipulateType } from "dayjs";
|
||||
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult, UnixTimestamp} from "../../../../core/Atomic.ts";
|
||||
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult} from "../../../../core/Atomic.ts";
|
||||
import { getRoot } from "../../../ioc.ts";
|
||||
import { removeUndefinedKeys } from '../../../../core/DataUtils.ts';
|
||||
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
|
||||
@@ -8,7 +8,7 @@ import type {AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRa
|
||||
import type {ListRecord, RecordOptions, TealClientData} from "../../infrastructure/config/client/tealfm.ts";
|
||||
import AbstractApiClient from "../AbstractApiClient.ts";
|
||||
import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts";
|
||||
import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.ts";
|
||||
import type { FmTealActorStatus, FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts";
|
||||
import { ScrobbleSubmitError } from "../../errors/MSErrors.ts";
|
||||
import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts";
|
||||
import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts";
|
||||
@@ -18,7 +18,19 @@ import type { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthentica
|
||||
import { UpstreamError } from "../../errors/UpstreamError.ts";
|
||||
import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto';
|
||||
import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts";
|
||||
import { isrcNoHyphens } from "../../../../core/PlayUtils.ts";
|
||||
import { isrcNoHyphens, sortByNewestPlayDate } from "../../../../core/PlayUtils.ts";
|
||||
import { isGenericUri } from "@atcute/lexicons/syntax";
|
||||
|
||||
type TealPlayRecord = FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main;
|
||||
|
||||
const TEAL_PLAY_COLLECTIONS = ['fm.teal.feed.play', 'fm.teal.alpha.feed.play'] as const;
|
||||
|
||||
const asMusicServiceUri = (musicService?: string): `${string}:${string}` | undefined => {
|
||||
if (musicService === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return isGenericUri(musicService) ? musicService : `https://${musicService}`;
|
||||
};
|
||||
|
||||
export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
|
||||
|
||||
@@ -43,10 +55,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
}
|
||||
|
||||
|
||||
async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
|
||||
async createScrobbleRecord(record: FmTealFeedPlay.Main): Promise<ScrobbleActionResult> {
|
||||
const input: ComAtprotoRepoCreateRecord.$input = {
|
||||
repo: this.client.userData.did,
|
||||
collection: 'fm.teal.alpha.feed.play',
|
||||
collection: 'fm.teal.feed.play',
|
||||
record
|
||||
};
|
||||
try {
|
||||
@@ -62,10 +74,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
|
||||
async updateStatusRecord(record: FmTealActorStatus.Main): Promise<ScrobbleActionResult> {
|
||||
const input: ComAtprotoRepoPutRecord.$input = {
|
||||
repo: this.client.userData.did,
|
||||
collection: "fm.teal.alpha.actor.status",
|
||||
collection: "fm.teal.actor.status",
|
||||
rkey: "self",
|
||||
record
|
||||
};
|
||||
@@ -92,37 +104,54 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
cursor = generateTID(dayjs.unix(to).toISOString());
|
||||
}
|
||||
|
||||
const resp = await this.client.get((client) => client.get('com.atproto.repo.listRecords', {
|
||||
const responses = await Promise.all(TEAL_PLAY_COLLECTIONS.map(collection => this.client.get((client) => client.get('com.atproto.repo.listRecords', {
|
||||
params: {
|
||||
repo: this.client.userData.did,
|
||||
collection: "fm.teal.alpha.feed.play",
|
||||
collection,
|
||||
limit,
|
||||
cursor
|
||||
}
|
||||
}));
|
||||
}))));
|
||||
|
||||
if(!resp.ok) {
|
||||
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
|
||||
const fromTimestamps: number[] = [],
|
||||
playSets: PlayObject[][] = [];
|
||||
|
||||
for (const resp of responses) {
|
||||
if (!resp.ok) {
|
||||
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
|
||||
}
|
||||
if(resp.data.cursor !== undefined) {
|
||||
fromTimestamps.push(usecToUnix(decodeTid(resp.data.cursor).timestampUs));
|
||||
}
|
||||
playSets.push((resp.data.records as ListRecord<TealPlayRecord>[]).map(x => listRecordToPlay(x)));
|
||||
}
|
||||
|
||||
let fromTS: UnixTimestamp;
|
||||
if(resp.data.cursor !== undefined) {
|
||||
const { timestampUs } = decodeTid(resp.data.cursor);
|
||||
fromTS = usecToUnix(timestampUs);
|
||||
}
|
||||
const from = fromTimestamps.length > 0 ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) : undefined;
|
||||
const plays = playSets.flat().sort(sortByNewestPlayDate)
|
||||
|
||||
const plays = (resp.data.records as unknown as ListRecord<FmTealAlphaFeedPlay.Main>[]).map(x => listRecordToPlay(x));
|
||||
|
||||
return {data: plays, meta: {to, from: fromTS, limit}};
|
||||
return {data: plays, meta: {to, from, limit, more: false, order: 'desc'}};
|
||||
}
|
||||
}
|
||||
|
||||
export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => {
|
||||
export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}): PlayObject => {
|
||||
const artists = record.artists ?? [];
|
||||
let musicService: string;
|
||||
if('musicServiceUri' in record) {
|
||||
musicService = record.musicServiceUri
|
||||
} else if(`musicServiceBaseDomain` in record) {
|
||||
musicService = record.musicServiceBaseDomain;
|
||||
}
|
||||
let origin: string;
|
||||
if('originUri' in record) {
|
||||
origin = record.originUri;
|
||||
} else if(`originUrl` in record) {
|
||||
origin = record.originUrl;
|
||||
}
|
||||
|
||||
const play: PlayObjectMinimal = {
|
||||
data: {
|
||||
track: record.trackName,
|
||||
artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
|
||||
artists: artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
|
||||
duration: record.duration,
|
||||
playDate: dayjs(record.playedTime),
|
||||
album: record.releaseName,
|
||||
@@ -131,10 +160,11 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
meta: {
|
||||
source: 'tealfm',
|
||||
parsedFrom: 'history',
|
||||
musicService: record.musicServiceBaseDomain,
|
||||
musicService,
|
||||
playId: options.playId,
|
||||
url: {
|
||||
web: options.web
|
||||
web: options.web,
|
||||
origin
|
||||
},
|
||||
user: options.user
|
||||
}
|
||||
@@ -143,7 +173,7 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
const brainz = removeUndefinedKeys<BrainzMeta>({
|
||||
recording: record.recordingMbId,
|
||||
album: record.releaseMbId,
|
||||
artist: record.artists.filter(x => x.artistMbId !== undefined).length > 0 ? record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
|
||||
artist: artists.filter(x => x.artistMbId !== undefined).length > 0 ? artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
|
||||
});
|
||||
|
||||
if (brainz !== undefined) {
|
||||
@@ -153,10 +183,24 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
return baseFormatPlayObj(record, play);
|
||||
}
|
||||
|
||||
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => {
|
||||
const { $type, ...item } = notPlaying
|
||||
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealActorStatus.Main => {
|
||||
const item = notPlaying
|
||||
? { trackName: "", artists: [] }
|
||||
: playToRecord(play);
|
||||
: (() => {
|
||||
const {
|
||||
$type,
|
||||
musicServiceUri,
|
||||
originUri,
|
||||
trackDiscriminant,
|
||||
releaseDiscriminant,
|
||||
...record
|
||||
} = playToRecord(play);
|
||||
return {
|
||||
...record,
|
||||
musicServiceBaseDomain: musicServiceUri,
|
||||
originUrl: originUri
|
||||
};
|
||||
})();
|
||||
|
||||
let expiry: Dayjs;
|
||||
if (notPlaying) {
|
||||
@@ -167,7 +211,7 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi
|
||||
}
|
||||
|
||||
return {
|
||||
$type: "fm.teal.alpha.actor.status",
|
||||
$type: "fm.teal.actor.status",
|
||||
time: dayjs().toISOString(),
|
||||
expiry: expiry.toISOString(),
|
||||
item: {
|
||||
@@ -186,17 +230,19 @@ export const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => {
|
||||
return mbidToUri(mbid);
|
||||
};
|
||||
export type MBIDURI = `mbid:${MBID}`;
|
||||
export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
|
||||
export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => {
|
||||
const musicService = musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService;
|
||||
|
||||
const record: FmTealAlphaFeedPlay.Main = {
|
||||
$type: "fm.teal.alpha.feed.play",
|
||||
const record: FmTealFeedPlay.Main = {
|
||||
$type: "fm.teal.feed.play",
|
||||
trackName: play.data.track,
|
||||
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
|
||||
artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
|
||||
duration: Math.round(play.data.duration),
|
||||
playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(),
|
||||
releaseName: play.data.album,
|
||||
submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`,
|
||||
musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService,
|
||||
musicServiceUri: asMusicServiceUri(musicService),
|
||||
originUri: isGenericUri(play.meta.url?.origin) ? play.meta.url?.origin : undefined,
|
||||
isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined,
|
||||
trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID),
|
||||
recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID),
|
||||
@@ -205,7 +251,7 @@ export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
|
||||
|
||||
return record;
|
||||
};
|
||||
export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main>): PlayObject => {
|
||||
export const listRecordToPlay = (listRecord: ListRecord<TealPlayRecord>): PlayObject => {
|
||||
const opts: RecordOptions = {};
|
||||
const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri);
|
||||
if (uriRes !== undefined) {
|
||||
@@ -215,5 +261,4 @@ export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main
|
||||
}
|
||||
return recordToPlay(listRecord.value, opts);
|
||||
};
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/);
|
||||
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal(?:\.alpha)?\.feed.play\/(?<tid>.*))/);
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ export default defineLexiconConfig({
|
||||
files: ['lexicons/**/*.json'],
|
||||
outdir: 'lexicons/',
|
||||
imports: ['@atcute/bluesky'],
|
||||
modules: { importSuffix: '.ts' },
|
||||
},
|
||||
pull: {
|
||||
outdir: path.resolve(__dirname, 'lexicons/'),
|
||||
@@ -19,8 +20,8 @@ export default defineLexiconConfig({
|
||||
type: 'git',
|
||||
remote: 'https://github.com/teal-fm/teal.git',
|
||||
ref: 'main',
|
||||
pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'],
|
||||
pattern: ['lexicons/fm.teal/feed/*.json','lexicons/fm.teal/actor/*.json'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
this directory contains lexicon documents pulled from the following sources:
|
||||
|
||||
- https://github.com/teal-fm/teal.git (ref: main)
|
||||
- commit: f661868af2a56199f1b3cae1ad20cd5831ec4212
|
||||
- commit: 82ea66cadbde3f1aee9c72b05f372c6543a4b3f6
|
||||
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
export * as FmTealActorDefs from "./types/fm/teal/actor/defs.ts";
|
||||
export * as FmTealActorGetProfile from "./types/fm/teal/actor/getProfile.ts";
|
||||
export * as FmTealActorGetProfiles from "./types/fm/teal/actor/getProfiles.ts";
|
||||
export * as FmTealActorProfile from "./types/fm/teal/actor/profile.ts";
|
||||
export * as FmTealActorProfileStatus from "./types/fm/teal/actor/profileStatus.ts";
|
||||
export * as FmTealActorSearchActors from "./types/fm/teal/actor/searchActors.ts";
|
||||
export * as FmTealActorStatus from "./types/fm/teal/actor/status.ts";
|
||||
export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts";
|
||||
export * as FmTealFeedGetActorFeed from "./types/fm/teal/feed/getActorFeed.ts";
|
||||
export * as FmTealFeedGetPlay from "./types/fm/teal/feed/getPlay.ts";
|
||||
export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts";
|
||||
export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.ts";
|
||||
export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.ts";
|
||||
export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.ts";
|
||||
@@ -8,4 +19,4 @@ export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.ts";
|
||||
export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts";
|
||||
export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts";
|
||||
export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
import * as FmTealActorProfile from "./profile.ts";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _miniProfileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#miniProfileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()),
|
||||
});
|
||||
const _profileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#profileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* IPLD of the banner image
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(FmTealActorProfile.featuredItemSchema);
|
||||
},
|
||||
get status() {
|
||||
return /*#__PURE__*/ v.optional(statusViewSchema);
|
||||
},
|
||||
});
|
||||
const _statusViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#statusView"),
|
||||
),
|
||||
/**
|
||||
* The datetime after which the status is no longer current.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return /*#__PURE__*/ v.optional(FmTealFeedDefs.playViewSchema);
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
});
|
||||
|
||||
type miniProfileView$schematype = typeof _miniProfileViewSchema;
|
||||
type profileView$schematype = typeof _profileViewSchema;
|
||||
type statusView$schematype = typeof _statusViewSchema;
|
||||
|
||||
export interface miniProfileViewSchema extends miniProfileView$schematype {}
|
||||
export interface profileViewSchema extends profileView$schematype {}
|
||||
export interface statusViewSchema extends statusView$schematype {}
|
||||
|
||||
export const miniProfileViewSchema =
|
||||
_miniProfileViewSchema as miniProfileViewSchema;
|
||||
export const profileViewSchema = _profileViewSchema as profileViewSchema;
|
||||
export const statusViewSchema = _statusViewSchema as statusViewSchema;
|
||||
|
||||
export interface MiniProfileView extends v.InferInput<
|
||||
typeof miniProfileViewSchema
|
||||
> {}
|
||||
export interface ProfileView extends v.InferInput<typeof profileViewSchema> {}
|
||||
export interface StatusView extends v.InferInput<typeof statusViewSchema> {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfile", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The actor's DID or handle
|
||||
*/
|
||||
actor: /*#__PURE__*/ v.actorIdentifierString(),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actor() {
|
||||
return FmTealActorDefs.profileViewSchema;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfiles", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Array of actor DIDs or handles
|
||||
* @minLength 1
|
||||
*/
|
||||
actors: /*#__PURE__*/ v.constrain(
|
||||
/*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()),
|
||||
[/*#__PURE__*/ v.arrayLength(1)],
|
||||
),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfiles": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
|
||||
const _featuredItemSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.profile#featuredItem"),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the item, formatted as mbid:<uuid>
|
||||
*/
|
||||
mbid: /*#__PURE__*/ v.genericUriString(),
|
||||
/**
|
||||
* The MusicBrainz entity type of the item.
|
||||
*/
|
||||
type: /*#__PURE__*/ v.string<
|
||||
"artist" | "recording" | "release" | (string & {})
|
||||
>(),
|
||||
});
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profile"),
|
||||
/**
|
||||
* Small image to be displayed next to posts from account. AKA, 'profile picture'
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Larger horizontal image to display behind profile view.
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
* @maxLength 2560
|
||||
* @maxGraphemes 256
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 2560),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 256),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc).
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 64
|
||||
*/
|
||||
displayName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 64),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(featuredItemSchema);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type featuredItem$schematype = typeof _featuredItemSchema;
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface featuredItemSchema extends featuredItem$schematype {}
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const featuredItemSchema = _featuredItemSchema as featuredItemSchema;
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface FeaturedItem extends v.InferInput<typeof featuredItemSchema> {}
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profileStatus"),
|
||||
/**
|
||||
* The onboarding completion status
|
||||
*/
|
||||
completedOnboarding: /*#__PURE__*/ v.string<
|
||||
| "complete"
|
||||
| "none"
|
||||
| "playOnboarding"
|
||||
| "profileOnboarding"
|
||||
| (string & {})
|
||||
>(),
|
||||
/**
|
||||
* The timestamp when this status was created
|
||||
*/
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The timestamp when this status was last updated
|
||||
*/
|
||||
updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profileStatus": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.searchActors", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The maximum number of actors to return
|
||||
* @minimum 1
|
||||
* @maximum 25
|
||||
*/
|
||||
limit: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
|
||||
/*#__PURE__*/ v.integerRange(1, 25),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The search query
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 128
|
||||
*/
|
||||
q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 128),
|
||||
]),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.searchActors": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.status"),
|
||||
/**
|
||||
* The datetime after which the status is no longer current. If unavailable, default to 10 minutes after the start time.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return FmTealFeedDefs.playViewSchema;
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.datetimeString(),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.status": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
|
||||
const _artistSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.feed.defs#artist"),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz artist ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
artistMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the artist
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
artistName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
});
|
||||
const _playViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.feed.defs#playView"),
|
||||
),
|
||||
/**
|
||||
* Array of artists in order of original appearance.
|
||||
*/
|
||||
get artists() {
|
||||
return /*#__PURE__*/ v.array(artistSchema);
|
||||
},
|
||||
/**
|
||||
* The length of the track in seconds
|
||||
*/
|
||||
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
/**
|
||||
* The ISRC code associated with the recording
|
||||
*/
|
||||
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The canonical URI identifying the listening surface or music service.
|
||||
*/
|
||||
musicServiceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The exact URI where the listening event originated.
|
||||
*/
|
||||
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The datetime at which playback began.
|
||||
*/
|
||||
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the release/album
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
releaseName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided.
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
submissionClientAgent: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the track
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
});
|
||||
|
||||
type artist$schematype = typeof _artistSchema;
|
||||
type playView$schematype = typeof _playViewSchema;
|
||||
|
||||
export interface artistSchema extends artist$schematype {}
|
||||
export interface playViewSchema extends playView$schematype {}
|
||||
|
||||
export const artistSchema = _artistSchema as artistSchema;
|
||||
export const playViewSchema = _playViewSchema as playViewSchema;
|
||||
|
||||
export interface Artist extends v.InferInput<typeof artistSchema> {}
|
||||
export interface PlayView extends v.InferInput<typeof playViewSchema> {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getActorFeed", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The author's DID for the play
|
||||
*/
|
||||
authorDID: /*#__PURE__*/ v.didString(),
|
||||
/**
|
||||
* The cursor to start the query from
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The upper limit of tracks to get per request. Default is 20, max is 50.
|
||||
*/
|
||||
limit: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get plays() {
|
||||
return /*#__PURE__*/ v.array(FmTealFeedDefs.playViewSchema);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.feed.getActorFeed": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getPlay", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The author's DID for the play
|
||||
*/
|
||||
authorDID: /*#__PURE__*/ v.didString(),
|
||||
/**
|
||||
* The record key of the play
|
||||
*/
|
||||
rkey: /*#__PURE__*/ v.recordKeyString(),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get play() {
|
||||
return FmTealFeedDefs.playViewSchema;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.feed.getPlay": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.tidString(),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.feed.play"),
|
||||
/**
|
||||
* DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs.
|
||||
* @deprecated
|
||||
*/
|
||||
artistMbIds: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(/*#__PURE__*/ v.string()),
|
||||
),
|
||||
/**
|
||||
* DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance.
|
||||
* @deprecated
|
||||
*/
|
||||
artistNames: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
),
|
||||
/**
|
||||
* Array of artists in order of original appearance.
|
||||
*/
|
||||
get artists() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(FmTealFeedDefs.artistSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* The length of the track in seconds
|
||||
*/
|
||||
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
/**
|
||||
* The ISRC code associated with the recording
|
||||
*/
|
||||
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The canonical URI identifying the listening surface or music service.
|
||||
*/
|
||||
musicServiceUri: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.genericUriString(),
|
||||
),
|
||||
/**
|
||||
* The exact URI where the listening event originated.
|
||||
*/
|
||||
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The datetime at which playback began.
|
||||
*/
|
||||
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities.
|
||||
* @maxLength 128
|
||||
* @maxGraphemes 1280
|
||||
*/
|
||||
releaseDiscriminant: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 128),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 1280),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the release/album
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
releaseName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided.
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
submissionClientAgent: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities.
|
||||
* @maxLength 128
|
||||
* @maxGraphemes 1280
|
||||
*/
|
||||
trackDiscriminant: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 128),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 1280),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the track
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.feed.play": mainSchema;
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -12,8 +12,9 @@ import TransformerManager from "./common/transforms/TransformerManager.ts";
|
||||
import type {TransformerCommonConfig} from "../core/Atomic.ts";
|
||||
import prom from 'prom-client';
|
||||
import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.ts";
|
||||
import { version } from "./version.ts";
|
||||
import { version, stable } from "./version.ts";
|
||||
import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts";
|
||||
import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts";
|
||||
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
export interface RootOptions {
|
||||
@@ -96,12 +97,14 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
|
||||
dbFunc = async () => db;
|
||||
}
|
||||
|
||||
const cEmitter = new WildcardEmitter();
|
||||
const cEmitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
// do nothing, just catch
|
||||
cEmitter.on('error', (e) => null);
|
||||
const sEmitter = new WildcardEmitter();
|
||||
cEmitter.on('error', (e) => {
|
||||
logger.warn(new Error('Client emitter threw an error', {cause: e}));
|
||||
});
|
||||
const sEmitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
sEmitter.on('error', (e) => {
|
||||
const f = e;
|
||||
logger.warn(new Error('Source emitter threw an error', {cause: e}));
|
||||
});
|
||||
|
||||
const transformerManager = new TransformerManager(logger, maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc());
|
||||
@@ -127,8 +130,18 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
|
||||
|
||||
const portVal: number | string = process.env.PORT ?? port;
|
||||
|
||||
const versionParts: string[] = [];
|
||||
if(stable === version) {
|
||||
versionParts.push(stable);
|
||||
} else {
|
||||
if(stable !== 'unknown') {
|
||||
versionParts.push(stable);
|
||||
}
|
||||
versionParts.push(version);
|
||||
}
|
||||
|
||||
return createContainer().add({
|
||||
version,
|
||||
version: versionParts.join('-'),
|
||||
configDir: getConfigDir(),
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
// @ts-ignore
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.ts";
|
||||
import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.ts";
|
||||
import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.ts";
|
||||
import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.ts";
|
||||
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
|
||||
|
||||
export class Notifiers {
|
||||
|
||||
@@ -14,21 +15,21 @@ export class Notifiers {
|
||||
|
||||
emitter: EventEmitter;
|
||||
|
||||
clientEmitter: EventEmitter;
|
||||
sourceEmitter: EventEmitter;
|
||||
clientEmitter: EventEmitter<MSBackendEventMap>;
|
||||
sourceEmitter: EventEmitter<MSBackendEventMap>;
|
||||
|
||||
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter, parentLogger: Logger) {
|
||||
constructor(emitter: EventEmitter, clientEmitter: EventEmitter<MSBackendEventMap>, sourceEmitter: EventEmitter<MSBackendEventMap>, parentLogger: Logger) {
|
||||
this.emitter = emitter;
|
||||
this.clientEmitter = clientEmitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
|
||||
this.logger = childLogger(parentLogger, 'Notifiers');
|
||||
|
||||
this.sourceEmitter.on('notify', async (payload: WebhookPayload) => {
|
||||
await this.notify(payload);
|
||||
this.sourceEmitter.on('notify', async (payload) => {
|
||||
await this.notify(payload.data);
|
||||
})
|
||||
this.clientEmitter.on('notify', async (payload: WebhookPayload) => {
|
||||
await this.notify(payload);
|
||||
this.clientEmitter.on('notify', async (payload) => {
|
||||
await this.notify(payload.data);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { prettifyError, ZodError } from 'zod';
|
||||
import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvConfigSchema, generateConfigLocation, transformPresetEnv, type CommonConfigPrimitives, type UnparsedConfig } from '../common/infrastructure/config/common.ts';
|
||||
import type { CommonClientConfig } from '../common/infrastructure/config/client/index.ts';
|
||||
import { getClientEnvSchema, validateClientAIOJson, validateClientJson, type ClientTypeConfigMap } from '../common/infrastructure/config/client/clientsMap.ts';
|
||||
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
|
||||
|
||||
type UnparsedClientConfig = UnparsedConfig<ClientType>;
|
||||
|
||||
@@ -37,13 +38,16 @@ export default class ScrobbleClients {
|
||||
|
||||
internalConfig: InternalConfig;
|
||||
|
||||
emitter: WildcardEmitter;
|
||||
emitter: WildcardEmitter<MSBackendEventMap>;
|
||||
|
||||
sourceEmitter: WildcardEmitter;
|
||||
sourceEmitter: WildcardEmitter<MSBackendEventMap>;
|
||||
|
||||
scrobbleToNamesWarnings: string[] = [];
|
||||
|
||||
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) {
|
||||
configErrors: (string | Error)[] = [];
|
||||
instantiateErrors: Error[] = [];
|
||||
|
||||
constructor(emitter: WildcardEmitter<MSBackendEventMap>, sourceEmitter: WildcardEmitter<MSBackendEventMap>, internal: InternalConfigOptional, parentLogger: Logger) {
|
||||
this.emitter = emitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
@@ -52,14 +56,14 @@ export default class ScrobbleClients {
|
||||
logger: this.logger
|
||||
}
|
||||
|
||||
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
|
||||
this.sourceEmitter.on('playerUpdate', async (payload) => {
|
||||
// agressively update Now Playing so scrobblers that display based on duration are mostly synced
|
||||
// but aggressively *stop* updating if state becomes stale/orphaned
|
||||
this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
|
||||
});
|
||||
|
||||
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
|
||||
await this.scrobble(payload.data, payload.options);
|
||||
this.sourceEmitter.on('discoveredToScrobble', async (payload) => {
|
||||
await this.scrobble(payload.data.data, payload.data.options);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +102,8 @@ export default class ScrobbleClients {
|
||||
|
||||
buildClientsFromConfig = async () => {
|
||||
const unparsedConfigs: UnparsedClientConfig[] = [];
|
||||
this.configErrors = [];
|
||||
this.instantiateErrors = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
@@ -181,7 +187,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.startsWith(`${configTypeUpper}_`));
|
||||
if (clientKeys.length > 0) {
|
||||
clientUnparsedConfigs.push({
|
||||
config: pick(process.env, ...clientKeys),
|
||||
@@ -198,16 +207,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,
|
||||
@@ -215,13 +225,28 @@ export default class ScrobbleClients {
|
||||
}
|
||||
};
|
||||
} break;
|
||||
case 'file':
|
||||
case 'aio': {
|
||||
case 'file': {
|
||||
// only file config has a combined array of both source and client configs
|
||||
//
|
||||
// if configureAs is missing (optional) we assume it is a client
|
||||
// and only skip it if it is explicitly set as a source
|
||||
//
|
||||
if ('configureAs' in entry.config && entry.config.configureAs === 'source') {
|
||||
this.logger.debug(`Skipping ${generateConfigLocation('client', entry)} because it is configured as a Source`);
|
||||
this.logger.debug(`Skipping ${generateConfigLocation('client', entry)} because it is configured as a Source (configureAs set to 'source')`);
|
||||
continue;
|
||||
}
|
||||
const parsed = entry.source === 'file' ? (await validateClientJson(entry.type, entry.config)) : (await validateClientAIOJson(entry.type, entry.config));
|
||||
const parsed = await validateClientJson(entry.type, entry.config);
|
||||
parsedConfig = {
|
||||
...parsed,
|
||||
name: parsed.name ?? parsed.id,
|
||||
source: generateConfigLocation('client', entry)
|
||||
}
|
||||
} break;
|
||||
case 'aio': {
|
||||
// aio entries can also optionally have `configureAs` but it must always be `client`
|
||||
// and we are only including entries from the `clients` array at this point
|
||||
// so there's no need to manually check if `configureAs` is present
|
||||
const parsed = await validateClientAIOJson(entry.type, entry.config)
|
||||
parsedConfig = {
|
||||
...parsed,
|
||||
name: parsed.name ?? parsed.id,
|
||||
@@ -232,9 +257,15 @@ export default class ScrobbleClients {
|
||||
} catch (e) {
|
||||
const msg = `Failed to validate ${generateConfigLocation('client', entry)}`;
|
||||
if (e instanceof ZodError) {
|
||||
const prettyError = `${msg}:\n${prettifyError(e)}`
|
||||
this.logger.error(`${msg}:\n${prettifyError(e)}`);
|
||||
this.emitter.emit('configError', prettyError);
|
||||
this.configErrors.push(prettyError);
|
||||
} else {
|
||||
const err = new Error(msg, { cause: e });
|
||||
this.logger.error(new Error(msg, { cause: e }));
|
||||
this.emitter.emit('configError', err);
|
||||
this.configErrors.push(err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -276,7 +307,10 @@ export default class ScrobbleClients {
|
||||
newClient.logger.info(`Client added from ${s.source}`);
|
||||
this.clients.push(newClient);
|
||||
} catch (e) {
|
||||
this.logger.error(new Error(`${s.source} was not added due to unrecoverable errors`, { cause: e }));
|
||||
const err = new Error(`${s.source} was not added due to unrecoverable errors`, { cause: e });
|
||||
this.logger.error(err);
|
||||
this.emitter.emit('instantiateError', err);
|
||||
this.instantiateErrors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient
|
||||
import { fromStream } from '@atcute/repo';
|
||||
import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";
|
||||
import { isAbortError } from "abort-controller-x";
|
||||
import type { FmTealAlphaFeedPlay } from "../common/vendor/teal/lexicons/index.ts";
|
||||
import type { FmTealAlphaFeedPlay, FmTealFeedPlay } from "../common/vendor/teal/lexicons/index.ts";
|
||||
|
||||
export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
|
||||
|
||||
@@ -209,11 +209,11 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
|
||||
logger.info('Starting CAR conversion to historical plays...');
|
||||
|
||||
for await (const entry of repo) {
|
||||
if(entry.collection === 'fm.teal.alpha.feed.play') {
|
||||
if(entry.collection === 'fm.teal.feed.play' || entry.collection === 'fm.teal.alpha.feed.play') {
|
||||
let play: PlayObject;
|
||||
try {
|
||||
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, {
|
||||
web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined,
|
||||
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main, {
|
||||
web: did !== undefined ? `at://did:plc:${did}/${entry.collection}/${entry.rkey}` : undefined,
|
||||
playId: entry.rkey,
|
||||
user: did
|
||||
});
|
||||
@@ -287,4 +287,3 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
|
||||
const isNextapi = nextQs === 'true';
|
||||
|
||||
const session = await bsseDef.createSession(req, res);
|
||||
scrobbleSources.emitter.on('*', (payload: any, eventName: string) => {
|
||||
scrobbleSources.emitter.onAny((eventName: string, payload: any) => {
|
||||
if(payload.from !== undefined) {
|
||||
if(isNextapi) {
|
||||
session.push({event: eventName, ...payload}, eventName);
|
||||
@@ -168,7 +168,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
|
||||
}
|
||||
}
|
||||
});
|
||||
scrobbleClients.emitter.on('*', (payload: any, eventName: string) => {
|
||||
scrobbleClients.emitter.onAny((eventName: string, payload: any) => {
|
||||
if(payload.from !== undefined) {
|
||||
if(isNextapi) {
|
||||
session.push({event: eventName, ...payload}, eventName);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -423,7 +423,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
|
||||
if(newDiscoveredPlays.length > 0) {
|
||||
newDiscoveredPlays.sort(sortByOldestPlayDate);
|
||||
this.emitter.emit('discoveredToScrobble', {
|
||||
this.emitEvent('discoveredToScrobble', {
|
||||
data: await pMap(newDiscoveredPlays, this.staggerMappers.postCompare(async (x) => await this.transformPlay(x, TRANSFORM_HOOK.postCompare)), {concurrency: 3}),
|
||||
options: {
|
||||
...options,
|
||||
|
||||
@@ -159,7 +159,7 @@ export default class AppleMusicSource extends AbstractSource {
|
||||
|
||||
private getTracks = async (limit: number): Promise<PlayObject[]> => {
|
||||
const clampedLimit = Math.max(1, Math.min(this.UPSTREAM_TRACK_LIMIT, Math.round(limit)));
|
||||
const result = await this.musicKit.me.history.getRecentlyPlayedTracks({ limit: clampedLimit as MeHistoryRecentlyPlayedTracksProps["limit"], types: ["songs"] });
|
||||
const result = await this.musicKit.me.history.getRecentlyPlayedTracks({ limit: clampedLimit as MeHistoryRecentlyPlayedTracksProps["limit"], types: ["songs", "library-songs"] });
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
this.setStatus('Checking for new Plays');
|
||||
let resp: DeezerHistoryResponse;
|
||||
try {
|
||||
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
|
||||
.query({
|
||||
@@ -221,7 +222,7 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
start: 0
|
||||
});
|
||||
// returns listening history in descending order (newest to oldest)
|
||||
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
|
||||
resp = (await this.callApi(req)) as DeezerHistoryResponse;
|
||||
let errList: string[] = [];
|
||||
if('error' in resp.results) {
|
||||
errList = resp.results.error;
|
||||
@@ -243,6 +244,9 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
}
|
||||
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
|
||||
} catch (e) {
|
||||
if(resp !== undefined) {
|
||||
this.logger.debug({body: resp},'Deezer raw body response');
|
||||
}
|
||||
throw new Error('Failed to get recently played tracks', {cause: e});
|
||||
}
|
||||
}
|
||||
@@ -312,6 +316,12 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
const err = new Error((error as string[]).join(' | '));
|
||||
throw err;
|
||||
}
|
||||
if(resp.statusCode !== 200) {
|
||||
this.logger.debug(`Non 200 status code returned: ${resp.statusCode}`);
|
||||
}
|
||||
if(!resp.header['Content-Type']?.includes('json')) {
|
||||
this.logger.debug(`Non json content-type returned: ${resp.header['Content-Type']}`);
|
||||
}
|
||||
return resp.body;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
import { childLogger, type Logger } from '@foxxmd/logging';
|
||||
import type EventEmitter from "events";
|
||||
import type {InternalConfig, InternalConfigOptional} from "../common/infrastructure/Atomic.ts";
|
||||
import { clientTypes, isSourceType } from "../../core/Atomic.ts";
|
||||
import { sourceTypes } from "../../core/Atomic.ts";
|
||||
@@ -18,6 +17,7 @@ import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvC
|
||||
import { getSourceEnvSchema, validateSourceAIOJson, validateSourceJson } from '../common/infrastructure/config/source/sourcesMap.ts';
|
||||
import type { SourceTypeConfigMap } from "../common/infrastructure/config/source/sourcesMap.ts";
|
||||
import { stripIndents } from 'common-tags';
|
||||
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
|
||||
|
||||
type UnparsedSourceConfig = UnparsedConfig<SourceType>;
|
||||
|
||||
@@ -28,10 +28,12 @@ export default class ScrobbleSources {
|
||||
sources: AbstractSource[] = [];
|
||||
logger: Logger;
|
||||
internalConfig: InternalConfig;
|
||||
configErrors: (string | Error)[] = [];
|
||||
instantiateErrors: Error[] = [];
|
||||
|
||||
emitter: WildcardEmitter;
|
||||
emitter: WildcardEmitter<MSBackendEventMap>;
|
||||
|
||||
constructor(emitter: EventEmitter, internal: InternalConfigOptional, parentLogger: Logger) {
|
||||
constructor(emitter: WildcardEmitter<MSBackendEventMap>, internal: InternalConfigOptional, parentLogger: Logger) {
|
||||
this.emitter = emitter;
|
||||
this.logger = childLogger(parentLogger, 'Sources');
|
||||
this.internalConfig = {
|
||||
@@ -117,6 +119,8 @@ export default class ScrobbleSources {
|
||||
|
||||
buildSourcesFromConfig = async () => {
|
||||
const unparsedConfigs: UnparsedSourceConfig[] = [];
|
||||
this.configErrors = [];
|
||||
this.instantiateErrors = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
@@ -178,8 +182,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,7 +204,10 @@ export default class ScrobbleSources {
|
||||
}
|
||||
}
|
||||
|
||||
const configKeys = envKeys.filter(x => x.includes(configTypeUpper));
|
||||
const envSchema = await getSourceEnvSchema(configType);
|
||||
const configTypeUpper = envSchema.prefix.toUpperCase();
|
||||
|
||||
const configKeys = envKeys.filter(x => x.startsWith(`${configTypeUpper}_`));
|
||||
if (configKeys.length > 0) {
|
||||
sourceUnparsedConfigs.push({
|
||||
config: pick(process.env, ...configKeys),
|
||||
@@ -218,17 +223,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,
|
||||
@@ -237,14 +242,34 @@ export default class ScrobbleSources {
|
||||
};
|
||||
} break;
|
||||
case 'file':
|
||||
case 'aio': {
|
||||
if (('configureAs' in entry.config && entry.config.configureAs === 'client')
|
||||
// @ts-expect-error could be a client type
|
||||
|| (clientTypes.includes(entry.type) && entry.config.configureAs !== 'source')) {
|
||||
this.logger.debug(`Skipping ${generateConfigLocation('source', entry)} because it is configured as a Client`);
|
||||
continue;
|
||||
{
|
||||
// only file config has a combined array of both source and client configs
|
||||
//
|
||||
// for source configs, it is required that "configureAs": "source"
|
||||
//
|
||||
// if `configureAs` is missing OR its value is not `source` then we skip it
|
||||
//
|
||||
// @ts-expect-error could be a client type
|
||||
if(clientTypes.includes(entry.type)) {
|
||||
const confAs = 'configureAs' in entry.config ? entry.config.configureAs : undefined;
|
||||
if(confAs === undefined || confAs !== 'source') {
|
||||
const reason = confAs === undefined ? `configureAs was not defined` : `configureAs was not set to 'source'`;
|
||||
this.logger.debug(`Skipping ${generateConfigLocation('source', entry)} because it is configured as a Client (${reason})`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const parsed = entry.source === 'file' ? (await validateSourceJson(entry.type, entry.config)) : (await validateSourceAIOJson(entry.type, entry.config));
|
||||
const parsed = await validateSourceJson(entry.type, entry.config);
|
||||
parsedConfig = {
|
||||
...parsed,
|
||||
name: parsed.name ?? parsed.id,
|
||||
source: generateConfigLocation('source', entry)
|
||||
}
|
||||
} break;
|
||||
case 'aio': {
|
||||
// aio entries can also optionally have `configureAs` but it must always be `source`
|
||||
// and we are only including entries from the `sources` array at this point
|
||||
// so there's no need to manually check if `configureAs` is present
|
||||
const parsed = await validateSourceAIOJson(entry.type, entry.config)
|
||||
parsedConfig = {
|
||||
...parsed,
|
||||
name: parsed.name ?? parsed.id,
|
||||
@@ -255,9 +280,15 @@ export default class ScrobbleSources {
|
||||
} catch (e) {
|
||||
const msg = `Failed to validate ${generateConfigLocation('source', entry)}`;
|
||||
if (e instanceof ZodError) {
|
||||
this.logger.error(`${msg}:\n${prettifyError(e)}`);
|
||||
const prettyError = `${msg}:\n${prettifyError(e)}`
|
||||
this.logger.error(prettyError);
|
||||
this.emitter.emit('configError', prettyError);
|
||||
this.configErrors.push(prettyError);
|
||||
} else {
|
||||
this.logger.error(new Error(msg, { cause: e }));
|
||||
const err = new Error(msg, { cause: e });
|
||||
this.logger.error(err);
|
||||
this.emitter.emit('configError', err);
|
||||
this.configErrors.push(err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -288,7 +319,7 @@ export default class ScrobbleSources {
|
||||
sourceType: T,
|
||||
strongConfigs: CommonParsedConfig[],
|
||||
defaults: SourceDefaults,
|
||||
Ctor: new (name: string, config: SourceTypeConfigMap[T][0], internalConfig: InternalConfig, emitter: WildcardEmitter) => AbstractSource,
|
||||
Ctor: new (name: string, config: SourceTypeConfigMap[T][0], internalConfig: InternalConfig, emitter: WildcardEmitter<MSBackendEventMap>) => AbstractSource,
|
||||
) => {
|
||||
for (const s of strongConfigs) {
|
||||
try {
|
||||
@@ -298,7 +329,10 @@ export default class ScrobbleSources {
|
||||
newComponent.logger.info(`Source added from ${s.source}`);
|
||||
this.sources.push(newComponent);
|
||||
} catch (e) {
|
||||
this.logger.error(new Error(`${s.source} was not added due to unrecoverable errors`, { cause: e }));
|
||||
const err = new Error(`${s.source} was not added due to unrecoverable errors`, { cause: e })
|
||||
this.logger.error(err);
|
||||
this.emitter.emit('instantiateError', err);
|
||||
this.instantiateErrors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,22 @@ import path from "path";
|
||||
import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';
|
||||
import ScrobbleSources from '../../sources/ScrobbleSources.ts';
|
||||
import EventEmitter from "events";
|
||||
import { WildcardEmitter } from '../../common/WildcardEmitter.ts';
|
||||
import {loggerTest} from '@foxxmd/logging';
|
||||
import { clientTypes } from "../../../core/Atomic.ts";
|
||||
import { projectRootDir } from "../../common/infrastructure/Atomic.ts";
|
||||
import { sourceTypes } from "../../../core/Atomic.ts";
|
||||
import { difference } from '../../utils.ts';
|
||||
import { validateSourceJson } from '../../common/infrastructure/config/source/sourcesMap.ts';
|
||||
import { getSourceEnvSchema, validateSourceJson } from '../../common/infrastructure/config/source/sourcesMap.ts';
|
||||
import { readJson } from '../../utils/DataUtils.ts';
|
||||
import { prettifyError, ZodError } from 'zod';
|
||||
import { validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts';
|
||||
import { getClientEnvSchema, validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts';
|
||||
import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';
|
||||
import { zocker } from "zocker";
|
||||
import { generateCommonComponentEnvConfigSchema } from '../../common/infrastructure/config/common.ts';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { httpUrl, wsUrl } from '../../utils/ZodUtils.ts';
|
||||
import { generateNormalHttpUrl, generateNormalWSUrl } from '../../../core/tests/utils/apiFixtures.ts';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -45,6 +52,11 @@ describe('Sample Configs', function () {
|
||||
|
||||
describe('Source Configs', function () {
|
||||
let reset: any;
|
||||
let ogKeys: string[] = [];
|
||||
|
||||
before(function() {
|
||||
ogKeys = Object.keys(process.env);
|
||||
});
|
||||
|
||||
beforeEach(async function() {
|
||||
reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'sourceConfigParse'});
|
||||
@@ -52,6 +64,11 @@ describe('Sample Configs', function () {
|
||||
|
||||
afterEach(async function() {
|
||||
await reset();
|
||||
const envKeys = Object.keys(process.env);
|
||||
const addedKeys = difference(envKeys, ogKeys);
|
||||
for(const k of addedKeys) {
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +78,6 @@ describe('Sample Configs', function () {
|
||||
it(`Sample ${componentType}.json parses and validates in isolation`, async function () {
|
||||
this.timeout(5000);
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
|
||||
let fileContents = await readJson(`${componentType}.json`);
|
||||
@@ -82,7 +98,7 @@ describe('Sample Configs', function () {
|
||||
it(`Sample ${componentType}.json parses and validates in ScrobbleSources`, async function () {
|
||||
this.timeout(5000);
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
@@ -96,11 +112,68 @@ describe('Sample Configs', function () {
|
||||
await s.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it(`Sample ${componentType} ENV parses and validates in ScrobbleSources`, async function () {
|
||||
this.timeout(5000);
|
||||
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
|
||||
const envSchema = await getSourceEnvSchema(componentType);
|
||||
let zocObj = zocker(envSchema.env)
|
||||
.supply(httpUrl, () => generateNormalHttpUrl())
|
||||
.supply(wsUrl, () => generateNormalWSUrl())
|
||||
switch(componentType) {
|
||||
case 'azuracast':
|
||||
zocObj = zocObj.supply(envSchema.env.shape.AZURA_LISTENERS_NUM, true)
|
||||
break;
|
||||
case 'rocksky':
|
||||
zocObj = zocObj.supply(envSchema.env.shape.SOURCE_ROCKSKY_HANDLE, 'foxxmd.dev')
|
||||
break;
|
||||
case 'applemusic':
|
||||
delete zocObj.schema.shape.APPLEMUSIC_KEY_ID;
|
||||
delete zocObj.schema.shape.APPLEMUSIC_TEAM_ID;
|
||||
delete zocObj.schema.shape.APPLEMUSIC_KEY_P8
|
||||
zocObj = zocObj.supply(envSchema.env.shape.APPLEMUSIC_MEDIA_USER_TOKEN, '1234')
|
||||
.supply(envSchema.env.shape.APPLEMUSIC_TOKEN, '5678')
|
||||
.supply(envSchema.env.shape.APPLEMUSIC_ORIGIN_HEADER, 'https://apple.music.com');
|
||||
}
|
||||
const componentMockData = zocObj.generate();
|
||||
const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase());
|
||||
const primitiveMockData = zocker(primitives).generate();
|
||||
primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
|
||||
|
||||
const mockData = {
|
||||
...componentMockData,
|
||||
...primitiveMockData
|
||||
}
|
||||
|
||||
for(const key of Object.keys(mockData)) {
|
||||
if(mockData[key] === undefined || mockData[key] === null) {
|
||||
continue;
|
||||
}
|
||||
process.env[key] = mockData[key].toString();
|
||||
}
|
||||
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
version: 'test'
|
||||
}, loggerTest);
|
||||
await sources.buildSourcesFromConfig()
|
||||
expect(sources.configErrors,`${sources.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
|
||||
expect(sources.instantiateErrors, `${sources.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
|
||||
expect(sources.sources).length(1);
|
||||
expect(sources.sources[0].type).eq(componentType);
|
||||
for(const s of sources.sources) {
|
||||
await s.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Client Configs', function () {
|
||||
let reset: any;
|
||||
let ogKeys: string[] = [];
|
||||
|
||||
beforeEach(async function() {
|
||||
reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'clientConfigParse'});
|
||||
@@ -108,6 +181,11 @@ describe('Sample Configs', function () {
|
||||
|
||||
afterEach(async function() {
|
||||
await reset();
|
||||
const envKeys = Object.keys(process.env);
|
||||
const addedKeys = difference(envKeys, ogKeys);
|
||||
for(const k of addedKeys) {
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
for(const componentType of clientTypes) {
|
||||
@@ -134,11 +212,11 @@ describe('Sample Configs', function () {
|
||||
});
|
||||
|
||||
it(`Sample ${componentType}.json parses and validates in ScrobbleClients`, async function () {
|
||||
this.timeout(500000);
|
||||
this.timeout(5000);
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
const clients = new ScrobbleClients(emitter, new EventEmitter, {
|
||||
const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
version: 'test'
|
||||
@@ -147,6 +225,54 @@ describe('Sample Configs', function () {
|
||||
await clients.buildClientsFromConfig();
|
||||
expect(clients.clients).length(1);
|
||||
});
|
||||
|
||||
it(`Sample ${componentType} ENV parses and validates in ScrobbleClients`, async function () {
|
||||
this.timeout(5000);
|
||||
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
|
||||
const envSchema = await getClientEnvSchema(componentType);
|
||||
let zocObj = zocker(envSchema.env)
|
||||
.supply(httpUrl, () => generateNormalHttpUrl())
|
||||
.supply(wsUrl, () => generateNormalWSUrl())
|
||||
switch(componentType) {
|
||||
case 'rocksky':
|
||||
zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_HANDLE, 'foxxmd.dev')
|
||||
zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_TOKEN, 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ')
|
||||
break;
|
||||
case 'discord':
|
||||
zocObj = zocObj.supply(envSchema.env.shape.DISCORD_STATUS_OVERRIDE_ALLOW, 'online');
|
||||
zocObj = zocObj.supply(envSchema.env.shape.DISCORD_IPC_LOCATIONS, '/path/to/ipc,192.168.0.100:8999')
|
||||
}
|
||||
const componentMockData = zocObj.generate();
|
||||
const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase());
|
||||
const primitiveMockData = zocker(primitives).generate();
|
||||
primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
|
||||
|
||||
const mockData = {
|
||||
...componentMockData,
|
||||
...primitiveMockData
|
||||
}
|
||||
|
||||
for(const key of Object.keys(mockData)) {
|
||||
if(mockData[key] === undefined || mockData[key] === null) {
|
||||
continue;
|
||||
}
|
||||
process.env[key] = mockData[key].toString();
|
||||
}
|
||||
|
||||
const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
version: 'test'
|
||||
},
|
||||
loggerTest);
|
||||
await clients.buildClientsFromConfig()
|
||||
expect(clients.configErrors,`${clients.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
|
||||
expect(clients.instantiateErrors, `${clients.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
|
||||
expect(clients.clients).length(1);
|
||||
expect(clients.clients[0].type).eq(componentType);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -172,7 +298,7 @@ describe('Global ENVs with Config', function () {
|
||||
process.env.MPRIS_ID = 'test';
|
||||
process.env.MPRIS_ENABLE = 'true';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
@@ -189,7 +315,7 @@ describe('Global ENVs with Config', function () {
|
||||
process.env.MPRIS_ID = 'test';
|
||||
process.env.MPRIS_ENABLE = 'true';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
const sources = new ScrobbleSources(emitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
|
||||
@@ -32,6 +32,7 @@ import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';
|
||||
import { WildcardEmitter } from '../../common/WildcardEmitter.ts';
|
||||
import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts';
|
||||
import { loggerNoop } from '../../common/MaybeLogger.ts';
|
||||
import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -1148,13 +1149,13 @@ describe('Scrobble Clients Behavior', function() {
|
||||
|
||||
describe('Source filtering', function() {
|
||||
|
||||
let cEmitter: WildcardEmitter,
|
||||
sEmitter: WildcardEmitter,
|
||||
let cEmitter: WildcardEmitter<MSBackendEventMap>,
|
||||
sEmitter: WildcardEmitter<MSBackendEventMap>,
|
||||
clients: ScrobbleClients;
|
||||
|
||||
beforeEach(function() {
|
||||
cEmitter = new WildcardEmitter();
|
||||
sEmitter = new WildcardEmitter();
|
||||
cEmitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
sEmitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
clients = new ScrobbleClients(cEmitter, sEmitter, {
|
||||
localUrl: new URL('http://example.com'),
|
||||
configDir: process.cwd(),
|
||||
@@ -1169,10 +1170,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['foo']
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['foo'],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).is.empty;
|
||||
@@ -1190,10 +1197,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test']
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test'],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).length.greaterThan(0);
|
||||
@@ -1211,10 +1224,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test']
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test'],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).is.empty;
|
||||
@@ -1232,10 +1251,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test foo']
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['test foo'],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).is.empty;
|
||||
@@ -1253,10 +1278,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: []
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: [],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).is.empty;
|
||||
@@ -1274,10 +1305,16 @@ describe('Scrobble Clients Behavior', function() {
|
||||
clients.clients.push(testClient);
|
||||
|
||||
sEmitter.emit('discoveredToScrobble', {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['TesT foO']
|
||||
type: 'jellyfin',
|
||||
from: 'source',
|
||||
name: 'test',
|
||||
data: {
|
||||
data: [generatePlay()],
|
||||
options: {
|
||||
scrobbleFrom: 'testSource',
|
||||
scrobbleTo: ['TesT foO'],
|
||||
checkTime: dayjs()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(clients.scrobbleToNamesWarnings).is.empty;
|
||||
|
||||
@@ -21,11 +21,13 @@ import DeezerInternalSource from "../../sources/DeezerInternalSource.ts";
|
||||
import type {DeezerInternalSourceOptions} from "../../common/infrastructure/config/source/deezer.ts";
|
||||
import { artistCreditsToNames } from "../../../core/StringUtils.ts";
|
||||
import type { MarkOptional } from "ts-essentials";
|
||||
import { WildcardEmitter } from "../../common/WildcardEmitter.ts";
|
||||
import type { MSBackendEventMap } from "../../common/infrastructure/MSBackendEventMap.ts";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const emitter = new WildcardEmitter<MSBackendEventMap>();
|
||||
const generateSource = async () => {
|
||||
const source = new TestSource('spotify', 'test-basic', {id: `test-${Date.now()}`}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
|
||||
await source.initialize();
|
||||
@@ -96,11 +98,12 @@ describe('Sources use transform plays correctly', function () {
|
||||
expect(discovered.length).eq(1);
|
||||
expect(discovered[0].data.track).is.eq('my cool track');
|
||||
|
||||
const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<{data: [PlayObject] }>;
|
||||
const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<MSBackendEventMap['discoveredToScrobble'][0]>;
|
||||
source.handle(discovered);
|
||||
const e = await pAwaiter;
|
||||
expect(e.data.length).is.eq(1);
|
||||
expect(e.data[0].data.track).is.eq('my fun track');
|
||||
const res: PlayObject[] = !Array.isArray(e.data.data) ? [e.data.data] : e.data.data;
|
||||
expect(res.length).is.eq(1);
|
||||
expect(res[0].data.track).is.eq('my fun track');
|
||||
});
|
||||
|
||||
it('Transforms play existing comparison', async function() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user