Compare commits

...
Author SHA1 Message Date
FoxxMD 22b58c2f89 deezer response debug 2026-08-27 15:14:53 +00:00
Matt Foxx 86b8b4ad90 Merge pull request #681 from DirazCoder/master
confirm before removing all failed scrobbles
2026-08-25 14:15:08 -04:00
Matt Foxx 29ffc6b804 Merge pull request #680 from FoxxMD/listenbrainzUA
feat: Version and user-agent improvements
2026-08-24 18:10:39 -04:00
FoxxMD 3bdc9447b8 chore(release): Bump version for release 2026-08-24 21:38:46 +00:00
DirazCoder 4a27806825 Add confirmation for removing all dead scrobbles
Added confirmation dialog before removing all failed scrobbles.
2026-08-24 22:23:02 +01:00
FoxxMD b8cd53f554 fix(listenbrainz): Add user-agent to requests #679 2026-08-24 21:05:30 +00:00
Matt Foxx e7218074f8 Merge pull request #678 from halkeye/patch-1
fix(docsite): path.join doesn't really handle http links
2026-08-24 15:20:11 -04:00
Gavin Mogan a48ada9ac3 fix(docsite): path.join doesn't really handle http links 2026-08-22 23:30:43 -07:00
Matt Foxx bc28de66b1 Merge pull request #673 from mmattbtw/feature/teal-production-namespace
feat(teal): migrate play records to production namespace
2026-08-13 10:05:16 -04:00
FoxxMD bc18993cd3 fix(tealfm): Fix AI mistakes
* Remove gitignore entries and remove json lexicon files, we don't redistribute the lexicon
* regenerate lexicon with full definitions so we get functionality non-alpha namespace and re-add alpha to barrel export
* fix type narrowing for musicservice/origin
  * reuse isGenericUri instead of reinventing the regex wheel
* fix property assertions for renamed musicservice/origin(uri)
* use clientresponse type guard in a way typescript can actually understand it, fixes cursor type error
* reuse sortByNewestPlayDate instead of reinventing the sort wheel
2026-08-13 13:48:16 +00:00
mmattbtw d280ca5dd2 feat(teal): migrate play records to production namespace 2026-08-12 20:47:00 -05:00
FoxxMD 155d8a3252 fix(docs): Add zockerId keyword to ajv options 2026-08-12 18:01:08 +00:00
FoxxMD 68d9417cf7 fix(docs): override sharp version to fix node-gyp on arm
An old version of libvips was failing(?) to downloading
causing sharp to use node-gyp rebuild which errors due
to missing python on the base image

This shouldn't happen since we can def use a prebuild binary
so update sharp which updates libvips version used.
2026-08-12 17:24:00 +00:00
FoxxMD 9b7483db91 chore(release): Bump version for release 2026-08-12 16:40:23 +00:00
FoxxMD 7c0797b496 feat: Better validatation for normalized config urls 2026-08-12 16:32:42 +00:00
FoxxMD 1b3cbe9ab3 feat: Normalize domain:port url input 2026-08-12 13:27:10 +00:00
FoxxMD 15e0332ac2 feat: Update json schema to draft-2020-12 2026-08-11 20:37:24 +00:00
FoxxMD d532c85c2a fix: Relax some config url schema requirements with normalization 2026-08-11 20:36:47 +00:00
FoxxMD eecbea4cab add missing error clear when building clients 2026-08-11 18:40:48 +00:00
FoxxMD 823dd5d011 fix: Hand-roll applemusic env fixture data 2026-08-11 18:32:42 +00:00
FoxxMD 0fa9a8df10 fix(discord): Fix discord ENV parsing validation 2026-08-11 18:21:58 +00:00
FoxxMD a76213a7a0 docs: Hand-roll discord ENV updates 2026-08-11 15:56:54 +00:00
FoxxMD a42ab3d8fd fix: Hand-rolled env data fixtures for some edge cases 2026-08-11 15:46:19 +00:00
FoxxMD 475a3f1276 fix: narrow env config key collection to include prefix underscore 2026-08-11 15:14:08 +00:00
FoxxMD 6dcfd6028b fix: Narrow url/IP config zod types from string to purpose-built regex types 2026-08-11 15:06:52 +00:00
FoxxMD 54247e89c2 feat: Add ENV config test suite 2026-08-11 14:53:38 +00:00
Matt Foxx 433ea532ae Merge pull request #670 from FoxxMD/stronglyTypedEvents
feat: strongly type internal events
2026-08-11 08:56:24 -04:00
FoxxMD b615172ed1 feat: strongly-type internal events
* refactor wildcard emitter for better "any" usage
* strongly type emitter with event map
* fix webhook payload accessor #666
2026-08-10 21:03:31 +00:00
FoxxMD aa2beb5846 fix: Check for ENV prefix with startsWith instead of includes
#668
2026-08-10 20:53:53 +00:00
FoxxMD 06d3c6a3a8 docs: Add more guidance for File config example/editor
* Add configureAs callout for ambiguous sources
* Add editor validation limitation for ambiguous components
2026-08-10 18:15:49 +00:00
FoxxMD c0cfc5fa9f feat: relax configureAs requirements for AIO configs
* Do not require configureAs for AIO sources since we can determine from context
* Break out aio/file validation for better DX
2026-08-10 17:42:28 +00:00
FoxxMD 2195ac7057 chore(release): Bump for release 2026-08-10 16:36:23 +00:00
Matt Foxx 3fc67146c7 Merge pull request #669 from FoxxMD/envPrefixUsage
fix: Fix incorrect prefix not being used for env key collection
2026-08-10 12:35:34 -04:00
FoxxMD fe4e625824 docs: Improve scenario guidance for lfm/lz endpoints 2026-08-10 16:21:39 +00:00
FoxxMD b98756a262 fix: Fix incorrect prefix not being used for env key collection
#668
2026-08-10 14:21:59 +00:00
Matt Foxx 2b7521ebac Merge pull request #667 from Exerra/master
fix(AppleMusicSource): include library-songs in recent tracks
2026-08-09 16:55:29 -04:00
Exerra ecd48ca234 fix(AppleMusicSource): include library-songs in recent tracks
Updated the Apple Music recently played tracks query to include 'library-songs' types in addition to 'songs', ensuring a more comprehensive history retrieval.
2026-08-09 23:49:42 +03:00
FoxxMD 41aaaca530 docs: In 0.14.0 upgrade guide add note about ID requirement in 0.16.0+ 2026-08-08 21:06:31 -04:00
FoxxMD ed42da6843 chore(release): Bump version for release 2026-08-08 20:56:01 -04:00
Matt Foxx 34044401d4 Merge pull request #664 from FoxxMD/fixNewPlayRecent
fix(source): Fix missing id/uid on cached recent plays
2026-08-08 20:53:51 -04:00
FoxxMD f61e50fd45 fix(source): Fix missing id/uid on cached recent plays
#662
2026-08-08 17:12:13 -04:00
FoxxMD f5ad2ad29b chore(release): Bump version for release 2026-08-08 19:29:38 +00:00
FoxxMD 2e4acd0f00 fix: Add missing configureAs for ENV parsed components
#661
2026-08-08 19:24:45 +00:00
FoxxMD b9517bd066 docs: Add more type guidance for aio/file config 2026-08-07 17:22:20 +00:00
113 changed files with 2598 additions and 803 deletions
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+19 -15
View File
@@ -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>
+3 -1
View File
@@ -100,10 +100,12 @@ Add an `id` to the top-level for each Source/Client configuration, next to `data
:::important[Default ID]
**If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
**In 0.14.0, If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
If you decide to add an ID later, you must use the Source/Client name as the ID in order to keep Plays/Scobbles associated with the same config.
**In [0.16.0+](https://github.com/FoxxMD/multi-scrobbler/releases/tag/0.16.0) you are required to have an ID or a configuration error is thrown.**
:::
## Cached Scrobble Migration
+1 -1
View File
@@ -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: {
+593 -495
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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">
+6 -3
View File
@@ -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]);
+68 -2
View File
@@ -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
View File
@@ -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": {
+15
View File
@@ -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);
+32 -6
View File
@@ -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 */
+4
View File
@@ -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 {
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -7
View File
@@ -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);
})
}
+50 -16
View File
@@ -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);
}
}
}
+4 -5
View File
@@ -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 {
}
}
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -325,7 +325,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
// only need to update if its already in memory,
// and better to update in-memory than clear cache so we aren't refetching from db on every discover
if(recentPlays !== undefined) {
recentPlays.push(play);
recentPlays.push({...play, id: playRow[0].id, uid: playRow[0].uid});
recentPlays.sort(sortByOldestPlayDate);
this.cache.cacheDb.set(this.recentDiscoveredCacheKey(), recentPlays, '2m');
}
@@ -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,
+1 -1
View File
@@ -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);
}
+11 -1
View File
@@ -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) {
+56 -22
View File
@@ -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);
}
}
}
+135 -9
View File
@@ -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(),
+65 -28
View File
@@ -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;
+7 -4
View File
@@ -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