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