mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54159f9b9f | ||
|
|
9b9176ccf1 | ||
|
|
4a974a83d9 | ||
|
|
232c802b44 | ||
|
|
e335b44abd | ||
|
|
115911c63a | ||
|
|
f45493d10e | ||
|
|
574b723fa4 | ||
|
|
02f1eca649 | ||
|
|
212afaa5e5 | ||
|
|
006fbe1079 | ||
|
|
5cecac937b | ||
|
|
297c7dee95 | ||
|
|
42a6887141 | ||
|
|
cbdb58b36e | ||
|
|
ae20d46456 | ||
|
|
6b12bd2ef2 | ||
|
|
dde19cfc83 | ||
|
|
0938e95e1d | ||
|
|
3a40478cb1 | ||
|
|
935845a976 | ||
|
|
b494e8f93f | ||
|
|
9e13ead40f | ||
|
|
3b2f5ee1c8 | ||
|
|
51de79b92f | ||
|
|
b6f7e0e586 | ||
|
|
c69af47978 | ||
|
|
edde8f9e74 | ||
|
|
72d702acf0 | ||
|
|
67b13ffe40 | ||
|
|
b464c7507f | ||
|
|
3cf5b82247 | ||
|
|
1571f06662 | ||
|
|
b3fb04a6ae | ||
|
|
cb2a6168fd | ||
|
|
56fcbedff9 | ||
|
|
548c614b0a | ||
|
|
d3469aee7a | ||
|
|
0109cc6fcd | ||
|
|
1988fc5f54 | ||
|
|
aa93600166 | ||
|
|
09e9d0f79a | ||
|
|
9c84e2d8e3 | ||
|
|
4b8d4932aa | ||
|
|
970e200e76 | ||
|
|
3e05b3f964 | ||
|
|
86b8b4ad90 | ||
|
|
64d160782b | ||
|
|
39f8367392 | ||
|
|
62905e8010 | ||
|
|
203f4c343e | ||
|
|
3b93073694 | ||
|
|
649280582e | ||
|
|
0ad86ad692 | ||
|
|
e80f35f1b1 | ||
|
|
a885079a07 | ||
|
|
ab994770ab | ||
|
|
17d7882710 | ||
|
|
1dcbf92fed | ||
|
|
8686309993 | ||
|
|
b83f8872c2 | ||
|
|
9f22c56e87 | ||
|
|
6b86ef8629 | ||
|
|
d0001d2f62 | ||
|
|
e381be2f7e | ||
|
|
c1e9757aed | ||
|
|
61fbc39a91 | ||
|
|
4c764879d7 | ||
|
|
692b2160bf | ||
|
|
344c0a15ad | ||
|
|
f44835dfba | ||
|
|
204cc0bdb8 | ||
|
|
4b7542ddd9 | ||
|
|
1fc3ad1bd4 | ||
|
|
9f34929140 | ||
|
|
1178b0b6fb | ||
|
|
cad42cba04 | ||
|
|
68b7385f31 | ||
|
|
9a93f13a7d | ||
|
|
649ccde43d | ||
|
|
fc06c6a8a9 | ||
|
|
d089b960f9 | ||
|
|
c210590c8f | ||
|
|
78aa418b7d | ||
|
|
48cc18d373 | ||
|
|
258cd61b4b | ||
|
|
185ccdf71c | ||
|
|
b9dad6149b | ||
|
|
9ae74a193f | ||
|
|
99b30956ba | ||
|
|
93a5775363 | ||
|
|
e8a998ddda | ||
|
|
1b02dc684e | ||
|
|
23813a65ef | ||
|
|
0583c66743 | ||
|
|
cd4e88b232 | ||
|
|
8be739efb4 | ||
|
|
3f2e29c5c8 | ||
|
|
b109157350 | ||
|
|
ce15c4049c | ||
|
|
5a7c01a6d9 | ||
|
|
befd1ac043 | ||
|
|
714678fdab | ||
|
|
2a58edd9b2 | ||
|
|
abfceeb5f8 | ||
|
|
d7c88d4191 | ||
|
|
a44fe48314 | ||
|
|
29ffc6b804 | ||
|
|
3bdc9447b8 | ||
|
|
4a27806825 | ||
|
|
b8cd53f554 | ||
|
|
e7218074f8 | ||
|
|
a48ada9ac3 | ||
|
|
bc28de66b1 | ||
|
|
bc18993cd3 | ||
|
|
d280ca5dd2 | ||
|
|
155d8a3252 | ||
|
|
68d9417cf7 | ||
|
|
9b7483db91 | ||
|
|
7c0797b496 | ||
|
|
1b3cbe9ab3 | ||
|
|
15e0332ac2 | ||
|
|
d532c85c2a | ||
|
|
eecbea4cab | ||
|
|
823dd5d011 | ||
|
|
0fa9a8df10 | ||
|
|
a76213a7a0 | ||
|
|
a42ab3d8fd | ||
|
|
475a3f1276 | ||
|
|
6dcfd6028b | ||
|
|
54247e89c2 | ||
|
|
433ea532ae | ||
|
|
b615172ed1 | ||
|
|
aa2beb5846 | ||
|
|
06d3c6a3a8 | ||
|
|
c0cfc5fa9f | ||
|
|
2195ac7057 | ||
|
|
3fc67146c7 | ||
|
|
fe4e625824 | ||
|
|
b98756a262 | ||
|
|
2b7521ebac | ||
|
|
ecd48ca234 | ||
|
|
41aaaca530 |
@@ -29,7 +29,6 @@
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"TakumiI.markdowntable",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"bowlerr.sqlite-intelliview-vscode"
|
||||
|
||||
+1
-1
@@ -159,4 +159,4 @@ storybook-static
|
||||
|
||||
bun.lock
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
Vendored
+1
-6
@@ -6,13 +6,8 @@
|
||||
"**/build": true,
|
||||
"**/schema": true
|
||||
},
|
||||
"tailwindCSS.files.exclude": [
|
||||
"docsite/**"
|
||||
],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss",
|
||||
"**/config/*.json": "jsonc"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "src/client/index.css",
|
||||
}
|
||||
|
||||
}
|
||||
+4
-1
@@ -18,4 +18,7 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lib
|
||||
lib
|
||||
|
||||
docs/api/
|
||||
static/openapi.json
|
||||
@@ -6,7 +6,7 @@
|
||||
| `DISCORD_TOKEN` | string | | |
|
||||
| `DISCORD_ARTWORK` | boolean OR string OR array | | |
|
||||
| `DISCORD_APPLICATION_ID` | string | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string OR array | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string OR array | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string OR array | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | |
|
||||
@@ -5,4 +5,4 @@
|
||||
| `ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
|
||||
| `ROCKSKY_TOKEN` | string | | Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
|
||||
@@ -557,10 +557,12 @@ Remember, this URL need to be accessible on the public internet.
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :----------------------------- | ----------------- | --------------------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_APPLICATION_ID`**_ | string | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
</Config>
|
||||
@@ -323,12 +323,14 @@ The order in which multi-scrobbler determines what image to use is the same as t
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :--------------------------------- | ----------------- | --------------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_TOKEN`**_ | string | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | string | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
</Config>
|
||||
@@ -52,6 +52,6 @@ For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLO
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="KoitoClientConfig" fileContent={JsonConfig} client name="koito">
|
||||
<Config config="KoitoClientConfig" fileContent={JsonConfig} client ambiguous name="koito">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
|
||||
@@ -63,6 +63,6 @@ The Redirect URL can be explicitly specified in the configuration below, or by u
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmClientConfig" fileContent={JsonConfig} client name="lastfm">
|
||||
<Config config="LastfmClientConfig" fileContent={JsonConfig} client ambiguous name="lastfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -54,6 +54,6 @@ https://my.cool.libre.com/2.0/
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client name="librefm">
|
||||
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client ambiguous name="librefm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
|
||||
@@ -23,6 +23,6 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
|
||||
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client ambiguous name="listenbrainz">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -38,6 +38,6 @@ Finally, add the Maloja server URL and API Key to the configuration type you cho
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaClientConfig" fileContent={JsonConfig} client name="maloja">
|
||||
<Config config="MalojaClientConfig" fileContent={JsonConfig} client ambiguous name="maloja">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -76,6 +76,6 @@ Keys are now **deprecated** and functionality for them will be removed in a futu
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
|
||||
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client ambiguous name="rocksky">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -78,6 +78,6 @@ If using a handle:
|
||||
|
||||
If your identifier does not look like a DID and does not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} client name="tealfm">
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} client ambiguous name="tealfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -1,8 +1,8 @@
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------ |
|
||||
| _**`LFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `LFM_NAME` | string | Value of `LFM_ID` | A vanity name EX `My Cool Component` |
|
||||
| `LFM_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `LFM_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source |
|
||||
| `LFM_USERNAME` | string | | A fake username to differentiate LFM Endpoint Sources |
|
||||
| `LFM_API_KEY` | string | | A fake api key to differentiate LFM Endpoint Sources |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| ---------------------- | ------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`LFM_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `LFM_NAME` | string | Value of `LFM_ID` | A vanity name EX `My Cool Component` |
|
||||
| `LFM_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `LFM_SLUG` | string | | When using **multiple sources without apikey/user**, or **not** using the standard base URL, this is the URL base path that should be used to identify scrobbles for this source |
|
||||
| `LFM_USERNAME` | string | | A fake username to differentiate LFM Endpoint Sources |
|
||||
| `LFM_API_KEY` | string | | A fake api key to differentiate LFM Endpoint Sources |
|
||||
@@ -1,8 +1,8 @@
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| _**`LZE_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `LZE_NAME` | string | Value of `LZE_ID` | A vanity name EX `My Cool Component` |
|
||||
| `LZE_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `LZE_SLUG` | string | | The URL ending that should be used to identify scrobbles for this source |
|
||||
| `LZE_TOKEN` | string | | If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source |
|
||||
| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| ---------------------- | ------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`LZE_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `LZE_NAME` | string | Value of `LZE_ID` | A vanity name EX `My Cool Component` |
|
||||
| `LZE_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `LZE_SLUG` | string | | When using **multiple sources without tokens**, or **not** using a standard base URL, this is the URL base path that should be used to identify scrobbles for this source |
|
||||
| `LZE_TOKEN` | string | | If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source |
|
||||
| `LZE_USERNAME` | string | | The listenbrainz "username" to associate with this Source |
|
||||
@@ -4,4 +4,4 @@
|
||||
| `SOURCE_ROCKSKY_NAME` | string | Value of `SOURCE_ROCKSKY_ID` | A vanity name EX `My Cool Component` |
|
||||
| `SOURCE_ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `SOURCE_ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
|
||||
| _**`SOURCE_ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
|
||||
| _**`SOURCE_ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
|
||||
@@ -25,6 +25,6 @@ See the [Koito (Client)](/configuration/clients/koito) configuration for general
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Config config="KoitoSourceConfig" fileContent={KoitoConfig} name="koito">
|
||||
<Config config="KoitoSourceConfig" fileContent={KoitoConfig} configureAs ambiguous name="koito">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -29,7 +29,67 @@ If your application has the option to scrobble using Listenbrainz then use the [
|
||||
|
||||
## Setup
|
||||
|
||||
### URL
|
||||
:::tip[Scenario Picker]
|
||||
|
||||
Use **one** of the scenarios below based on how on your service(s) (things "scrobbling" to Multi-Scrobbler using this Source) will need to interact with MS.
|
||||
|
||||
<Tabs groupId="scenario" queryString>
|
||||
<TabItem value="single" label="One Service">
|
||||
> You have **one** service, like Panoscrobbler, that will scrobble to Multi-Scrobbler.
|
||||
|
||||
Configure **one** Last.fm Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and [apikey/user](#authentication), if necessary. Use the same apikey/user in MS as you do in your service.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="multiple" label="Many Services, Same User">
|
||||
> You may have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
|
||||
>
|
||||
> All of these services will scrobble for the same user (you) and all of the scrobbles should go to the same [Clients](/configuration/clients).
|
||||
|
||||
Configure **one** Lastfm Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and configure [apikey/user](#authentication) in MS, if necessary. Use the same apikey/user for all your services as you configure in MS.
|
||||
</TabItem>
|
||||
<TabItem value="multiple-tokens" label="Many Services, Many Users, Can use LFM Account">
|
||||
> You have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
|
||||
>
|
||||
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
|
||||
>
|
||||
> **All** of these services can accept a [**apikey/user**](#authentication) to authenticate a Last.fm user/account.
|
||||
|
||||
Configure one Last.fm Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
|
||||
|
||||
For each Lastfm Endpoint Source, configure a different [apikey/user](#authentication). Use that same apikey/user when configuring Last.fm for the associated service.
|
||||
|
||||
All Last.fm Endpoint Sources should use the same [standard base URL](#standard-base-url).
|
||||
</TabItem>
|
||||
<TabItem value="multiple-slug" label="Many Services, Many Users, Cannot use LFM Account">
|
||||
> You have **more than one** service that will scrobble to Multi-Scrobbler, like ArchiveTune and Panoscrobbler.
|
||||
>
|
||||
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
|
||||
>
|
||||
> All, or some, of these services **cannot** accept an [apikey/user](#authentication) to authenticate a Last.fm user/account.
|
||||
|
||||
**This is not a common scenario** but if it fits your usecase then use these guidelines to differentiate your services:
|
||||
|
||||
Configure one Last.fm Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
|
||||
|
||||
* For any groups of services that **can use [apikey/user](#authentication)**, use the instructions for [Many Services, Many Users, Can use LFM Account](./?scenario=multiple-tokens#setup)
|
||||
* For any groups of services that **cannot accept** an apikey/user...
|
||||
* You can still configure a Source **without an [apikey/user](#authentication)**. Any services not using an account will use this "default" Source.
|
||||
* If multiple groups cannot use an apikey/user, or the groups cannot use the [standard base URL](#standard-base-url), then use different [base url **Slugs**](#multiple-sources).
|
||||
</TabItem>
|
||||
<TabItem value="slug" label="Service cannot use standard URL">
|
||||
> You have **one** service, like PanoScrobbler, that will scrobble to Multi-Scrobbler.
|
||||
>
|
||||
> You have already tried the [**One Service**](./?scenario=single#setup) scenario with the [standard base URL](#standard-base-url) but it is not working.
|
||||
|
||||
**This is not a common scenario.** Check the [**Troubleshooting URL**](#troubleshooting-url) section before trying this.
|
||||
|
||||
At this point, you can try to use a different [base url **Slug**](#multiple-sources). Your service must be able to set a "fully custom" URL for Last.fm IE it must allow you to set more than just a domain/host.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::
|
||||
|
||||
### Standard Base URL
|
||||
|
||||
When setting up your Last.fm client to communicate with Multi-Scrobbler replace the Last.fm domain with your Multi-Scrobbler domain:
|
||||
|
||||
@@ -49,12 +109,14 @@ Currently, Multi-Scrobbler only supports the [**Mobile Application**](https://ww
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### Multiple Sources
|
||||
### Multiple Sources
|
||||
|
||||
If you have **more than one** Lastfm Endpoint Source then you should configure username/apiKey for MS, per Source. This enables MS to differentiate scrobbles for each Source.
|
||||
If you need configure **more than one** Lastfm Endpoint Source then you should configure username/apiKey for MS, per Source. This enables MS to differentiate scrobbles for each Source.
|
||||
|
||||
Use the same **username** and/or **API Key** you configure with MS when setting up your Last.fm Client. These values can be anything you want, as long as they match between MS and your client. Password can be anything and is not checked.
|
||||
|
||||
However, if you cannot configure username/apiKeys for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user. **This is not a common scenario** and should only be used if username/apiKey configuration is not sufficient or not possible.
|
||||
|
||||
<DetailsAdmo type="tip" summary="Different URL Base (Slug)">
|
||||
|
||||
If you cannot use different username/apiKey per Source (or Last.fm Client) you can still differentiate Sources by using a different **url base (slug)** for Last.fm communication.
|
||||
@@ -72,6 +134,30 @@ The above url base is equivalent to making calls to `http://yourMSDomain/2.0/`
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
## Troubleshooting URL
|
||||
|
||||
If you think your service should be able to use the [standard base URL](#standard-base-url) but it is not working follow these steps before trying the [the base URL Slug](#multiple-sources).
|
||||
|
||||
#### Verify URL Format
|
||||
|
||||
Some service may require `http/https` in the URL format (`http://myMSDomainOrIp:9078`) while others may not (`myDomainOrIp:9078`). Check the docs for your service to see if they specify this or have an example. Try both versions, if possible, to see if one works.
|
||||
|
||||
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
|
||||
|
||||
Some services that can scrobble to a custom Last.fm URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### Is MS is Reachable?
|
||||
|
||||
Verify that MS can be accessed by your service, or from the same host as the service is running. See the [**FAQ**](/FAQ/#ingress-connection) for guidance on troubleshooting ingress connection issues.
|
||||
|
||||
#### Service Logging
|
||||
|
||||
If you service has accessible logging, check to see if it outputs the URL it is trying to reach when authenticating the Last.fm apiKey/user, or trying to make a scrobble request.
|
||||
|
||||
If you decide to report this issue, please include this logging in your issue.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastFMEndpointSourceConfig" fileContent={LastfmEndpointConfig} name="endpointlfm">
|
||||
|
||||
@@ -24,6 +24,6 @@ To configure this Source use the same registration instructions and configuratio
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LastfmSourceConfig" fileContent={LastfmConfig} name="lastfm">
|
||||
<Config config="LastfmSourceConfig" fileContent={LastfmConfig} configureAs ambiguous name="lastfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -23,6 +23,6 @@ To configure this Source use the same registration instructions and configuratio
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="LibrefmSourceConfig" fileContent={JsonConfig} name="librefm">
|
||||
<Config config="LibrefmSourceConfig" fileContent={JsonConfig} configureAs ambiguous name="librefm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -23,16 +23,76 @@ If your service/player has a "Scrobble to Listenbrainz" feature and there is an
|
||||
|
||||
## Setup
|
||||
|
||||
### URL
|
||||
:::tip[Scenario Picker]
|
||||
|
||||
In general, you should use one of the following as the **base URL** when configuring your application to scrobble to multi-scrobbler:
|
||||
Use **one** of the scenarios below based on how on your service(s) (things "scrobbling" to Multi-Scrobbler using this Source) will need to interact with MS.
|
||||
|
||||
<Tabs groupId="scenario" queryString>
|
||||
<TabItem value="single" label="One Service">
|
||||
> You have **one** service, like Navidrome, that will scrobble to Multi-Scrobbler.
|
||||
|
||||
Configure **one** Listenbrainz Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and set a [Token](#token), if necessary. Use the same **Token** in MS as you do in your service.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="multiple" label="Many Services, Same User">
|
||||
> You may have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
|
||||
>
|
||||
> All of these services will scrobble for the same user (you) and all of the scrobbles should go to the same [Clients](/configuration/clients).
|
||||
|
||||
Configure **one** Listenbrainz Endpoint Source. Use the [standard base URL](#standard-base-url) to configure your service and configure a [Token](#token) in MS, if necessary. Use the same token for all your services as you configure in MS.
|
||||
</TabItem>
|
||||
<TabItem value="multiple-tokens" label="Many Services, Many Users, Can use Tokens">
|
||||
> You have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
|
||||
>
|
||||
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
|
||||
>
|
||||
> **All** of these services can accept a **Token** to authenticate a Listenbrainz user/account.
|
||||
|
||||
Configure one Listenbrainz Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
|
||||
|
||||
For each Listenbrainz Endpoint Source, configure a different [**Token**](#token). Use that same token when configuring Listenbrainz for the associated service.
|
||||
|
||||
All Listenbrainz Endpoint Sources should use the same [standard base URL](#standard-base-url).
|
||||
</TabItem>
|
||||
<TabItem value="multiple-slug" label="Many Services, Many Users, Cannot use Tokens">
|
||||
> You have **more than one** service that will scrobble to Multi-Scrobbler, like Navidrome and Panoscrobbler.
|
||||
>
|
||||
> Some of these services should scrobble for different users, scrobble to different [Clients](/configuration/clients), or generally be treated differently (such as for transforms).
|
||||
>
|
||||
> All, or some, of these services **cannot** accept a **Token** to authenticate a Listenbrainz user/account.
|
||||
|
||||
**This is not a common scenario** but if it fits your usecase then use these guidelines to differentiate your services:
|
||||
|
||||
Configure one Listenbrainz Endpoint Sources **per service that should be treated differently**. That is, if multiple services are all "the same user" then you only need one Source for that entire group of services.
|
||||
|
||||
* For any groups of services that **can accept** a token, use the instructions for [Many Services, Many Users, Can use Tokens](./?scenario=multiple-tokens#setup)
|
||||
* For any groups of services that **cannot accept** a token...
|
||||
* You can still configure a Source **without a [Token](#token)**. Any services not using a token will use this "default" Source.
|
||||
* If multiple groups cannot use a token, or the groups cannot use the [standard base URL](#standard-base-url), then use different [base url **Slugs**](#multiple-sources).
|
||||
</TabItem>
|
||||
<TabItem value="slug" label="Service cannot use standard URL">
|
||||
> You have **one** service, like Navidrome, that will scrobble to Multi-Scrobbler.
|
||||
>
|
||||
> You have already tried the [**One Service**](./?scenario=single#setup) scenario with both [standard base URL](#standard-base-url) and neither is working.
|
||||
|
||||
**This is not a common scenario.** Check the [**Troubleshooting URL**](#troubleshooting-url) section before trying this.
|
||||
|
||||
At this point, you can try to use a different [base url **Slug**](#multiple-sources). Your service must be able to set a "fully custom" URL for Listenbrainz IE it must allow you to set more than just a domain/host.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::
|
||||
|
||||
### Standard Base URL
|
||||
|
||||
In most scenarios you should use one of the following as the **base URL** when configuring your service to scrobble to multi-scrobbler:
|
||||
|
||||
* `http://myMultiScrobblerIP:9078`
|
||||
* `http://myMultiScrobblerIP:9078/1/`
|
||||
|
||||
<DetailsAdmo type="tip" summary="Default Scrobble Submit URL">
|
||||
|
||||
If you are not using a [slug for multiple endpoints](#multiple-endpoints) then multi-scrobbler will accept Listenbrainz scrobbles at
|
||||
Multi-scrobbler will accept Listenbrainz scrobbles at
|
||||
|
||||
```
|
||||
http://myMultiScrobblerIP:9078/1/submit-listens
|
||||
@@ -42,41 +102,34 @@ which is the **standard** Listenbrainz server path for scrobbling.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
|
||||
|
||||
Some services that can scrobble to a custom Listenbrainz URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
### Token
|
||||
|
||||
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
|
||||
Most Listenbrainz clients require an Authentication Token to be provided during setup. This value can be anything you want but ensure you use the same value for `token` (ENV `LZE_TOKEN`) in your configuration for this Listenbrainz Endpoint Source.
|
||||
|
||||
### Multiple Endpoints
|
||||
If your service does not require a token, or does not let you set one, then do not configure it for this Source either.
|
||||
|
||||
If you are setting up multiple [Listenbrainz (Endpoint) Sources](/configuration/sources/listenbrainz-endpoint) MS can differentiate scrobbles based on [Token](#token) so that you can use the same endpoint for many users.
|
||||
### Multiple Sources
|
||||
|
||||
However, if you cannot configure multiple tokens for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user.
|
||||
If you need configure **more than one** Listenbrainz Endpoint Source then MS can differentiate scrobbles based on [Token](#token) so that you can use the [**same, standard base URL**](#standard-base-url) for many users.
|
||||
|
||||
<details>
|
||||
However, if you cannot configure multiple tokens for your scenario you may also use a URL **Slug** to tell MS which Source belongs to a specific user. **This is not a common scenario** and should only be used if **Token** configuration is not sufficient or not possible.
|
||||
|
||||
<summary>Using a Slug</summary>
|
||||
<DetailsAdmo type="tip" summary="Different URL Base (Slug)">
|
||||
|
||||
When a **slug** is defined in ENV/File config MS will listen for listenbrainz routes using the slug as a the route path. Example:
|
||||
When a **slug** is defined in ENV/File config MS will listen for listenbrainz `submit-listens` requests using the slug as the base route path. Example:
|
||||
|
||||
```
|
||||
LZE_SLUG=foobar
|
||||
```
|
||||
|
||||
MS will accept calls to
|
||||
MS will accept requests for `submit-listens` (scrobble submissions and now playing) at
|
||||
|
||||
```
|
||||
http://myMultiScrobblerIP:9078/api/listenbrainz/foobar
|
||||
http://myMultiScrobblerIP:9078/api/listenbrainz/foobar/submit-listens
|
||||
```
|
||||
|
||||
for scrobble submission.
|
||||
|
||||
</details>
|
||||
</DetailsAdmo>
|
||||
|
||||
## Setup for Popular Apps
|
||||
|
||||
@@ -100,13 +153,37 @@ Set the [**ListenBrainz.BaseURL**](https://www.navidrome.org/docs/usage/configur
|
||||
* In the ListenBrainz Scrobbler configuration screen:
|
||||
* User Token is the [Token](#token) you configured
|
||||
* Toggle **Show advanced settings**
|
||||
* Base URL is the [URL](#url) you can configured, or the equivalent of `http://myMultiScrobblerIP:9078` for your MS instance
|
||||
* Base URL is the [URL](#standard-base-url) you can configured, or the equivalent of `http://myMultiScrobblerIP:9078` for your MS instance
|
||||
* Save the configuration and you are ready to scrobble
|
||||
|
||||
To troubleshoot any errors, and assuming you are using Home Assistant, view the Logs in the Music Assistant Add-ons. Please include these logs in any reported issue.
|
||||
|
||||
</details>
|
||||
|
||||
## Troubleshooting URL
|
||||
|
||||
If you think your service should be able to use the [standard base URL](#standard-base-url) but it is not working follow these steps before trying the [the base URL Slug](#multiple-sources).
|
||||
|
||||
#### Verify URL Format
|
||||
|
||||
Some service may require `http/https` in the URL format (`http://myMSDomainOrIp:9078`) while others may not (`myDomainOrIp:9078`). Check the docs for your service to see if they specify this or have an example. Try both versions, if possible, to see if one works.
|
||||
|
||||
<DetailsAdmo type="note" summary="Some Services Require HTTPS">
|
||||
|
||||
Some services that can scrobble to a custom Listenbrainz URL may require the URL to have a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### Is MS is Reachable?
|
||||
|
||||
Verify that MS can be accessed by your service, or from the same host as the service is running. See the [**FAQ**](/FAQ/#ingress-connection) for guidance on troubleshooting ingress connection issues.
|
||||
|
||||
#### Service Logging
|
||||
|
||||
If you service has accessible logging, check to see if it outputs the URL it is trying to reach when authenticating the Listenbrainz account/user, or trying to make a scrobble request.
|
||||
|
||||
If you decide to report this issue, please include this logging in your issue.
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenbrainzEndpointSourceConfig" fileContent={ListenbrainzEndpointConfig} name="endpointlz">
|
||||
|
||||
@@ -26,6 +26,6 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="ListenBrainzSourceConfig" fileContent={ListenbrainzConfig} name="listenbrainz">
|
||||
<Config config="ListenBrainzSourceConfig" fileContent={ListenbrainzConfig} configureAs ambiguous name="listenbrainz">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -22,6 +22,6 @@ See the [Maloja (Client)](/configuration/clients/maloja) configuration for gener
|
||||
|
||||
## Configuration
|
||||
|
||||
<Config config="MalojaSourceConfig" fileContent={MalojaConfig} name="maloja">
|
||||
<Config config="MalojaSourceConfig" fileContent={MalojaConfig} configureAs ambiguous name="maloja">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -33,6 +33,6 @@ See the [Rocksky (Client)](/configuration/clients/rocksky#setup) configuration f
|
||||
|
||||
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
|
||||
|
||||
<Config config="RockskySourceConfig" fileContent={JsonConfig} name="rocksky">
|
||||
<Config config="RockskySourceConfig" fileContent={JsonConfig} configureAs ambiguous name="rocksky">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -104,6 +104,6 @@ Allow scrobbles from plyr.fm and unknown services:
|
||||
|
||||
</details>
|
||||
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} name="tealfm">
|
||||
<Config config="TealClientConfig" fileContent={JsonConfig} configureAs ambiguous name="tealfm">
|
||||
<ENVConfig/>
|
||||
</Config>
|
||||
@@ -100,10 +100,12 @@ Add an `id` to the top-level for each Source/Client configuration, next to `data
|
||||
|
||||
:::important[Default ID]
|
||||
|
||||
**If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
|
||||
**In 0.14.0, If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
|
||||
|
||||
If you decide to add an ID later, you must use the Source/Client name as the ID in order to keep Plays/Scobbles associated with the same config.
|
||||
|
||||
**In [0.16.0+](https://github.com/FoxxMD/multi-scrobbler/releases/tag/0.16.0) you are required to have an ID or a configuration error is thrown.**
|
||||
|
||||
:::
|
||||
|
||||
## Cached Scrobble Migration
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from '@docusaurus/types';
|
||||
import * as themes from 'prism-react-renderer';
|
||||
import * as Renderers from './lib/socialCard/ImageRenderers.js';
|
||||
import path from 'node:path';
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
let baseSite: string = 'https://foxxmd.github.io';
|
||||
@@ -32,7 +33,7 @@ const config: Config = {
|
||||
v4: {
|
||||
removeLegacyPostBuildHeadAttribute: true, // required
|
||||
},
|
||||
experimental_faster: {
|
||||
faster: {
|
||||
ssgWorkerThreads: true,
|
||||
rspackBundler: true, // required flag
|
||||
rspackPersistentCache: true, // new flag
|
||||
@@ -75,20 +76,29 @@ const config: Config = {
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
docItemComponent: "@theme/ApiItem",
|
||||
routeBasePath: '/',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: (params: {
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
}
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
},
|
||||
// based on this https://github.com/facebook/docusaurus/issues/6127#issuecomment-3937124499
|
||||
// to filter out the autogenerated api folder sidebar item
|
||||
sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
const items = await defaultSidebarItemsGenerator(args);
|
||||
const filteredItems = items.filter(item => !(item.type === 'category' && item.label === 'api'));
|
||||
return filteredItems
|
||||
},
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
@@ -100,7 +110,7 @@ const config: Config = {
|
||||
blog: false,
|
||||
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
customCss: ['./src/css/custom.css','./src/css/openapiBase.css','./src/css/evergreen.css'],
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -122,10 +132,11 @@ const config: Config = {
|
||||
// ```
|
||||
},
|
||||
],
|
||||
'docusaurus-theme-github-codeblock'
|
||||
'docusaurus-theme-github-codeblock',
|
||||
"docusaurus-theme-openapi-docs"
|
||||
],
|
||||
plugins: [
|
||||
'docusaurus-plugin-sass',
|
||||
'docusaurus-plugin-sass',
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
@@ -167,7 +178,38 @@ const config: Config = {
|
||||
'docusaurus-plugin-content-pages': Renderers.docs,
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
[
|
||||
'docusaurus-plugin-openapi-docs',
|
||||
{
|
||||
id: "api", // plugin id
|
||||
docsPluginId: "classic", // configured for preset-classic
|
||||
config: {
|
||||
ms: {
|
||||
specPath: "static/openapi.json",
|
||||
outputDir: "docs/api",
|
||||
showInfoPage: true,
|
||||
maskCredentials: false, // Disable credential masking in code snippets
|
||||
sidebarOptions: {
|
||||
groupPathsBy: "tag",
|
||||
},
|
||||
} satisfies OpenApiPlugin.Options,
|
||||
}
|
||||
},
|
||||
],
|
||||
// [
|
||||
// '@docusaurus/plugin-content-docs',
|
||||
// {
|
||||
// sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
// const items = await defaultSidebarItemsGenerator(args);
|
||||
// for(const i of items) {
|
||||
// console.log(i);
|
||||
// }
|
||||
// //const filteredItems = items.filter(item => item.type !== 'doc' || !item.id.endsWith('/index'));
|
||||
// //return filteredItems
|
||||
// },
|
||||
// }
|
||||
// ]
|
||||
],
|
||||
themeConfig:
|
||||
{
|
||||
@@ -193,6 +235,11 @@ const config: Config = {
|
||||
position: 'left',
|
||||
label: 'Config Playground',
|
||||
},
|
||||
{
|
||||
to: 'api/multi-scrobbler-api',
|
||||
position: 'left',
|
||||
label: 'API',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
label: 'GitHub',
|
||||
|
||||
Generated
+2796
-904
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -14,14 +14,15 @@
|
||||
"typecheck": "tsc",
|
||||
"postinstall": "patch-package",
|
||||
"previews:build-renderer": "tsc --project tsconfig.client.json || exit 0",
|
||||
"prebuild": "npm run -s previews:build-renderer"
|
||||
"prebuild": "npm run -s previews:build-renderer && npm run -s openapi:generate",
|
||||
"openapi:generate": "docusaurus clean-api-docs ms && docusaurus gen-api-docs ms"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bony_chops/docusaurus-og": "^1.0.3-beta.3",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/faster": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@docusaurus/core": "3.10.2",
|
||||
"@docusaurus/faster": "3.10.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "3.10.2",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"ajv": "^8.18.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-plugin-sass": "^0.2.6",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"embla-carousel": "^9.0.0-rc02",
|
||||
"embla-carousel-react": "^9.0.0-rc02",
|
||||
"json5": "^2.2.3",
|
||||
@@ -44,9 +46,9 @@
|
||||
"sass": "^1.97.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.2",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@docusaurus/types": "^3.9.2",
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/tsconfig": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@types/react": "^19.2.4",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
@@ -69,6 +71,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"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -1,5 +1,5 @@
|
||||
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
|
||||
|
||||
import msSidebar from "./docs/api/sidebar.js";
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
@@ -19,21 +19,13 @@ const sidebars: SidebarsConfig = {
|
||||
type: "link",
|
||||
label: "Config Playground",
|
||||
href: "/playground"
|
||||
}
|
||||
],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Api",
|
||||
items: msSidebar,
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
|
||||
@@ -9,10 +9,15 @@ import AdmonitionIconImportant from './ImportantIcon';
|
||||
|
||||
export interface DetailsAdmoProps extends DetailProps {
|
||||
type?: 'warning' | 'danger' | 'note' | 'tip' | 'important'
|
||||
render?: boolean
|
||||
}
|
||||
|
||||
const DetailsAdmo = (props: DetailsAdmoProps) => {
|
||||
|
||||
if(props.render === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cn: string = '';
|
||||
let icon: ReactElement;
|
||||
|
||||
|
||||
@@ -37,10 +37,29 @@ import {useWindowSize} from '@docusaurus/theme-common';
|
||||
* **text** mode lets you edit the JSON directly.
|
||||
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
|
||||
|
||||
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
|
||||
Both modes **validate** that the configuration is correct. Any errors show up as squiggly lines.
|
||||
|
||||
<span style={{display: props.ambiguous === true ? 'inherit' : 'none'}}>
|
||||
:::important
|
||||
|
||||
Only <strong children={props.client ? 'client' : 'source'}/> <span children={props.name}/> configs will validate correctly in the editor but *both* **source** and **client** <span children={props.name}/> configs are allowed in the real <code>CONFIG/<span children={props.name}/>.json</code>
|
||||
|
||||
:::
|
||||
</span>
|
||||
|
||||
After you finish editing, switch to **text** and then copy all text to get a completed config.
|
||||
</DetailsAdmo>
|
||||
<DetailsAdmo type="important" summary="configureAs Required" render={props.configureAs === true}>
|
||||
When configuring a <span children={props.name}/> **Source** in a **File** Config you **must** set
|
||||
|
||||
```
|
||||
"configureAs": "source"
|
||||
```
|
||||
|
||||
Without this property, Multi-Scrobbler will assume the config is for a <span children={props.name}/> **Client** config.
|
||||
|
||||
This requirement does not apply to **AIO** <span children={props.name}/> configs.
|
||||
</DetailsAdmo >
|
||||
</div>
|
||||
<div className="fileEditorMobile">
|
||||
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./json-schema-viewer-styles.css";
|
||||
import "./modern-json-react-styles.css";
|
||||
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
|
||||
import f from "ajv-formats"
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import DetailsAdmo from "./AdmonitionDetails";
|
||||
|
||||
const validator = createAjvValidator({
|
||||
@@ -21,9 +22,11 @@ const validator = createAjvValidator({
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useColorMode } from '@docusaurus/theme-common';
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
|
||||
import "../modern-json-react-styles.css";
|
||||
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import f from "ajv-formats"
|
||||
import json5 from 'json5';
|
||||
|
||||
@@ -29,9 +30,11 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
}, [props.schema]);
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Evergreen theme for docusaurus-openapi-docs demo.
|
||||
*
|
||||
* A modern riff on the classic Docusaurus green. Keeps the signature
|
||||
* #2e8555 primary family, adds Plus Jakarta Sans, a richer dark mode,
|
||||
* and cleaner shadows — what the default template could look like with
|
||||
* a bit more design polish.
|
||||
*
|
||||
* Load alongside custom.css in docusaurus.config.ts:
|
||||
*
|
||||
* customCss: ["./src/css/custom.css", "./src/css/themes/evergreen.css"]
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* Fonts — Plus Jakarta Sans (body) + Fira Code (code)
|
||||
* ============================================================ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap");
|
||||
|
||||
/* ============================================================
|
||||
* Light mode
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
/* Primary — the classic Docusaurus green, slightly enriched */
|
||||
/* --ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e; */
|
||||
|
||||
/* Content */
|
||||
/* --ifm-color-content: #1c1e21;
|
||||
--ifm-heading-color: #1c1e21;
|
||||
--ifm-color-content-secondary: #525860; */
|
||||
|
||||
/* Semantic */
|
||||
/* --ifm-color-success: #2e8555;
|
||||
--ifm-color-warning: #d97706;
|
||||
--ifm-color-danger: #dc2626;
|
||||
--ifm-color-info: #0284c7; */
|
||||
|
||||
/* Typography */
|
||||
/* --ifm-font-size-base: 95%;
|
||||
--ifm-code-font-size: 85%;
|
||||
--ifm-font-family-base:
|
||||
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--ifm-heading-font-family:
|
||||
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--ifm-font-family-monospace:
|
||||
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Courier New", monospace;
|
||||
--font-mono:
|
||||
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Courier New", monospace;
|
||||
--ifm-font-weight-base: 400;
|
||||
--ifm-font-weight-semibold: 600;
|
||||
--ifm-heading-font-weight: 700; */
|
||||
|
||||
/* Heading scale */
|
||||
/* --ifm-h1-font-size: 2rem;
|
||||
--ifm-h2-font-size: 1.6rem;
|
||||
--ifm-h3-font-size: 1.3rem;
|
||||
--ifm-h4-font-size: 1.1rem;
|
||||
--ifm-h5-font-size: 1rem;
|
||||
--ifm-h6-font-size: 0.9rem; */
|
||||
|
||||
/* Theme hooks for custom.css */
|
||||
--theme-primary-rgb: 46, 133, 85;
|
||||
--site-primary-hue-saturation: 148 49%;
|
||||
--theme-border-radius-sm: 4px;
|
||||
--theme-border-radius-md: 8px;
|
||||
--theme-border-radius-lg: 12px;
|
||||
--theme-border-radius-xl: 16px;
|
||||
--theme-border-radius-2xl: 24px;
|
||||
|
||||
/* Shadow scale — neutral, not green-tinted */
|
||||
--theme-shadow-sm:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
--theme-shadow-md:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
||||
--theme-shadow-lg:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* --ifm-hr-background-color: var(--ifm-color-emphasis-200);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(46, 133, 85, 0.1); */
|
||||
|
||||
/* Explorer — unified font size for code tabs and request body */
|
||||
--openapi-explorer-font-size-code: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Dark mode — richer than the Docusaurus default
|
||||
* ============================================================ */
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Lighter green — readable on dark surfaces */
|
||||
/* --ifm-color-primary: #3cad6e;
|
||||
--ifm-color-primary-dark: #359962;
|
||||
--ifm-color-primary-darker: #33925d;
|
||||
--ifm-color-primary-darkest: #2e8555;
|
||||
--ifm-color-primary-light: #4db87c;
|
||||
--ifm-color-primary-lighter: #5ec389;
|
||||
--ifm-color-primary-lightest: #85d4a8; */
|
||||
|
||||
/* --ifm-color-content: #dadde1;
|
||||
--ifm-color-content-secondary: #9ba1aa;
|
||||
--ifm-heading-color: #f5f6f7; */
|
||||
|
||||
/* Richer dark background vs. the flat Docusaurus default */
|
||||
/* --ifm-background-color: #18191a !important;
|
||||
--ifm-navbar-background-color: #18191a; */
|
||||
|
||||
/* Warm-neutral dark gray scale */
|
||||
/* --ifm-color-emphasis-100: #242526;
|
||||
--ifm-color-emphasis-200: #3a3b3c;
|
||||
--ifm-color-emphasis-300: #4e4f50;
|
||||
--ifm-color-emphasis-400: #6b6c6d;
|
||||
--ifm-color-emphasis-500: #8d8f91;
|
||||
--ifm-color-emphasis-600: #b0b3b8;
|
||||
--ifm-color-emphasis-700: #dadde1;
|
||||
--ifm-color-emphasis-800: #e4e6eb;
|
||||
--ifm-color-emphasis-900: #f0f2f5; */
|
||||
|
||||
/* --ifm-link-color: #3cad6e;
|
||||
--ifm-link-hover-color: #5ec389; */
|
||||
|
||||
--docusaurus-highlighted-code-line-bg: rgba(60, 173, 110, 0.12);
|
||||
/* --theme-primary-rgb: 60, 173, 110; */
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Method badge colors — sidebar pills
|
||||
* ============================================================ */
|
||||
|
||||
.get > .menu__link::before {
|
||||
background-color: rgba(46, 133, 85, 0.12);
|
||||
color: #29784c;
|
||||
}
|
||||
.get > .menu__link--active::before {
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.post > .menu__link::before {
|
||||
background-color: rgba(2, 132, 199, 0.12);
|
||||
color: #0369a1;
|
||||
}
|
||||
.post > .menu__link--active::before {
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.delete > .menu__link::before {
|
||||
background-color: rgba(220, 38, 38, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
.delete > .menu__link--active::before {
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.put > .menu__link::before {
|
||||
background-color: rgba(217, 119, 6, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
.put > .menu__link--active::before {
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.patch > .menu__link::before {
|
||||
background-color: rgba(217, 119, 6, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
.patch > .menu__link--active::before {
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .get > .menu__link::before {
|
||||
background-color: rgba(60, 173, 110, 0.15);
|
||||
color: #3cad6e;
|
||||
}
|
||||
[data-theme="dark"] .get > .menu__link--active::before {
|
||||
background-color: #3cad6e;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .post > .menu__link::before {
|
||||
background-color: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
}
|
||||
[data-theme="dark"] .post > .menu__link--active::before {
|
||||
background-color: #0ea5e9;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .delete > .menu__link::before {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
color: #f87171;
|
||||
}
|
||||
[data-theme="dark"] .delete > .menu__link--active::before {
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .put > .menu__link::before {
|
||||
background-color: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
[data-theme="dark"] .put > .menu__link--active::before {
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .patch > .menu__link::before {
|
||||
background-color: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
[data-theme="dark"] .patch > .menu__link--active::before {
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API page method badges (.badge--*)
|
||||
* ============================================================ */
|
||||
|
||||
.badge--primary {
|
||||
/* GET */
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--success {
|
||||
/* POST */
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--danger {
|
||||
/* DELETE */
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--info {
|
||||
/* PUT */
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--warning {
|
||||
/* PATCH */
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge--primary {
|
||||
/* GET */
|
||||
background-color: #3cad6e;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--success {
|
||||
/* POST */
|
||||
background-color: #0ea5e9;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--danger {
|
||||
/* DELETE */
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
[data-theme="dark"] .badge--info {
|
||||
/* PUT */
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--warning {
|
||||
/* PATCH */
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API Explorer — request body editor font size
|
||||
*
|
||||
* react-simple-code-editor renders an inner textarea + pre that
|
||||
* can slip past the outer !important font shorthand. Target them
|
||||
* directly so the body editor matches the code snippet tabs.
|
||||
* ============================================================ */
|
||||
|
||||
.openapi-explorer__playground-editor,
|
||||
.openapi-explorer__playground-editor textarea,
|
||||
.openapi-explorer__playground-editor pre {
|
||||
font-size: var(--openapi-explorer-font-size-code) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API Explorer — Send button dark mode
|
||||
* ============================================================ */
|
||||
|
||||
[data-theme="dark"] .openapi-explorer__request-btn {
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .openapi-explorer__request-btn:hover {
|
||||
background-color: #3cad6e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Structural CSS for the docusaurus-openapi-docs demo site.
|
||||
*
|
||||
* This file contains layout, component behavior, animations, and utilities.
|
||||
* It is intentionally free of hard-coded colors and fonts — those live in
|
||||
* a theme file loaded alongside this one via docusaurus.config.ts:
|
||||
*
|
||||
* customCss: ["./src/css/custom.css", "./src/css/themes/panw.css"]
|
||||
*
|
||||
* To try a different palette, swap the theme file. The following CSS
|
||||
* variables must be defined by the active theme:
|
||||
*
|
||||
* --theme-primary-rgb RGB triplet of primary color (for animations)
|
||||
* --theme-border-radius-sm/md Border radius tokens
|
||||
* --site-primary-hue-saturation Hue+saturation for the announcement bar
|
||||
*
|
||||
* Structure inspired by @gleanwork/docusaurus-theme-glean.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* Typography
|
||||
* ============================================================ */
|
||||
|
||||
.markdown h1,
|
||||
article h1,
|
||||
.main-wrapper h1 {
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: var(--ifm-heading-font-weight);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.markdown h2,
|
||||
article h2,
|
||||
.markdown h3,
|
||||
article h3 {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
}
|
||||
|
||||
/* .markdown strong,
|
||||
article strong,
|
||||
.markdown b,
|
||||
article b {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
} */
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
/* font-variant-ligatures: none; */
|
||||
}
|
||||
|
||||
.table-of-contents {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 50px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Links
|
||||
* ============================================================ */
|
||||
|
||||
a:any-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
article p a,
|
||||
article ul a,
|
||||
article li a,
|
||||
.markdown p a,
|
||||
.theme-doc-markdown p a {
|
||||
text-decoration: underline 1px var(--ifm-color-primary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
article p a:hover,
|
||||
article ul a:hover,
|
||||
article li a:hover,
|
||||
.markdown p a:hover,
|
||||
.theme-doc-markdown p a:hover {
|
||||
text-decoration: underline 2px var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar structure
|
||||
* ============================================================ */
|
||||
|
||||
/* Slightly off-white sidebar in light mode */
|
||||
.theme-doc-sidebar-container {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-doc-sidebar-container {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Tighter nested indentation */
|
||||
.menu__list .menu__list {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* No underlines on menu links */
|
||||
.menu__link {
|
||||
text-decoration: none;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
/* Top-level category labels: uppercase, small caps, bold */
|
||||
/* .theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link,
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link.menu__link--active {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
} */
|
||||
|
||||
/* Hover: text darkens, no background fill */
|
||||
/* .menu__link:hover {
|
||||
background-color: transparent;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
text-decoration: none;
|
||||
} */
|
||||
|
||||
/* Active item: primary-colored text, no background */
|
||||
/* .menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
) {
|
||||
color: var(--ifm-color-primary);
|
||||
background-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
):hover {
|
||||
color: var(--ifm-color-primary);
|
||||
background-color: transparent;
|
||||
} */
|
||||
|
||||
/* [data-theme="dark"]
|
||||
.menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
) {
|
||||
color: var(--ifm-color-primary-light);
|
||||
}
|
||||
|
||||
.menu__list-item-collapsible:hover,
|
||||
.menu__list-item-collapsible:active,
|
||||
.menu__list-item-collapsible:focus-within,
|
||||
.menu__list-item-collapsible.menu__list-item-collapsible--active {
|
||||
background: transparent;
|
||||
} */
|
||||
|
||||
/* Custom caret */
|
||||
.menu__link--sublist-caret:after,
|
||||
.menu__caret::before {
|
||||
--ifm-menu-link-sublist-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="8px" height="8px" viewBox="0 0 24 24"><path fill="rgba(0,0,0,0.5)" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"></path></svg>');
|
||||
content: " ";
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — Default (pill style)
|
||||
*
|
||||
* Shape and content labels only. Colors are in the theme file.
|
||||
* ============================================================ */
|
||||
|
||||
.api-method > .menu__link,
|
||||
.schema > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.api-method > .menu__link::before,
|
||||
.schema > .menu__link::before {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1.25;
|
||||
width: 2.25rem;
|
||||
height: auto;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Content labels */
|
||||
.get > .menu__link::before {
|
||||
content: "get";
|
||||
}
|
||||
.post > .menu__link::before {
|
||||
content: "post";
|
||||
}
|
||||
.delete > .menu__link::before {
|
||||
content: "del";
|
||||
}
|
||||
.put > .menu__link::before {
|
||||
content: "put";
|
||||
}
|
||||
.patch > .menu__link::before {
|
||||
content: "patch";
|
||||
}
|
||||
|
||||
/* HEAD / EVENT / SCHEMA — neutral colors, no theme needed */
|
||||
.head > .menu__link::before {
|
||||
content: "head";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.event > .menu__link::before {
|
||||
content: "event";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.schema > .menu__link::before {
|
||||
content: "schema";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
/* Deprecated */
|
||||
.menu__list-item--deprecated > .menu__link,
|
||||
.menu__list-item--deprecated > .menu__link:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — High Contrast variant (unchanged)
|
||||
* ============================================================ */
|
||||
|
||||
.api-method-contrast > .menu__link,
|
||||
.schema-contrast > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.api-method-contrast > .menu__link::before,
|
||||
.schema-contrast > .menu__link::before {
|
||||
width: 55px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid;
|
||||
border-inline-start-width: 5px;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.get-contrast > .menu__link::before {
|
||||
content: "get";
|
||||
background-color: var(--ifm-color-info-contrast-background);
|
||||
color: var(--ifm-color-info-contrast-foreground);
|
||||
border-color: var(--ifm-color-info-dark);
|
||||
}
|
||||
.post-contrast > .menu__link::before {
|
||||
content: "post";
|
||||
background-color: var(--ifm-color-success-contrast-background);
|
||||
color: var(--ifm-color-success-contrast-foreground);
|
||||
border-color: var(--ifm-color-success-dark);
|
||||
}
|
||||
.delete-contrast > .menu__link::before {
|
||||
content: "del";
|
||||
background-color: var(--ifm-color-danger-contrast-background);
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
border-color: var(--ifm-color-danger-dark);
|
||||
}
|
||||
.put-contrast > .menu__link::before {
|
||||
content: "put";
|
||||
background-color: var(--ifm-color-warning-contrast-background);
|
||||
color: var(--ifm-color-warning-contrast-foreground);
|
||||
border-color: var(--ifm-color-warning-dark);
|
||||
}
|
||||
.patch-contrast > .menu__link::before {
|
||||
content: "patch";
|
||||
background-color: var(--ifm-color-success-contrast-background);
|
||||
color: var(--ifm-color-success-contrast-foreground);
|
||||
border-color: var(--ifm-color-success-dark);
|
||||
}
|
||||
.head-contrast > .menu__link::before {
|
||||
content: "head";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
.event-contrast > .menu__link::before {
|
||||
content: "event";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
.schema-contrast > .menu__link::before {
|
||||
content: "schema";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — Simple variant (unchanged)
|
||||
* ============================================================ */
|
||||
|
||||
.api-method-simple > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
.api-method-simple > .menu__link::before {
|
||||
width: 55px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
align-content: start;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.get-simple > .menu__link::before {
|
||||
content: "get";
|
||||
color: var(--ifm-color-info);
|
||||
}
|
||||
.post-simple > .menu__link::before {
|
||||
content: "post";
|
||||
color: var(--ifm-color-success);
|
||||
}
|
||||
.delete-simple > .menu__link::before {
|
||||
content: "del";
|
||||
color: var(--ifm-color-danger);
|
||||
}
|
||||
.put-simple > .menu__link::before {
|
||||
content: "put";
|
||||
color: var(--ifm-color-warning);
|
||||
}
|
||||
.patch-simple > .menu__link::before {
|
||||
content: "patch";
|
||||
color: var(--ifm-color-warning);
|
||||
}
|
||||
.head-simple > .menu__link::before {
|
||||
content: "head";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
.event-simple > .menu__link::before {
|
||||
content: "event";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
.schema-simple > .menu__link::before {
|
||||
content: "schema";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Admonitions
|
||||
* ============================================================ */
|
||||
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: var(--theme-border-radius-md, 8px);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Navbar header icons
|
||||
* ============================================================ */
|
||||
|
||||
.header-github-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-github-link:before {
|
||||
content: "";
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .header-github-link:before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.header-medium-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-medium-link:before {
|
||||
content: "\f23a";
|
||||
font-family: "Font Awesome 5 Brands";
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Announcement bar
|
||||
* ============================================================ */
|
||||
|
||||
div[class^="announcementBar_"] {
|
||||
--site-announcement-bar-stripe-color1: hsl(
|
||||
var(--site-primary-hue-saturation) 85%
|
||||
);
|
||||
--site-announcement-bar-stripe-color2: hsl(
|
||||
var(--site-primary-hue-saturation) 95%
|
||||
);
|
||||
background: repeating-linear-gradient(
|
||||
35deg,
|
||||
var(--site-announcement-bar-stripe-color1),
|
||||
var(--site-announcement-bar-stripe-color1) 20px,
|
||||
var(--site-announcement-bar-stripe-color2) 10px,
|
||||
var(--site-announcement-bar-stripe-color2) 40px
|
||||
);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Schema parameter highlight animation
|
||||
*
|
||||
* Color driven by --theme-primary-rgb (defined in the theme file).
|
||||
* ============================================================ */
|
||||
|
||||
[id]:target {
|
||||
animation: highlight-flash 2s ease-out;
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
@keyframes highlight-flash {
|
||||
0% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
|
||||
box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
|
||||
box-shadow: 0 0 0 2px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.openapi-markdown__details [id]:target,
|
||||
.openapi-schema__list-item [id]:target {
|
||||
animation: highlight-flash 2s ease-out;
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Code tab scrollbar
|
||||
* ============================================================ */
|
||||
|
||||
.tabs-container [role="tabpanel"] {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre {
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-track {
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
border-radius: var(--theme-border-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb {
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
border-radius: var(--theme-border-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* OpenAPI code tab language icons
|
||||
* ============================================================ */
|
||||
|
||||
.openapi-tabs__code-item--python {
|
||||
color: var(--ifm-color-success);
|
||||
}
|
||||
.openapi-tabs__code-item--python::after {
|
||||
content: "";
|
||||
width: var(--code-tab-logo-width);
|
||||
height: var(--code-tab-logo-height);
|
||||
background: url("https://raw.githubusercontent.com/devicons/devicon/master/icons/python/python-plain.svg")
|
||||
no-repeat;
|
||||
margin-block: auto;
|
||||
}
|
||||
.openapi-tabs__code-item--python.active {
|
||||
box-shadow: 0 0 0 3px var(--openapi-code-tab-shadow-color-python);
|
||||
border-color: var(--openapi-code-tab-border-color-python);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Grid utilities
|
||||
* ============================================================ */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.my-4 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.my-6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Dashboard for Multi Scrobbler"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app. aaaa</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index-next.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+579
-1085
File diff suppressed because it is too large
Load Diff
+11
-18
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.2",
|
||||
"version": "0.17.0-RC1",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema:docs",
|
||||
"schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts",
|
||||
"openapi:docs": "node src/backend/utils/OpenApiUtils.ts",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "mocha --reporter spec",
|
||||
@@ -18,7 +19,7 @@
|
||||
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
|
||||
"docs:install": "cd docsite && npm install --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
|
||||
"docs:build": "npm run -s schema:docs && npm run -s openapi:docs && cd docsite && npm run build",
|
||||
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
|
||||
"postinstall": "patch-package",
|
||||
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@minisylar/express-typed-router": "^1.9.8",
|
||||
"@pierre/diffs": "^1.2.4",
|
||||
"@rocksky/sdk": "^0.3.0",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -157,21 +159,17 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@flamefrontend/sse-runtime-react": "^0.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@lukemorales/query-key-factory": "^1.3.4",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@react-hook/window-size": "^3.1.1",
|
||||
"@react-nano/use-event-source": "^0.16.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@storybook/addon-a11y": "^10.4.3",
|
||||
"@storybook/addon-docs": "^10.4.3",
|
||||
"@storybook/react-vite": "^10.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/form-core": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-form": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
@@ -195,25 +193,23 @@
|
||||
"@types/qs": "^6.15.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@welldone-software/why-did-you-render": "^10.0.1",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^1.0.0-rc.2-c5a84d1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-boundaries": "^7.0.2",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-mocha": "^11.3.0",
|
||||
"eslint-plugin-mocha": "^12.0.2",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
@@ -228,28 +224,25 @@
|
||||
"msw": "^2.14.6",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemon": "^3.0.3",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sass": "^1.99.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "^10.4.3",
|
||||
"storybook-addon-remix-react-router": "^6.1.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"supertest": "^7.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"with-local-tmp-dir": "^7.0.1",
|
||||
"zocker": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/node_modules/spotify-web-api-node/src/http-manager.js b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
index 6c3991f..89e7a99 100644
|
||||
--- a/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
+++ b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
@@ -63,6 +63,11 @@ HttpManager._makeRequest = function(method, options, uri, callback) {
|
||||
req.send(options.data);
|
||||
}
|
||||
|
||||
+ req.timeout({
|
||||
+ response: 5000, // Wait 5 seconds for the server to start sending,
|
||||
+ deadline: 9000, // but allow 9 seconds for entire response to complete
|
||||
+ });
|
||||
+
|
||||
req.end(function(err, response) {
|
||||
if (err) {
|
||||
if (err.timeout) {
|
||||
@@ -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);
|
||||
@@ -7,7 +7,7 @@ import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, ty
|
||||
import { buildTrackString } from "../../core/StringUtils.ts";
|
||||
import type {CommonClientConfig} from "./infrastructure/config/client/index.ts";
|
||||
import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StageChangeError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import {
|
||||
FLOW_CONTROL_TERM,
|
||||
type PlayTransformRules,
|
||||
@@ -33,13 +33,13 @@ import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isComp
|
||||
import type {DbConcrete} from "./database/drizzle/drizzleUtils.ts";
|
||||
import type {ComponentSelect} from "./database/drizzle/drizzleTypes.ts";
|
||||
import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.ts";
|
||||
import type {ClientType, MonitoringStatus} from "../../core/Atomic.ts";
|
||||
import type {ClientType, MonitoringStatus, OptionalCacheUsage} from "../../core/Atomic.ts";
|
||||
import type {SourceType} from "../../core/Atomic.ts";
|
||||
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { COMPONENT_STATE, type ComponentCommonApi, type ComponentCommonApiJson, type ComponentState, type PlayApiCommonDetailed } from "../../core/Api.ts";
|
||||
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
|
||||
import type { MarkRequired } from "ts-essentials";
|
||||
import type { ElementOf, MarkRequired } from "ts-essentials";
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
|
||||
@@ -67,7 +67,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
type: ClientType | SourceType;
|
||||
name: string;
|
||||
|
||||
lastActiveAt?: Dayjs
|
||||
lastActiveAt?: Dayjs;
|
||||
lastReadyAt?: Dayjs;
|
||||
protected lastUpdatedComponentDatesAt?: Dayjs
|
||||
|
||||
@@ -116,8 +116,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
name: this.config?.name ?? this.name
|
||||
});
|
||||
this.componentId = this.dbComponent.id;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt ?? undefined;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt ?? undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
public abstract stop(opts?: {reason?: string | Error}): Promise<void>
|
||||
public abstract start(opts?: {forceInit?: boolean}): Promise<boolean>
|
||||
|
||||
public async restart(opts: { reason?: string | Error, forceInit?: boolean } = {}): Promise<void> {
|
||||
try {
|
||||
await this.stop(opts);
|
||||
await this.start(opts);
|
||||
} catch (e) {
|
||||
const err = new StageChangeError('Failed to restart', { cause: e });
|
||||
this.replaceErrors(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public retentionCleanup = async () => {
|
||||
if(this.databaseOK !== true) {
|
||||
this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
|
||||
@@ -233,7 +247,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus('Retention cleanup finished');
|
||||
} catch (e) {
|
||||
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
|
||||
this.warning = retentionErr;
|
||||
this.warnings.push(retentionErr);
|
||||
this.logger.warn(retentionErr);
|
||||
this.setStatus('Retention cleanup failed');
|
||||
}
|
||||
@@ -248,7 +262,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
|
||||
}
|
||||
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, transformOpts: {log?: boolean | 'all'} & OptionalCacheUsage = {}) => {
|
||||
|
||||
const {
|
||||
log,
|
||||
useCachedResult = true
|
||||
} = transformOpts;
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
@@ -280,7 +299,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
|
||||
|
||||
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
|
||||
const cachedSteps = await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash);
|
||||
const cachedSteps = useCachedResult ? await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash) : undefined;
|
||||
if(cachedSteps !== undefined) {
|
||||
logger.trace(`Cache hit for Steps => ${transformHash}`);
|
||||
//return cachedTransformPlay;
|
||||
@@ -294,7 +313,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
const opts = {
|
||||
logger,
|
||||
asyncId
|
||||
asyncId,
|
||||
useCachedResult
|
||||
}
|
||||
|
||||
if(cachedSteps !== undefined) {
|
||||
@@ -343,7 +363,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const historyToDiff: {name: string, data?: PlayData}[] = [
|
||||
{name: 'Pre Transform', data: play.data}
|
||||
];
|
||||
let isNew = false;
|
||||
let isNew = false,
|
||||
diffFailure = false;
|
||||
if(steps.length > 0 && transformedPlay.lifecycle === undefined) {
|
||||
transformedPlay.lifecycle = steps;
|
||||
isNew = true;
|
||||
@@ -352,6 +373,11 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
lifecycle = []
|
||||
} = transformedPlay;
|
||||
steps.forEach((s, index) => {
|
||||
if(diffFailure) {
|
||||
return;
|
||||
}
|
||||
const stepIdHint = `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`;
|
||||
try {
|
||||
if(!isNew) {
|
||||
const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier());
|
||||
if(existingStepIndex !== -1) {
|
||||
@@ -363,12 +389,24 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
if(shouldLog === 'all') {
|
||||
if(s.patch === undefined) {
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`});
|
||||
historyToDiff.push({name: `${stepIdHint}`});
|
||||
} else {
|
||||
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
const lastTransformedData = historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data;
|
||||
const patched = patchObject(lastTransformedData, s.patch);
|
||||
historyToDiff.push({name: `${stepIdHint} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if(`patch` in s) {
|
||||
this.logger.debug({
|
||||
patch: s.patch,
|
||||
playData: historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data,
|
||||
cached: s.cached
|
||||
}, 'Patch and Play data context');
|
||||
}
|
||||
this.logger.warn(new SimpleError(`Error occurred while trying to generate diffed Plays for console logging on step ${stepIdHint} but will continue`, {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
});
|
||||
if(shouldLog !== false) {
|
||||
if(steps.filter(x => x.patch !== undefined).length === 0) {
|
||||
@@ -376,25 +414,45 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
} else {
|
||||
const diffs: string[] = [];
|
||||
|
||||
try {
|
||||
let lastTransformed: ElementOf<typeof historyToDiff>;
|
||||
historyToDiff.forEach((curr, index) => {
|
||||
if(index === 0) {
|
||||
lastTransformed = curr;
|
||||
return;
|
||||
}
|
||||
const last = historyToDiff[index - 1];
|
||||
if(curr.data === undefined) {
|
||||
diffs.push(`${last.name} => ${curr.name} -- No Change`);
|
||||
} else {
|
||||
const formattedDiff = diffObjectsConsoleOutput(last.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
lastTransformed = curr;
|
||||
} catch(e) {
|
||||
this.logger.debug({pre: lastTransformed.data, post: curr.data}, 'Compared Pre and Post play data');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
|
||||
if(shouldLog === true || steps.filter(x => x.patch !== undefined).length > 2) {
|
||||
const finalData = transformedPlay.data;
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
}
|
||||
if(diffFailure) {
|
||||
diffs.push('A failure during diff generation occurred but the transform was successful.');
|
||||
}
|
||||
|
||||
logger.debug(`Transform Diff\n${diffs.join('\n')}`);
|
||||
}
|
||||
}
|
||||
@@ -415,17 +473,18 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } & OptionalCacheUsage = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
const {
|
||||
onSuccess = 'continue',
|
||||
onFailure = 'stop',
|
||||
onSkip = 'continue',
|
||||
failureReturnPartial = false
|
||||
failureReturnPartial = false,
|
||||
} = hookItem;
|
||||
|
||||
const {
|
||||
logger = loggerNoop,
|
||||
asyncId = nanoid(6)
|
||||
asyncId = nanoid(6),
|
||||
useCachedResult
|
||||
} = opts;
|
||||
|
||||
const {
|
||||
@@ -446,7 +505,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
stageName: string = 'Unnamed',
|
||||
err: Error;
|
||||
try {
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, asyncId);
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, {asyncId, useCachedResult});
|
||||
newTransformedPlay = clone(newTransformedPlay);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
@@ -533,7 +592,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
public abstract getRunningState(): ComponentState
|
||||
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players' | 'deadLetterPlays' | 'deadLetterPlaysTotal' | 'queued'> & Pick<ComponentCommonApi, 'state' | 'errors' | 'warnings'> {
|
||||
let state: ComponentState;
|
||||
if(!this.initializedOnce || this.initializing) {
|
||||
state = COMPONENT_STATE.INITIALIZING;
|
||||
@@ -551,10 +610,10 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
monitoringStatus: this.getMonitoringStatus(),
|
||||
countNonLive: this.dbComponent.countNonLive,
|
||||
createdAt: this.dbComponent.createdAt?.toISOString(),
|
||||
lastReadyAt: this.lastReadyAt?.toISOString(),
|
||||
lastActiveAt: this.lastActiveAt?.toISOString(),
|
||||
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
|
||||
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
|
||||
lastReadyAt: this.lastActiveAt !== undefined ? this.lastReadyAt.toISOString() : undefined,
|
||||
lastActiveAt: this.lastActiveAt !== undefined ? this.lastActiveAt?.toISOString() : undefined,
|
||||
errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
...this.additionalApiData()
|
||||
}
|
||||
}
|
||||
@@ -569,12 +628,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
});
|
||||
}
|
||||
|
||||
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('error' in payload && payload.error instanceof Error) {
|
||||
payload.error = serializeError(payload.error);
|
||||
public emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('errors' in payload) {
|
||||
if(payload.errors.length > 0) {
|
||||
payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.errors = [];
|
||||
}
|
||||
}
|
||||
if('warning' in payload && payload.warning instanceof Error) {
|
||||
payload.warning = serializeError(payload.warning);
|
||||
if('warnings' in payload) {
|
||||
if(payload.warnings.length > 0) {
|
||||
payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.warnings = [];
|
||||
}
|
||||
}
|
||||
this.emitEvent('componentUpdate', payload);
|
||||
}
|
||||
@@ -590,7 +657,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus(payload.title);
|
||||
}
|
||||
|
||||
protected setStatus = (status: string) => {
|
||||
public setStatus = (status: string) => {
|
||||
this.status = status;
|
||||
this.emitComponentUpdate({status});
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import {truncateStringToLength } from "../../core/StringUtils.ts";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.ts";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.ts";
|
||||
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError } from "./errors/MSErrors.ts";
|
||||
import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.ts";
|
||||
import { AuthCheckError, AuthError, BuildDataError, ConnectionCheckError, findAuthIssue, ParseCacheError, PostInitError, StageError, type AuthErrorMap } from "./errors/MSErrors.ts";
|
||||
import { generateErrorTruthyTest, messageWithCausesTruncatedDefault, type TruthyErrorsOpts } from "../../core/ErrorUtils.ts";
|
||||
import { spawn } from 'abort-controller-x';
|
||||
|
||||
import { COMPONENT_AUTH_TYPE, type ComponentAuthType } from "../../core/Atomic.ts";
|
||||
export default abstract class AbstractInitializable {
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authType: ComponentAuthType = COMPONENT_AUTH_TYPE.none;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
|
||||
@@ -19,8 +20,8 @@ export default abstract class AbstractInitializable {
|
||||
databaseOK?: boolean | null;
|
||||
connectionOK?: boolean | null;
|
||||
cacheOK?: boolean | null;
|
||||
error?: Error;
|
||||
warning?: Error;
|
||||
errors?: Error[] = [];
|
||||
warnings?: Error[] = [];
|
||||
|
||||
protected initializedOnce: boolean = false;
|
||||
initializing: boolean = false;
|
||||
@@ -88,15 +89,15 @@ export default abstract class AbstractInitializable {
|
||||
} catch (e) {
|
||||
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
|
||||
}
|
||||
this.error = undefined;
|
||||
this.warning = undefined;
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
return true;
|
||||
} catch(e) {
|
||||
if(notify) {
|
||||
await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
|
||||
}
|
||||
const initError = new Error('Initialization failed', {cause: e});
|
||||
this.error = initError;
|
||||
this.errors = [initError];
|
||||
throw initError;
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
@@ -283,17 +284,31 @@ export default abstract class AbstractInitializable {
|
||||
return;
|
||||
}
|
||||
|
||||
authGated = () => this.requiresAuth && !this.authed
|
||||
authGated = () => this.authType !== COMPONENT_AUTH_TYPE.none && !this.authed
|
||||
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && !this.hasUnrecoverableAuthFailure()
|
||||
|
||||
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
|
||||
findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(opts: Parameters<typeof findAuthIssue<T>>[1] = {}): AuthErrorMap[T] | undefined => {
|
||||
for(const err of this.errors) {
|
||||
const authError = findAuthIssue(err, opts);
|
||||
if(authError !== undefined) {
|
||||
return authError;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
hasAuthIssue = () => this.findAuthIssue() !== undefined;
|
||||
|
||||
hasUnrecoverableAuthFailure = () => this.findAuthIssue({unrecoverable: true}) !== undefined;
|
||||
|
||||
canAuthUnattended = () => !this.authGated() || this.authType === COMPONENT_AUTH_TYPE.unattended || (this.authType === COMPONENT_AUTH_TYPE.interactive && !this.hasUnrecoverableAuthFailure()) ;
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async (force: boolean = false) => {
|
||||
if(!this.requiresAuth) {
|
||||
if(this.authType === COMPONENT_AUTH_TYPE.none) {
|
||||
return;
|
||||
}
|
||||
if(this.authed) {
|
||||
@@ -303,24 +318,46 @@ export default abstract class AbstractInitializable {
|
||||
this.logger.debug('Auth OK but step was forced');
|
||||
}
|
||||
|
||||
if(this.authFailure) {
|
||||
// only throw *before* testing if we have previously tested and the error was unrecoverable
|
||||
// there is no reason to constantly retry auth when we already know it won't succeed
|
||||
//
|
||||
// test retries should be forced by api calls and auth callback flows *after* we have made changes to component credentials
|
||||
const unrecoverableAuth = this.findAuthIssue({unrecoverable: true});
|
||||
if(unrecoverableAuth !== undefined) {
|
||||
let unrecoverable: boolean | undefined,
|
||||
cause: Error;
|
||||
if(!force) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
|
||||
if(unrecoverableAuth instanceof AuthCheckError) {
|
||||
// we threw a fallback AuthCheckError
|
||||
unrecoverable = true;
|
||||
cause = unrecoverableAuth.cause as Error;
|
||||
} else {
|
||||
cause = unrecoverableAuth;
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
|
||||
if(this.authType === COMPONENT_AUTH_TYPE.interactive) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication', {cause, unrecoverable});
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated', {cause, unrecoverable});
|
||||
}
|
||||
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
|
||||
}
|
||||
|
||||
try {
|
||||
this.authed = await this.doAuthentication();
|
||||
this.authFailure = !this.authed;
|
||||
await this.doAuthentication();
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
let unrecoverableMsg: boolean,
|
||||
unrecoverable: boolean | undefined;
|
||||
if(e instanceof AuthError) {
|
||||
unrecoverableMsg = e.unrecoverable;
|
||||
} else {
|
||||
// if component auth test didn't throw AuthError (it should have!)
|
||||
// then fallback teo determining by these conditions
|
||||
unrecoverable = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
unrecoverableMsg = unrecoverable;
|
||||
}
|
||||
this.authed = false;
|
||||
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
|
||||
throw new AuthCheckError(`Authentication test failed!${unrecoverableMsg === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e, unrecoverable});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +386,20 @@ export default abstract class AbstractInitializable {
|
||||
public additionalApiData(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public clearErrors(opts?: TruthyErrorsOpts) {
|
||||
if (!opts) { this.errors = []; return; }
|
||||
|
||||
const test = generateErrorTruthyTest(opts);
|
||||
this.errors = this.errors.filter(x => !test(x));
|
||||
}
|
||||
|
||||
public replaceErrors(e: Error, opts?: TruthyErrorsOpts) {
|
||||
if(opts !== undefined) {
|
||||
this.clearErrors(opts);
|
||||
} else {
|
||||
this.clearErrors({instance: e});
|
||||
}
|
||||
this.errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,73 @@
|
||||
import type { SqliteDatabase, Migration } from 'sqlite-up';
|
||||
import type { MigrateBaseContext } from '../appMigrator.ts';
|
||||
import { playInputs, plays as drizzlePlays } from '../drizzle/schema/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { playContentBasicInvariantTransform } from '../../../utils/PlayComparisonUtils.ts';
|
||||
import { hashObject } from '../../../utils/StringUtils.ts';
|
||||
|
||||
|
||||
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
|
||||
ctx.logger.info('Generating hashes for Play input data...');
|
||||
|
||||
let more = true;
|
||||
let offset = 0,
|
||||
processed = 0,
|
||||
updated = 0;
|
||||
|
||||
// first update input hashes
|
||||
while (more) {
|
||||
const inputRows = await ctx.db.select().from(playInputs).limit(100).offset(offset);
|
||||
for (const row of inputRows) {
|
||||
if (row.playHash !== null) {
|
||||
processed++;
|
||||
}
|
||||
try {
|
||||
await ctx.db.update(playInputs).set({
|
||||
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
|
||||
}).where(eq(playInputs.id, row.id));
|
||||
updated++;
|
||||
processed++;
|
||||
} catch (e) {
|
||||
ctx.logger.warn(new Error(`Failed to generate hash for Play Input ${row.id}`, { cause: e }));
|
||||
}
|
||||
}
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Play Input Hash Generation Progress: Processed ${processed} | Updated ${updated}`);
|
||||
if (inputRows.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info('Regenerating hashes for Play data...');
|
||||
|
||||
offset = 0;
|
||||
processed = 0;
|
||||
updated = 0;
|
||||
more = true;
|
||||
// then update Plays so hashes reflect Plays after transforms are done
|
||||
while (more) {
|
||||
const playsRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset);
|
||||
for (const row of playsRows) {
|
||||
try {
|
||||
await ctx.db.update(drizzlePlays).set({
|
||||
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
|
||||
}).where(eq(drizzlePlays.id, row.id));
|
||||
updated++;
|
||||
processed++;
|
||||
} catch (e) {
|
||||
ctx.logger.warn(new Error(`Failed to generate hash for Play ${row.id} (${row.uid})`, { cause: e }));
|
||||
}
|
||||
}
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Play Hash Regeneration Progress: Processed ${processed} | Updated ${updated}`);
|
||||
if (playsRows.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
// Rollback code here
|
||||
// context is passed as ctx
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SqliteDatabase, Migration } from 'sqlite-up';
|
||||
import type { MigrateBaseContext } from '../appMigrator.ts';
|
||||
import { queueStates } from '../drizzle/schema/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
|
||||
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
|
||||
ctx.logger.info('Beginning queue entities consolidation.');
|
||||
|
||||
ctx.logger.verbose('Deleting (now) unused completed queue states...');
|
||||
|
||||
ctx.db.delete(queueStates).where(eq(queueStates.queueStatus,'completed'));
|
||||
|
||||
ctx.logger.verbose('Done with completed queue state deletions');
|
||||
|
||||
ctx.logger.info('Converting dead queue states to ingress states with failure + retries...');
|
||||
|
||||
let more = true;
|
||||
let offset = 0,
|
||||
updated = 0;
|
||||
|
||||
while (more) {
|
||||
const plays = await ctx.db.query.plays.findMany({
|
||||
where: {
|
||||
queueStates: {
|
||||
queueName: 'dead'
|
||||
}
|
||||
},
|
||||
with: {
|
||||
queueStates: true
|
||||
},
|
||||
limit: 100,
|
||||
offset
|
||||
});
|
||||
|
||||
for(const p of plays) {
|
||||
const ingress = p.queueStates.find(x => x.queueName === 'ingress');
|
||||
const dead = p.queueStates.find(x => x.queueName === 'dead');
|
||||
|
||||
if(ingress === undefined) {
|
||||
await ctx.db.insert(queueStates).values([{
|
||||
componentId: p.componentId,
|
||||
playId: p.id,
|
||||
queueName: 'ingress',
|
||||
queueStatus: 'failed',
|
||||
retries: dead.retries,
|
||||
error: dead.error,
|
||||
createdAt: dead.updatedAt,
|
||||
updatedAt: dead.updatedAt
|
||||
}]);
|
||||
} else {
|
||||
await ctx.db.update(queueStates).set({
|
||||
queueStatus: 'failed',
|
||||
retries: dead.retries,
|
||||
error: dead.error,
|
||||
updatedAt: dead.updatedAt}).where(eq(queueStates.id, ingress.id));
|
||||
}
|
||||
updated++;
|
||||
await ctx.db.delete(queueStates).where(eq(queueStates.id, dead.id));
|
||||
}
|
||||
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Conversion Progress: Updated ${updated}`);
|
||||
if (plays.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info('Done.');
|
||||
};
|
||||
|
||||
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
// Rollback code here
|
||||
// context is passed as ctx
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {DBQueryConfig, DBQueryConfigWith, KnownKeysOnly, RelationFieldsFilterInternals, BuildQueryResult, RelationsFilter} from "drizzle-orm";
|
||||
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical } from "./schema/schema.ts";
|
||||
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical, playEvents } from "./schema/schema.ts";
|
||||
import type {TSchema, TableName} from "./schema/schema.ts";
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export type PlayNew = typeof plays.$inferInsert;
|
||||
export type PlayHistoricalSelect = typeof playsHistorical.$inferSelect;
|
||||
export type PlayHistoricalNew = typeof playsHistorical.$inferInsert;
|
||||
|
||||
export type PlayEventSelect = typeof playEvents.$inferSelect;
|
||||
export type PlayEventNew = typeof playEvents.$inferInsert;
|
||||
|
||||
// useful references for building types
|
||||
// https://github.com/drizzle-team/drizzle-orm/discussions/2596
|
||||
// https://github.com/drizzle-team/drizzle-orm/discussions/1539
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import assert from "node:assert";
|
||||
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates} from "./drizzleTypes.ts";
|
||||
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates, QueueStateSelect} from "./drizzleTypes.ts";
|
||||
import type {PlayInputNew} from "./drizzleTypes.ts";
|
||||
import type {QueueStateNew} from "./drizzleTypes.ts";
|
||||
import type {ComponentNew} from "./drizzleTypes.ts";
|
||||
import type { MarkOptional } from "ts-essentials";
|
||||
import { CLIENT_DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type PlayObject } from "../../../../core/Atomic.ts";
|
||||
import type { MarkOptional, MarkRequired } from "ts-essentials";
|
||||
import { DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type LifecycleStep, type PlayObject } from "../../../../core/Atomic.ts";
|
||||
import dayjs from "dayjs";
|
||||
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.ts";
|
||||
import { hashObject } from "../../../utils/StringUtils.ts";
|
||||
import { serializeError } from "serialize-error";
|
||||
import { PLAY_EVENT_TYPE, type PlayEventDupeCheck, type PlayEventDupeCheckData, type PlayEventPlayStateChange, type PlayEventPlayStateChangeData, type PlayEventQueueStateChange, type PlayEventQueueStateChangeData, type PlayEventScrobbleResult, type PlayEventScrobbleResultData, type PlayEventTransform } from "../../../../core/PlayEvent.ts";
|
||||
|
||||
export const generateComponentEntity = (data: MarkOptional<ComponentNew, 'uid'>): ComponentNew => {
|
||||
assert(data.name !== undefined, 'Must provide name');
|
||||
@@ -72,7 +73,7 @@ export const hydratePlaySelect = <T extends PlaySelect | PlayHistoricalSelect>(s
|
||||
}
|
||||
|
||||
export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, serializedError: boolean = false): DeadLetterScrobble<PlayObject> => {
|
||||
const deadQueue = select.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE);
|
||||
const deadQueue = select.queueStates.find(x => x.queueName === DEAD_QUEUE);
|
||||
return {
|
||||
play: select.play,
|
||||
id: select.uid,
|
||||
@@ -85,9 +86,82 @@ export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, seri
|
||||
}
|
||||
|
||||
export const generateInputEntity = (data: PlayInputNew): PlayInputNew => {
|
||||
return data;
|
||||
const {
|
||||
playHash = hashObject(playContentBasicInvariantTransform(data.play).data)
|
||||
} = data;
|
||||
return {...data, playHash};
|
||||
}
|
||||
|
||||
export const generateQueueStateEntity = (data: QueueStateNew): QueueStateNew => {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const queueStateToEventData = (qs: QueueStateSelect): PlayEventQueueStateChangeData => {
|
||||
const {
|
||||
queueName,
|
||||
queueStatus,
|
||||
error,
|
||||
retries
|
||||
} = qs;
|
||||
return {
|
||||
queueName,
|
||||
queueStatus,
|
||||
error,
|
||||
retries
|
||||
}
|
||||
}
|
||||
|
||||
export const transformToPlayEvent = (lifecycle: LifecycleStep[]): Omit<PlayEventTransform, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.transform,
|
||||
createdAt: dayjs(lifecycle[0].createdAt),
|
||||
data: lifecycle
|
||||
});
|
||||
|
||||
export const stateChangeToPlayEvent = (partial: PlayEventPlayStateChangeData): Omit<PlayEventPlayStateChange, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.playStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: partial
|
||||
})
|
||||
|
||||
export const queueStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context'>): Omit<PlayEventQueueStateChange, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.queueStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: partial
|
||||
});
|
||||
export const queueCompletionStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context' | 'retries'>): Omit<PlayEventQueueStateChange, 'playId'> => {
|
||||
const {
|
||||
context,
|
||||
retries,
|
||||
...rest
|
||||
} = partial;
|
||||
return {
|
||||
eventName: PLAY_EVENT_TYPE.queueStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: rest
|
||||
}
|
||||
}
|
||||
|
||||
export const dupeCheckToPlayEvent = (partial: MarkRequired<Partial<PlayEventDupeCheckData>, 'match'>): Omit<PlayEventDupeCheck, 'playId'> => {
|
||||
const {match, ...rest} = partial;
|
||||
const data: PlayEventDupeCheckData = {
|
||||
match,
|
||||
score: match ? 1 : 0,
|
||||
breakdowns: [],
|
||||
createdAt: dayjs().toISOString(),
|
||||
...rest
|
||||
};
|
||||
return {
|
||||
eventName: PLAY_EVENT_TYPE.dupeCheck,
|
||||
createdAt: dayjs(),
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
export const scrobbleToPlayEvent = (data: PlayEventScrobbleResultData): Omit<PlayEventScrobbleResult, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.scrobbleResult,
|
||||
createdAt: data.createdAt ?? dayjs(),
|
||||
data
|
||||
});
|
||||
|
||||
export const entityIsPlayEntity = (obj: object): obj is PlaySelectWithQueueStates => `play` in obj && typeof obj.play === 'object'
|
||||
&& `componentId` in obj && typeof obj.componentId === 'number'
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_inputs` ADD `playHash` text;
|
||||
+1104
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `play_events` (
|
||||
`id` integer PRIMARY KEY,
|
||||
`playId` integer NOT NULL,
|
||||
`eventName` text(50) NOT NULL,
|
||||
`data` text,
|
||||
`error` text,
|
||||
`createdAt` number NOT NULL,
|
||||
CONSTRAINT `fk_play_events_playId_plays_id_fk` FOREIGN KEY (`playId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `play_event_id_idx` ON `play_events` (`playId`);
|
||||
+1206
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_queue_states` ADD `context` text;
|
||||
+1216
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,9 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
|
||||
await this.db.delete(this.table).where(inArray(this.table.id, ids));
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<void> {
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<typeof this.table.$inferSelect> {
|
||||
assert(id !== null && id !== undefined, `${id === null ? 'null' : 'undefined'} given for entity id`);
|
||||
await this.db.update(this.table).set(data).where(eq(this.table.id, id));
|
||||
return (await this.db.update(this.table).set(data).where(eq(this.table.id, id)).returning())[0];
|
||||
}
|
||||
|
||||
async create(data: typeof this.table.$inferInsert): Promise<typeof this.table.$inferSelect> {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {DbConcrete} from "../drizzleUtils.ts";
|
||||
export class DrizzlePlayEventsRepository extends DrizzleBaseRepository<'playEvents'> {
|
||||
|
||||
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
|
||||
super(db, 'playEvents', 'Play Events', opts);
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,29 @@ import dayjs, { type Dayjs } from "dayjs";
|
||||
import { eq, inArray, relationsFilterToSQL, sql } from "drizzle-orm";
|
||||
import assert from "node:assert";
|
||||
import type { MarkOptional } from "ts-essentials";
|
||||
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, type QueueName, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
|
||||
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, QUEUE_STATUS_QUEUED, type QueueName, SCROBBLE_TS_SOC_END, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
|
||||
import { removeUndefinedKeys } from '../../../../../core/DataUtils.ts';
|
||||
import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.ts";
|
||||
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.ts";
|
||||
import { hashObject } from "../../../../utils/StringUtils.ts";
|
||||
import { comparePlayTemporally, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
|
||||
import { comparePlayTemporally, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
|
||||
import { type CompactableProperty, type RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.ts";
|
||||
import type {SourceType} from "../../../../../core/Atomic.ts";
|
||||
import type {ErrorLike, SourceType} from "../../../../../core/Atomic.ts";
|
||||
import type {FindMany, FindWhere, FindWith, PlayInputNew, PlayNew, PlaySelect, PlaySelectWithQueueStates, PlayWith, QueueStateSelect, WhereClause} from "../drizzleTypes.ts";
|
||||
import { type DbConcrete, runTransaction } from "../drizzleUtils.ts";
|
||||
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
|
||||
import { playInputs, plays, relations } from "../schema/schema.ts";
|
||||
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, stateChangeToPlayEvent, transformToPlayEvent, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
|
||||
import { playEvents, playInputs, plays, queueStates, relations, type TSchema } from "../schema/schema.ts";
|
||||
import { buildDateCompare, type CompareDateOp, type ComponentConstrainedRepoOpts, DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {PaginatedResponse} from "../../../../../core/Api.ts";
|
||||
import type {PaginatedQueryResponse} from "../../../../../core/Api.ts";
|
||||
;
|
||||
import { type PlayEventTransform } from "../../../../../core/PlayEvent.ts";
|
||||
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
|
||||
|
||||
export interface QueueCriteria {
|
||||
queueName: QueueName
|
||||
queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export interface PlayWhereOpts<D extends DateLike = Dayjs> {
|
||||
@@ -38,7 +39,7 @@ export interface PlayWhereOpts<D extends DateLike = Dayjs> {
|
||||
text?: string[]
|
||||
}
|
||||
|
||||
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues';
|
||||
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues' | 'events' | 'children';
|
||||
export interface QueryPlaysOpts<D extends DateLike = Dayjs> extends PlayWhereOpts<D> {
|
||||
sort?: 'seenAt' | 'playedAt'
|
||||
order?: 'asc' | 'desc'
|
||||
@@ -92,6 +93,26 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
return res as PlaySelectWithQueueStates;
|
||||
}
|
||||
|
||||
findByIdWith = async <K extends keyof TSchema['plays']["relations"]>(id: number, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
|
||||
const res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
with: buildPlayWith(args)
|
||||
});
|
||||
return res as unknown as PlayWith<K> | undefined;
|
||||
}
|
||||
|
||||
findByUidWith = async <K extends keyof TSchema['plays']["relations"]>(uid: string, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
|
||||
const res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
uid
|
||||
},
|
||||
with: buildPlayWith(args)
|
||||
});
|
||||
return res as unknown as PlayWith<K> | undefined;
|
||||
}
|
||||
|
||||
createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}): Promise<PlayWith<'input'>[]> => {
|
||||
|
||||
const {
|
||||
@@ -103,11 +124,14 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
|
||||
const entitiesData = entitiesOpts.map((data) => {
|
||||
const {
|
||||
play,
|
||||
play: {
|
||||
lifecycle,
|
||||
...playRest
|
||||
},
|
||||
input,
|
||||
...rest
|
||||
} = data;
|
||||
return generatePlayEntity(play, { componentId: this.componentId, ...rest });
|
||||
return generatePlayEntity(playRest, { componentId: this.componentId, ...rest });
|
||||
});
|
||||
|
||||
const nakedPlays = await this.db.insert(plays).values(entitiesData).returning();
|
||||
@@ -126,6 +150,23 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
});
|
||||
|
||||
const inputRow = await this.db.insert(playInputs).values(inputDatas);
|
||||
|
||||
const eventData = nakedPlays.map((x, index) => {
|
||||
const {
|
||||
play: {
|
||||
lifecycle = [],
|
||||
} = {}
|
||||
} = entitiesOpts[index];
|
||||
|
||||
if(lifecycle.length > 0) {
|
||||
return {...transformToPlayEvent(lifecycle), playId: x.id}
|
||||
}
|
||||
return undefined;
|
||||
}).filter(x => x !== undefined);
|
||||
if(eventData.length > 0) {
|
||||
await this.db.insert(playEvents).values(eventData);
|
||||
}
|
||||
|
||||
playRows = nakedPlays.map((x, index) => ({...x, play: hydratePlaySelect(x, hydrate), input: inputRow[index]}));
|
||||
});
|
||||
|
||||
@@ -182,6 +223,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
case 'queues':
|
||||
query.with.queueStates = true;
|
||||
break;
|
||||
case 'events':
|
||||
query.with.events = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown relation ${w}`);
|
||||
}
|
||||
@@ -408,28 +452,28 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`);
|
||||
} else {
|
||||
for(const id of ids) {
|
||||
let compactedPlay: PlayObject;
|
||||
if(compactTypes.includes('input')) {
|
||||
await this.db.update(playInputs).set({
|
||||
data: {removedReason: 'Removed by compaction'}
|
||||
}).where(eq(playInputs.playId, id));
|
||||
}
|
||||
if(compactTypes.includes('transform')) {
|
||||
const playRow = await this.db.query.plays.findFirst({where: {id: id}});
|
||||
if(playRow === undefined) {
|
||||
// uhh shouldn't be
|
||||
loggerCom.warn(`No Play found with ID ${id}, but it should have been...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
compactedPlay = playRow.play;
|
||||
if(compactedPlay.lifecycle !== undefined) {
|
||||
compactedPlay.lifecycle = compactedPlay.lifecycle.map(x => {
|
||||
if(x.inputs == undefined) {
|
||||
const events = await this.db.query.playEvents.findMany({where: {playId: id}});
|
||||
for(const ev of events) {
|
||||
if(ev.eventName === 'transform') {
|
||||
const transformEvent = ev as PlayEventTransform;
|
||||
let compactedInput = false;
|
||||
transformEvent.data = transformEvent.data.map(x => {
|
||||
if(x.inputs !== undefined && x.inputs.length > 0) {
|
||||
compactedInput = true;
|
||||
return {...x, inputs: x.inputs.map((y) => ({type: y.type, input: 'Removed by compaction'}))};
|
||||
}
|
||||
return x;
|
||||
});
|
||||
if(compactedInput) {
|
||||
await this.db.update(playEvents).set({data: transformEvent.data}).where(eq(playEvents.id, transformEvent.id));
|
||||
}
|
||||
return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,9 +481,6 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
const vals: Parameters<typeof updater.set>[0] = {
|
||||
compacted: compactedFlags.join('-')
|
||||
};
|
||||
if(compactedPlay !== undefined) {
|
||||
vals.play = compactedPlay;
|
||||
}
|
||||
await this.db.update(plays).set(vals).where(eq(plays.id, id));
|
||||
}
|
||||
loggerCom.trace(`Compacted ${ids.length} '${retentionType}' plays`);
|
||||
@@ -454,65 +495,75 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
}
|
||||
|
||||
protected prepareGetQueueNext = () => this.db.query.plays.findFirst({
|
||||
// https://stackoverflow.com/a/78551796
|
||||
// cannot bind arrays in sqlite
|
||||
where: {
|
||||
componentId: sql.placeholder('componentId'),
|
||||
queueStates: {
|
||||
queueName: sql.placeholder('queueName'),
|
||||
queueStatus: 'queued',
|
||||
queueStatus: sql.placeholder('status'),
|
||||
retries: {
|
||||
lte: sql.placeholder('retries')
|
||||
}
|
||||
},
|
||||
},
|
||||
with: {
|
||||
queueStates: true
|
||||
},
|
||||
orderBy: {
|
||||
seenAt: 'asc'
|
||||
queueStates: {
|
||||
orderBy: {
|
||||
updatedAt: 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
// orderBy: {
|
||||
// seenAt: 'asc'
|
||||
// },
|
||||
}).prepare()
|
||||
|
||||
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number, notIds?: number[], status?: QueueStateSelect['queueStatus']} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
const {
|
||||
retries = 0,
|
||||
retries = 1000,
|
||||
notIds,
|
||||
order = 'asc',
|
||||
status = QUEUE_STATUS_QUEUED,
|
||||
componentId = this.componentId
|
||||
} = opts;
|
||||
|
||||
// let where: FindWhere<'plays'> = {
|
||||
// componentId
|
||||
// }
|
||||
let res: PlaySelectWithQueueStates | undefined;
|
||||
|
||||
// if(retries !== undefined) {
|
||||
// where.queueStates = {
|
||||
// queueName,
|
||||
// queueStatus: 'queued',
|
||||
// retries: {
|
||||
// lte: retries
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// where.queueStates = {
|
||||
// queueName,
|
||||
// queueStatus: 'queued'
|
||||
// }
|
||||
// }
|
||||
if (notIds === undefined) {
|
||||
if (this.getQueueNextPrepared === undefined) {
|
||||
this.getQueueNextPrepared = this.prepareGetQueueNext();
|
||||
}
|
||||
|
||||
// const res = await this.db.query.plays.findFirst({
|
||||
// where: where,
|
||||
// orderBy: {
|
||||
// seenAt: order
|
||||
// },
|
||||
// with: {
|
||||
// queueStates: true
|
||||
// }
|
||||
// });
|
||||
|
||||
if(this.getQueueNextPrepared === undefined) {
|
||||
this.getQueueNextPrepared = this.prepareGetQueueNext();
|
||||
res = await this.getQueueNextPrepared.execute({ queueName, retries, componentId, status });
|
||||
} else {
|
||||
// cannot bind arrays in sqlite so this has to be non-prepared
|
||||
res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
id: {
|
||||
notIn: notIds
|
||||
},
|
||||
componentId: componentId,
|
||||
queueStates: {
|
||||
queueName: queueName,
|
||||
queueStatus: status,
|
||||
retries: {
|
||||
lte: retries
|
||||
}
|
||||
},
|
||||
},
|
||||
with: {
|
||||
queueStates: {
|
||||
orderBy: {
|
||||
updatedAt: 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
// orderBy: {
|
||||
// seenAt: 'asc'
|
||||
// },
|
||||
});
|
||||
}
|
||||
|
||||
const res = await this.getQueueNextPrepared.execute({queueName, retries, componentId});
|
||||
|
||||
if(res === undefined) {
|
||||
return undefined;
|
||||
@@ -613,12 +664,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
return {data: res.map(x => ({...x, play: hydratePlaySelect(x, hydrate)})), meta: {limit, offset}};
|
||||
}
|
||||
|
||||
public checkExisting = async (play: PlayObject, opts: {queueName?: string, states?: PlaySelect['state'][], taAccuracy?: TemporalAccuracy[]} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
public checkExisting = async (play: PlayObject, opts: {
|
||||
queueName?: string,
|
||||
states?: PlaySelect['state'][],
|
||||
taAccuracy?: TemporalAccuracy[],
|
||||
inputHash?: string | PlayObject,
|
||||
notId?: number
|
||||
} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
const {
|
||||
queueName,
|
||||
componentId = this.componentId,
|
||||
taAccuracy = TA_DEFAULT_ACCURACY,
|
||||
states
|
||||
states,
|
||||
inputHash,
|
||||
notId,
|
||||
} = opts;
|
||||
const hash = hashObject(playContentBasicInvariantTransform(play).data);
|
||||
|
||||
@@ -626,19 +685,25 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
// which we can then use with temporal comparison to make sure we are comparing the correct dates
|
||||
//
|
||||
// this isn't as fast as just comparing playDate directly but its still much faster/cheaper than paginating plays and doing everything in-memory
|
||||
const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
|
||||
let endRange: Dayjs;
|
||||
if(play.data.playDateCompleted !== undefined) {
|
||||
// this will be present if source reports it
|
||||
// or we tracked it live with MemorySource
|
||||
endRange = play.data.playDateCompleted.add(dateGranularity, 's');
|
||||
} else {
|
||||
endRange = play.data.playDate.add(dateGranularity, 's');
|
||||
}
|
||||
// const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
|
||||
// let endRange: Dayjs;
|
||||
// if(play.data.playDateCompleted !== undefined) {
|
||||
// // this will be present if source reports it
|
||||
// // or we tracked it live with MemorySource
|
||||
// endRange = play.data.playDateCompleted.add(dateGranularity, 's');
|
||||
// } else {
|
||||
// endRange = play.data.playDate.add(dateGranularity, 's');
|
||||
// }
|
||||
const where: FindWhere<'plays'> = {
|
||||
componentId,
|
||||
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
|
||||
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {useDuration: true})),
|
||||
};
|
||||
|
||||
if(notId !== undefined) {
|
||||
where.NOT = {
|
||||
id: notId
|
||||
}
|
||||
}
|
||||
|
||||
if(queueName !== undefined) {
|
||||
where.queueStates = {
|
||||
@@ -653,19 +718,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
}
|
||||
|
||||
const mbidId = playMbidIdentifier(play);
|
||||
if(mbidId !== undefined) {
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
playHash: hash
|
||||
},
|
||||
{
|
||||
mbidIdentifier: mbidId
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
if (mbidId !== undefined || inputHash !== undefined) {
|
||||
where.AND = [{
|
||||
OR: [
|
||||
{
|
||||
playHash: hash
|
||||
}
|
||||
]
|
||||
}];
|
||||
if (mbidId !== undefined) {
|
||||
where.AND[0].OR.push({ mbidIdentifier: mbidId });
|
||||
}
|
||||
if (inputHash !== undefined) {
|
||||
where.AND[0].OR.push({ input: { playHash: typeof inputHash === 'string' ? inputHash : hashObject(playContentBasicInvariantTransform(inputHash).data) } });
|
||||
}
|
||||
} else {
|
||||
where.playHash = hash;
|
||||
}
|
||||
@@ -673,7 +739,8 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
const res = await this.db.query.plays.findMany({
|
||||
where,
|
||||
with: {
|
||||
queueStates: true
|
||||
queueStates: true,
|
||||
input: true
|
||||
}
|
||||
});
|
||||
if(res.length === 0) {
|
||||
@@ -685,12 +752,13 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
})
|
||||
}
|
||||
|
||||
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[] } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
|
||||
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[], notId?: number } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
|
||||
const {
|
||||
componentId = this.componentId,
|
||||
bufferTime,
|
||||
states,
|
||||
with: qWith
|
||||
with: qWith,
|
||||
notId
|
||||
} = opts;
|
||||
|
||||
const query: FindMany<'plays'> = {};
|
||||
@@ -704,6 +772,11 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
in: states
|
||||
}
|
||||
}
|
||||
if(notId !== undefined) {
|
||||
where.id = {
|
||||
NOT: notId
|
||||
}
|
||||
}
|
||||
query.where = where;
|
||||
|
||||
return ((await this.db.query.plays.findMany({
|
||||
@@ -734,26 +807,75 @@ where compacted IS NOT NULL
|
||||
group by componentId,compacted;`);
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<PlayNew> & {event?: boolean, reason?: string, error?: ErrorLike}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.updateById(id, data) as PlaySelect;
|
||||
if(data.event === true) {
|
||||
if(data.state !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...stateChangeToPlayEvent(removeUndefinedKeys({state: data.state, reason: data.reason, error: data.error})), playId: id});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for state change ${data.state} on Play ${id}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await runTransaction(this.db, async () => {
|
||||
await this.db.delete(playInputs).where(eq(playInputs.playId, id));
|
||||
await this.db.delete(playEvents).where(eq(playEvents.playId, id));
|
||||
await this.db.delete(queueStates).where(eq(queueStates.playId, id));
|
||||
await this.db.update(plays).set({parentId: null}).where(eq(plays.parentId, id));
|
||||
await this.db.delete(plays).where(eq(plays.id, id));
|
||||
});
|
||||
}
|
||||
async deleteByIds(ids: number[]): Promise<void> {
|
||||
await runTransaction(this.db, async () => {
|
||||
await this.db.delete(playInputs).where(inArray(playInputs.playId, ids));
|
||||
await this.db.delete(playEvents).where(inArray(playEvents.playId, ids));
|
||||
await this.db.delete(queueStates).where(inArray(queueStates.playId, ids));
|
||||
await this.db.update(plays).set({parentId: null}).where(inArray(plays.parentId, ids));
|
||||
await this.db.delete(plays).where(inArray(plays.id, ids));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
|
||||
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean, useDuration?: boolean} = {}): CompareDateOp => {
|
||||
const {
|
||||
// use either provided arg or default to using source granularity
|
||||
bufferTime = getTemporalAccuracyCloseVal(play.meta.source as SourceType),
|
||||
useCompleted = true
|
||||
useCompleted,
|
||||
useDuration,
|
||||
} = opts;
|
||||
// we get all plays with a play date between playdate - (buffer) AND (playDateCompleted or playDate) + (buffer)
|
||||
let endRange: Dayjs;
|
||||
if(play.data.playDateCompleted !== undefined && useCompleted) {
|
||||
let startRange: Dayjs,
|
||||
endRange: Dayjs;
|
||||
|
||||
// make sure we use the
|
||||
const [sotPlayDate, SOT] = getScrobbleTsSOCDateWithContext(play);
|
||||
|
||||
if(useDuration && play.data.duration !== undefined) {
|
||||
if(SOT === SCROBBLE_TS_SOC_END) {
|
||||
endRange = play.data.playDate.add(bufferTime, 's');
|
||||
startRange = play.data.playDate.subtract(play.data.duration + bufferTime,'s')
|
||||
} else {
|
||||
endRange = play.data.playDate.add(play.data.duration + bufferTime, 's');
|
||||
startRange = play.data.playDate.subtract(bufferTime,'s')
|
||||
}
|
||||
} else if(play.data.playDateCompleted !== undefined && useCompleted) {
|
||||
startRange = play.data.playDate.subtract(bufferTime, 's');
|
||||
// this will be present if source reports it
|
||||
// or we tracked it live with MemorySource
|
||||
endRange = play.data.playDateCompleted.add(bufferTime, 's');
|
||||
} else {
|
||||
endRange = play.data.playDate.add(bufferTime, 's');
|
||||
startRange = sotPlayDate.subtract(bufferTime, 's');
|
||||
endRange = sotPlayDate.add(bufferTime, 's');
|
||||
}
|
||||
return {
|
||||
type: 'between',
|
||||
range: [play.data.playDate.subtract(bufferTime, 's'), endRange]
|
||||
range: [startRange, endRange]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +902,12 @@ export const buildPlayWith = (args: WithPlayRelation[] | undefined): FindWith<'p
|
||||
case 'queues':
|
||||
qWith.queueStates = true;
|
||||
break;
|
||||
case 'events':
|
||||
qWith.events = true;
|
||||
break;
|
||||
case 'children':
|
||||
qWith.children = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown relation ${w}`);
|
||||
}
|
||||
@@ -829,14 +957,26 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
|
||||
// or else assigning an array to OR using only `typeof where.queueStates` causes a type error
|
||||
const queueWhere: typeof where.queueStates.OR[0][] = [];
|
||||
for(const q of queues) {
|
||||
queueWhere.push(
|
||||
{
|
||||
queueName: q.queueName,
|
||||
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
in: q.queueStatus
|
||||
}
|
||||
const qWhereCriteria: typeof where.queueStates.OR[0] = {
|
||||
queueName: q.queueName,
|
||||
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
in: q.queueStatus
|
||||
}
|
||||
)
|
||||
};
|
||||
if(q.retries !== undefined) {
|
||||
qWhereCriteria.retries = {
|
||||
lte: q.retries
|
||||
}
|
||||
}
|
||||
queueWhere.push(qWhereCriteria);
|
||||
// queueWhere.push(
|
||||
// {
|
||||
// queueName: q.queueName,
|
||||
// queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
// in: q.queueStatus
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
}
|
||||
if(queueWhere.length === 1) {
|
||||
where.queueStates = queueWhere[0];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq, and, lte, inArray } from "drizzle-orm";
|
||||
import { eq, and, gte, lte, inArray } from "drizzle-orm";
|
||||
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {DbConcrete} from "../drizzleUtils.ts";
|
||||
import type {QueueStateSelect} from "../drizzleTypes.ts";
|
||||
import { queueStates } from "../schema/schema.ts";
|
||||
import { CLIENT_DEAD_QUEUE } from "../../../../../core/Atomic.ts";
|
||||
import type {PlaySelect, QueueStateSelect} from "../drizzleTypes.ts";
|
||||
import { playEvents, queueStates } from "../schema/schema.ts";
|
||||
import { DEAD_QUEUE, INGRESS_QUEUE } from "../../../../../core/Atomic.ts";
|
||||
import { queueStateToPlayEvent } from "../entityUtils.ts";
|
||||
export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'> {
|
||||
|
||||
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
|
||||
@@ -17,7 +18,7 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
|
||||
eq(queueStates.componentId, componentId),
|
||||
lte(queueStates.retries, retries),
|
||||
eq(queueStates.queueStatus, 'failed'),
|
||||
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
|
||||
eq(queueStates.queueName, INGRESS_QUEUE)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -27,15 +28,56 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
|
||||
}).where(and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
eq(queueStates.queueStatus, 'queued'),
|
||||
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
|
||||
eq(queueStates.queueName, DEAD_QUEUE)
|
||||
));
|
||||
}
|
||||
|
||||
public getQueueCount = async (componentId: number, queueNames: string[], queueStatus: QueueStateSelect['queueStatus'][] = ['queued']): Promise<number> => {
|
||||
public getQueueCount = async (componentId: number, queueNames: string[], opts: {
|
||||
queueStatus?: QueueStateSelect['queueStatus'][],
|
||||
retries?: number
|
||||
retryEq?: 'lte' | 'gte'
|
||||
} = {}): Promise<number> => {
|
||||
const {
|
||||
queueStatus = ['queued'],
|
||||
retries,
|
||||
retryEq = 'lte'
|
||||
} = opts
|
||||
if(retries === undefined) {
|
||||
return await this.db.$count(queueStates, and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
inArray(queueStates.queueName, queueNames),
|
||||
inArray(queueStates.queueStatus, queueStatus)
|
||||
));
|
||||
}
|
||||
return await this.db.$count(queueStates, and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
inArray(queueStates.queueName, queueNames),
|
||||
inArray(queueStates.queueStatus, queueStatus)
|
||||
));
|
||||
inArray(queueStates.queueStatus, queueStatus),
|
||||
retryEq === 'lte' ? lte(queueStates.retries, retries) : gte(queueStates.retries, retries)
|
||||
));
|
||||
}
|
||||
|
||||
async create(data: typeof this.table.$inferInsert & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.create(data) as QueueStateSelect;
|
||||
if(data.event === true && data.playId !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for new queue creation on Play ${data.playId}`));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert> & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.updateById(id, data) as QueueStateSelect;
|
||||
if(data.event === true && data.playId !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for queue ${res.queueName} on Play ${data.playId}`));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { integer, sqliteTable, text, index, uniqueIndex, customType, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { defineRelations } from 'drizzle-orm';
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject } from "../../../../../core/Atomic.ts";
|
||||
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject, type QueueContext } from "../../../../../core/Atomic.ts";
|
||||
import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.ts";
|
||||
import type {ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm} from "../../../../../core/Transform.ts";
|
||||
import type {JobRangeCount, JobRangeTime} from "../../../infrastructure/Job.ts";
|
||||
@@ -94,6 +94,7 @@ export const playInputs = sqliteTable("play_inputs", {
|
||||
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
|
||||
data: text({ mode: 'json' }).$type<object>(),
|
||||
play: PlayJson('play').notNull(),//text({ mode: 'json' }).notNull().$type<PlayObject>(),
|
||||
playHash: text(),
|
||||
createdAt: DayjsTimestamp('createdAt').$defaultFn(() => dayjs())
|
||||
}, (table) => [
|
||||
uniqueIndex('play_input_id_idx').on(table.playId)
|
||||
@@ -143,6 +144,7 @@ export const queueStates = sqliteTable("play_queue_states", {
|
||||
queueStatus: text({enum: ['queued','completed','failed']}).notNull().default('queued'),
|
||||
retries: integer().notNull().default(0),
|
||||
error: ErrorLikeJson('error'),
|
||||
context: text({mode: 'json'}).$type<QueueContext & {isRetry?: boolean}>(),
|
||||
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
|
||||
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs())
|
||||
}, (table) => [
|
||||
@@ -211,7 +213,18 @@ export const jobs = sqliteTable("jobs", {
|
||||
completedAt: DayjsTimestamp('completedAt')
|
||||
});
|
||||
|
||||
const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
|
||||
export const playEvents = sqliteTable("play_events", {
|
||||
id: integer({ mode: 'number' }).primaryKey(),
|
||||
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
|
||||
eventName: text({length: 50}).notNull(),
|
||||
data: text({ mode: 'json' }),
|
||||
error: ErrorLikeJson('error'),
|
||||
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
|
||||
}, (table) => [
|
||||
index('play_event_id_idx').on(table.playId)
|
||||
]);
|
||||
|
||||
const playRelations = defineRelations({ plays, queueStates, playEvents, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
|
||||
plays: {
|
||||
queueStates: r.many.queueStates(),
|
||||
input: r.one.playInputs({
|
||||
@@ -233,7 +246,8 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
|
||||
from: r.plays.jobId,
|
||||
to: r.jobs.id,
|
||||
optional: true
|
||||
})
|
||||
}),
|
||||
events: r.many.playEvents(),
|
||||
},
|
||||
playsHistorical: {
|
||||
component: r.one.components({
|
||||
@@ -265,7 +279,13 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
|
||||
},
|
||||
jobs: {
|
||||
plays: r.many.plays()
|
||||
}
|
||||
},
|
||||
playEvents: {
|
||||
play: r.one.plays({
|
||||
from: r.playEvents.playId,
|
||||
to: r.plays.id
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
export const relations = playRelations;
|
||||
@@ -280,6 +300,8 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
|
||||
return components;
|
||||
case 'playInputs':
|
||||
return playInputs;
|
||||
case 'playEvents':
|
||||
return playEvents;
|
||||
case 'queueStates':
|
||||
return queueStates;
|
||||
case 'componentMigrations':
|
||||
@@ -289,7 +311,7 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
|
||||
export const schema = {playInputs, plays, playEvents, components, componentMigrations, queueStates, jobs};
|
||||
|
||||
export type TSchema = typeof relations;
|
||||
export type Schema = typeof schema;
|
||||
|
||||
@@ -33,16 +33,38 @@ export class ConnectionCheckError extends StageError {
|
||||
}
|
||||
addKnownErrorConstructor(ConnectionCheckError);
|
||||
|
||||
export class AuthError extends NamedError {
|
||||
override name = 'Authentication Error';
|
||||
unrecoverable: boolean = false;
|
||||
|
||||
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
|
||||
super(msg, opts);
|
||||
this.unrecoverable = opts.unrecoverable ?? false;
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(AuthError);
|
||||
export const isAuthError = (e: Error): e is AuthError => e.name === 'Authentication Error' || e instanceof AuthError;
|
||||
export class AuthCheckError extends StageError {
|
||||
name = 'Authentication Check';
|
||||
override name = 'Authentication Check';
|
||||
unrecoverable?: boolean;
|
||||
|
||||
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
|
||||
super(msg, opts);
|
||||
this.unrecoverable = opts.unrecoverable;
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(AuthCheckError);
|
||||
export const isAuthCheckError = (e: Error): e is AuthCheckError => e.name === 'Authentication Check' || e instanceof AuthCheckError;
|
||||
|
||||
export class PostInitError extends StageError {
|
||||
name = 'Post Initialization';
|
||||
}
|
||||
addKnownErrorConstructor(PostInitError);
|
||||
|
||||
export class StageChangeError extends NamedError {
|
||||
override name = 'Stage Change';
|
||||
}
|
||||
|
||||
const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/);
|
||||
|
||||
export class SimpleError extends Error implements HasSimpleError {
|
||||
@@ -200,3 +222,33 @@ export class InvalidRegexError extends SimpleError {
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(InvalidRegexError, () => new InvalidRegexError(new RegExp(/1/)));
|
||||
|
||||
export type AuthErrorMap = {
|
||||
root: AuthError
|
||||
check: AuthCheckError
|
||||
auth: AuthError | AuthCheckError
|
||||
parent: Error
|
||||
}
|
||||
|
||||
export const findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(err: Error, opts: { type?: T, unrecoverable?: boolean } = {}): AuthErrorMap[T] | undefined => {
|
||||
const {
|
||||
type = 'auth',
|
||||
unrecoverable
|
||||
} = opts;
|
||||
const authError = findCauseByFunc(err, (e) => {
|
||||
if (['check','auth'].includes(type) && isAuthCheckError(e)) {
|
||||
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
|
||||
}
|
||||
if (['root','auth'].includes(type) && isAuthError(e)) {
|
||||
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (authError !== undefined) {
|
||||
if (type === 'parent') {
|
||||
return err as AuthErrorMap[T];
|
||||
}
|
||||
return authError as AuthErrorMap[T];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NamedError } from "./MSErrors.ts";
|
||||
import type { PlayProcessingResult } from "../infrastructure/PlayProcessing.ts";
|
||||
|
||||
export interface PlayProcessingErrorData extends PlayProcessingResult {
|
||||
showStopping: boolean
|
||||
}
|
||||
|
||||
export class PlayProcessingError extends NamedError {
|
||||
override name = 'Play Processing Error';
|
||||
result: PlayProcessingResult
|
||||
showStopping: boolean
|
||||
|
||||
constructor(error: Error, data: PlayProcessingErrorData, opts: ErrorOptions = {}) {
|
||||
super('Error occured while processing Play', {...opts, cause: error});
|
||||
this.result = data;
|
||||
this.showStopping = data.showStopping;
|
||||
}
|
||||
}
|
||||
@@ -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[], wasMonitored?: boolean } },{},SourceType>]
|
||||
configError: [Error | string]
|
||||
instantiateError: [Error]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PlayEvent } from "../../../core/PlayEvent.ts"
|
||||
import type { PlaySelectWithQueueStates, QueueStateSelect } from "../database/drizzle/drizzleTypes.ts"
|
||||
|
||||
export interface PlayProcessingResult {
|
||||
playEntity: PlaySelectWithQueueStates,
|
||||
events: Omit<PlayEvent, 'playId'>[],
|
||||
queue: QueueStateSelect
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
|
||||
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape} from "../common.ts";
|
||||
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape, deadLetterOptionsSchema} from "../common.ts";
|
||||
import {retentionConfigDurationValueSchema} from "../database.ts";
|
||||
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
@@ -124,6 +124,7 @@ export const commonClientOptionsSchema = z.object({
|
||||
...monitorOptionsSchema.shape,
|
||||
...requestRetryOptionsSchema.shape,
|
||||
...upstreamRefreshOptionsSchema.shape,
|
||||
...deadLetterOptionsSchema.shape,
|
||||
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
@@ -143,17 +144,6 @@ export const commonClientOptionsSchema = z.object({
|
||||
}).optional().meta({
|
||||
description: "Options used for increasing verbosity of logging in MS (used for debugging)"
|
||||
}),
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 3
|
||||
* @examples [3]
|
||||
* */
|
||||
deadLetterRetries: z.number().optional().meta({
|
||||
description: "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
default: 3,
|
||||
examples: [3]
|
||||
}),
|
||||
|
||||
/** Enhance/correct Play data by applying a transform pipeline */
|
||||
playTransform: playTransformOptionsSchema.optional().meta({description: 'Enhance/correct Play data by applying a transform pipeline'}),
|
||||
|
||||
@@ -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"]
|
||||
}),
|
||||
|
||||
@@ -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/"
|
||||
}),
|
||||
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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"]
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -155,6 +155,21 @@ export const monitorOptionsSchema = z.object({
|
||||
})
|
||||
export type MonitorOptions = z.infer<typeof monitorOptionsSchema>;
|
||||
|
||||
export const deadLetterOptionsSchema = z.object({
|
||||
/**
|
||||
* Number of times MS should automatically retry Plays in dead letter queue
|
||||
*
|
||||
* @default 3
|
||||
* @examples [3]
|
||||
* */
|
||||
deadLetterRetries: z.number().optional().meta({
|
||||
description: "Number of times MS should automatically retry Plays in dead letter queue",
|
||||
default: 3,
|
||||
examples: [3]
|
||||
})
|
||||
})
|
||||
export type DeadLetterOptions = z.infer<typeof deadLetterOptionsSchema>;
|
||||
|
||||
export type UnparsedConfig<T extends (SourceType | ClientType)> = {config: object, type: T, source?: 'file' | 'aio' | 'env', pos: string};
|
||||
|
||||
export const generateConfigLocation = (configType: string, config: UnparsedConfig<any>): string => {
|
||||
|
||||
@@ -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"]
|
||||
}),
|
||||
|
||||
@@ -81,6 +81,14 @@ export type DeezerInternalData = z.infer<typeof deezerInternalDataSchema>;
|
||||
|
||||
export const deezerInternalSourceOptionsSchema = z.object({
|
||||
...commonSourceOptionsSchema.shape,
|
||||
/**
|
||||
* Set the heuristic level of duplicate/repeated track pruning from listening history.
|
||||
*
|
||||
* Some third party integrations (Sonos) cause Deezer to report the same track many times in listening history
|
||||
*
|
||||
* * `true` => detect plays with play times at exact, close to, or fuzzy (beginning/end of predicated play time + duration)
|
||||
* * `aggressive` => above AND check for plays during previous play times that occurred during the track (50% +/- duration plus playedAt date)
|
||||
*/
|
||||
fuzzyDiscoveryIgnore: z.union([z.boolean(), z.literal('aggressive')]).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import * as z from "zod";
|
||||
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema} from "../common.ts";
|
||||
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema, deadLetterOptionsSchema} from "../common.ts";
|
||||
import {retentionConfigDurationValueSchema} from "../database.ts";
|
||||
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
|
||||
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
|
||||
@@ -73,6 +73,7 @@ export const fileLogOptionsSchema = z.object({
|
||||
export const commonSourceOptionsSchema = z.object({
|
||||
...monitorOptionsSchema.shape,
|
||||
...sourceRetryOptionsSchema.shape,
|
||||
...deadLetterOptionsSchema.shape,
|
||||
/**
|
||||
* * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output
|
||||
* * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -50,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"});
|
||||
|
||||
|
||||
@@ -52,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"});
|
||||
|
||||
|
||||
@@ -54,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"});
|
||||
|
||||
|
||||
@@ -50,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'});
|
||||
|
||||
|
||||
@@ -48,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"
|
||||
}),
|
||||
|
||||
|
||||
@@ -56,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"]
|
||||
|
||||
@@ -59,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 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { childLogger, type Logger } from "@foxxmd/logging";
|
||||
import type { PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
|
||||
import type { OptionalCacheUsage, PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
|
||||
import { isStageTyped, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
|
||||
import AbstractInitializable from "../AbstractInitializable.ts";
|
||||
import type { StageConfig } from "../../../core/Transform.ts";
|
||||
@@ -61,13 +61,16 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
|
||||
protected abstract doParseConfig(data: StageConfig): Y;
|
||||
|
||||
public async handle(data: Y, play: PlayObject): Promise<PlayObject> {
|
||||
public async handle(data: Y, play: PlayObject, opts?: OptionalCacheUsage): Promise<PlayObject> {
|
||||
const {
|
||||
useCachedResult = true,
|
||||
} = (opts ?? {});
|
||||
const cacheKey = `transformResult-${this.configHash}-${hashObject(data)}-${hashObject(playContentInvariantTransform(play))}`
|
||||
try {
|
||||
const cachedTransformData = await this.cache.get<T>(cacheKey);
|
||||
const cachedTransformData = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
|
||||
if(cachedTransformData !== undefined) {
|
||||
this.logger.debug('Transform cache hit');
|
||||
const transformed = await this.doHandle(data, play, cachedTransformData);
|
||||
const transformed = await this.doHandle(data, play, cachedTransformData, opts);
|
||||
return transformed;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -82,7 +85,7 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handlePreFetch(play, data);
|
||||
await this.handlePreFetch(play, data, opts);
|
||||
} catch (e) {
|
||||
if(e instanceof SkipTransformStageError) {
|
||||
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
|
||||
@@ -93,13 +96,13 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
let transformData: T;
|
||||
let fetchedTransformData: any;
|
||||
try {
|
||||
fetchedTransformData = await this.getTransformerData(play, data);
|
||||
fetchedTransformData = await this.getTransformerData(play, data, opts);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not fetch transformer data`, { cause: e });
|
||||
}
|
||||
|
||||
try {
|
||||
transformData = await this.handlePostFetch(play, fetchedTransformData, data);
|
||||
transformData = await this.handlePostFetch(play, fetchedTransformData, data, opts);
|
||||
} catch (e) {
|
||||
if(e instanceof StagePrerequisiteError) {
|
||||
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
|
||||
@@ -107,22 +110,22 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
throw new Error('postFetch did not pass', { cause: e });
|
||||
}
|
||||
|
||||
const transformed = await this.doHandle(data, play, transformData);
|
||||
const transformed = await this.doHandle(data, play, transformData, opts);
|
||||
await this.cache.set(cacheKey, transformData, this.config.options?.ttl ?? '15s');
|
||||
return transformed;
|
||||
}
|
||||
|
||||
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T): Promise<PlayObject>;
|
||||
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T, opts?: OptionalCacheUsage): Promise<PlayObject>;
|
||||
|
||||
public async getTransformerData(play: PlayObject, stageConfig: Y): Promise<any> {
|
||||
public async getTransformerData(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<any> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y): Promise<T> {
|
||||
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y, opts?: OptionalCacheUsage): Promise<T> {
|
||||
return transformData;
|
||||
}
|
||||
|
||||
public async handlePreFetch(play: PlayObject, stageConfig: Y): Promise<void> {
|
||||
public async handlePreFetch(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
|
||||
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
|
||||
import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
|
||||
import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";
|
||||
import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts";
|
||||
@@ -429,7 +429,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts?: OptionalCacheUsage): Promise<IRecordingMSList> {
|
||||
|
||||
const {
|
||||
// preserve order of search from before searchOrder
|
||||
@@ -443,25 +443,25 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
try {
|
||||
switch(searchType) {
|
||||
case 'isrc':
|
||||
results = await this.searchByIsrc(play, stageConfig);
|
||||
results = await this.searchByIsrc(play, stageConfig, opts);
|
||||
break;
|
||||
case 'album':
|
||||
results = await this.searchByAlbum(play, stageConfig);
|
||||
results = await this.searchByAlbum(play, stageConfig, opts);
|
||||
break;
|
||||
case 'artist':
|
||||
results = await this.searchByArtist(play, stageConfig);
|
||||
results = await this.searchByArtist(play, stageConfig, opts);
|
||||
break;
|
||||
case 'basic':
|
||||
results = await this.searchByBasicFields(play, stageConfig);
|
||||
results = await this.searchByBasicFields(play, stageConfig, opts);
|
||||
break;
|
||||
case 'freetext':
|
||||
results = await this.searchByFreeText(play, stageConfig);
|
||||
results = await this.searchByFreeText(play, stageConfig, opts);
|
||||
break;
|
||||
case 'basicorids':
|
||||
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig);
|
||||
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig, opts);
|
||||
break;
|
||||
case 'mbidrecording':
|
||||
results = await this.searchByRecordingMbid(play, stageConfig);
|
||||
results = await this.searchByRecordingMbid(play, stageConfig, opts);
|
||||
break;
|
||||
}
|
||||
queries.push({type: `mbQuery-${searchType}${results.recordings.length === 0 ? '-empty' : ''}`, input: results.requestQuery});
|
||||
@@ -486,12 +486,12 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
return {...results, requestQueries: queries};
|
||||
}
|
||||
|
||||
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
this.logger.debug({labels: ['Basic Search']}, 'Searching by artist/album/track');
|
||||
return await this.api.searchByRecording(play);
|
||||
return await this.api.searchByRecording(play, opts);
|
||||
}
|
||||
|
||||
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
const using: UsingTypes[] = [];
|
||||
const {
|
||||
data: {
|
||||
@@ -512,31 +512,31 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
using.push((brainz.artist ?? []).length > 0 ? 'mbidartist' : 'artist');
|
||||
|
||||
this.logger.debug({labels: ['Basic Or MBID Search']}, `Searching using ${using.join(', ')}}`);
|
||||
return await this.api.searchByRecording(play, {using});
|
||||
return await this.api.searchByRecording(play, {using, ...opts});
|
||||
}
|
||||
|
||||
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
if(play.data.isrc !== undefined) {
|
||||
this.logger.debug({labels: ['ISRC Search']},'Searching with ISRC');
|
||||
return await this.api.searchByRecording(play, {using: ['isrc']});
|
||||
return await this.api.searchByRecording(play, {using: ['isrc'], ...opts});
|
||||
}
|
||||
throw new SearchPrerequisiteError('Play does not have ISRC');
|
||||
}
|
||||
|
||||
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
if(play.data.meta?.brainz?.recording !== undefined) {
|
||||
this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID');
|
||||
return await this.api.searchByRecording(play, {using: ['mbidrecording']});
|
||||
return await this.api.searchByRecording(play, {using: ['mbidrecording'], ...opts});
|
||||
}
|
||||
throw new SearchPrerequisiteError('Play does not have recording MBID');
|
||||
}
|
||||
|
||||
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
// possibly the artist is incorrect (may be combined as one string)
|
||||
// if we have an album we can likely still get a decent hit w/o using artist
|
||||
if(play.data.album !== undefined && play.data.artists !== undefined && play.data.artists.length > 0) {
|
||||
this.logger.debug({labels: ['Album Search']},'Searching with only track+album');
|
||||
return await this.api.searchByRecording(play, {using: ['title','album']});
|
||||
return await this.api.searchByRecording(play, {using: ['title','album'], ...opts});
|
||||
}
|
||||
if(play.data.album === undefined) {
|
||||
throw new SearchPrerequisiteError('Play does not have an album');
|
||||
@@ -549,7 +549,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
const {
|
||||
searchArtistMethod = this.defaults.searchArtistMethod,
|
||||
} = stageConfig;
|
||||
@@ -582,7 +582,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
// so we split out all artists by all found delimiters
|
||||
const nativePlay = nativeParse(play, {titleClean: true, delimiters: DELIMITERS});
|
||||
this.logger.debug({labels: ['Parsed Artist Search']},'Searching with aggressive native parsing');
|
||||
return await this.api.searchByRecording(nativePlay, {using: ['title','artist']});
|
||||
return await this.api.searchByRecording(nativePlay, {using: ['title','artist'], ...opts});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,9 +594,9 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
this.logger.debug({labels: ['Freetext Search']},'Trying freetext search');
|
||||
const results = await this.api.searchByRecording(play, {freetext: true}) as IRecordingMSList;
|
||||
const results = await this.api.searchByRecording(play, {freetext: true, ...opts}) as IRecordingMSList;
|
||||
results.freeText = true;
|
||||
return results;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user