Compare commits

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

This shouldn't happen since we can def use a prebuild binary
so update sharp which updates libvips version used.
2026-08-12 17:24:00 +00:00
FoxxMD 9b7483db91 chore(release): Bump version for release 2026-08-12 16:40:23 +00:00
FoxxMD 7c0797b496 feat: Better validatation for normalized config urls 2026-08-12 16:32:42 +00:00
FoxxMD 1b3cbe9ab3 feat: Normalize domain:port url input 2026-08-12 13:27:10 +00:00
FoxxMD 15e0332ac2 feat: Update json schema to draft-2020-12 2026-08-11 20:37:24 +00:00
FoxxMD d532c85c2a fix: Relax some config url schema requirements with normalization 2026-08-11 20:36:47 +00:00
FoxxMD eecbea4cab add missing error clear when building clients 2026-08-11 18:40:48 +00:00
FoxxMD 823dd5d011 fix: Hand-roll applemusic env fixture data 2026-08-11 18:32:42 +00:00
FoxxMD 0fa9a8df10 fix(discord): Fix discord ENV parsing validation 2026-08-11 18:21:58 +00:00
FoxxMD a76213a7a0 docs: Hand-roll discord ENV updates 2026-08-11 15:56:54 +00:00
FoxxMD a42ab3d8fd fix: Hand-rolled env data fixtures for some edge cases 2026-08-11 15:46:19 +00:00
FoxxMD 475a3f1276 fix: narrow env config key collection to include prefix underscore 2026-08-11 15:14:08 +00:00
FoxxMD 6dcfd6028b fix: Narrow url/IP config zod types from string to purpose-built regex types 2026-08-11 15:06:52 +00:00
FoxxMD 54247e89c2 feat: Add ENV config test suite 2026-08-11 14:53:38 +00:00
Matt Foxx 433ea532ae Merge pull request #670 from FoxxMD/stronglyTypedEvents
feat: strongly type internal events
2026-08-11 08:56:24 -04:00
FoxxMD b615172ed1 feat: strongly-type internal events
* refactor wildcard emitter for better "any" usage
* strongly type emitter with event map
* fix webhook payload accessor #666
2026-08-10 21:03:31 +00:00
FoxxMD aa2beb5846 fix: Check for ENV prefix with startsWith instead of includes
#668
2026-08-10 20:53:53 +00:00
FoxxMD 06d3c6a3a8 docs: Add more guidance for File config example/editor
* Add configureAs callout for ambiguous sources
* Add editor validation limitation for ambiguous components
2026-08-10 18:15:49 +00:00
FoxxMD c0cfc5fa9f feat: relax configureAs requirements for AIO configs
* Do not require configureAs for AIO sources since we can determine from context
* Break out aio/file validation for better DX
2026-08-10 17:42:28 +00:00
102 changed files with 2339 additions and 732 deletions
+1 -1
View File
@@ -159,4 +159,4 @@ storybook-static
bun.lock
.DS_Store
**/.DS_Store
**/.DS_Store
@@ -6,7 +6,7 @@
| `DISCORD_TOKEN` | string | | |
| `DISCORD_ARTWORK` | boolean OR string OR array | | |
| `DISCORD_APPLICATION_ID` | string | | |
| `DISCORD_IPC_LOCATIONS` | string OR array | | |
| `DISCORD_IPC_LOCATIONS` | string | | |
| `DISCORD_ARTWORK_DEFAULT_URL` | string | | |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string OR array | | |
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string OR array | | |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | | |
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | |
@@ -5,4 +5,4 @@
| `ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
| `ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
| `ROCKSKY_TOKEN` | string | | Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account |
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
@@ -557,10 +557,12 @@ Remember, this URL need to be accessible on the public internet.
## Configuration
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
| Environmental Variable | Required? | Default | Description |
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
| Environmental Variable | Type | Default | Description |
| :----------------------------- | ----------------- | --------------------- | :--------------------------------------------------------------------------------------------------------------- |
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
| _**`DISCORD_APPLICATION_ID`**_ | string | | Application ID |
| `DISCORD_IPC_LOCATIONS` | string | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
</Config>
@@ -323,12 +323,14 @@ The order in which multi-scrobbler determines what image to use is the same as t
## Configuration
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
| Environmental Variable | Required? | Default | Description |
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
| Environmental Variable | Type | Default | Description |
| :--------------------------------- | ----------------- | --------------------- | :---------------------------------------------------------------------------------------------------------------- |
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
| _**`DISCORD_TOKEN`**_ | string | | User Token acquired from an active Discord sessions |
| `DISCORD_APPLICATION_ID` | string | | Application ID used to display album art |
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
</Config>
+1 -1
View File
@@ -52,6 +52,6 @@ For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLO
## Configuration
<Config config="KoitoClientConfig" fileContent={JsonConfig} client name="koito">
<Config config="KoitoClientConfig" fileContent={JsonConfig} client ambiguous name="koito">
<ENVConfig/>
</Config>
@@ -63,6 +63,6 @@ The Redirect URL can be explicitly specified in the configuration below, or by u
## Configuration
<Config config="LastfmClientConfig" fileContent={JsonConfig} client name="lastfm">
<Config config="LastfmClientConfig" fileContent={JsonConfig} client ambiguous name="lastfm">
<ENVConfig/>
</Config>
@@ -54,6 +54,6 @@ https://my.cool.libre.com/2.0/
## Configuration
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client name="librefm">
<Config config="LibrefmClientConfig" fileContent={JsonConfig} client ambiguous name="librefm">
<ENVConfig/>
</Config>
@@ -23,6 +23,6 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
## Configuration
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client ambiguous name="listenbrainz">
<ENVConfig/>
</Config>
@@ -38,6 +38,6 @@ Finally, add the Maloja server URL and API Key to the configuration type you cho
## Configuration
<Config config="MalojaClientConfig" fileContent={JsonConfig} client name="maloja">
<Config config="MalojaClientConfig" fileContent={JsonConfig} client ambiguous name="maloja">
<ENVConfig/>
</Config>
@@ -76,6 +76,6 @@ Keys are now **deprecated** and functionality for them will be removed in a futu
</DetailsAdmo>
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client ambiguous name="rocksky">
<ENVConfig/>
</Config>
@@ -78,6 +78,6 @@ If using a handle:
If your identifier does not look like a DID and does not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
<Config config="TealClientConfig" fileContent={JsonConfig} client name="tealfm">
<Config config="TealClientConfig" fileContent={JsonConfig} client ambiguous name="tealfm">
<ENVConfig/>
</Config>
@@ -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>
+1 -1
View File
@@ -87,7 +87,7 @@ const config: Config = {
permalink: string;
locale: string;
}) => {
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
}
},
// blog: {
+593 -495
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -69,6 +69,7 @@
"overrides": {
"lodash": "4.18.1",
"satori": "0.25.0",
"hast-util-to-jsx-runtime": "2.3.6"
"hast-util-to-jsx-runtime": "2.3.6",
"sharp": "0.35.3"
}
}
@@ -9,10 +9,15 @@ import AdmonitionIconImportant from './ImportantIcon';
export interface DetailsAdmoProps extends DetailProps {
type?: 'warning' | 'danger' | 'note' | 'tip' | 'important'
render?: boolean
}
const DetailsAdmo = (props: DetailsAdmoProps) => {
if(props.render === false) {
return null;
}
let cn: string = '';
let icon: ReactElement;
@@ -37,10 +37,29 @@ import {useWindowSize} from '@docusaurus/theme-common';
* **text** mode lets you edit the JSON directly.
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
Both modes **validate** that the configuration is correct. Any errors show up as squiggly lines.
<span style={{display: props.ambiguous === true ? 'inherit' : 'none'}}>
:::important
Only <strong children={props.client ? 'client' : 'source'}/> <span children={props.name}/> configs will validate correctly in the editor but *both* **source** and **client** <span children={props.name}/> configs are allowed in the real <code>CONFIG/<span children={props.name}/>.json</code>
:::
</span>
After you finish editing, switch to **text** and then copy all text to get a completed config.
</DetailsAdmo>
<DetailsAdmo type="important" summary="configureAs Required" render={props.configureAs === true}>
When configuring a <span children={props.name}/> **Source** in a **File** Config you **must** set
```
"configureAs": "source"
```
Without this property, Multi-Scrobbler will assume the config is for a <span children={props.name}/> **Client** config.
This requirement does not apply to **AIO** <span children={props.name}/> configs.
</DetailsAdmo >
</div>
<div className="fileEditorMobile">
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
+6 -3
View File
@@ -11,6 +11,7 @@ import "./json-schema-viewer-styles.css";
import "./modern-json-react-styles.css";
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import f from "ajv-formats"
import Ajv2020 from 'ajv/dist/2020';
import DetailsAdmo from "./AdmonitionDetails";
const validator = createAjvValidator({
@@ -21,9 +22,11 @@ const validator = createAjvValidator({
allowUnionTypes: true,
},
onCreateAjv: (ajv) => {
ajv.addKeyword('deprecationMessage');
f.default(ajv);
return ajv;
const a = new Ajv2020();
a.addKeyword('deprecationMessage');
a.addKeyword('zockerId');
f.default(a);
return a;
}
});
@@ -5,6 +5,7 @@ import { useColorMode } from '@docusaurus/theme-common';
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
import "../modern-json-react-styles.css";
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import Ajv2020 from 'ajv/dist/2020';
import f from "ajv-formats"
import json5 from 'json5';
@@ -29,9 +30,11 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
allowUnionTypes: true,
},
onCreateAjv: (ajv) => {
ajv.addKeyword('deprecationMessage');
f.default(ajv);
return ajv;
const a = new Ajv2020();
a.addKeyword('deprecationMessage');
a.addKeyword('zockerId');
f.default(a);
return a;
}
});
}, [props.schema]);
+68 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.16.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
View File
@@ -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": {
+15
View File
@@ -0,0 +1,15 @@
diff --git a/node_modules/zocker/dist/index.cjs b/node_modules/zocker/dist/index.cjs
index 0745620..ab9cba8 100644
--- a/node_modules/zocker/dist/index.cjs
+++ b/node_modules/zocker/dist/index.cjs
@@ -51,7 +51,9 @@ function generate$1(schema, ctx) {
}
}
const generate_value$1 = (schema, generation_context) => {
- const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema);
+ const reg = zod_v4.globalRegistry.get(schema);
+ const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema
+ || (reg?.zockerId !== undefined && zod_v4.globalRegistry.get(g.schema)?.zockerId === reg.zockerId));
if (reference_generator) return reference_generator.generator(schema, generation_context);
const instanceof_generator = generation_context.instanceof_generators.find((g) => schema instanceof g.schema);
if (instanceof_generator) return instanceof_generator.generator(schema, generation_context);
+32 -6
View File
@@ -1,9 +1,35 @@
import EventEmitter from "events";
export class WildcardEmitter extends EventEmitter {
emit(type: string, ...args: any) {
const argsWithName = args.length === 0 ? [type] : [...args, type];
super.emit('*', ...argsWithName);
return super.emit(type, ...argsWithName) || super.emit('', ...argsWithName);
type DefaultEventMap = [never];
type EventMap<T> = Record<keyof T, any[]> | DefaultEventMap;
type Key<K, T> = T extends DefaultEventMap ? string | symbol : K | keyof T;
type AnyRest = [...args: any[]];
type Args<K, T> = T extends DefaultEventMap ? AnyRest : (
K extends keyof T ? T[K] : never
);
type WildcardKey<T> = T extends DefaultEventMap ? string | symbol : keyof T;
type WildcardHandler<T> = (event: WildcardKey<T>, ...args: unknown[]) => void;
export class WildcardEmitter<T extends EventMap<T> = DefaultEventMap> extends EventEmitter<T> {
private wildcardHandlers: Array<WildcardHandler<T>> = [];
emit<K>(eventName: Key<K, T>, ...args: Args<K, T>): boolean {
this.wildcardHandlers.forEach((h) => h(eventName as WildcardKey<T>, ...args));
return super.emit(eventName, ...args);
}
}
onAny(handler: WildcardHandler<T>): () => void {
this.wildcardHandlers.push(handler);
return () => {
this.wildcardHandlers = this.wildcardHandlers.filter((h) => h !== handler);
};
}
removeAllListeners(eventName?: unknown): this {
if (eventName === undefined) {
this.wildcardHandlers = [];
}
return super.removeAllListeners(eventName as Key<unknown, T>);
}
}
@@ -0,0 +1,22 @@
import type { Dayjs } from "dayjs";
import type { EmittedMSEvent, PlayObject, SourcePlayerObj, SourceType } from "../../../core/Atomic.ts";
import type { MSCoreEvents } from "../../../core/MSCoreEventMap.ts";
import type { WebhookPayload } from "./config/health/webhooks.ts";
export interface MSBackendEventMap extends Omit<MSCoreEvents, 'playerUpdate'> {
notify: [EmittedMSEvent<WebhookPayload>]
discoveredToScrobble: [EmittedMSEvent<{
data: PlayObject | PlayObject[]
options: {
forceRefresh?: boolean,
[key: string]: any,
discoverLocation?: 'backlog' | [key: string]
checkTime: Dayjs
scrobbleFrom: string
scrobbleTo: string[]
}
}>]
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[] } },{},SourceType>]
configError: [Error | string]
instantiateError: [Error]
}
@@ -1,21 +1,39 @@
import * as z from "zod";
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
import { transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
export const statusTypeSchema = z.union([z.literal("online"), z.literal("idle"), z.literal("dnd"), z.literal("invisible")]);
export type StatusType = z.infer<typeof statusTypeSchema>;
const ipcLocationTupleSchema = z.custom<[number, string]>((val) => Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'string');
const HOST_PORT_LOOSE_REGEX = new RegExp(/.+:\d+/i)
const ipcTransform = z.transform((val: string, ctx) => {
const sp = val.split(':');
if(sp.length === 1) {
// assume its a path
return val;
}
if(!HOST_PORT_LOOSE_REGEX.test(val)) {
ctx.issues.push({
code: 'custom',
message: "Not shaped like host:port",
input: val
});
return z.NEVER;
}
return [parseInt(sp[1]), sp[0]]
})
export const discordDataSchema = z.object({
token: z.string().optional(),
applicationId: z.string().optional(),
artwork: z.union([z.boolean(), z.string(), z.array(z.string())]).optional(),
artworkDefaultUrl: z.string().optional(),
statusOverrideAllow: z.union([z.string(), z.array(statusTypeSchema)]).optional(),
statusOverrideAllow: z.array(statusTypeSchema).optional(),
listeningActivityAllow: z.union([z.string(), z.array(z.string())]).optional(),
ipcLocations: z.union([z.string(), z.array(z.union([z.string(), ipcLocationTupleSchema]))]).optional()
ipcLocations: z.array(z.string().pipe(ipcTransform)).optional()
});
export type DiscordData = z.infer<typeof discordDataSchema>;
@@ -24,10 +42,10 @@ const envDataSchema = z.object({
DISCORD_TOKEN: discordDataSchema.shape.token,
DISCORD_ARTWORK: discordDataSchema.shape.artwork,
DISCORD_APPLICATION_ID: discordDataSchema.shape.applicationId,
DISCORD_IPC_LOCATIONS: discordDataSchema.shape.ipcLocations,
DISCORD_IPC_LOCATIONS: z.string().optional().pipe(transformSplitMaybeString),
DISCORD_ARTWORK_DEFAULT_URL: discordDataSchema.shape.artworkDefaultUrl,
DISCORD_STATUS_OVERRIDE_ALLOW: discordDataSchema.shape.statusOverrideAllow,
DISCORD_LISTENING_ACTIVITY_ALLOW: discordDataSchema.shape.listeningActivityAllow,
DISCORD_STATUS_OVERRIDE_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.statusOverrideAllow.meta()),
DISCORD_LISTENING_ACTIVITY_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.listeningActivityAllow.meta()),
});
export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConfig> = {
@@ -40,7 +58,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConf
applicationId: partial.DISCORD_APPLICATION_ID,
ipcLocations: partial.DISCORD_IPC_LOCATIONS,
artworkDefaultUrl: partial.DISCORD_ARTWORK_DEFAULT_URL,
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW,
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW as StatusType[],
listeningActivityAllow: partial.DISCORD_LISTENING_ACTIVITY_ALLOW
}
})
@@ -85,7 +103,7 @@ export const discordStrongDataSchema = discordDataSchema.extend({
artwork: z.union([z.boolean(), z.array(z.string())]).optional(),
statusOverrideAllow: z.array(statusTypeSchema).optional(),
listeningActivityAllow: z.array(z.string()).optional(),
ipcLocations: z.array(z.union([z.string(), ipcLocationTupleSchema])).optional()
//ipcLocations: z.array(z.union([z.string(), z.tuple([z.number(),z.string()])])).optional()
});
export type DiscordStrongData = z.infer<typeof discordStrongDataSchema>;
@@ -101,7 +119,7 @@ export const discordIPCDataSchema = discordStrongDataSchema.extend({
//ipcLocations: (string | [number, string])[]
});
export type DiscordIPCData = z.infer<typeof discordIPCDataSchema>;
export type DiscordIPCData = Omit<z.infer<typeof discordIPCDataSchema>, 'ipcLocations'> & {ipcLocations: (string | [number, string])[]};
export interface ActivityAssets {
largeImage?: string
@@ -3,6 +3,7 @@ import type {UnixTimestamp} from "../../../../../core/Atomic.ts";
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
import {requestRetryOptionsSchema} from "../common.ts";
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
import { httpUrl } from "../../../../utils/ZodUtils.ts";
export interface ListensResponse {
items: ListenObjectResponse[]
@@ -54,7 +55,7 @@ export const koitoDataSchema = z.object({
*
* @examples ["http://192.168.0.100:4110"]
* */
url: z.string().meta({
url: httpUrl.meta({
description: "URL for the Koito server",
examples: ["http://192.168.0.100:4110"]
}),
@@ -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 */
+4
View File
@@ -29,6 +29,7 @@ import { findCauseByFunc } from '../../utils/ErrorUtils.ts';
import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts';
import { playToSubmitPayload } from './listenbrainz/lzUtils.ts';
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
import { getRoot } from '../../ioc.ts';
export interface SubmitOptions {
@@ -59,6 +60,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
declare config: ListenBrainzClientData;
url: URLData;
userAgent: string
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
super('ListenBrainz', name, config, options);
@@ -66,6 +68,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
url = 'https://api.listenbrainz.org/'
} = config;
let cleanUrl = url;
this.userAgent = `multi-scrobbler/${getRoot().items.version}`
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
if(pathedUrl !== undefined) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
@@ -84,6 +87,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
try {
req.set('Authorization', `Token ${this.config.token}`);
req.set('User-Agent', this.userAgent);
return await req as T;
} catch (e) {
const {
+4
View File
@@ -21,10 +21,14 @@ import {
import assert from "node:assert";
import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.ts";
import { isNodeNetworkException } from "../../errors/NodeErrors.ts";
import * as z from 'zod';
export const HANDLE_REGEX = new RegExp(/.+\..+/);
export const HandleZodType = z.stringFormat('atproto-handle', HANDLE_REGEX)
export const ATSIGN_REGEX = new RegExp(/^@(.+)/);
export const AtSignZodType = z.stringFormat('atproto-atsign-handle', ATSIGN_REGEX);
export const DID_REGEX = new RegExp(/did:(?:plc|web):.+/);
export const DidZodType = z.stringFormat('did', DID_REGEX);
export interface HandleOptions {
logger?: Logger
+2 -1
View File
@@ -185,7 +185,8 @@ export const configToIPCConfig = (data: DiscordStrongData): DiscordIPCData => {
const parsedPaths: (string | [number, string])[] = [];
for(const p of ipcLocations) {
if(Array.isArray(p)) {
parsedPaths.push(p)
if(typeof p[0] === 'number' && typeof p[1] === 'string')
parsedPaths.push([p[0],p[1]]);
} else {
const sp = p.split(':');
if(sp.length > 1) {
@@ -9,10 +9,8 @@ import { difference, sleep } from "../../../utils.ts";
import {SequentialRoundRobin} from 'round-robin-js';
import type { Cacheable } from "cacheable";
import { getRoot } from "../../../ioc.ts";
import { version } from "../../../version.ts";
import { hashObject } from "../../../utils/StringUtils.ts";
import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.ts";
;
import { AsyncLocalStorage } from "async_hooks";
import { nanoid } from "nanoid";
import { stripIndents } from "common-tags";
@@ -79,7 +77,7 @@ export class MusicbrainzApiClient extends AbstractApiClient {
if(mb === undefined) {
const api = new MusicBrainzApi({
appName: 'multi-scrobbler',
appVersion: version,
appVersion: getRoot().items.version,
appContactInfo: mbConfig.contact,
baseUrl: u.url.toString(),
preRequest: (method, url, headers) => {
+82 -37
View File
@@ -1,5 +1,5 @@
import dayjs, { type Dayjs, type ManipulateType } from "dayjs";
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult, UnixTimestamp} from "../../../../core/Atomic.ts";
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult} from "../../../../core/Atomic.ts";
import { getRoot } from "../../../ioc.ts";
import { removeUndefinedKeys } from '../../../../core/DataUtils.ts';
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
@@ -8,7 +8,7 @@ import type {AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRa
import type {ListRecord, RecordOptions, TealClientData} from "../../infrastructure/config/client/tealfm.ts";
import AbstractApiClient from "../AbstractApiClient.ts";
import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts";
import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.ts";
import type { FmTealActorStatus, FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts";
import { ScrobbleSubmitError } from "../../errors/MSErrors.ts";
import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts";
import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts";
@@ -18,7 +18,19 @@ import type { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthentica
import { UpstreamError } from "../../errors/UpstreamError.ts";
import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto';
import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts";
import { isrcNoHyphens } from "../../../../core/PlayUtils.ts";
import { isrcNoHyphens, sortByNewestPlayDate } from "../../../../core/PlayUtils.ts";
import { isGenericUri } from "@atcute/lexicons/syntax";
type TealPlayRecord = FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main;
const TEAL_PLAY_COLLECTIONS = ['fm.teal.feed.play', 'fm.teal.alpha.feed.play'] as const;
const asMusicServiceUri = (musicService?: string): `${string}:${string}` | undefined => {
if (musicService === undefined) {
return undefined;
}
return isGenericUri(musicService) ? musicService : `https://${musicService}`;
};
export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
@@ -43,10 +55,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
}
async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
async createScrobbleRecord(record: FmTealFeedPlay.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoCreateRecord.$input = {
repo: this.client.userData.did,
collection: 'fm.teal.alpha.feed.play',
collection: 'fm.teal.feed.play',
record
};
try {
@@ -62,10 +74,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
}
}
async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
async updateStatusRecord(record: FmTealActorStatus.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoPutRecord.$input = {
repo: this.client.userData.did,
collection: "fm.teal.alpha.actor.status",
collection: "fm.teal.actor.status",
rkey: "self",
record
};
@@ -92,37 +104,54 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
cursor = generateTID(dayjs.unix(to).toISOString());
}
const resp = await this.client.get((client) => client.get('com.atproto.repo.listRecords', {
const responses = await Promise.all(TEAL_PLAY_COLLECTIONS.map(collection => this.client.get((client) => client.get('com.atproto.repo.listRecords', {
params: {
repo: this.client.userData.did,
collection: "fm.teal.alpha.feed.play",
collection,
limit,
cursor
}
}));
}))));
if(!resp.ok) {
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
const fromTimestamps: number[] = [],
playSets: PlayObject[][] = [];
for (const resp of responses) {
if (!resp.ok) {
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
}
if(resp.data.cursor !== undefined) {
fromTimestamps.push(usecToUnix(decodeTid(resp.data.cursor).timestampUs));
}
playSets.push((resp.data.records as ListRecord<TealPlayRecord>[]).map(x => listRecordToPlay(x)));
}
let fromTS: UnixTimestamp;
if(resp.data.cursor !== undefined) {
const { timestampUs } = decodeTid(resp.data.cursor);
fromTS = usecToUnix(timestampUs);
}
const from = fromTimestamps.length > 0 ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) : undefined;
const plays = playSets.flat().sort(sortByNewestPlayDate)
const plays = (resp.data.records as unknown as ListRecord<FmTealAlphaFeedPlay.Main>[]).map(x => listRecordToPlay(x));
return {data: plays, meta: {to, from: fromTS, limit}};
return {data: plays, meta: {to, from, limit, more: false, order: 'desc'}};
}
}
export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => {
export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}): PlayObject => {
const artists = record.artists ?? [];
let musicService: string;
if('musicServiceUri' in record) {
musicService = record.musicServiceUri
} else if(`musicServiceBaseDomain` in record) {
musicService = record.musicServiceBaseDomain;
}
let origin: string;
if('originUri' in record) {
origin = record.originUri;
} else if(`originUrl` in record) {
origin = record.originUrl;
}
const play: PlayObjectMinimal = {
data: {
track: record.trackName,
artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
artists: artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
duration: record.duration,
playDate: dayjs(record.playedTime),
album: record.releaseName,
@@ -131,10 +160,11 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
meta: {
source: 'tealfm',
parsedFrom: 'history',
musicService: record.musicServiceBaseDomain,
musicService,
playId: options.playId,
url: {
web: options.web
web: options.web,
origin
},
user: options.user
}
@@ -143,7 +173,7 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
const brainz = removeUndefinedKeys<BrainzMeta>({
recording: record.recordingMbId,
album: record.releaseMbId,
artist: record.artists.filter(x => x.artistMbId !== undefined).length > 0 ? record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
artist: artists.filter(x => x.artistMbId !== undefined).length > 0 ? artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
});
if (brainz !== undefined) {
@@ -153,10 +183,24 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
return baseFormatPlayObj(record, play);
}
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => {
const { $type, ...item } = notPlaying
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealActorStatus.Main => {
const item = notPlaying
? { trackName: "", artists: [] }
: playToRecord(play);
: (() => {
const {
$type,
musicServiceUri,
originUri,
trackDiscriminant,
releaseDiscriminant,
...record
} = playToRecord(play);
return {
...record,
musicServiceBaseDomain: musicServiceUri,
originUrl: originUri
};
})();
let expiry: Dayjs;
if (notPlaying) {
@@ -167,7 +211,7 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi
}
return {
$type: "fm.teal.alpha.actor.status",
$type: "fm.teal.actor.status",
time: dayjs().toISOString(),
expiry: expiry.toISOString(),
item: {
@@ -186,17 +230,19 @@ export const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => {
return mbidToUri(mbid);
};
export type MBIDURI = `mbid:${MBID}`;
export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => {
const musicService = musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService;
const record: FmTealAlphaFeedPlay.Main = {
$type: "fm.teal.alpha.feed.play",
const record: FmTealFeedPlay.Main = {
$type: "fm.teal.feed.play",
trackName: play.data.track,
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
duration: Math.round(play.data.duration),
playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(),
releaseName: play.data.album,
submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`,
musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService,
musicServiceUri: asMusicServiceUri(musicService),
originUri: isGenericUri(play.meta.url?.origin) ? play.meta.url?.origin : undefined,
isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined,
trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID),
recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID),
@@ -205,7 +251,7 @@ export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
return record;
};
export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main>): PlayObject => {
export const listRecordToPlay = (listRecord: ListRecord<TealPlayRecord>): PlayObject => {
const opts: RecordOptions = {};
const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri);
if (uriRes !== undefined) {
@@ -215,5 +261,4 @@ export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main
}
return recordToPlay(listRecord.value, opts);
};
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/);
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal(?:\.alpha)?\.feed.play\/(?<tid>.*))/);
+3 -2
View File
@@ -11,6 +11,7 @@ export default defineLexiconConfig({
files: ['lexicons/**/*.json'],
outdir: 'lexicons/',
imports: ['@atcute/bluesky'],
modules: { importSuffix: '.ts' },
},
pull: {
outdir: path.resolve(__dirname, 'lexicons/'),
@@ -19,8 +20,8 @@ export default defineLexiconConfig({
type: 'git',
remote: 'https://github.com/teal-fm/teal.git',
ref: 'main',
pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'],
pattern: ['lexicons/fm.teal/feed/*.json','lexicons/fm.teal/actor/*.json'],
},
],
},
});
});
+1 -1
View File
@@ -3,4 +3,4 @@
this directory contains lexicon documents pulled from the following sources:
- https://github.com/teal-fm/teal.git (ref: main)
- commit: f661868af2a56199f1b3cae1ad20cd5831ec4212
- commit: 82ea66cadbde3f1aee9c72b05f372c6543a4b3f6
+12 -1
View File
@@ -1,3 +1,14 @@
export * as FmTealActorDefs from "./types/fm/teal/actor/defs.ts";
export * as FmTealActorGetProfile from "./types/fm/teal/actor/getProfile.ts";
export * as FmTealActorGetProfiles from "./types/fm/teal/actor/getProfiles.ts";
export * as FmTealActorProfile from "./types/fm/teal/actor/profile.ts";
export * as FmTealActorProfileStatus from "./types/fm/teal/actor/profileStatus.ts";
export * as FmTealActorSearchActors from "./types/fm/teal/actor/searchActors.ts";
export * as FmTealActorStatus from "./types/fm/teal/actor/status.ts";
export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts";
export * as FmTealFeedGetActorFeed from "./types/fm/teal/feed/getActorFeed.ts";
export * as FmTealFeedGetPlay from "./types/fm/teal/feed/getPlay.ts";
export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts";
export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.ts";
export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.ts";
export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.ts";
@@ -8,4 +19,4 @@ export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.ts";
export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts";
export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts";
export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts";
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
@@ -0,0 +1,96 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
import * as FmTealActorProfile from "./profile.ts";
import * as FmTealFeedDefs from "../feed/defs.ts";
const _miniProfileViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.actor.defs#miniProfileView"),
),
/**
* IPLD of the avatar
*/
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
/**
* The decentralized identifier of the actor
*/
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()),
});
const _profileViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.actor.defs#profileView"),
),
/**
* IPLD of the avatar
*/
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
/**
* IPLD of the banner image
*/
banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* Free-form profile description text.
*/
description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.
*/
get descriptionFacets() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
);
},
/**
* The decentralized identifier of the actor
*/
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The user's most recent item featured on their profile.
*/
get featuredItem() {
return /*#__PURE__*/ v.optional(FmTealActorProfile.featuredItemSchema);
},
get status() {
return /*#__PURE__*/ v.optional(statusViewSchema);
},
});
const _statusViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.actor.defs#statusView"),
),
/**
* The datetime after which the status is no longer current.
*/
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
get item() {
return /*#__PURE__*/ v.optional(FmTealFeedDefs.playViewSchema);
},
/**
* The datetime at which the status was recorded.
*/
time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
});
type miniProfileView$schematype = typeof _miniProfileViewSchema;
type profileView$schematype = typeof _profileViewSchema;
type statusView$schematype = typeof _statusViewSchema;
export interface miniProfileViewSchema extends miniProfileView$schematype {}
export interface profileViewSchema extends profileView$schematype {}
export interface statusViewSchema extends statusView$schematype {}
export const miniProfileViewSchema =
_miniProfileViewSchema as miniProfileViewSchema;
export const profileViewSchema = _profileViewSchema as profileViewSchema;
export const statusViewSchema = _statusViewSchema as statusViewSchema;
export interface MiniProfileView extends v.InferInput<
typeof miniProfileViewSchema
> {}
export interface ProfileView extends v.InferInput<typeof profileViewSchema> {}
export interface StatusView extends v.InferInput<typeof statusViewSchema> {}
@@ -0,0 +1,36 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealActorDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfile", {
params: /*#__PURE__*/ v.object({
/**
* The actor's DID or handle
*/
actor: /*#__PURE__*/ v.actorIdentifierString(),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actor() {
return FmTealActorDefs.profileViewSchema;
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.actor.getProfile": mainSchema;
}
}
@@ -0,0 +1,40 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealActorDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfiles", {
params: /*#__PURE__*/ v.object({
/**
* Array of actor DIDs or handles
* @minLength 1
*/
actors: /*#__PURE__*/ v.constrain(
/*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()),
[/*#__PURE__*/ v.arrayLength(1)],
),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actors() {
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.actor.getProfiles": mainSchema;
}
}
@@ -0,0 +1,102 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
const _featuredItemSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.actor.profile#featuredItem"),
),
/**
* The MusicBrainz ID URI of the item, formatted as mbid:<uuid>
*/
mbid: /*#__PURE__*/ v.genericUriString(),
/**
* The MusicBrainz entity type of the item.
*/
type: /*#__PURE__*/ v.string<
"artist" | "recording" | "release" | (string & {})
>(),
});
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profile"),
/**
* Small image to be displayed next to posts from account. AKA, 'profile picture'
* @accept image/jpeg, image/png
* @maxSize 1000000
*/
avatar: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
/*#__PURE__*/ v.blobSize(1000000),
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
]),
),
/**
* Larger horizontal image to display behind profile view.
* @accept image/jpeg, image/png
* @maxSize 1000000
*/
banner: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
/*#__PURE__*/ v.blobSize(1000000),
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
]),
),
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* Free-form profile description text.
* @maxLength 2560
* @maxGraphemes 256
*/
description: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 2560),
/*#__PURE__*/ v.stringGraphemes(0, 256),
]),
),
/**
* Annotations of text in the profile description (mentions, URLs, hashtags, etc).
*/
get descriptionFacets() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
);
},
/**
* @maxLength 640
* @maxGraphemes 64
*/
displayName: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 640),
/*#__PURE__*/ v.stringGraphemes(0, 64),
]),
),
/**
* The user's most recent item featured on their profile.
*/
get featuredItem() {
return /*#__PURE__*/ v.optional(featuredItemSchema);
},
}),
);
type featuredItem$schematype = typeof _featuredItemSchema;
type main$schematype = typeof _mainSchema;
export interface featuredItemSchema extends featuredItem$schematype {}
export interface mainSchema extends main$schematype {}
export const featuredItemSchema = _featuredItemSchema as featuredItemSchema;
export const mainSchema = _mainSchema as mainSchema;
export interface FeaturedItem extends v.InferInput<typeof featuredItemSchema> {}
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.actor.profile": mainSchema;
}
}
@@ -0,0 +1,42 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profileStatus"),
/**
* The onboarding completion status
*/
completedOnboarding: /*#__PURE__*/ v.string<
| "complete"
| "none"
| "playOnboarding"
| "profileOnboarding"
| (string & {})
>(),
/**
* The timestamp when this status was created
*/
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The timestamp when this status was last updated
*/
updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
}),
);
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.actor.profileStatus": mainSchema;
}
}
@@ -0,0 +1,59 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealActorDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.searchActors", {
params: /*#__PURE__*/ v.object({
/**
* Cursor for pagination
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The maximum number of actors to return
* @minimum 1
* @maximum 25
*/
limit: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
/*#__PURE__*/ v.integerRange(1, 25),
]),
),
/**
* The search query
* @maxLength 640
* @maxGraphemes 128
*/
q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 640),
/*#__PURE__*/ v.stringGraphemes(0, 128),
]),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get actors() {
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
},
/**
* Cursor for pagination
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.actor.searchActors": mainSchema;
}
}
@@ -0,0 +1,36 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealFeedDefs from "../feed/defs.ts";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.literal("self"),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.actor.status"),
/**
* The datetime after which the status is no longer current. If unavailable, default to 10 minutes after the start time.
*/
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
get item() {
return FmTealFeedDefs.playViewSchema;
},
/**
* The datetime at which the status was recorded.
*/
time: /*#__PURE__*/ v.datetimeString(),
}),
);
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.actor.status": mainSchema;
}
}
@@ -0,0 +1,109 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
const _artistSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.feed.defs#artist"),
),
/**
* The MusicBrainz artist ID URI, formatted as mbid:<uuid>
*/
artistMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the artist
* @minLength 1
* @maxLength 256
* @maxGraphemes 2560
*/
artistName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
});
const _playViewSchema = /*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.literal("fm.teal.feed.defs#playView"),
),
/**
* Array of artists in order of original appearance.
*/
get artists() {
return /*#__PURE__*/ v.array(artistSchema);
},
/**
* The length of the track in seconds
*/
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
/**
* The ISRC code associated with the recording
*/
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The canonical URI identifying the listening surface or music service.
*/
musicServiceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The exact URI where the listening event originated.
*/
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The datetime at which playback began.
*/
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
*/
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
*/
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the release/album
* @maxLength 256
* @maxGraphemes 2560
*/
releaseName: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
/**
* A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided.
* @maxLength 256
* @maxGraphemes 2560
*/
submissionClientAgent: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
/**
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
*/
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the track
* @minLength 1
* @maxLength 256
* @maxGraphemes 2560
*/
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
});
type artist$schematype = typeof _artistSchema;
type playView$schematype = typeof _playViewSchema;
export interface artistSchema extends artist$schematype {}
export interface playViewSchema extends playView$schematype {}
export const artistSchema = _artistSchema as artistSchema;
export const playViewSchema = _playViewSchema as playViewSchema;
export interface Artist extends v.InferInput<typeof artistSchema> {}
export interface PlayView extends v.InferInput<typeof playViewSchema> {}
@@ -0,0 +1,44 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealFeedDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getActorFeed", {
params: /*#__PURE__*/ v.object({
/**
* The author's DID for the play
*/
authorDID: /*#__PURE__*/ v.didString(),
/**
* The cursor to start the query from
*/
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The upper limit of tracks to get per request. Default is 20, max is 50.
*/
limit: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get plays() {
return /*#__PURE__*/ v.array(FmTealFeedDefs.playViewSchema);
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.feed.getActorFeed": mainSchema;
}
}
@@ -0,0 +1,40 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealFeedDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getPlay", {
params: /*#__PURE__*/ v.object({
/**
* The author's DID for the play
*/
authorDID: /*#__PURE__*/ v.didString(),
/**
* The record key of the play
*/
rkey: /*#__PURE__*/ v.recordKeyString(),
}),
output: {
type: "lex",
schema: /*#__PURE__*/ v.object({
get play() {
return FmTealFeedDefs.playViewSchema;
},
}),
},
});
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface $params extends v.InferInput<mainSchema["params"]> {}
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
declare module "@atcute/lexicons/ambient" {
interface XRPCQueries {
"fm.teal.feed.getPlay": mainSchema;
}
}
@@ -0,0 +1,140 @@
import type {} from "@atcute/lexicons";
import * as v from "@atcute/lexicons/validations";
import type {} from "@atcute/lexicons/ambient";
import * as FmTealFeedDefs from "./defs.ts";
const _mainSchema = /*#__PURE__*/ v.record(
/*#__PURE__*/ v.tidString(),
/*#__PURE__*/ v.object({
$type: /*#__PURE__*/ v.literal("fm.teal.feed.play"),
/**
* DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs.
* @deprecated
*/
artistMbIds: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(/*#__PURE__*/ v.string()),
),
/**
* DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance.
* @deprecated
*/
artistNames: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
),
/**
* Array of artists in order of original appearance.
*/
get artists() {
return /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.array(FmTealFeedDefs.artistSchema),
);
},
/**
* The length of the track in seconds
*/
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
/**
* The ISRC code associated with the recording
*/
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
/**
* The canonical URI identifying the listening surface or music service.
*/
musicServiceUri: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.genericUriString(),
),
/**
* The exact URI where the listening event originated.
*/
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The datetime at which playback began.
*/
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
/**
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
*/
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities.
* @maxLength 128
* @maxGraphemes 1280
*/
releaseDiscriminant: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 128),
/*#__PURE__*/ v.stringGraphemes(0, 1280),
]),
),
/**
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
*/
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the release/album
* @maxLength 256
* @maxGraphemes 2560
*/
releaseName: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
/**
* A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided.
* @maxLength 256
* @maxGraphemes 2560
*/
submissionClientAgent: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
),
/**
* Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities.
* @maxLength 128
* @maxGraphemes 1280
*/
trackDiscriminant: /*#__PURE__*/ v.optional(
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(0, 128),
/*#__PURE__*/ v.stringGraphemes(0, 1280),
]),
),
/**
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
*/
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
/**
* The name of the track
* @minLength 1
* @maxLength 256
* @maxGraphemes 2560
*/
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
/*#__PURE__*/ v.stringLength(1, 256),
/*#__PURE__*/ v.stringGraphemes(0, 2560),
]),
}),
);
type main$schematype = typeof _mainSchema;
export interface mainSchema extends main$schematype {}
export const mainSchema = _mainSchema as mainSchema;
export interface Main extends v.InferInput<typeof mainSchema> {}
declare module "@atcute/lexicons/ambient" {
interface Records {
"fm.teal.feed.play": mainSchema;
}
}
+19 -6
View File
@@ -12,8 +12,9 @@ import TransformerManager from "./common/transforms/TransformerManager.ts";
import type {TransformerCommonConfig} from "../core/Atomic.ts";
import prom from 'prom-client';
import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.ts";
import { version } from "./version.ts";
import { version, stable } from "./version.ts";
import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts";
import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts";
let root: ReturnType<typeof createRoot>;
export interface RootOptions {
@@ -96,12 +97,14 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
dbFunc = async () => db;
}
const cEmitter = new WildcardEmitter();
const cEmitter = new WildcardEmitter<MSBackendEventMap>();
// do nothing, just catch
cEmitter.on('error', (e) => null);
const sEmitter = new WildcardEmitter();
cEmitter.on('error', (e) => {
logger.warn(new Error('Client emitter threw an error', {cause: e}));
});
const sEmitter = new WildcardEmitter<MSBackendEventMap>();
sEmitter.on('error', (e) => {
const f = e;
logger.warn(new Error('Source emitter threw an error', {cause: e}));
});
const transformerManager = new TransformerManager(logger, maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc());
@@ -127,8 +130,18 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
const portVal: number | string = process.env.PORT ?? port;
const versionParts: string[] = [];
if(stable === version) {
versionParts.push(stable);
} else {
if(stable !== 'unknown') {
versionParts.push(stable);
}
versionParts.push(version);
}
return createContainer().add({
version,
version: versionParts.join('-'),
configDir: getConfigDir(),
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
// @ts-ignore
+8 -7
View File
@@ -5,6 +5,7 @@ import type { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.ts";
import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.ts";
import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.ts";
import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.ts";
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
export class Notifiers {
@@ -14,21 +15,21 @@ export class Notifiers {
emitter: EventEmitter;
clientEmitter: EventEmitter;
sourceEmitter: EventEmitter;
clientEmitter: EventEmitter<MSBackendEventMap>;
sourceEmitter: EventEmitter<MSBackendEventMap>;
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter, parentLogger: Logger) {
constructor(emitter: EventEmitter, clientEmitter: EventEmitter<MSBackendEventMap>, sourceEmitter: EventEmitter<MSBackendEventMap>, parentLogger: Logger) {
this.emitter = emitter;
this.clientEmitter = clientEmitter;
this.sourceEmitter = sourceEmitter;
this.logger = childLogger(parentLogger, 'Notifiers');
this.sourceEmitter.on('notify', async (payload: WebhookPayload) => {
await this.notify(payload);
this.sourceEmitter.on('notify', async (payload) => {
await this.notify(payload.data);
})
this.clientEmitter.on('notify', async (payload: WebhookPayload) => {
await this.notify(payload);
this.clientEmitter.on('notify', async (payload) => {
await this.notify(payload.data);
})
}
+42 -12
View File
@@ -18,6 +18,7 @@ import { prettifyError, ZodError } from 'zod';
import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvConfigSchema, generateConfigLocation, transformPresetEnv, type CommonConfigPrimitives, type UnparsedConfig } from '../common/infrastructure/config/common.ts';
import type { CommonClientConfig } from '../common/infrastructure/config/client/index.ts';
import { getClientEnvSchema, validateClientAIOJson, validateClientJson, type ClientTypeConfigMap } from '../common/infrastructure/config/client/clientsMap.ts';
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
type UnparsedClientConfig = UnparsedConfig<ClientType>;
@@ -37,13 +38,16 @@ export default class ScrobbleClients {
internalConfig: InternalConfig;
emitter: WildcardEmitter;
emitter: WildcardEmitter<MSBackendEventMap>;
sourceEmitter: WildcardEmitter;
sourceEmitter: WildcardEmitter<MSBackendEventMap>;
scrobbleToNamesWarnings: string[] = [];
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) {
configErrors: (string | Error)[] = [];
instantiateErrors: Error[] = [];
constructor(emitter: WildcardEmitter<MSBackendEventMap>, sourceEmitter: WildcardEmitter<MSBackendEventMap>, internal: InternalConfigOptional, parentLogger: Logger) {
this.emitter = emitter;
this.sourceEmitter = sourceEmitter;
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
@@ -52,14 +56,14 @@ export default class ScrobbleClients {
logger: this.logger
}
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
this.sourceEmitter.on('playerUpdate', async (payload) => {
// agressively update Now Playing so scrobblers that display based on duration are mostly synced
// but aggressively *stop* updating if state becomes stale/orphaned
this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
});
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
await this.scrobble(payload.data, payload.options);
this.sourceEmitter.on('discoveredToScrobble', async (payload) => {
await this.scrobble(payload.data.data, payload.data.options);
});
}
@@ -98,6 +102,8 @@ export default class ScrobbleClients {
buildClientsFromConfig = async () => {
const unparsedConfigs: UnparsedClientConfig[] = [];
this.configErrors = [];
this.instantiateErrors = [];
let configFile;
try {
@@ -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);
}
}
}
+4 -5
View File
@@ -24,7 +24,7 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient
import { fromStream } from '@atcute/repo';
import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";
import { isAbortError } from "abort-controller-x";
import type { FmTealAlphaFeedPlay } from "../common/vendor/teal/lexicons/index.ts";
import type { FmTealAlphaFeedPlay, FmTealFeedPlay } from "../common/vendor/teal/lexicons/index.ts";
export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
@@ -209,11 +209,11 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
logger.info('Starting CAR conversion to historical plays...');
for await (const entry of repo) {
if(entry.collection === 'fm.teal.alpha.feed.play') {
if(entry.collection === 'fm.teal.feed.play' || entry.collection === 'fm.teal.alpha.feed.play') {
let play: PlayObject;
try {
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, {
web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined,
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main, {
web: did !== undefined ? `at://did:plc:${did}/${entry.collection}/${entry.rkey}` : undefined,
playId: entry.rkey,
user: did
});
@@ -287,4 +287,3 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
}
}
+2 -2
View File
@@ -159,7 +159,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
const isNextapi = nextQs === 'true';
const session = await bsseDef.createSession(req, res);
scrobbleSources.emitter.on('*', (payload: any, eventName: string) => {
scrobbleSources.emitter.onAny((eventName: string, payload: any) => {
if(payload.from !== undefined) {
if(isNextapi) {
session.push({event: eventName, ...payload}, eventName);
@@ -168,7 +168,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
}
});
scrobbleClients.emitter.on('*', (payload: any, eventName: string) => {
scrobbleClients.emitter.onAny((eventName: string, payload: any) => {
if(payload.from !== undefined) {
if(isNextapi) {
session.push({event: eventName, ...payload}, eventName);
+1 -1
View File
@@ -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,
+11 -1
View File
@@ -210,6 +210,7 @@ export default class DeezerInternalSource extends MemorySource {
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
this.setStatus('Checking for new Plays');
let resp: DeezerHistoryResponse;
try {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
@@ -221,7 +222,7 @@ export default class DeezerInternalSource extends MemorySource {
start: 0
});
// returns listening history in descending order (newest to oldest)
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
resp = (await this.callApi(req)) as DeezerHistoryResponse;
let errList: string[] = [];
if('error' in resp.results) {
errList = resp.results.error;
@@ -243,6 +244,9 @@ export default class DeezerInternalSource extends MemorySource {
}
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
} catch (e) {
if(resp !== undefined) {
this.logger.debug({body: resp},'Deezer raw body response');
}
throw new Error('Failed to get recently played tracks', {cause: e});
}
}
@@ -312,6 +316,12 @@ export default class DeezerInternalSource extends MemorySource {
const err = new Error((error as string[]).join(' | '));
throw err;
}
if(resp.statusCode !== 200) {
this.logger.debug(`Non 200 status code returned: ${resp.statusCode}`);
}
if(!resp.header['Content-Type']?.includes('json')) {
this.logger.debug(`Non json content-type returned: ${resp.header['Content-Type']}`);
}
return resp.body;
} catch (e) {
if(retries < maxRequestRetries) {
+48 -15
View File
@@ -1,6 +1,5 @@
import { childLogger, type Logger } from '@foxxmd/logging';
import type EventEmitter from "events";
import type {InternalConfig, InternalConfigOptional} from "../common/infrastructure/Atomic.ts";
import { clientTypes, isSourceType } from "../../core/Atomic.ts";
import { sourceTypes } from "../../core/Atomic.ts";
@@ -18,6 +17,7 @@ import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvC
import { getSourceEnvSchema, validateSourceAIOJson, validateSourceJson } from '../common/infrastructure/config/source/sourcesMap.ts';
import type { SourceTypeConfigMap } from "../common/infrastructure/config/source/sourcesMap.ts";
import { stripIndents } from 'common-tags';
import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
type UnparsedSourceConfig = UnparsedConfig<SourceType>;
@@ -28,10 +28,12 @@ export default class ScrobbleSources {
sources: AbstractSource[] = [];
logger: Logger;
internalConfig: InternalConfig;
configErrors: (string | Error)[] = [];
instantiateErrors: Error[] = [];
emitter: WildcardEmitter;
emitter: WildcardEmitter<MSBackendEventMap>;
constructor(emitter: EventEmitter, internal: InternalConfigOptional, parentLogger: Logger) {
constructor(emitter: WildcardEmitter<MSBackendEventMap>, internal: InternalConfigOptional, parentLogger: Logger) {
this.emitter = emitter;
this.logger = childLogger(parentLogger, 'Sources');
this.internalConfig = {
@@ -117,6 +119,8 @@ export default class ScrobbleSources {
buildSourcesFromConfig = async () => {
const unparsedConfigs: UnparsedSourceConfig[] = [];
this.configErrors = [];
this.instantiateErrors = [];
let configFile;
try {
@@ -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);
}
}
}
+135 -9
View File
@@ -7,15 +7,22 @@ import path from "path";
import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';
import ScrobbleSources from '../../sources/ScrobbleSources.ts';
import EventEmitter from "events";
import { WildcardEmitter } from '../../common/WildcardEmitter.ts';
import {loggerTest} from '@foxxmd/logging';
import { clientTypes } from "../../../core/Atomic.ts";
import { projectRootDir } from "../../common/infrastructure/Atomic.ts";
import { sourceTypes } from "../../../core/Atomic.ts";
import { difference } from '../../utils.ts';
import { validateSourceJson } from '../../common/infrastructure/config/source/sourcesMap.ts';
import { getSourceEnvSchema, validateSourceJson } from '../../common/infrastructure/config/source/sourcesMap.ts';
import { readJson } from '../../utils/DataUtils.ts';
import { prettifyError, ZodError } from 'zod';
import { validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts';
import { getClientEnvSchema, validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts';
import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';
import { zocker } from "zocker";
import { generateCommonComponentEnvConfigSchema } from '../../common/infrastructure/config/common.ts';
import { serializeError } from 'serialize-error';
import { httpUrl, wsUrl } from '../../utils/ZodUtils.ts';
import { generateNormalHttpUrl, generateNormalWSUrl } from '../../../core/tests/utils/apiFixtures.ts';
chai.use(asPromised);
@@ -45,6 +52,11 @@ describe('Sample Configs', function () {
describe('Source Configs', function () {
let reset: any;
let ogKeys: string[] = [];
before(function() {
ogKeys = Object.keys(process.env);
});
beforeEach(async function() {
reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'sourceConfigParse'});
@@ -52,6 +64,11 @@ describe('Sample Configs', function () {
afterEach(async function() {
await reset();
const envKeys = Object.keys(process.env);
const addedKeys = difference(envKeys, ogKeys);
for(const k of addedKeys) {
delete process.env[k];
}
});
@@ -61,7 +78,6 @@ describe('Sample Configs', function () {
it(`Sample ${componentType}.json parses and validates in isolation`, async function () {
this.timeout(5000);
const emitter = new EventEmitter();
await copyFile(samplePath(componentType), `${componentType}.json`);
let fileContents = await readJson(`${componentType}.json`);
@@ -82,7 +98,7 @@ describe('Sample Configs', function () {
it(`Sample ${componentType}.json parses and validates in ScrobbleSources`, async function () {
this.timeout(5000);
const emitter = new EventEmitter();
const emitter = new WildcardEmitter<MSBackendEventMap>();
await copyFile(samplePath(componentType), `${componentType}.json`);
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
@@ -96,11 +112,68 @@ describe('Sample Configs', function () {
await s.destroy();
}
});
it(`Sample ${componentType} ENV parses and validates in ScrobbleSources`, async function () {
this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>();
const envSchema = await getSourceEnvSchema(componentType);
let zocObj = zocker(envSchema.env)
.supply(httpUrl, () => generateNormalHttpUrl())
.supply(wsUrl, () => generateNormalWSUrl())
switch(componentType) {
case 'azuracast':
zocObj = zocObj.supply(envSchema.env.shape.AZURA_LISTENERS_NUM, true)
break;
case 'rocksky':
zocObj = zocObj.supply(envSchema.env.shape.SOURCE_ROCKSKY_HANDLE, 'foxxmd.dev')
break;
case 'applemusic':
delete zocObj.schema.shape.APPLEMUSIC_KEY_ID;
delete zocObj.schema.shape.APPLEMUSIC_TEAM_ID;
delete zocObj.schema.shape.APPLEMUSIC_KEY_P8
zocObj = zocObj.supply(envSchema.env.shape.APPLEMUSIC_MEDIA_USER_TOKEN, '1234')
.supply(envSchema.env.shape.APPLEMUSIC_TOKEN, '5678')
.supply(envSchema.env.shape.APPLEMUSIC_ORIGIN_HEADER, 'https://apple.music.com');
}
const componentMockData = zocObj.generate();
const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase());
const primitiveMockData = zocker(primitives).generate();
primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
const mockData = {
...componentMockData,
...primitiveMockData
}
for(const key of Object.keys(mockData)) {
if(mockData[key] === undefined || mockData[key] === null) {
continue;
}
process.env[key] = mockData[key].toString();
}
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
version: 'test'
}, loggerTest);
await sources.buildSourcesFromConfig()
expect(sources.configErrors,`${sources.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
expect(sources.instantiateErrors, `${sources.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
expect(sources.sources).length(1);
expect(sources.sources[0].type).eq(componentType);
for(const s of sources.sources) {
await s.destroy();
}
});
}
});
describe('Client Configs', function () {
let reset: any;
let ogKeys: string[] = [];
beforeEach(async function() {
reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'clientConfigParse'});
@@ -108,6 +181,11 @@ describe('Sample Configs', function () {
afterEach(async function() {
await reset();
const envKeys = Object.keys(process.env);
const addedKeys = difference(envKeys, ogKeys);
for(const k of addedKeys) {
delete process.env[k];
}
});
for(const componentType of clientTypes) {
@@ -134,11 +212,11 @@ describe('Sample Configs', function () {
});
it(`Sample ${componentType}.json parses and validates in ScrobbleClients`, async function () {
this.timeout(500000);
this.timeout(5000);
const emitter = new EventEmitter();
const emitter = new WildcardEmitter<MSBackendEventMap>();
await copyFile(samplePath(componentType), `${componentType}.json`);
const clients = new ScrobbleClients(emitter, new EventEmitter, {
const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
version: 'test'
@@ -147,6 +225,54 @@ describe('Sample Configs', function () {
await clients.buildClientsFromConfig();
expect(clients.clients).length(1);
});
it(`Sample ${componentType} ENV parses and validates in ScrobbleClients`, async function () {
this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>();
const envSchema = await getClientEnvSchema(componentType);
let zocObj = zocker(envSchema.env)
.supply(httpUrl, () => generateNormalHttpUrl())
.supply(wsUrl, () => generateNormalWSUrl())
switch(componentType) {
case 'rocksky':
zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_HANDLE, 'foxxmd.dev')
zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_TOKEN, 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ')
break;
case 'discord':
zocObj = zocObj.supply(envSchema.env.shape.DISCORD_STATUS_OVERRIDE_ALLOW, 'online');
zocObj = zocObj.supply(envSchema.env.shape.DISCORD_IPC_LOCATIONS, '/path/to/ipc,192.168.0.100:8999')
}
const componentMockData = zocObj.generate();
const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase());
const primitiveMockData = zocker(primitives).generate();
primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
const mockData = {
...componentMockData,
...primitiveMockData
}
for(const key of Object.keys(mockData)) {
if(mockData[key] === undefined || mockData[key] === null) {
continue;
}
process.env[key] = mockData[key].toString();
}
const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
version: 'test'
},
loggerTest);
await clients.buildClientsFromConfig()
expect(clients.configErrors,`${clients.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
expect(clients.instantiateErrors, `${clients.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty;
expect(clients.clients).length(1);
expect(clients.clients[0].type).eq(componentType);
});
}
});
});
@@ -172,7 +298,7 @@ describe('Global ENVs with Config', function () {
process.env.MPRIS_ID = 'test';
process.env.MPRIS_ENABLE = 'true';
const emitter = new EventEmitter();
const emitter = new WildcardEmitter<MSBackendEventMap>();
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
@@ -189,7 +315,7 @@ describe('Global ENVs with Config', function () {
process.env.MPRIS_ID = 'test';
process.env.MPRIS_ENABLE = 'true';
const emitter = new EventEmitter();
const emitter = new WildcardEmitter<MSBackendEventMap>();
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
+65 -28
View File
@@ -32,6 +32,7 @@ import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';
import { WildcardEmitter } from '../../common/WildcardEmitter.ts';
import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts';
import { loggerNoop } from '../../common/MaybeLogger.ts';
import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';
chai.use(asPromised);
@@ -1148,13 +1149,13 @@ describe('Scrobble Clients Behavior', function() {
describe('Source filtering', function() {
let cEmitter: WildcardEmitter,
sEmitter: WildcardEmitter,
let cEmitter: WildcardEmitter<MSBackendEventMap>,
sEmitter: WildcardEmitter<MSBackendEventMap>,
clients: ScrobbleClients;
beforeEach(function() {
cEmitter = new WildcardEmitter();
sEmitter = new WildcardEmitter();
cEmitter = new WildcardEmitter<MSBackendEventMap>();
sEmitter = new WildcardEmitter<MSBackendEventMap>();
clients = new ScrobbleClients(cEmitter, sEmitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
@@ -1169,10 +1170,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['foo']
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['foo'],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).is.empty;
@@ -1190,10 +1197,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test']
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test'],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).length.greaterThan(0);
@@ -1211,10 +1224,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test']
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test'],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).is.empty;
@@ -1232,10 +1251,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test foo']
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['test foo'],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).is.empty;
@@ -1253,10 +1278,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: []
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: [],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).is.empty;
@@ -1274,10 +1305,16 @@ describe('Scrobble Clients Behavior', function() {
clients.clients.push(testClient);
sEmitter.emit('discoveredToScrobble', {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['TesT foO']
type: 'jellyfin',
from: 'source',
name: 'test',
data: {
data: [generatePlay()],
options: {
scrobbleFrom: 'testSource',
scrobbleTo: ['TesT foO'],
checkTime: dayjs()
}
}
});
expect(clients.scrobbleToNamesWarnings).is.empty;
+7 -4
View File
@@ -21,11 +21,13 @@ import DeezerInternalSource from "../../sources/DeezerInternalSource.ts";
import type {DeezerInternalSourceOptions} from "../../common/infrastructure/config/source/deezer.ts";
import { artistCreditsToNames } from "../../../core/StringUtils.ts";
import type { MarkOptional } from "ts-essentials";
import { WildcardEmitter } from "../../common/WildcardEmitter.ts";
import type { MSBackendEventMap } from "../../common/infrastructure/MSBackendEventMap.ts";
chai.use(asPromised);
const emitter = new EventEmitter();
const emitter = new WildcardEmitter<MSBackendEventMap>();
const generateSource = async () => {
const source = new TestSource('spotify', 'test-basic', {id: `test-${Date.now()}`}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
await source.initialize();
@@ -96,11 +98,12 @@ describe('Sources use transform plays correctly', function () {
expect(discovered.length).eq(1);
expect(discovered[0].data.track).is.eq('my cool track');
const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<{data: [PlayObject] }>;
const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<MSBackendEventMap['discoveredToScrobble'][0]>;
source.handle(discovered);
const e = await pAwaiter;
expect(e.data.length).is.eq(1);
expect(e.data[0].data.track).is.eq('my fun track');
const res: PlayObject[] = !Array.isArray(e.data.data) ? [e.data.data] : e.data.data;
expect(res.length).is.eq(1);
expect(res[0].data.track).is.eq('my fun track');
});
it('Transforms play existing comparison', async function() {
+24 -2
View File
@@ -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);
});
});
+19 -3
View File
@@ -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
}
}
+1 -1
View File
@@ -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)) {
+2 -2
View File
@@ -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() === '') {
+40 -1
View File
@@ -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?$/});
+2
View File
@@ -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;
};
+6 -1
View File
@@ -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
View File
@@ -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
}
+14
View File
@@ -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