mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b94b2e368c | ||
|
|
ea08239aeb | ||
|
|
7456ab647b | ||
|
|
9ee6a2a01c | ||
|
|
898938cbaf | ||
|
|
b9b81a3152 | ||
|
|
2eb3933083 | ||
|
|
69f828943f | ||
|
|
c6d705dca0 | ||
|
|
136fc81e53 | ||
|
|
fcc1f709f1 | ||
|
|
4a974a83d9 | ||
|
|
f6f70ca6ca | ||
|
|
38b90bb059 | ||
|
|
232c802b44 | ||
|
|
e335b44abd | ||
|
|
115911c63a | ||
|
|
f45493d10e | ||
|
|
574b723fa4 | ||
|
|
02f1eca649 | ||
|
|
212afaa5e5 | ||
|
|
006fbe1079 | ||
|
|
5cecac937b | ||
|
|
297c7dee95 | ||
|
|
42a6887141 | ||
|
|
cbdb58b36e | ||
|
|
ae20d46456 | ||
|
|
6b12bd2ef2 | ||
|
|
dde19cfc83 | ||
|
|
0938e95e1d | ||
|
|
3a40478cb1 | ||
|
|
935845a976 | ||
|
|
b494e8f93f | ||
|
|
9e13ead40f | ||
|
|
3b2f5ee1c8 | ||
|
|
51de79b92f | ||
|
|
b6f7e0e586 | ||
|
|
c69af47978 | ||
|
|
edde8f9e74 | ||
|
|
72d702acf0 | ||
|
|
67b13ffe40 | ||
|
|
b464c7507f | ||
|
|
3cf5b82247 | ||
|
|
1571f06662 | ||
|
|
b3fb04a6ae | ||
|
|
cb2a6168fd | ||
|
|
56fcbedff9 | ||
|
|
548c614b0a | ||
|
|
d3469aee7a | ||
|
|
0109cc6fcd | ||
|
|
1988fc5f54 | ||
|
|
aa93600166 | ||
|
|
09e9d0f79a | ||
|
|
9c84e2d8e3 | ||
|
|
4b8d4932aa | ||
|
|
970e200e76 | ||
|
|
3e05b3f964 | ||
|
|
86b8b4ad90 | ||
|
|
64d160782b | ||
|
|
39f8367392 | ||
|
|
62905e8010 | ||
|
|
203f4c343e | ||
|
|
3b93073694 | ||
|
|
649280582e | ||
|
|
0ad86ad692 | ||
|
|
e80f35f1b1 | ||
|
|
a885079a07 | ||
|
|
ab994770ab | ||
|
|
17d7882710 | ||
|
|
1dcbf92fed | ||
|
|
8686309993 | ||
|
|
b83f8872c2 | ||
|
|
9f22c56e87 | ||
|
|
6b86ef8629 | ||
|
|
d0001d2f62 | ||
|
|
e381be2f7e | ||
|
|
c1e9757aed | ||
|
|
61fbc39a91 | ||
|
|
4c764879d7 | ||
|
|
692b2160bf | ||
|
|
344c0a15ad | ||
|
|
f44835dfba | ||
|
|
204cc0bdb8 | ||
|
|
4b7542ddd9 | ||
|
|
1fc3ad1bd4 | ||
|
|
9f34929140 | ||
|
|
1178b0b6fb | ||
|
|
cad42cba04 | ||
|
|
68b7385f31 | ||
|
|
9a93f13a7d | ||
|
|
649ccde43d | ||
|
|
fc06c6a8a9 | ||
|
|
d089b960f9 | ||
|
|
c210590c8f | ||
|
|
78aa418b7d | ||
|
|
48cc18d373 | ||
|
|
258cd61b4b | ||
|
|
185ccdf71c | ||
|
|
b9dad6149b | ||
|
|
9ae74a193f | ||
|
|
99b30956ba | ||
|
|
93a5775363 | ||
|
|
e8a998ddda | ||
|
|
1b02dc684e | ||
|
|
23813a65ef | ||
|
|
0583c66743 | ||
|
|
cd4e88b232 | ||
|
|
8be739efb4 | ||
|
|
3f2e29c5c8 | ||
|
|
b109157350 | ||
|
|
ce15c4049c | ||
|
|
5a7c01a6d9 | ||
|
|
befd1ac043 | ||
|
|
714678fdab | ||
|
|
2a58edd9b2 | ||
|
|
abfceeb5f8 | ||
|
|
d7c88d4191 | ||
|
|
a44fe48314 | ||
|
|
29ffc6b804 | ||
|
|
3bdc9447b8 | ||
|
|
4a27806825 | ||
|
|
b8cd53f554 | ||
|
|
e7218074f8 | ||
|
|
a48ada9ac3 | ||
|
|
bc28de66b1 | ||
|
|
bc18993cd3 | ||
|
|
d280ca5dd2 | ||
|
|
155d8a3252 | ||
|
|
68d9417cf7 | ||
|
|
9b7483db91 | ||
|
|
7c0797b496 | ||
|
|
1b3cbe9ab3 | ||
|
|
15e0332ac2 | ||
|
|
d532c85c2a | ||
|
|
eecbea4cab | ||
|
|
823dd5d011 | ||
|
|
0fa9a8df10 | ||
|
|
a76213a7a0 | ||
|
|
a42ab3d8fd | ||
|
|
475a3f1276 | ||
|
|
6dcfd6028b | ||
|
|
54247e89c2 | ||
|
|
433ea532ae |
@@ -12,10 +12,10 @@ jobs:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e #4.0.0
|
||||
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 #4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4 #1.0.16
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f #1.2.2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
+10
-10
@@ -78,14 +78,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -94,13 +94,13 @@ jobs:
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e #v4.3.0
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
|
||||
with:
|
||||
# specify foreign repo#sha from PR that is used to build image
|
||||
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #7.0.1
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -147,25 +147,25 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 #6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Create comment
|
||||
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff #3.0.3
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 #3.0.5
|
||||
with:
|
||||
recreate: true
|
||||
header: "pr-release"
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -102,14 +102,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -118,13 +118,13 @@ jobs:
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e #v4.3.0
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
env:
|
||||
VARS_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #7.0.1
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -170,25 +170,25 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 #6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
|
||||
@@ -20,16 +20,16 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo (gha)
|
||||
if: ${{ !env.ACT }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out the repo (act)
|
||||
if: ${{ env.ACT }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #7.0.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
+1
-1
@@ -159,4 +159,4 @@ storybook-static
|
||||
|
||||
bun.lock
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lib
|
||||
lib
|
||||
|
||||
docs/api/
|
||||
static/openapi.json
|
||||
@@ -6,7 +6,7 @@
|
||||
| `DISCORD_TOKEN` | string | | |
|
||||
| `DISCORD_ARTWORK` | boolean OR string OR array | | |
|
||||
| `DISCORD_APPLICATION_ID` | string | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string OR array | | |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string OR array | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string OR array | | |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | | |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | |
|
||||
@@ -5,4 +5,4 @@
|
||||
| `ROCKSKY_ENABLE` | boolean | true | Should this component be used? |
|
||||
| `ROCKSKY_KEY` | string | | API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account |
|
||||
| `ROCKSKY_TOKEN` | string | | Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account, like: |
|
||||
| _**`ROCKSKY_HANDLE`**_ | string | | The **fully-qualified** handle for your ATPRoto/Bluesky account |
|
||||
@@ -557,10 +557,12 @@ Remember, this URL need to be accessible on the public internet.
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :----------------------------- | ----------------- | --------------------- | :--------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_APPLICATION_ID`**_ | string | | Application ID |
|
||||
| `DISCORD_IPC_LOCATIONS` | string | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
</Config>
|
||||
@@ -323,12 +323,14 @@ The order in which multi-scrobbler determines what image to use is the same as t
|
||||
## Configuration
|
||||
|
||||
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
| Environmental Variable | Type | Default | Description |
|
||||
| :--------------------------------- | ----------------- | --------------------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| _**`DISCORD_ID`**_ | string | | A globally unique ID EX `myComponentId` |
|
||||
| `DISCORD_NAME` | string | Value of `DISCORD_ID` | A vanity name EX `My Cool Component` |
|
||||
| _**`DISCORD_TOKEN`**_ | string | | User Token acquired from an active Discord sessions |
|
||||
| `DISCORD_APPLICATION_ID` | string | | Application ID used to display album art |
|
||||
| `DISCORD_ARTWORK` | boolean | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
|
||||
| `DISCORD_ARTWORK_DEFAULT_URL` | string or boolean | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
|
||||
| `DISCORD_STATUS_OVERRIDE_ALLOW` | string | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
|
||||
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | string | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
|
||||
</Config>
|
||||
@@ -26,7 +26,7 @@ To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)]
|
||||
|
||||
<DetailsAdmo type="important" summary="Your Data Is Public">
|
||||
|
||||
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
Atproto PDS access is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ To monitor and re-scrobble activity from a ATProto Profile create a [teal.fm (So
|
||||
|
||||
<DetailsAdmo type="warning" summary="Your Data Is Public">
|
||||
|
||||
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
Atproto PDS access is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
@@ -39,7 +39,6 @@ The default Bluesky PDS is **public**, meaning your scrobbles are also public (r
|
||||
|
||||
To view your teal.fm scrobble data you can:
|
||||
|
||||
* use [teal-relay-production.up.railway.app](https://teal-relay-production.up.railway.app) or [teal-slice.wisp.place](https://tealfm-slice.wisp.place/) for a preview of teal.fm
|
||||
* use [yearinmusic.teal.fm](https://yearinmusic.teal.fm) or [wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/) for wrapped-style stats of your teal.fm scrobbles
|
||||
* use [atproto.at](https://atproto.at) to view the raw data (convenience URLs are generated in multi-scrobbler, per scrobble, for you to do this)
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
title: Security
|
||||
description: Securing your instance
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
:::important
|
||||
|
||||
**Multi-scrobbler is not designed to accessible from the public internet.**
|
||||
|
||||
It is designed for self-hosting on a **secure, private network** or in something like a tailscale mesh network. If you are considering exposing your instance publicly with no security, **DO NOT.**
|
||||
|
||||
:::
|
||||
|
||||
## Why Not Expose It?
|
||||
|
||||
Currently, there are no *destructive* actions that can be taken in multi-scrobbler *but that will likely change in the near future.* Even without destructive actions, an actor can trigger bulk scrobble/retries that exhaust rate limits for things like tealfm or cause you to be blocked for excessive activity for services like listenbrainz/musicbrainz.
|
||||
|
||||
Additionally, exposing MS without security can potentially leak **personally identifable information.** Consider:
|
||||
|
||||
* some Services, like Spotify, have descriptive device names that can be seen in MS like `bobs-android-auto` or `marys-apartment-roku`
|
||||
* these can potentially tell a bad actor *where you are* or what you are currently *not* doing (at home? in the car?)
|
||||
* if the device name is named after a location it's even worse, like an airbnb: `123-fun-street-samsung-tv`
|
||||
* general listening activity can be a sign of your daily routine
|
||||
* EX listening usually stops at 11:45pm -> you go to sleep before midnight
|
||||
* EX high listening activity between 7:30 - 8:30am -> you are commuting
|
||||
|
||||
## Securing Multi-Scrobbler
|
||||
|
||||
If you absolutely need to host Multi-Scrobbler on a machine that is also accessible from the public internet then use one of the mitigating solutions below to harden your instance.
|
||||
|
||||
### Without Ingress
|
||||
|
||||
If you do not need
|
||||
|
||||
* any [Ingress-based Sources](/configuration/sources/?sourceComm=ingress#by-communication-method) like Webscrobbler, Listenbrainz Endpoint, or Last.fm Endpoint
|
||||
|
||||
then use of these methods:
|
||||
|
||||
#### Restrict Docker Port
|
||||
|
||||
If running MS in a [Docker container](/installation#docker), you can run MS **headless** by removing the exposed port from the compose file. Note: this will also remove your ability to access the dashboard.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
// highlight-start
|
||||
# comment out ports section
|
||||
#
|
||||
# ports:
|
||||
# - 9078:9078
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
Alternatively, lock the exposed port to a specific IP address:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
// highlight-start
|
||||
ports:
|
||||
# only accessible from private ip or tailscale ip
|
||||
- 192.168.0.101:9078:9078
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
#### Disable Web
|
||||
|
||||
Alternatively, use the [Disable Web](/configuration#disable-web) option to completely disable the web server. This is useful if you are not using the docker image or cannot control port exposure in your environment. Note: this will also remove your ability to access the dashboard.
|
||||
|
||||
### With Ingress or Remote Instance Dashboard
|
||||
|
||||
If you:
|
||||
|
||||
* need to use an [Ingress-based Source](/configuration/sources/?sourceComm=ingress#by-communication-method)
|
||||
* or your instance is on a machine outside your private network and you cannot/do not want to run it [headlessly](#restrict-docker-port)
|
||||
|
||||
then do one of these:
|
||||
|
||||
<DetailsAdmo type="tip" summary="Alternative for Listenbrainz Endpoint">
|
||||
|
||||
For Subsonic-based music services (like Navidrome) you can use a [Subsonic Source](/configuration/sources/subsonic) instead of a [Listenbrainz Endpoint](/configuration/sources/listenbrainz-endpoint) to avoid needing ingress.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
#### VPN
|
||||
|
||||
Use something like [Tailscale](https://tailscale.com/) or [Netbird](https://netbird.io/) to create a private VPN-like mesh for your machines/devices to communicate over. **This is the most secure option since it does not require MS to be publicly exposed at all.**
|
||||
|
||||
MS only needs to be on a mesh network with the devices that are sending it Ingress scrobbles, or with the devices that will be viewing the dashboard. It does not need to be in the same mesh as any other services it is monitoring.
|
||||
|
||||
#### Reverse Proxy with Authentication
|
||||
|
||||
Use a reverse proxy with some kind of authentication gate. Optionally, restrict the gate to protect all routes that are *not* related to ingress.
|
||||
|
||||
The simplest (and least secure) version of this would be using [basic auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#basic_authentication_scheme).
|
||||
|
||||
* Traefik [basic auth](https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/basicauth/)
|
||||
* NPM [auth gatway](https://nginxproxymanager.com/third-party/npm-auth-gateway.html#npm-auth-gateway) or [nginx basic auth](https://docs.nginx.com/nginx-instance-manager/admin-guide/authentication/basic-auth/set-up-basic-authentication/)
|
||||
* Caddy [basic auth](https://caddyserver.com/docs/caddyfile/directives/basic_auth)
|
||||
|
||||
:::note
|
||||
|
||||
Optimally, you should be using a *more robust* authentication implementation IE tinyauth, authentik, keycloak, etc... that integrates into your reverse proxy.
|
||||
|
||||
:::
|
||||
|
||||
<DetailsAdmo type="tip" summary="List of Unprotected Routes">
|
||||
|
||||
Routes that need to be unprotected in order for devices to communicate with MS:
|
||||
|
||||
* `/1/*` - standard route for [Listenbrainz Endpoint](/configuration/sources/listenbrainz-endpoint)
|
||||
* `/api/listenbrainz*` - custom Listenbrainz Endpoint with slug
|
||||
* `/2.0/` - standard route for [Last.fm Endpoint](/configuration/sources/lastfm-endpoint)
|
||||
* `/api/lastfm*` - custom Last.fm Endpoint with slug
|
||||
* `/api/webscrobbler*` - standard/custom route for [Webscrobbler](/configuration/sources/webscrobbler)
|
||||
|
||||
In the examples below you can remove any of the above routes for Sources/Clients you are not using.
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
|
||||
Below are examples of protecting all non-ingress and callback routes with basic auth for the above reverse proxies.
|
||||
|
||||
<Tabs groupId="rpExample">
|
||||
<TabItem value="traefik" label="Traefik">
|
||||
Using a [file provider](https://doc.traefik.io/traefik/reference/install-configuration/providers/others/file/):
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
ms-route:
|
||||
entryPoints:
|
||||
- "websecure"
|
||||
|
||||
# can remove any set in the parenthesis group that is unused. Order:
|
||||
# LZ endpoint, webscrobbler, LFM endpoint
|
||||
#
|
||||
# if not using any then leave only -> Host(`...`)
|
||||
rule: >
|
||||
Host(`ms.mydomain.com`) && (
|
||||
|
||||
!PathPrefix(`/1/`)
|
||||
&& !PathPrefix(`/api/listenbrainz`)
|
||||
|
||||
&& !PathPrefix(`/api/webscrobbler`)
|
||||
|
||||
&& !PathPrefix(`/2.0/`)
|
||||
&& !PathPrefix(`/api/lastfm`)
|
||||
|
||||
)
|
||||
|
||||
middlewares:
|
||||
- ms-auth
|
||||
service: ms-service
|
||||
services:
|
||||
ms-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
# can use container/docker service name if
|
||||
# traefik and MS are on the same docker network
|
||||
#
|
||||
# otherwise replace domain with the host IP
|
||||
- url: "http://multi-scrobbler:9078"
|
||||
middlewares:
|
||||
ms-auth:
|
||||
basicAuth:
|
||||
users:
|
||||
# use htpasswd cli to generate password
|
||||
#
|
||||
# or self-hosted https://github.com/sharevb/it-tools/tree/v2026.7.11
|
||||
# has an htpasswd generator tool
|
||||
- "testUsername:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
|
||||
```
|
||||
|
||||
The above could also be done using [docker provider labels](https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/).
|
||||
</TabItem>
|
||||
<TabItem value="caddy" label="Caddy">
|
||||
```Caddyfile
|
||||
multi-scrobbler.domain.tld {
|
||||
# remove any paths you are NOT using from `not path` line.
|
||||
# Order: LZ, LZ, WebScrobbler, LFM, LFM
|
||||
@if-not-scrobble {
|
||||
not path /1/* /api/listenbrainz* /api/webscrobbler* /2.0/* /api/lastfm*
|
||||
}
|
||||
# if request is to any route not listed above, force client to basic auth
|
||||
# you must hash your password using `caddy hash-password`
|
||||
# https://caddyserver.com/docs/command-line#caddy-hash-password
|
||||
basic_auth @if-not-scrobble {
|
||||
msUser $2a$14$.UWNn4Zn.cQHY6Z1mkXXrOhO7mg4BC2wiC.4Tb8dNBMidjSRHAw3e
|
||||
}
|
||||
# requests will be proxied they are to a scrobble endpoint or basic auth has been passed.
|
||||
# can use container/service name if Caddy & MS are on the same docker network.
|
||||
# otherwise, replace `multi-scrobbler` with host IP
|
||||
reverse_proxy multi-scrobbler:9078
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -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 |
|
||||
@@ -109,6 +109,48 @@ Example of multiple servers:
|
||||
|
||||
</details>
|
||||
|
||||
<DetailsAdmo type="tip" summary="BrainzMash Musicbrainz Server">
|
||||
|
||||
[**BrainzMash**](https://brainzmash.cc) can be used as a supplementary Musicbrainz Server (see **Additional Musicbrainz Servers** above).
|
||||
|
||||
BrainzMash is a community-run pool of read-only hybrid Musicbrainz/[Lidarr](https://lidarr.audio/) metadata servers. Requests are made to one endpoint that load-balances traffic out to all participating servers. This service is **publicly available** and can be treated like a normal Musicbrainz server from Multi-Scrobbler's perspective.
|
||||
|
||||
There are pros and cons to using this service. Read these carefully before using BrainzMash:
|
||||
|
||||
* **Pros**
|
||||
* Higher rate limit than official Musicbrainz server -- Multi-scrobbler can make requests more often and faster
|
||||
* Potentially less downtime due to distributed nature
|
||||
* **Cons**
|
||||
* There is implicit trust in how BrainzMash handles your traffic:
|
||||
* you do not know which community server is getting your traffic and what the operator *may* be logging/collecting in terms of metrics
|
||||
* the load balancer endpoint collects minimal metrics: it retains anonymized recent searches and network data for presenting aggregate stats on BrainzMash public dashboard
|
||||
* Musicbrainz DB data is updated on a weekly basis so new entries and recent edits will lag behind the official server
|
||||
|
||||
If these trade-offs are acceptable to you, add BrainzMash to your Musicbrainz config like this:
|
||||
|
||||
```json5
|
||||
{
|
||||
"contact": "anything@notused.com",
|
||||
"url": "https://api.brainzmash.cc"
|
||||
}
|
||||
```
|
||||
|
||||
The public dashboard can be found here: [https://dash.brainzmash.cc](https://dash.brainzmash.cc)
|
||||
|
||||
Learn more about BrainzMash:
|
||||
|
||||
* [Main website](https://brainzmash.cc/)
|
||||
* Join the [Discord server](https://discord.gg/MSTteNvANt) for updates, support, and lidarr instructions
|
||||
* Contribute your server to the community pool by deploying [brainzmash-hearring-aid](https://github.com/statichum/brainzmash-hearring-aid)
|
||||
|
||||
:::important[Support and Bugs]
|
||||
|
||||
If you have issues with the Musicbrainz Transformer and API communication then [**create an issue for Multi-Scrobbler first**](https://github.com/FoxxMD/multi-scrobbler/issues), do not use BrainzMash discord for general help.
|
||||
|
||||
:::
|
||||
|
||||
</DetailsAdmo>
|
||||
|
||||
### Stage Configuration
|
||||
|
||||
All of the properties found in [**Matching with Musicbrainz**](#matching-with-musicbrainz) section are configured in [Stage Configuration](/configuration/transforms#configuring-stages) as `defaults`.
|
||||
|
||||
@@ -86,6 +86,14 @@ services:
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
:::important
|
||||
|
||||
If your machine is accessible from the public internet consider installing multi-scrobbler on a different, private machine.
|
||||
|
||||
**Read [Securing Multi-Scrobbler](/configuration/security)**
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="baseUrl" label="Base URL">
|
||||
**Optionally**, when
|
||||
|
||||
@@ -343,6 +343,7 @@ Visit `http://192.168.0.100:9078` to see the dashboard where
|
||||
|
||||
## Next Steps
|
||||
|
||||
* **If your instance is accessible from the public internet, read [Securing Multi-Scrobbler](/configuration/security)**
|
||||
* See more advanced docker options as well as other install methods in the [**Installation**](/installation#docker) docs
|
||||
* Setup [secondary caching](/configuration#secondary-caching) with [valkey](/installation?dockerSetting=caching#recommended-settings) for increased performance and reduced memory usage
|
||||
* Review the [**Configuration**](/configuration) docs
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from '@docusaurus/types';
|
||||
import * as themes from 'prism-react-renderer';
|
||||
import * as Renderers from './lib/socialCard/ImageRenderers.js';
|
||||
import path from 'node:path';
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
let baseSite: string = 'https://foxxmd.github.io';
|
||||
@@ -32,7 +33,7 @@ const config: Config = {
|
||||
v4: {
|
||||
removeLegacyPostBuildHeadAttribute: true, // required
|
||||
},
|
||||
experimental_faster: {
|
||||
faster: {
|
||||
ssgWorkerThreads: true,
|
||||
rspackBundler: true, // required flag
|
||||
rspackPersistentCache: true, // new flag
|
||||
@@ -75,20 +76,29 @@ const config: Config = {
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
docItemComponent: "@theme/ApiItem",
|
||||
routeBasePath: '/',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: (params: {
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
}
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
},
|
||||
// based on this https://github.com/facebook/docusaurus/issues/6127#issuecomment-3937124499
|
||||
// to filter out the autogenerated api folder sidebar item
|
||||
sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
const items = await defaultSidebarItemsGenerator(args);
|
||||
const filteredItems = items.filter(item => !(item.type === 'category' && item.label === 'api'));
|
||||
return filteredItems
|
||||
},
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
@@ -100,7 +110,7 @@ const config: Config = {
|
||||
blog: false,
|
||||
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
customCss: ['./src/css/custom.css','./src/css/openapiBase.css','./src/css/evergreen.css'],
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -122,10 +132,11 @@ const config: Config = {
|
||||
// ```
|
||||
},
|
||||
],
|
||||
'docusaurus-theme-github-codeblock'
|
||||
'docusaurus-theme-github-codeblock',
|
||||
"docusaurus-theme-openapi-docs"
|
||||
],
|
||||
plugins: [
|
||||
'docusaurus-plugin-sass',
|
||||
'docusaurus-plugin-sass',
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
@@ -167,7 +178,38 @@ const config: Config = {
|
||||
'docusaurus-plugin-content-pages': Renderers.docs,
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
[
|
||||
'docusaurus-plugin-openapi-docs',
|
||||
{
|
||||
id: "api", // plugin id
|
||||
docsPluginId: "classic", // configured for preset-classic
|
||||
config: {
|
||||
ms: {
|
||||
specPath: "static/openapi.json",
|
||||
outputDir: "docs/api",
|
||||
showInfoPage: true,
|
||||
maskCredentials: false, // Disable credential masking in code snippets
|
||||
sidebarOptions: {
|
||||
groupPathsBy: "tag",
|
||||
},
|
||||
} satisfies OpenApiPlugin.Options,
|
||||
}
|
||||
},
|
||||
],
|
||||
// [
|
||||
// '@docusaurus/plugin-content-docs',
|
||||
// {
|
||||
// sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
// const items = await defaultSidebarItemsGenerator(args);
|
||||
// for(const i of items) {
|
||||
// console.log(i);
|
||||
// }
|
||||
// //const filteredItems = items.filter(item => item.type !== 'doc' || !item.id.endsWith('/index'));
|
||||
// //return filteredItems
|
||||
// },
|
||||
// }
|
||||
// ]
|
||||
],
|
||||
themeConfig:
|
||||
{
|
||||
@@ -193,6 +235,11 @@ const config: Config = {
|
||||
position: 'left',
|
||||
label: 'Config Playground',
|
||||
},
|
||||
{
|
||||
to: 'api/multi-scrobbler-api',
|
||||
position: 'left',
|
||||
label: 'API',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
label: 'GitHub',
|
||||
|
||||
Generated
+2796
-904
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -14,14 +14,15 @@
|
||||
"typecheck": "tsc",
|
||||
"postinstall": "patch-package",
|
||||
"previews:build-renderer": "tsc --project tsconfig.client.json || exit 0",
|
||||
"prebuild": "npm run -s previews:build-renderer"
|
||||
"prebuild": "npm run -s previews:build-renderer && npm run -s openapi:generate",
|
||||
"openapi:generate": "docusaurus clean-api-docs ms && docusaurus gen-api-docs ms"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bony_chops/docusaurus-og": "^1.0.3-beta.3",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/faster": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@docusaurus/core": "3.10.2",
|
||||
"@docusaurus/faster": "3.10.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "3.10.2",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"ajv": "^8.18.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-plugin-sass": "^0.2.6",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"embla-carousel": "^9.0.0-rc02",
|
||||
"embla-carousel-react": "^9.0.0-rc02",
|
||||
"json5": "^2.2.3",
|
||||
@@ -44,9 +46,9 @@
|
||||
"sass": "^1.97.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.2",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@docusaurus/types": "^3.9.2",
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/tsconfig": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@types/react": "^19.2.4",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
@@ -69,6 +71,7 @@
|
||||
"overrides": {
|
||||
"lodash": "4.18.1",
|
||||
"satori": "0.25.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.6"
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -1,5 +1,5 @@
|
||||
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
|
||||
|
||||
import msSidebar from "./docs/api/sidebar.js";
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
@@ -19,21 +19,13 @@ const sidebars: SidebarsConfig = {
|
||||
type: "link",
|
||||
label: "Config Playground",
|
||||
href: "/playground"
|
||||
}
|
||||
],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Api",
|
||||
items: msSidebar,
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./json-schema-viewer-styles.css";
|
||||
import "./modern-json-react-styles.css";
|
||||
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
|
||||
import f from "ajv-formats"
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import DetailsAdmo from "./AdmonitionDetails";
|
||||
|
||||
const validator = createAjvValidator({
|
||||
@@ -21,9 +22,11 @@ const validator = createAjvValidator({
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useColorMode } from '@docusaurus/theme-common';
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
|
||||
import "../modern-json-react-styles.css";
|
||||
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
|
||||
import Ajv2020 from 'ajv/dist/2020';
|
||||
import f from "ajv-formats"
|
||||
import json5 from 'json5';
|
||||
|
||||
@@ -29,9 +30,11 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
|
||||
allowUnionTypes: true,
|
||||
},
|
||||
onCreateAjv: (ajv) => {
|
||||
ajv.addKeyword('deprecationMessage');
|
||||
f.default(ajv);
|
||||
return ajv;
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
});
|
||||
}, [props.schema]);
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Evergreen theme for docusaurus-openapi-docs demo.
|
||||
*
|
||||
* A modern riff on the classic Docusaurus green. Keeps the signature
|
||||
* #2e8555 primary family, adds Plus Jakarta Sans, a richer dark mode,
|
||||
* and cleaner shadows — what the default template could look like with
|
||||
* a bit more design polish.
|
||||
*
|
||||
* Load alongside custom.css in docusaurus.config.ts:
|
||||
*
|
||||
* customCss: ["./src/css/custom.css", "./src/css/themes/evergreen.css"]
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* Fonts — Plus Jakarta Sans (body) + Fira Code (code)
|
||||
* ============================================================ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap");
|
||||
|
||||
/* ============================================================
|
||||
* Light mode
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
/* Primary — the classic Docusaurus green, slightly enriched */
|
||||
/* --ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e; */
|
||||
|
||||
/* Content */
|
||||
/* --ifm-color-content: #1c1e21;
|
||||
--ifm-heading-color: #1c1e21;
|
||||
--ifm-color-content-secondary: #525860; */
|
||||
|
||||
/* Semantic */
|
||||
/* --ifm-color-success: #2e8555;
|
||||
--ifm-color-warning: #d97706;
|
||||
--ifm-color-danger: #dc2626;
|
||||
--ifm-color-info: #0284c7; */
|
||||
|
||||
/* Typography */
|
||||
/* --ifm-font-size-base: 95%;
|
||||
--ifm-code-font-size: 85%;
|
||||
--ifm-font-family-base:
|
||||
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--ifm-heading-font-family:
|
||||
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--ifm-font-family-monospace:
|
||||
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Courier New", monospace;
|
||||
--font-mono:
|
||||
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Courier New", monospace;
|
||||
--ifm-font-weight-base: 400;
|
||||
--ifm-font-weight-semibold: 600;
|
||||
--ifm-heading-font-weight: 700; */
|
||||
|
||||
/* Heading scale */
|
||||
/* --ifm-h1-font-size: 2rem;
|
||||
--ifm-h2-font-size: 1.6rem;
|
||||
--ifm-h3-font-size: 1.3rem;
|
||||
--ifm-h4-font-size: 1.1rem;
|
||||
--ifm-h5-font-size: 1rem;
|
||||
--ifm-h6-font-size: 0.9rem; */
|
||||
|
||||
/* Theme hooks for custom.css */
|
||||
--theme-primary-rgb: 46, 133, 85;
|
||||
--site-primary-hue-saturation: 148 49%;
|
||||
--theme-border-radius-sm: 4px;
|
||||
--theme-border-radius-md: 8px;
|
||||
--theme-border-radius-lg: 12px;
|
||||
--theme-border-radius-xl: 16px;
|
||||
--theme-border-radius-2xl: 24px;
|
||||
|
||||
/* Shadow scale — neutral, not green-tinted */
|
||||
--theme-shadow-sm:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
--theme-shadow-md:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
||||
--theme-shadow-lg:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* --ifm-hr-background-color: var(--ifm-color-emphasis-200);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(46, 133, 85, 0.1); */
|
||||
|
||||
/* Explorer — unified font size for code tabs and request body */
|
||||
--openapi-explorer-font-size-code: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Dark mode — richer than the Docusaurus default
|
||||
* ============================================================ */
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Lighter green — readable on dark surfaces */
|
||||
/* --ifm-color-primary: #3cad6e;
|
||||
--ifm-color-primary-dark: #359962;
|
||||
--ifm-color-primary-darker: #33925d;
|
||||
--ifm-color-primary-darkest: #2e8555;
|
||||
--ifm-color-primary-light: #4db87c;
|
||||
--ifm-color-primary-lighter: #5ec389;
|
||||
--ifm-color-primary-lightest: #85d4a8; */
|
||||
|
||||
/* --ifm-color-content: #dadde1;
|
||||
--ifm-color-content-secondary: #9ba1aa;
|
||||
--ifm-heading-color: #f5f6f7; */
|
||||
|
||||
/* Richer dark background vs. the flat Docusaurus default */
|
||||
/* --ifm-background-color: #18191a !important;
|
||||
--ifm-navbar-background-color: #18191a; */
|
||||
|
||||
/* Warm-neutral dark gray scale */
|
||||
/* --ifm-color-emphasis-100: #242526;
|
||||
--ifm-color-emphasis-200: #3a3b3c;
|
||||
--ifm-color-emphasis-300: #4e4f50;
|
||||
--ifm-color-emphasis-400: #6b6c6d;
|
||||
--ifm-color-emphasis-500: #8d8f91;
|
||||
--ifm-color-emphasis-600: #b0b3b8;
|
||||
--ifm-color-emphasis-700: #dadde1;
|
||||
--ifm-color-emphasis-800: #e4e6eb;
|
||||
--ifm-color-emphasis-900: #f0f2f5; */
|
||||
|
||||
/* --ifm-link-color: #3cad6e;
|
||||
--ifm-link-hover-color: #5ec389; */
|
||||
|
||||
--docusaurus-highlighted-code-line-bg: rgba(60, 173, 110, 0.12);
|
||||
/* --theme-primary-rgb: 60, 173, 110; */
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Method badge colors — sidebar pills
|
||||
* ============================================================ */
|
||||
|
||||
.get > .menu__link::before {
|
||||
background-color: rgba(46, 133, 85, 0.12);
|
||||
color: #29784c;
|
||||
}
|
||||
.get > .menu__link--active::before {
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.post > .menu__link::before {
|
||||
background-color: rgba(2, 132, 199, 0.12);
|
||||
color: #0369a1;
|
||||
}
|
||||
.post > .menu__link--active::before {
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.delete > .menu__link::before {
|
||||
background-color: rgba(220, 38, 38, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
.delete > .menu__link--active::before {
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.put > .menu__link::before {
|
||||
background-color: rgba(217, 119, 6, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
.put > .menu__link--active::before {
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.patch > .menu__link::before {
|
||||
background-color: rgba(217, 119, 6, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
.patch > .menu__link--active::before {
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .get > .menu__link::before {
|
||||
background-color: rgba(60, 173, 110, 0.15);
|
||||
color: #3cad6e;
|
||||
}
|
||||
[data-theme="dark"] .get > .menu__link--active::before {
|
||||
background-color: #3cad6e;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .post > .menu__link::before {
|
||||
background-color: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
}
|
||||
[data-theme="dark"] .post > .menu__link--active::before {
|
||||
background-color: #0ea5e9;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .delete > .menu__link::before {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
color: #f87171;
|
||||
}
|
||||
[data-theme="dark"] .delete > .menu__link--active::before {
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .put > .menu__link::before {
|
||||
background-color: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
[data-theme="dark"] .put > .menu__link--active::before {
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .patch > .menu__link::before {
|
||||
background-color: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
[data-theme="dark"] .patch > .menu__link--active::before {
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API page method badges (.badge--*)
|
||||
* ============================================================ */
|
||||
|
||||
.badge--primary {
|
||||
/* GET */
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--success {
|
||||
/* POST */
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--danger {
|
||||
/* DELETE */
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--info {
|
||||
/* PUT */
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
.badge--warning {
|
||||
/* PATCH */
|
||||
background-color: #d97706;
|
||||
color: #ffffff;
|
||||
--ifm-badge-border-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge--primary {
|
||||
/* GET */
|
||||
background-color: #3cad6e;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--success {
|
||||
/* POST */
|
||||
background-color: #0ea5e9;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--danger {
|
||||
/* DELETE */
|
||||
background-color: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
[data-theme="dark"] .badge--info {
|
||||
/* PUT */
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
[data-theme="dark"] .badge--warning {
|
||||
/* PATCH */
|
||||
background-color: #f59e0b;
|
||||
color: #18191a;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API Explorer — request body editor font size
|
||||
*
|
||||
* react-simple-code-editor renders an inner textarea + pre that
|
||||
* can slip past the outer !important font shorthand. Target them
|
||||
* directly so the body editor matches the code snippet tabs.
|
||||
* ============================================================ */
|
||||
|
||||
.openapi-explorer__playground-editor,
|
||||
.openapi-explorer__playground-editor textarea,
|
||||
.openapi-explorer__playground-editor pre {
|
||||
font-size: var(--openapi-explorer-font-size-code) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* API Explorer — Send button dark mode
|
||||
* ============================================================ */
|
||||
|
||||
[data-theme="dark"] .openapi-explorer__request-btn {
|
||||
background-color: #2e8555;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .openapi-explorer__request-btn:hover {
|
||||
background-color: #3cad6e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Structural CSS for the docusaurus-openapi-docs demo site.
|
||||
*
|
||||
* This file contains layout, component behavior, animations, and utilities.
|
||||
* It is intentionally free of hard-coded colors and fonts — those live in
|
||||
* a theme file loaded alongside this one via docusaurus.config.ts:
|
||||
*
|
||||
* customCss: ["./src/css/custom.css", "./src/css/themes/panw.css"]
|
||||
*
|
||||
* To try a different palette, swap the theme file. The following CSS
|
||||
* variables must be defined by the active theme:
|
||||
*
|
||||
* --theme-primary-rgb RGB triplet of primary color (for animations)
|
||||
* --theme-border-radius-sm/md Border radius tokens
|
||||
* --site-primary-hue-saturation Hue+saturation for the announcement bar
|
||||
*
|
||||
* Structure inspired by @gleanwork/docusaurus-theme-glean.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* Typography
|
||||
* ============================================================ */
|
||||
|
||||
.markdown h1,
|
||||
article h1,
|
||||
.main-wrapper h1 {
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: var(--ifm-heading-font-weight);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.markdown h2,
|
||||
article h2,
|
||||
.markdown h3,
|
||||
article h3 {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
}
|
||||
|
||||
/* .markdown strong,
|
||||
article strong,
|
||||
.markdown b,
|
||||
article b {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
} */
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
/* font-variant-ligatures: none; */
|
||||
}
|
||||
|
||||
.table-of-contents {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 50px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Links
|
||||
* ============================================================ */
|
||||
|
||||
a:any-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
article p a,
|
||||
article ul a,
|
||||
article li a,
|
||||
.markdown p a,
|
||||
.theme-doc-markdown p a {
|
||||
text-decoration: underline 1px var(--ifm-color-primary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
article p a:hover,
|
||||
article ul a:hover,
|
||||
article li a:hover,
|
||||
.markdown p a:hover,
|
||||
.theme-doc-markdown p a:hover {
|
||||
text-decoration: underline 2px var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar structure
|
||||
* ============================================================ */
|
||||
|
||||
/* Slightly off-white sidebar in light mode */
|
||||
.theme-doc-sidebar-container {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-doc-sidebar-container {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Tighter nested indentation */
|
||||
.menu__list .menu__list {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* No underlines on menu links */
|
||||
.menu__link {
|
||||
text-decoration: none;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
/* Top-level category labels: uppercase, small caps, bold */
|
||||
/* .theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link,
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link.menu__link--active {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
} */
|
||||
|
||||
/* Hover: text darkens, no background fill */
|
||||
/* .menu__link:hover {
|
||||
background-color: transparent;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
text-decoration: none;
|
||||
} */
|
||||
|
||||
/* Active item: primary-colored text, no background */
|
||||
/* .menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
) {
|
||||
color: var(--ifm-color-primary);
|
||||
background-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
):hover {
|
||||
color: var(--ifm-color-primary);
|
||||
background-color: transparent;
|
||||
} */
|
||||
|
||||
/* [data-theme="dark"]
|
||||
.menu__link--active:not(
|
||||
.theme-doc-sidebar-item-category-level-1
|
||||
> .menu__list-item-collapsible
|
||||
> .menu__link
|
||||
) {
|
||||
color: var(--ifm-color-primary-light);
|
||||
}
|
||||
|
||||
.menu__list-item-collapsible:hover,
|
||||
.menu__list-item-collapsible:active,
|
||||
.menu__list-item-collapsible:focus-within,
|
||||
.menu__list-item-collapsible.menu__list-item-collapsible--active {
|
||||
background: transparent;
|
||||
} */
|
||||
|
||||
/* Custom caret */
|
||||
.menu__link--sublist-caret:after,
|
||||
.menu__caret::before {
|
||||
--ifm-menu-link-sublist-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="8px" height="8px" viewBox="0 0 24 24"><path fill="rgba(0,0,0,0.5)" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"></path></svg>');
|
||||
content: " ";
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — Default (pill style)
|
||||
*
|
||||
* Shape and content labels only. Colors are in the theme file.
|
||||
* ============================================================ */
|
||||
|
||||
.api-method > .menu__link,
|
||||
.schema > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.api-method > .menu__link::before,
|
||||
.schema > .menu__link::before {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1.25;
|
||||
width: 2.25rem;
|
||||
height: auto;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Content labels */
|
||||
.get > .menu__link::before {
|
||||
content: "get";
|
||||
}
|
||||
.post > .menu__link::before {
|
||||
content: "post";
|
||||
}
|
||||
.delete > .menu__link::before {
|
||||
content: "del";
|
||||
}
|
||||
.put > .menu__link::before {
|
||||
content: "put";
|
||||
}
|
||||
.patch > .menu__link::before {
|
||||
content: "patch";
|
||||
}
|
||||
|
||||
/* HEAD / EVENT / SCHEMA — neutral colors, no theme needed */
|
||||
.head > .menu__link::before {
|
||||
content: "head";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.event > .menu__link::before {
|
||||
content: "event";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.schema > .menu__link::before {
|
||||
content: "schema";
|
||||
background-color: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
/* Deprecated */
|
||||
.menu__list-item--deprecated > .menu__link,
|
||||
.menu__list-item--deprecated > .menu__link:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — High Contrast variant (unchanged)
|
||||
* ============================================================ */
|
||||
|
||||
.api-method-contrast > .menu__link,
|
||||
.schema-contrast > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.api-method-contrast > .menu__link::before,
|
||||
.schema-contrast > .menu__link::before {
|
||||
width: 55px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid;
|
||||
border-inline-start-width: 5px;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.get-contrast > .menu__link::before {
|
||||
content: "get";
|
||||
background-color: var(--ifm-color-info-contrast-background);
|
||||
color: var(--ifm-color-info-contrast-foreground);
|
||||
border-color: var(--ifm-color-info-dark);
|
||||
}
|
||||
.post-contrast > .menu__link::before {
|
||||
content: "post";
|
||||
background-color: var(--ifm-color-success-contrast-background);
|
||||
color: var(--ifm-color-success-contrast-foreground);
|
||||
border-color: var(--ifm-color-success-dark);
|
||||
}
|
||||
.delete-contrast > .menu__link::before {
|
||||
content: "del";
|
||||
background-color: var(--ifm-color-danger-contrast-background);
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
border-color: var(--ifm-color-danger-dark);
|
||||
}
|
||||
.put-contrast > .menu__link::before {
|
||||
content: "put";
|
||||
background-color: var(--ifm-color-warning-contrast-background);
|
||||
color: var(--ifm-color-warning-contrast-foreground);
|
||||
border-color: var(--ifm-color-warning-dark);
|
||||
}
|
||||
.patch-contrast > .menu__link::before {
|
||||
content: "patch";
|
||||
background-color: var(--ifm-color-success-contrast-background);
|
||||
color: var(--ifm-color-success-contrast-foreground);
|
||||
border-color: var(--ifm-color-success-dark);
|
||||
}
|
||||
.head-contrast > .menu__link::before {
|
||||
content: "head";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
.event-contrast > .menu__link::before {
|
||||
content: "event";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
.schema-contrast > .menu__link::before {
|
||||
content: "schema";
|
||||
background-color: var(--ifm-color-secondary-contrast-background);
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
border-color: var(--ifm-color-secondary-dark);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Sidebar Method labels — Simple variant (unchanged)
|
||||
* ============================================================ */
|
||||
|
||||
.api-method-simple > .menu__link {
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
.api-method-simple > .menu__link::before {
|
||||
width: 55px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
align-content: start;
|
||||
margin-right: var(--ifm-spacing-horizontal);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.get-simple > .menu__link::before {
|
||||
content: "get";
|
||||
color: var(--ifm-color-info);
|
||||
}
|
||||
.post-simple > .menu__link::before {
|
||||
content: "post";
|
||||
color: var(--ifm-color-success);
|
||||
}
|
||||
.delete-simple > .menu__link::before {
|
||||
content: "del";
|
||||
color: var(--ifm-color-danger);
|
||||
}
|
||||
.put-simple > .menu__link::before {
|
||||
content: "put";
|
||||
color: var(--ifm-color-warning);
|
||||
}
|
||||
.patch-simple > .menu__link::before {
|
||||
content: "patch";
|
||||
color: var(--ifm-color-warning);
|
||||
}
|
||||
.head-simple > .menu__link::before {
|
||||
content: "head";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
.event-simple > .menu__link::before {
|
||||
content: "event";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
.schema-simple > .menu__link::before {
|
||||
content: "schema";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Admonitions
|
||||
* ============================================================ */
|
||||
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: var(--theme-border-radius-md, 8px);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Navbar header icons
|
||||
* ============================================================ */
|
||||
|
||||
.header-github-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-github-link:before {
|
||||
content: "";
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .header-github-link:before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.header-medium-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-medium-link:before {
|
||||
content: "\f23a";
|
||||
font-family: "Font Awesome 5 Brands";
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Announcement bar
|
||||
* ============================================================ */
|
||||
|
||||
div[class^="announcementBar_"] {
|
||||
--site-announcement-bar-stripe-color1: hsl(
|
||||
var(--site-primary-hue-saturation) 85%
|
||||
);
|
||||
--site-announcement-bar-stripe-color2: hsl(
|
||||
var(--site-primary-hue-saturation) 95%
|
||||
);
|
||||
background: repeating-linear-gradient(
|
||||
35deg,
|
||||
var(--site-announcement-bar-stripe-color1),
|
||||
var(--site-announcement-bar-stripe-color1) 20px,
|
||||
var(--site-announcement-bar-stripe-color2) 10px,
|
||||
var(--site-announcement-bar-stripe-color2) 40px
|
||||
);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Schema parameter highlight animation
|
||||
*
|
||||
* Color driven by --theme-primary-rgb (defined in the theme file).
|
||||
* ============================================================ */
|
||||
|
||||
[id]:target {
|
||||
animation: highlight-flash 2s ease-out;
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
}
|
||||
|
||||
@keyframes highlight-flash {
|
||||
0% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
|
||||
box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
|
||||
box-shadow: 0 0 0 2px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.openapi-markdown__details [id]:target,
|
||||
.openapi-schema__list-item [id]:target {
|
||||
animation: highlight-flash 2s ease-out;
|
||||
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Code tab scrollbar
|
||||
* ============================================================ */
|
||||
|
||||
.tabs-container [role="tabpanel"] {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre {
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-track {
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
border-radius: var(--theme-border-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb {
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
border-radius: var(--theme-border-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* OpenAPI code tab language icons
|
||||
* ============================================================ */
|
||||
|
||||
.openapi-tabs__code-item--python {
|
||||
color: var(--ifm-color-success);
|
||||
}
|
||||
.openapi-tabs__code-item--python::after {
|
||||
content: "";
|
||||
width: var(--code-tab-logo-width);
|
||||
height: var(--code-tab-logo-height);
|
||||
background: url("https://raw.githubusercontent.com/devicons/devicon/master/icons/python/python-plain.svg")
|
||||
no-repeat;
|
||||
margin-block: auto;
|
||||
}
|
||||
.openapi-tabs__code-item--python.active {
|
||||
box-shadow: 0 0 0 3px var(--openapi-code-tab-shadow-color-python);
|
||||
border-color: var(--openapi-code-tab-border-color-python);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Grid utilities
|
||||
* ============================================================ */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.my-4 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.my-6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+590
-22
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.3",
|
||||
"version": "0.17.0-RC1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.3",
|
||||
"version": "0.17.0-RC1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -30,6 +30,7 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@minisylar/express-typed-router": "^1.9.8",
|
||||
"@pierre/diffs": "^1.2.4",
|
||||
"@rocksky/sdk": "^0.3.0",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -132,6 +133,8 @@
|
||||
"@storybook/addon-docs": "^10.4.3",
|
||||
"@storybook/react-vite": "^10.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/form-core": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-form": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
@@ -159,6 +162,7 @@
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@welldone-software/why-did-you-render": "^10.0.1",
|
||||
@@ -173,7 +177,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-boundaries": "^7.0.2",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-mocha": "^11.3.0",
|
||||
"eslint-plugin-mocha": "^12.0.2",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
@@ -202,6 +206,7 @@
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "^10.4.3",
|
||||
"storybook-addon-remix-react-router": "^6.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
@@ -209,7 +214,9 @@
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"with-local-tmp-dir": "^7.0.1",
|
||||
"zocker": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2269,9 +2276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2999,6 +3006,22 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@minisylar/express-typed-router": {
|
||||
"version": "1.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@minisylar/express-typed-router/-/express-typed-router-1.9.8.tgz",
|
||||
"integrity": "sha512-C0VW62eAPNFYP/pPqpepVQLitsJLPI1/8CFSVGIfZO4uaPkJZchN9eDpcCE/S2Rc1RXC+71tZ1A/A/yCu8kp7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.18.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mjackson/form-data-parser": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz",
|
||||
@@ -5328,7 +5351,6 @@
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stdlib/error-tools-fmtprodmsg": {
|
||||
@@ -5812,6 +5834,258 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
|
||||
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
|
||||
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
|
||||
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
|
||||
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
|
||||
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
|
||||
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-transform-svg-component": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
|
||||
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-preset": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
|
||||
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
|
||||
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
|
||||
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
|
||||
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
|
||||
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
|
||||
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
|
||||
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
|
||||
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/core": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
|
||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
"camelcase": "^6.2.0",
|
||||
"cosmiconfig": "^8.1.3",
|
||||
"snake-case": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/core/node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"import-fresh": "^3.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"parse-json": "^5.2.0",
|
||||
"path-type": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.9.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/hast-util-to-babel-ast": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
|
||||
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.21.3",
|
||||
"entities": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/plugin-jsx": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
|
||||
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
"@svgr/hast-util-to-babel-ast": "8.0.0",
|
||||
"svg-parser": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@svgr/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@svrooij/sonos": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz",
|
||||
@@ -6143,6 +6417,38 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/form-core": {
|
||||
"version": "2.0.0-alpha.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-2.0.0-alpha.2.tgz",
|
||||
"integrity": "sha512-u/2s1rJ1pSdFgU/oKz2zt/Wdb3XQK9LY1IIL2pLMu0AVYK5f8Ue5UwaCjcUxEAI8g6XJpbmEuy2wLIwHZbZnwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/pacer-lite": "^0.2.2",
|
||||
"@tanstack/store": "^0.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/pacer-lite": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.2.tgz",
|
||||
"integrity": "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
@@ -6165,6 +6471,27 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-form": {
|
||||
"version": "2.0.0-alpha.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-2.0.0-alpha.2.tgz",
|
||||
"integrity": "sha512-rIm6kRhpK9Fbb5p3xiwKKofOKHXAK3K28NeR4sM5T0h8CU0TpCLMz27SAHY2xjvG2iSgx8n4mLVEyTxw7iHJdw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/form-core": "2.0.0-alpha.2",
|
||||
"@tanstack/react-store": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
@@ -6200,6 +6527,25 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
|
||||
"integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/store": "0.11.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
|
||||
@@ -6218,6 +6564,17 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
|
||||
"integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
|
||||
@@ -6859,6 +7216,17 @@
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz",
|
||||
"integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -10316,6 +10684,17 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz",
|
||||
@@ -10325,6 +10704,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",
|
||||
@@ -11479,27 +11868,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-mocha": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-11.3.0.tgz",
|
||||
"integrity": "sha512-anENwrIwmdvunmmssjMn5a4nTd+mYMkqBlwjksxOECcIThLNhefWJIiTWY7pY/arMQFjNwHQjVOZb6pQ9PrLjg==",
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-12.0.2.tgz",
|
||||
"integrity": "sha512-RzjnzVBoidGFjVcvzQShLbfWug5pL/6d6LZFbBZk1b5duZ0ooq6dasmpu6qLyNowUZBqaWvQbnR5U1hsKX/+5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.1",
|
||||
"globals": "^15.14.0"
|
||||
"@eslint-community/eslint-utils": "4.10.1",
|
||||
"@eslint/core": "1.2.1",
|
||||
"@types/estree": "1.0.9",
|
||||
"globals": "17.8.0",
|
||||
"json-schema-to-ts": "3.1.1",
|
||||
"type-fest": "5.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=9.0.0"
|
||||
"eslint": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-mocha/node_modules/globals": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"node_modules/eslint-plugin-mocha/node_modules/type-fest": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -13190,9 +13589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
||||
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
|
||||
"version": "17.8.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
|
||||
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14469,6 +14868,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
@@ -15089,6 +15502,16 @@
|
||||
"get-func-name": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lower-case": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
@@ -15985,6 +16408,17 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
@@ -17310,6 +17744,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 +18288,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",
|
||||
@@ -18521,6 +18979,17 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/snake-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
@@ -18961,6 +19430,42 @@
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest/node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -18986,6 +19491,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-parser": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.5.tgz",
|
||||
"integrity": "sha512-FuT74puvVMJQ8sgFxgOKPex5MbxPeltqCXJV3wR9Xq+vPHaF+tTdE1lhJcwspUxjtjvXy1NwqcSLZx9UNwHawg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
@@ -19223,6 +19738,13 @@
|
||||
"integrity": "sha512-0s7O4OFi0f8DpD03spao04Osrt2b4p1v2uags7JBMLsAe36pUI0mQU6XMgQuQSv5jOTqTFS0W50wTPrlzST80w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-dedent": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
|
||||
@@ -20139,6 +20661,21 @@
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-svgr": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||
"integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.3.0",
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/plugin-jsx": "^8.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vlc-client": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vlc-client/-/vlc-client-1.1.1.tgz",
|
||||
@@ -20506,6 +21043,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",
|
||||
|
||||
+11
-3
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.3",
|
||||
"version": "0.17.0-RC1",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema:docs",
|
||||
"schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts",
|
||||
"openapi:docs": "node src/backend/utils/OpenApiUtils.ts",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "mocha --reporter spec",
|
||||
@@ -18,7 +19,7 @@
|
||||
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
|
||||
"docs:install": "cd docsite && npm install --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
|
||||
"docs:build": "npm run -s schema:docs && npm run -s openapi:docs && cd docsite && npm run build",
|
||||
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
|
||||
"postinstall": "patch-package",
|
||||
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@minisylar/express-typed-router": "^1.9.8",
|
||||
"@pierre/diffs": "^1.2.4",
|
||||
"@rocksky/sdk": "^0.3.0",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -172,6 +174,8 @@
|
||||
"@storybook/addon-docs": "^10.4.3",
|
||||
"@storybook/react-vite": "^10.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/form-core": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-form": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
@@ -199,6 +203,7 @@
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@welldone-software/why-did-you-render": "^10.0.1",
|
||||
@@ -213,7 +218,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-boundaries": "^7.0.2",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-mocha": "^11.3.0",
|
||||
"eslint-plugin-mocha": "^12.0.2",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
@@ -242,6 +247,7 @@
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "^10.4.3",
|
||||
"storybook-addon-remix-react-router": "^6.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
@@ -249,7 +255,9 @@
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"with-local-tmp-dir": "^7.0.1",
|
||||
"zocker": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/node_modules/spotify-web-api-node/src/http-manager.js b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
index 6c3991f..89e7a99 100644
|
||||
--- a/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
+++ b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
@@ -63,6 +63,11 @@ HttpManager._makeRequest = function(method, options, uri, callback) {
|
||||
req.send(options.data);
|
||||
}
|
||||
|
||||
+ req.timeout({
|
||||
+ response: 5000, // Wait 5 seconds for the server to start sending,
|
||||
+ deadline: 9000, // but allow 9 seconds for entire response to complete
|
||||
+ });
|
||||
+
|
||||
req.end(function(err, response) {
|
||||
if (err) {
|
||||
if (err.timeout) {
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/node_modules/zocker/dist/index.cjs b/node_modules/zocker/dist/index.cjs
|
||||
index 0745620..ab9cba8 100644
|
||||
--- a/node_modules/zocker/dist/index.cjs
|
||||
+++ b/node_modules/zocker/dist/index.cjs
|
||||
@@ -51,7 +51,9 @@ function generate$1(schema, ctx) {
|
||||
}
|
||||
}
|
||||
const generate_value$1 = (schema, generation_context) => {
|
||||
- const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema);
|
||||
+ const reg = zod_v4.globalRegistry.get(schema);
|
||||
+ const reference_generator = generation_context.reference_generators.find((g) => g.schema === schema
|
||||
+ || (reg?.zockerId !== undefined && zod_v4.globalRegistry.get(g.schema)?.zockerId === reg.zockerId));
|
||||
if (reference_generator) return reference_generator.generator(schema, generation_context);
|
||||
const instanceof_generator = generation_context.instanceof_generators.find((g) => schema instanceof g.schema);
|
||||
if (instanceof_generator) return instanceof_generator.generator(schema, generation_context);
|
||||
@@ -7,7 +7,7 @@ import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, ty
|
||||
import { buildTrackString } from "../../core/StringUtils.ts";
|
||||
import type {CommonClientConfig} from "./infrastructure/config/client/index.ts";
|
||||
import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StageChangeError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import {
|
||||
FLOW_CONTROL_TERM,
|
||||
type PlayTransformRules,
|
||||
@@ -33,13 +33,13 @@ import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isComp
|
||||
import type {DbConcrete} from "./database/drizzle/drizzleUtils.ts";
|
||||
import type {ComponentSelect} from "./database/drizzle/drizzleTypes.ts";
|
||||
import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.ts";
|
||||
import type {ClientType, MonitoringStatus} from "../../core/Atomic.ts";
|
||||
import type {ClientType, MonitoringStatus, OptionalCacheUsage} from "../../core/Atomic.ts";
|
||||
import type {SourceType} from "../../core/Atomic.ts";
|
||||
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { COMPONENT_STATE, type ComponentCommonApi, type ComponentCommonApiJson, type ComponentState, type PlayApiCommonDetailed } from "../../core/Api.ts";
|
||||
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
|
||||
import type { MarkRequired } from "ts-essentials";
|
||||
import type { ElementOf, MarkRequired } from "ts-essentials";
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
|
||||
@@ -67,7 +67,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
type: ClientType | SourceType;
|
||||
name: string;
|
||||
|
||||
lastActiveAt?: Dayjs
|
||||
lastActiveAt?: Dayjs;
|
||||
lastReadyAt?: Dayjs;
|
||||
protected lastUpdatedComponentDatesAt?: Dayjs
|
||||
|
||||
@@ -116,8 +116,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
name: this.config?.name ?? this.name
|
||||
});
|
||||
this.componentId = this.dbComponent.id;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt ?? undefined;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt ?? undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
public abstract stop(opts?: {reason?: string | Error}): Promise<void>
|
||||
public abstract start(opts?: {forceInit?: boolean}): Promise<boolean>
|
||||
|
||||
public async restart(opts: { reason?: string | Error, forceInit?: boolean } = {}): Promise<void> {
|
||||
try {
|
||||
await this.stop(opts);
|
||||
await this.start(opts);
|
||||
} catch (e) {
|
||||
const err = new StageChangeError('Failed to restart', { cause: e });
|
||||
this.replaceErrors(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public retentionCleanup = async () => {
|
||||
if(this.databaseOK !== true) {
|
||||
this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
|
||||
@@ -233,7 +247,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus('Retention cleanup finished');
|
||||
} catch (e) {
|
||||
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
|
||||
this.warning = retentionErr;
|
||||
this.warnings.push(retentionErr);
|
||||
this.logger.warn(retentionErr);
|
||||
this.setStatus('Retention cleanup failed');
|
||||
}
|
||||
@@ -248,7 +262,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
|
||||
}
|
||||
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, transformOpts: {log?: boolean | 'all'} & OptionalCacheUsage = {}) => {
|
||||
|
||||
const {
|
||||
log,
|
||||
useCachedResult = true
|
||||
} = transformOpts;
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
@@ -280,7 +299,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
|
||||
|
||||
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
|
||||
const cachedSteps = await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash);
|
||||
const cachedSteps = useCachedResult ? await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash) : undefined;
|
||||
if(cachedSteps !== undefined) {
|
||||
logger.trace(`Cache hit for Steps => ${transformHash}`);
|
||||
//return cachedTransformPlay;
|
||||
@@ -294,7 +313,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
const opts = {
|
||||
logger,
|
||||
asyncId
|
||||
asyncId,
|
||||
useCachedResult
|
||||
}
|
||||
|
||||
if(cachedSteps !== undefined) {
|
||||
@@ -343,7 +363,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const historyToDiff: {name: string, data?: PlayData}[] = [
|
||||
{name: 'Pre Transform', data: play.data}
|
||||
];
|
||||
let isNew = false;
|
||||
let isNew = false,
|
||||
diffFailure = false;
|
||||
if(steps.length > 0 && transformedPlay.lifecycle === undefined) {
|
||||
transformedPlay.lifecycle = steps;
|
||||
isNew = true;
|
||||
@@ -352,6 +373,11 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
lifecycle = []
|
||||
} = transformedPlay;
|
||||
steps.forEach((s, index) => {
|
||||
if(diffFailure) {
|
||||
return;
|
||||
}
|
||||
const stepIdHint = `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`;
|
||||
try {
|
||||
if(!isNew) {
|
||||
const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier());
|
||||
if(existingStepIndex !== -1) {
|
||||
@@ -363,12 +389,24 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
if(shouldLog === 'all') {
|
||||
if(s.patch === undefined) {
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`});
|
||||
historyToDiff.push({name: `${stepIdHint}`});
|
||||
} else {
|
||||
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
const lastTransformedData = historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data;
|
||||
const patched = patchObject(lastTransformedData, s.patch);
|
||||
historyToDiff.push({name: `${stepIdHint} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if(`patch` in s) {
|
||||
this.logger.debug({
|
||||
patch: s.patch,
|
||||
playData: historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data,
|
||||
cached: s.cached
|
||||
}, 'Patch and Play data context');
|
||||
}
|
||||
this.logger.warn(new SimpleError(`Error occurred while trying to generate diffed Plays for console logging on step ${stepIdHint} but will continue`, {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
});
|
||||
if(shouldLog !== false) {
|
||||
if(steps.filter(x => x.patch !== undefined).length === 0) {
|
||||
@@ -376,25 +414,45 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
} else {
|
||||
const diffs: string[] = [];
|
||||
|
||||
try {
|
||||
let lastTransformed: ElementOf<typeof historyToDiff>;
|
||||
historyToDiff.forEach((curr, index) => {
|
||||
if(index === 0) {
|
||||
lastTransformed = curr;
|
||||
return;
|
||||
}
|
||||
const last = historyToDiff[index - 1];
|
||||
if(curr.data === undefined) {
|
||||
diffs.push(`${last.name} => ${curr.name} -- No Change`);
|
||||
} else {
|
||||
const formattedDiff = diffObjectsConsoleOutput(last.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
lastTransformed = curr;
|
||||
} catch(e) {
|
||||
this.logger.debug({pre: lastTransformed.data, post: curr.data}, 'Compared Pre and Post play data');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
|
||||
if(shouldLog === true || steps.filter(x => x.patch !== undefined).length > 2) {
|
||||
const finalData = transformedPlay.data;
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
}
|
||||
if(diffFailure) {
|
||||
diffs.push('A failure during diff generation occurred but the transform was successful.');
|
||||
}
|
||||
|
||||
logger.debug(`Transform Diff\n${diffs.join('\n')}`);
|
||||
}
|
||||
}
|
||||
@@ -415,17 +473,18 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } & OptionalCacheUsage = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
const {
|
||||
onSuccess = 'continue',
|
||||
onFailure = 'stop',
|
||||
onSkip = 'continue',
|
||||
failureReturnPartial = false
|
||||
failureReturnPartial = false,
|
||||
} = hookItem;
|
||||
|
||||
const {
|
||||
logger = loggerNoop,
|
||||
asyncId = nanoid(6)
|
||||
asyncId = nanoid(6),
|
||||
useCachedResult
|
||||
} = opts;
|
||||
|
||||
const {
|
||||
@@ -446,7 +505,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
stageName: string = 'Unnamed',
|
||||
err: Error;
|
||||
try {
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, asyncId);
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, {asyncId, useCachedResult});
|
||||
newTransformedPlay = clone(newTransformedPlay);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
@@ -533,7 +592,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
public abstract getRunningState(): ComponentState
|
||||
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players' | 'deadLetterPlays' | 'deadLetterPlaysTotal' | 'queued'> & Pick<ComponentCommonApi, 'state' | 'errors' | 'warnings'> {
|
||||
let state: ComponentState;
|
||||
if(!this.initializedOnce || this.initializing) {
|
||||
state = COMPONENT_STATE.INITIALIZING;
|
||||
@@ -551,10 +610,10 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
monitoringStatus: this.getMonitoringStatus(),
|
||||
countNonLive: this.dbComponent.countNonLive,
|
||||
createdAt: this.dbComponent.createdAt?.toISOString(),
|
||||
lastReadyAt: this.lastReadyAt?.toISOString(),
|
||||
lastActiveAt: this.lastActiveAt?.toISOString(),
|
||||
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
|
||||
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
|
||||
lastReadyAt: this.lastActiveAt !== undefined ? this.lastReadyAt.toISOString() : undefined,
|
||||
lastActiveAt: this.lastActiveAt !== undefined ? this.lastActiveAt?.toISOString() : undefined,
|
||||
errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
...this.additionalApiData()
|
||||
}
|
||||
}
|
||||
@@ -569,12 +628,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
});
|
||||
}
|
||||
|
||||
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('error' in payload && payload.error instanceof Error) {
|
||||
payload.error = serializeError(payload.error);
|
||||
public emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('errors' in payload) {
|
||||
if(payload.errors.length > 0) {
|
||||
payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.errors = [];
|
||||
}
|
||||
}
|
||||
if('warning' in payload && payload.warning instanceof Error) {
|
||||
payload.warning = serializeError(payload.warning);
|
||||
if('warnings' in payload) {
|
||||
if(payload.warnings.length > 0) {
|
||||
payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.warnings = [];
|
||||
}
|
||||
}
|
||||
this.emitEvent('componentUpdate', payload);
|
||||
}
|
||||
@@ -590,7 +657,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus(payload.title);
|
||||
}
|
||||
|
||||
protected setStatus = (status: string) => {
|
||||
public setStatus = (status: string) => {
|
||||
this.status = status;
|
||||
this.emitComponentUpdate({status});
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import {truncateStringToLength } from "../../core/StringUtils.ts";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.ts";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.ts";
|
||||
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError } from "./errors/MSErrors.ts";
|
||||
import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.ts";
|
||||
import { AuthCheckError, AuthError, BuildDataError, ConnectionCheckError, findAuthIssue, ParseCacheError, PostInitError, StageError, type AuthErrorMap } from "./errors/MSErrors.ts";
|
||||
import { generateErrorTruthyTest, messageWithCausesTruncatedDefault, type TruthyErrorsOpts } from "../../core/ErrorUtils.ts";
|
||||
import { spawn } from 'abort-controller-x';
|
||||
|
||||
import { COMPONENT_AUTH_TYPE, type ComponentAuthType } from "../../core/Atomic.ts";
|
||||
export default abstract class AbstractInitializable {
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authType: ComponentAuthType = COMPONENT_AUTH_TYPE.none;
|
||||
authed: boolean = false;
|
||||
authFailure?: boolean;
|
||||
|
||||
@@ -19,8 +20,8 @@ export default abstract class AbstractInitializable {
|
||||
databaseOK?: boolean | null;
|
||||
connectionOK?: boolean | null;
|
||||
cacheOK?: boolean | null;
|
||||
error?: Error;
|
||||
warning?: Error;
|
||||
errors?: Error[] = [];
|
||||
warnings?: Error[] = [];
|
||||
|
||||
protected initializedOnce: boolean = false;
|
||||
initializing: boolean = false;
|
||||
@@ -88,15 +89,15 @@ export default abstract class AbstractInitializable {
|
||||
} catch (e) {
|
||||
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
|
||||
}
|
||||
this.error = undefined;
|
||||
this.warning = undefined;
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
return true;
|
||||
} catch(e) {
|
||||
if(notify) {
|
||||
await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
|
||||
}
|
||||
const initError = new Error('Initialization failed', {cause: e});
|
||||
this.error = initError;
|
||||
this.errors = [initError];
|
||||
throw initError;
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
@@ -283,17 +284,31 @@ export default abstract class AbstractInitializable {
|
||||
return;
|
||||
}
|
||||
|
||||
authGated = () => this.requiresAuth && !this.authed
|
||||
authGated = () => this.authType !== COMPONENT_AUTH_TYPE.none && !this.authed
|
||||
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && !this.hasUnrecoverableAuthFailure()
|
||||
|
||||
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
|
||||
findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(opts: Parameters<typeof findAuthIssue<T>>[1] = {}): AuthErrorMap[T] | undefined => {
|
||||
for(const err of this.errors) {
|
||||
const authError = findAuthIssue(err, opts);
|
||||
if(authError !== undefined) {
|
||||
return authError;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
hasAuthIssue = () => this.findAuthIssue() !== undefined;
|
||||
|
||||
hasUnrecoverableAuthFailure = () => this.findAuthIssue({unrecoverable: true}) !== undefined;
|
||||
|
||||
canAuthUnattended = () => !this.authGated() || this.authType === COMPONENT_AUTH_TYPE.unattended || (this.authType === COMPONENT_AUTH_TYPE.interactive && !this.hasUnrecoverableAuthFailure()) ;
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async (force: boolean = false) => {
|
||||
if(!this.requiresAuth) {
|
||||
if(this.authType === COMPONENT_AUTH_TYPE.none) {
|
||||
return;
|
||||
}
|
||||
if(this.authed) {
|
||||
@@ -303,24 +318,46 @@ export default abstract class AbstractInitializable {
|
||||
this.logger.debug('Auth OK but step was forced');
|
||||
}
|
||||
|
||||
if(this.authFailure) {
|
||||
// only throw *before* testing if we have previously tested and the error was unrecoverable
|
||||
// there is no reason to constantly retry auth when we already know it won't succeed
|
||||
//
|
||||
// test retries should be forced by api calls and auth callback flows *after* we have made changes to component credentials
|
||||
const unrecoverableAuth = this.findAuthIssue({unrecoverable: true});
|
||||
if(unrecoverableAuth !== undefined) {
|
||||
let unrecoverable: boolean | undefined,
|
||||
cause: Error;
|
||||
if(!force) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
|
||||
if(unrecoverableAuth instanceof AuthCheckError) {
|
||||
// we threw a fallback AuthCheckError
|
||||
unrecoverable = true;
|
||||
cause = unrecoverableAuth.cause as Error;
|
||||
} else {
|
||||
cause = unrecoverableAuth;
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
|
||||
if(this.authType === COMPONENT_AUTH_TYPE.interactive) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication', {cause, unrecoverable});
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated', {cause, unrecoverable});
|
||||
}
|
||||
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
|
||||
}
|
||||
|
||||
try {
|
||||
this.authed = await this.doAuthentication();
|
||||
this.authFailure = !this.authed;
|
||||
await this.doAuthentication();
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
let unrecoverableMsg: boolean,
|
||||
unrecoverable: boolean | undefined;
|
||||
if(e instanceof AuthError) {
|
||||
unrecoverableMsg = e.unrecoverable;
|
||||
} else {
|
||||
// if component auth test didn't throw AuthError (it should have!)
|
||||
// then fallback teo determining by these conditions
|
||||
unrecoverable = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
unrecoverableMsg = unrecoverable;
|
||||
}
|
||||
this.authed = false;
|
||||
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
|
||||
throw new AuthCheckError(`Authentication test failed!${unrecoverableMsg === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e, unrecoverable});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +386,20 @@ export default abstract class AbstractInitializable {
|
||||
public additionalApiData(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public clearErrors(opts?: TruthyErrorsOpts) {
|
||||
if (!opts) { this.errors = []; return; }
|
||||
|
||||
const test = generateErrorTruthyTest(opts);
|
||||
this.errors = this.errors.filter(x => !test(x));
|
||||
}
|
||||
|
||||
public replaceErrors(e: Error, opts?: TruthyErrorsOpts) {
|
||||
if(opts !== undefined) {
|
||||
this.clearErrors(opts);
|
||||
} else {
|
||||
this.clearErrors({instance: e});
|
||||
}
|
||||
this.errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SqliteDatabase, Migration } from 'sqlite-up';
|
||||
import type { MigrateBaseContext } from '../appMigrator.ts';
|
||||
import { playInputs, plays as drizzlePlays } from '../drizzle/schema/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { playContentBasicInvariantTransform } from '../../../utils/PlayComparisonUtils.ts';
|
||||
import { hashObject } from '../../../utils/StringUtils.ts';
|
||||
|
||||
|
||||
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
|
||||
ctx.logger.info('Generating hashes for Play input data...');
|
||||
|
||||
let more = true;
|
||||
let offset = 0,
|
||||
processed = 0,
|
||||
updated = 0;
|
||||
|
||||
// first update input hashes
|
||||
while (more) {
|
||||
const inputRows = await ctx.db.select().from(playInputs).limit(100).offset(offset);
|
||||
for (const row of inputRows) {
|
||||
if (row.playHash !== null) {
|
||||
processed++;
|
||||
}
|
||||
try {
|
||||
await ctx.db.update(playInputs).set({
|
||||
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
|
||||
}).where(eq(playInputs.id, row.id));
|
||||
updated++;
|
||||
processed++;
|
||||
} catch (e) {
|
||||
ctx.logger.warn(new Error(`Failed to generate hash for Play Input ${row.id}`, { cause: e }));
|
||||
}
|
||||
}
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Play Input Hash Generation Progress: Processed ${processed} | Updated ${updated}`);
|
||||
if (inputRows.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info('Regenerating hashes for Play data...');
|
||||
|
||||
offset = 0;
|
||||
processed = 0;
|
||||
updated = 0;
|
||||
more = true;
|
||||
// then update Plays so hashes reflect Plays after transforms are done
|
||||
while (more) {
|
||||
const playsRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset);
|
||||
for (const row of playsRows) {
|
||||
try {
|
||||
await ctx.db.update(drizzlePlays).set({
|
||||
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
|
||||
}).where(eq(drizzlePlays.id, row.id));
|
||||
updated++;
|
||||
processed++;
|
||||
} catch (e) {
|
||||
ctx.logger.warn(new Error(`Failed to generate hash for Play ${row.id} (${row.uid})`, { cause: e }));
|
||||
}
|
||||
}
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Play Hash Regeneration Progress: Processed ${processed} | Updated ${updated}`);
|
||||
if (playsRows.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
// Rollback code here
|
||||
// context is passed as ctx
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SqliteDatabase, Migration } from 'sqlite-up';
|
||||
import type { MigrateBaseContext } from '../appMigrator.ts';
|
||||
import { queueStates } from '../drizzle/schema/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
|
||||
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
|
||||
ctx.logger.info('Beginning queue entities consolidation.');
|
||||
|
||||
ctx.logger.verbose('Deleting (now) unused completed queue states...');
|
||||
|
||||
ctx.db.delete(queueStates).where(eq(queueStates.queueStatus,'completed'));
|
||||
|
||||
ctx.logger.verbose('Done with completed queue state deletions');
|
||||
|
||||
ctx.logger.info('Converting dead queue states to ingress states with failure + retries...');
|
||||
|
||||
let more = true;
|
||||
let offset = 0,
|
||||
updated = 0;
|
||||
|
||||
while (more) {
|
||||
const plays = await ctx.db.query.plays.findMany({
|
||||
where: {
|
||||
queueStates: {
|
||||
queueName: 'dead'
|
||||
}
|
||||
},
|
||||
with: {
|
||||
queueStates: true
|
||||
},
|
||||
limit: 100,
|
||||
offset
|
||||
});
|
||||
|
||||
for(const p of plays) {
|
||||
const ingress = p.queueStates.find(x => x.queueName === 'ingress');
|
||||
const dead = p.queueStates.find(x => x.queueName === 'dead');
|
||||
|
||||
if(ingress === undefined) {
|
||||
await ctx.db.insert(queueStates).values([{
|
||||
componentId: p.componentId,
|
||||
playId: p.id,
|
||||
queueName: 'ingress',
|
||||
queueStatus: 'failed',
|
||||
retries: dead.retries,
|
||||
error: dead.error,
|
||||
createdAt: dead.updatedAt,
|
||||
updatedAt: dead.updatedAt
|
||||
}]);
|
||||
} else {
|
||||
await ctx.db.update(queueStates).set({
|
||||
queueStatus: 'failed',
|
||||
retries: dead.retries,
|
||||
error: dead.error,
|
||||
updatedAt: dead.updatedAt}).where(eq(queueStates.id, ingress.id));
|
||||
}
|
||||
updated++;
|
||||
await ctx.db.delete(queueStates).where(eq(queueStates.id, dead.id));
|
||||
}
|
||||
|
||||
offset += 100;
|
||||
ctx.logger.verbose(`Conversion Progress: Updated ${updated}`);
|
||||
if (plays.length < 100) {
|
||||
more = false;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info('Done.');
|
||||
};
|
||||
|
||||
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
|
||||
// Rollback code here
|
||||
// context is passed as ctx
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {DBQueryConfig, DBQueryConfigWith, KnownKeysOnly, RelationFieldsFilterInternals, BuildQueryResult, RelationsFilter} from "drizzle-orm";
|
||||
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical } from "./schema/schema.ts";
|
||||
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical, playEvents } from "./schema/schema.ts";
|
||||
import type {TSchema, TableName} from "./schema/schema.ts";
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export type PlayNew = typeof plays.$inferInsert;
|
||||
export type PlayHistoricalSelect = typeof playsHistorical.$inferSelect;
|
||||
export type PlayHistoricalNew = typeof playsHistorical.$inferInsert;
|
||||
|
||||
export type PlayEventSelect = typeof playEvents.$inferSelect;
|
||||
export type PlayEventNew = typeof playEvents.$inferInsert;
|
||||
|
||||
// useful references for building types
|
||||
// https://github.com/drizzle-team/drizzle-orm/discussions/2596
|
||||
// https://github.com/drizzle-team/drizzle-orm/discussions/1539
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import assert from "node:assert";
|
||||
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates} from "./drizzleTypes.ts";
|
||||
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates, QueueStateSelect} from "./drizzleTypes.ts";
|
||||
import type {PlayInputNew} from "./drizzleTypes.ts";
|
||||
import type {QueueStateNew} from "./drizzleTypes.ts";
|
||||
import type {ComponentNew} from "./drizzleTypes.ts";
|
||||
import type { MarkOptional } from "ts-essentials";
|
||||
import { CLIENT_DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type PlayObject } from "../../../../core/Atomic.ts";
|
||||
import type { MarkOptional, MarkRequired } from "ts-essentials";
|
||||
import { DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type LifecycleStep, type PlayObject } from "../../../../core/Atomic.ts";
|
||||
import dayjs from "dayjs";
|
||||
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.ts";
|
||||
import { hashObject } from "../../../utils/StringUtils.ts";
|
||||
import { serializeError } from "serialize-error";
|
||||
import { PLAY_EVENT_TYPE, type PlayEventDupeCheck, type PlayEventDupeCheckData, type PlayEventPlayStateChange, type PlayEventPlayStateChangeData, type PlayEventQueueStateChange, type PlayEventQueueStateChangeData, type PlayEventScrobbleResult, type PlayEventScrobbleResultData, type PlayEventTransform } from "../../../../core/PlayEvent.ts";
|
||||
|
||||
export const generateComponentEntity = (data: MarkOptional<ComponentNew, 'uid'>): ComponentNew => {
|
||||
assert(data.name !== undefined, 'Must provide name');
|
||||
@@ -72,7 +73,7 @@ export const hydratePlaySelect = <T extends PlaySelect | PlayHistoricalSelect>(s
|
||||
}
|
||||
|
||||
export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, serializedError: boolean = false): DeadLetterScrobble<PlayObject> => {
|
||||
const deadQueue = select.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE);
|
||||
const deadQueue = select.queueStates.find(x => x.queueName === DEAD_QUEUE);
|
||||
return {
|
||||
play: select.play,
|
||||
id: select.uid,
|
||||
@@ -85,9 +86,82 @@ export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, seri
|
||||
}
|
||||
|
||||
export const generateInputEntity = (data: PlayInputNew): PlayInputNew => {
|
||||
return data;
|
||||
const {
|
||||
playHash = hashObject(playContentBasicInvariantTransform(data.play).data)
|
||||
} = data;
|
||||
return {...data, playHash};
|
||||
}
|
||||
|
||||
export const generateQueueStateEntity = (data: QueueStateNew): QueueStateNew => {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const queueStateToEventData = (qs: QueueStateSelect): PlayEventQueueStateChangeData => {
|
||||
const {
|
||||
queueName,
|
||||
queueStatus,
|
||||
error,
|
||||
retries
|
||||
} = qs;
|
||||
return {
|
||||
queueName,
|
||||
queueStatus,
|
||||
error,
|
||||
retries
|
||||
}
|
||||
}
|
||||
|
||||
export const transformToPlayEvent = (lifecycle: LifecycleStep[]): Omit<PlayEventTransform, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.transform,
|
||||
createdAt: dayjs(lifecycle[0].createdAt),
|
||||
data: lifecycle
|
||||
});
|
||||
|
||||
export const stateChangeToPlayEvent = (partial: PlayEventPlayStateChangeData): Omit<PlayEventPlayStateChange, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.playStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: partial
|
||||
})
|
||||
|
||||
export const queueStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context'>): Omit<PlayEventQueueStateChange, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.queueStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: partial
|
||||
});
|
||||
export const queueCompletionStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context' | 'retries'>): Omit<PlayEventQueueStateChange, 'playId'> => {
|
||||
const {
|
||||
context,
|
||||
retries,
|
||||
...rest
|
||||
} = partial;
|
||||
return {
|
||||
eventName: PLAY_EVENT_TYPE.queueStateChange,
|
||||
createdAt: dayjs(),
|
||||
data: rest
|
||||
}
|
||||
}
|
||||
|
||||
export const dupeCheckToPlayEvent = (partial: MarkRequired<Partial<PlayEventDupeCheckData>, 'match'>): Omit<PlayEventDupeCheck, 'playId'> => {
|
||||
const {match, ...rest} = partial;
|
||||
const data: PlayEventDupeCheckData = {
|
||||
match,
|
||||
score: match ? 1 : 0,
|
||||
breakdowns: [],
|
||||
createdAt: dayjs().toISOString(),
|
||||
...rest
|
||||
};
|
||||
return {
|
||||
eventName: PLAY_EVENT_TYPE.dupeCheck,
|
||||
createdAt: dayjs(),
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
export const scrobbleToPlayEvent = (data: PlayEventScrobbleResultData): Omit<PlayEventScrobbleResult, 'playId'> => ({
|
||||
eventName: PLAY_EVENT_TYPE.scrobbleResult,
|
||||
createdAt: data.createdAt ?? dayjs(),
|
||||
data
|
||||
});
|
||||
|
||||
export const entityIsPlayEntity = (obj: object): obj is PlaySelectWithQueueStates => `play` in obj && typeof obj.play === 'object'
|
||||
&& `componentId` in obj && typeof obj.componentId === 'number'
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_inputs` ADD `playHash` text;
|
||||
+1104
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `play_events` (
|
||||
`id` integer PRIMARY KEY,
|
||||
`playId` integer NOT NULL,
|
||||
`eventName` text(50) NOT NULL,
|
||||
`data` text,
|
||||
`error` text,
|
||||
`createdAt` number NOT NULL,
|
||||
CONSTRAINT `fk_play_events_playId_plays_id_fk` FOREIGN KEY (`playId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `play_event_id_idx` ON `play_events` (`playId`);
|
||||
+1206
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_queue_states` ADD `context` text;
|
||||
+1216
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,9 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
|
||||
await this.db.delete(this.table).where(inArray(this.table.id, ids));
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<void> {
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<typeof this.table.$inferSelect> {
|
||||
assert(id !== null && id !== undefined, `${id === null ? 'null' : 'undefined'} given for entity id`);
|
||||
await this.db.update(this.table).set(data).where(eq(this.table.id, id));
|
||||
return (await this.db.update(this.table).set(data).where(eq(this.table.id, id)).returning())[0];
|
||||
}
|
||||
|
||||
async create(data: typeof this.table.$inferInsert): Promise<typeof this.table.$inferSelect> {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {DbConcrete} from "../drizzleUtils.ts";
|
||||
export class DrizzlePlayEventsRepository extends DrizzleBaseRepository<'playEvents'> {
|
||||
|
||||
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
|
||||
super(db, 'playEvents', 'Play Events', opts);
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,29 @@ import dayjs, { type Dayjs } from "dayjs";
|
||||
import { eq, inArray, relationsFilterToSQL, sql } from "drizzle-orm";
|
||||
import assert from "node:assert";
|
||||
import type { MarkOptional } from "ts-essentials";
|
||||
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, type QueueName, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
|
||||
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, QUEUE_STATUS_QUEUED, type QueueName, SCROBBLE_TS_SOC_END, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
|
||||
import { removeUndefinedKeys } from '../../../../../core/DataUtils.ts';
|
||||
import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.ts";
|
||||
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.ts";
|
||||
import { hashObject } from "../../../../utils/StringUtils.ts";
|
||||
import { comparePlayTemporally, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
|
||||
import { comparePlayTemporally, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
|
||||
import { type CompactableProperty, type RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.ts";
|
||||
import type {SourceType} from "../../../../../core/Atomic.ts";
|
||||
import type {ErrorLike, SourceType} from "../../../../../core/Atomic.ts";
|
||||
import type {FindMany, FindWhere, FindWith, PlayInputNew, PlayNew, PlaySelect, PlaySelectWithQueueStates, PlayWith, QueueStateSelect, WhereClause} from "../drizzleTypes.ts";
|
||||
import { type DbConcrete, runTransaction } from "../drizzleUtils.ts";
|
||||
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
|
||||
import { playInputs, plays, relations } from "../schema/schema.ts";
|
||||
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, stateChangeToPlayEvent, transformToPlayEvent, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
|
||||
import { playEvents, playInputs, plays, queueStates, relations, type TSchema } from "../schema/schema.ts";
|
||||
import { buildDateCompare, type CompareDateOp, type ComponentConstrainedRepoOpts, DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {PaginatedResponse} from "../../../../../core/Api.ts";
|
||||
import type {PaginatedQueryResponse} from "../../../../../core/Api.ts";
|
||||
;
|
||||
import { type PlayEventTransform } from "../../../../../core/PlayEvent.ts";
|
||||
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
|
||||
|
||||
export interface QueueCriteria {
|
||||
queueName: QueueName
|
||||
queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export interface PlayWhereOpts<D extends DateLike = Dayjs> {
|
||||
@@ -38,7 +39,7 @@ export interface PlayWhereOpts<D extends DateLike = Dayjs> {
|
||||
text?: string[]
|
||||
}
|
||||
|
||||
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues';
|
||||
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues' | 'events' | 'children';
|
||||
export interface QueryPlaysOpts<D extends DateLike = Dayjs> extends PlayWhereOpts<D> {
|
||||
sort?: 'seenAt' | 'playedAt'
|
||||
order?: 'asc' | 'desc'
|
||||
@@ -92,6 +93,26 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
return res as PlaySelectWithQueueStates;
|
||||
}
|
||||
|
||||
findByIdWith = async <K extends keyof TSchema['plays']["relations"]>(id: number, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
|
||||
const res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
with: buildPlayWith(args)
|
||||
});
|
||||
return res as unknown as PlayWith<K> | undefined;
|
||||
}
|
||||
|
||||
findByUidWith = async <K extends keyof TSchema['plays']["relations"]>(uid: string, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
|
||||
const res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
uid
|
||||
},
|
||||
with: buildPlayWith(args)
|
||||
});
|
||||
return res as unknown as PlayWith<K> | undefined;
|
||||
}
|
||||
|
||||
createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}): Promise<PlayWith<'input'>[]> => {
|
||||
|
||||
const {
|
||||
@@ -103,11 +124,14 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
|
||||
const entitiesData = entitiesOpts.map((data) => {
|
||||
const {
|
||||
play,
|
||||
play: {
|
||||
lifecycle,
|
||||
...playRest
|
||||
},
|
||||
input,
|
||||
...rest
|
||||
} = data;
|
||||
return generatePlayEntity(play, { componentId: this.componentId, ...rest });
|
||||
return generatePlayEntity(playRest, { componentId: this.componentId, ...rest });
|
||||
});
|
||||
|
||||
const nakedPlays = await this.db.insert(plays).values(entitiesData).returning();
|
||||
@@ -126,6 +150,23 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
});
|
||||
|
||||
const inputRow = await this.db.insert(playInputs).values(inputDatas);
|
||||
|
||||
const eventData = nakedPlays.map((x, index) => {
|
||||
const {
|
||||
play: {
|
||||
lifecycle = [],
|
||||
} = {}
|
||||
} = entitiesOpts[index];
|
||||
|
||||
if(lifecycle.length > 0) {
|
||||
return {...transformToPlayEvent(lifecycle), playId: x.id}
|
||||
}
|
||||
return undefined;
|
||||
}).filter(x => x !== undefined);
|
||||
if(eventData.length > 0) {
|
||||
await this.db.insert(playEvents).values(eventData);
|
||||
}
|
||||
|
||||
playRows = nakedPlays.map((x, index) => ({...x, play: hydratePlaySelect(x, hydrate), input: inputRow[index]}));
|
||||
});
|
||||
|
||||
@@ -182,6 +223,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
case 'queues':
|
||||
query.with.queueStates = true;
|
||||
break;
|
||||
case 'events':
|
||||
query.with.events = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown relation ${w}`);
|
||||
}
|
||||
@@ -408,28 +452,28 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`);
|
||||
} else {
|
||||
for(const id of ids) {
|
||||
let compactedPlay: PlayObject;
|
||||
if(compactTypes.includes('input')) {
|
||||
await this.db.update(playInputs).set({
|
||||
data: {removedReason: 'Removed by compaction'}
|
||||
}).where(eq(playInputs.playId, id));
|
||||
}
|
||||
if(compactTypes.includes('transform')) {
|
||||
const playRow = await this.db.query.plays.findFirst({where: {id: id}});
|
||||
if(playRow === undefined) {
|
||||
// uhh shouldn't be
|
||||
loggerCom.warn(`No Play found with ID ${id}, but it should have been...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
compactedPlay = playRow.play;
|
||||
if(compactedPlay.lifecycle !== undefined) {
|
||||
compactedPlay.lifecycle = compactedPlay.lifecycle.map(x => {
|
||||
if(x.inputs == undefined) {
|
||||
const events = await this.db.query.playEvents.findMany({where: {playId: id}});
|
||||
for(const ev of events) {
|
||||
if(ev.eventName === 'transform') {
|
||||
const transformEvent = ev as PlayEventTransform;
|
||||
let compactedInput = false;
|
||||
transformEvent.data = transformEvent.data.map(x => {
|
||||
if(x.inputs !== undefined && x.inputs.length > 0) {
|
||||
compactedInput = true;
|
||||
return {...x, inputs: x.inputs.map((y) => ({type: y.type, input: 'Removed by compaction'}))};
|
||||
}
|
||||
return x;
|
||||
});
|
||||
if(compactedInput) {
|
||||
await this.db.update(playEvents).set({data: transformEvent.data}).where(eq(playEvents.id, transformEvent.id));
|
||||
}
|
||||
return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,9 +481,6 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
const vals: Parameters<typeof updater.set>[0] = {
|
||||
compacted: compactedFlags.join('-')
|
||||
};
|
||||
if(compactedPlay !== undefined) {
|
||||
vals.play = compactedPlay;
|
||||
}
|
||||
await this.db.update(plays).set(vals).where(eq(plays.id, id));
|
||||
}
|
||||
loggerCom.trace(`Compacted ${ids.length} '${retentionType}' plays`);
|
||||
@@ -454,65 +495,75 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
}
|
||||
|
||||
protected prepareGetQueueNext = () => this.db.query.plays.findFirst({
|
||||
// https://stackoverflow.com/a/78551796
|
||||
// cannot bind arrays in sqlite
|
||||
where: {
|
||||
componentId: sql.placeholder('componentId'),
|
||||
queueStates: {
|
||||
queueName: sql.placeholder('queueName'),
|
||||
queueStatus: 'queued',
|
||||
queueStatus: sql.placeholder('status'),
|
||||
retries: {
|
||||
lte: sql.placeholder('retries')
|
||||
}
|
||||
},
|
||||
},
|
||||
with: {
|
||||
queueStates: true
|
||||
},
|
||||
orderBy: {
|
||||
seenAt: 'asc'
|
||||
queueStates: {
|
||||
orderBy: {
|
||||
updatedAt: 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
// orderBy: {
|
||||
// seenAt: 'asc'
|
||||
// },
|
||||
}).prepare()
|
||||
|
||||
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number, notIds?: number[], status?: QueueStateSelect['queueStatus']} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
const {
|
||||
retries = 0,
|
||||
retries = 1000,
|
||||
notIds,
|
||||
order = 'asc',
|
||||
status = QUEUE_STATUS_QUEUED,
|
||||
componentId = this.componentId
|
||||
} = opts;
|
||||
|
||||
// let where: FindWhere<'plays'> = {
|
||||
// componentId
|
||||
// }
|
||||
let res: PlaySelectWithQueueStates | undefined;
|
||||
|
||||
// if(retries !== undefined) {
|
||||
// where.queueStates = {
|
||||
// queueName,
|
||||
// queueStatus: 'queued',
|
||||
// retries: {
|
||||
// lte: retries
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// where.queueStates = {
|
||||
// queueName,
|
||||
// queueStatus: 'queued'
|
||||
// }
|
||||
// }
|
||||
if (notIds === undefined) {
|
||||
if (this.getQueueNextPrepared === undefined) {
|
||||
this.getQueueNextPrepared = this.prepareGetQueueNext();
|
||||
}
|
||||
|
||||
// const res = await this.db.query.plays.findFirst({
|
||||
// where: where,
|
||||
// orderBy: {
|
||||
// seenAt: order
|
||||
// },
|
||||
// with: {
|
||||
// queueStates: true
|
||||
// }
|
||||
// });
|
||||
|
||||
if(this.getQueueNextPrepared === undefined) {
|
||||
this.getQueueNextPrepared = this.prepareGetQueueNext();
|
||||
res = await this.getQueueNextPrepared.execute({ queueName, retries, componentId, status });
|
||||
} else {
|
||||
// cannot bind arrays in sqlite so this has to be non-prepared
|
||||
res = await this.db.query.plays.findFirst({
|
||||
where: {
|
||||
id: {
|
||||
notIn: notIds
|
||||
},
|
||||
componentId: componentId,
|
||||
queueStates: {
|
||||
queueName: queueName,
|
||||
queueStatus: status,
|
||||
retries: {
|
||||
lte: retries
|
||||
}
|
||||
},
|
||||
},
|
||||
with: {
|
||||
queueStates: {
|
||||
orderBy: {
|
||||
updatedAt: 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
// orderBy: {
|
||||
// seenAt: 'asc'
|
||||
// },
|
||||
});
|
||||
}
|
||||
|
||||
const res = await this.getQueueNextPrepared.execute({queueName, retries, componentId});
|
||||
|
||||
if(res === undefined) {
|
||||
return undefined;
|
||||
@@ -613,12 +664,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
return {data: res.map(x => ({...x, play: hydratePlaySelect(x, hydrate)})), meta: {limit, offset}};
|
||||
}
|
||||
|
||||
public checkExisting = async (play: PlayObject, opts: {queueName?: string, states?: PlaySelect['state'][], taAccuracy?: TemporalAccuracy[]} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
public checkExisting = async (play: PlayObject, opts: {
|
||||
queueName?: string,
|
||||
states?: PlaySelect['state'][],
|
||||
taAccuracy?: TemporalAccuracy[],
|
||||
inputHash?: string | PlayObject,
|
||||
notId?: number
|
||||
} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
|
||||
const {
|
||||
queueName,
|
||||
componentId = this.componentId,
|
||||
taAccuracy = TA_DEFAULT_ACCURACY,
|
||||
states
|
||||
states,
|
||||
inputHash,
|
||||
notId,
|
||||
} = opts;
|
||||
const hash = hashObject(playContentBasicInvariantTransform(play).data);
|
||||
|
||||
@@ -626,19 +685,25 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
// which we can then use with temporal comparison to make sure we are comparing the correct dates
|
||||
//
|
||||
// this isn't as fast as just comparing playDate directly but its still much faster/cheaper than paginating plays and doing everything in-memory
|
||||
const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
|
||||
let endRange: Dayjs;
|
||||
if(play.data.playDateCompleted !== undefined) {
|
||||
// this will be present if source reports it
|
||||
// or we tracked it live with MemorySource
|
||||
endRange = play.data.playDateCompleted.add(dateGranularity, 's');
|
||||
} else {
|
||||
endRange = play.data.playDate.add(dateGranularity, 's');
|
||||
}
|
||||
// const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
|
||||
// let endRange: Dayjs;
|
||||
// if(play.data.playDateCompleted !== undefined) {
|
||||
// // this will be present if source reports it
|
||||
// // or we tracked it live with MemorySource
|
||||
// endRange = play.data.playDateCompleted.add(dateGranularity, 's');
|
||||
// } else {
|
||||
// endRange = play.data.playDate.add(dateGranularity, 's');
|
||||
// }
|
||||
const where: FindWhere<'plays'> = {
|
||||
componentId,
|
||||
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
|
||||
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {useDuration: true})),
|
||||
};
|
||||
|
||||
if(notId !== undefined) {
|
||||
where.NOT = {
|
||||
id: notId
|
||||
}
|
||||
}
|
||||
|
||||
if(queueName !== undefined) {
|
||||
where.queueStates = {
|
||||
@@ -653,19 +718,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
}
|
||||
|
||||
const mbidId = playMbidIdentifier(play);
|
||||
if(mbidId !== undefined) {
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
playHash: hash
|
||||
},
|
||||
{
|
||||
mbidIdentifier: mbidId
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
if (mbidId !== undefined || inputHash !== undefined) {
|
||||
where.AND = [{
|
||||
OR: [
|
||||
{
|
||||
playHash: hash
|
||||
}
|
||||
]
|
||||
}];
|
||||
if (mbidId !== undefined) {
|
||||
where.AND[0].OR.push({ mbidIdentifier: mbidId });
|
||||
}
|
||||
if (inputHash !== undefined) {
|
||||
where.AND[0].OR.push({ input: { playHash: typeof inputHash === 'string' ? inputHash : hashObject(playContentBasicInvariantTransform(inputHash).data) } });
|
||||
}
|
||||
} else {
|
||||
where.playHash = hash;
|
||||
}
|
||||
@@ -673,7 +739,8 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
const res = await this.db.query.plays.findMany({
|
||||
where,
|
||||
with: {
|
||||
queueStates: true
|
||||
queueStates: true,
|
||||
input: true
|
||||
}
|
||||
});
|
||||
if(res.length === 0) {
|
||||
@@ -685,12 +752,13 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
})
|
||||
}
|
||||
|
||||
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[] } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
|
||||
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[], notId?: number } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
|
||||
const {
|
||||
componentId = this.componentId,
|
||||
bufferTime,
|
||||
states,
|
||||
with: qWith
|
||||
with: qWith,
|
||||
notId
|
||||
} = opts;
|
||||
|
||||
const query: FindMany<'plays'> = {};
|
||||
@@ -704,6 +772,11 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
|
||||
in: states
|
||||
}
|
||||
}
|
||||
if(notId !== undefined) {
|
||||
where.id = {
|
||||
NOT: notId
|
||||
}
|
||||
}
|
||||
query.where = where;
|
||||
|
||||
return ((await this.db.query.plays.findMany({
|
||||
@@ -734,26 +807,75 @@ where compacted IS NOT NULL
|
||||
group by componentId,compacted;`);
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<PlayNew> & {event?: boolean, reason?: string, error?: ErrorLike}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.updateById(id, data) as PlaySelect;
|
||||
if(data.event === true) {
|
||||
if(data.state !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...stateChangeToPlayEvent(removeUndefinedKeys({state: data.state, reason: data.reason, error: data.error})), playId: id});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for state change ${data.state} on Play ${id}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await runTransaction(this.db, async () => {
|
||||
await this.db.delete(playInputs).where(eq(playInputs.playId, id));
|
||||
await this.db.delete(playEvents).where(eq(playEvents.playId, id));
|
||||
await this.db.delete(queueStates).where(eq(queueStates.playId, id));
|
||||
await this.db.update(plays).set({parentId: null}).where(eq(plays.parentId, id));
|
||||
await this.db.delete(plays).where(eq(plays.id, id));
|
||||
});
|
||||
}
|
||||
async deleteByIds(ids: number[]): Promise<void> {
|
||||
await runTransaction(this.db, async () => {
|
||||
await this.db.delete(playInputs).where(inArray(playInputs.playId, ids));
|
||||
await this.db.delete(playEvents).where(inArray(playEvents.playId, ids));
|
||||
await this.db.delete(queueStates).where(inArray(queueStates.playId, ids));
|
||||
await this.db.update(plays).set({parentId: null}).where(inArray(plays.parentId, ids));
|
||||
await this.db.delete(plays).where(inArray(plays.id, ids));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
|
||||
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean, useDuration?: boolean} = {}): CompareDateOp => {
|
||||
const {
|
||||
// use either provided arg or default to using source granularity
|
||||
bufferTime = getTemporalAccuracyCloseVal(play.meta.source as SourceType),
|
||||
useCompleted = true
|
||||
useCompleted,
|
||||
useDuration,
|
||||
} = opts;
|
||||
// we get all plays with a play date between playdate - (buffer) AND (playDateCompleted or playDate) + (buffer)
|
||||
let endRange: Dayjs;
|
||||
if(play.data.playDateCompleted !== undefined && useCompleted) {
|
||||
let startRange: Dayjs,
|
||||
endRange: Dayjs;
|
||||
|
||||
// make sure we use the
|
||||
const [sotPlayDate, SOT] = getScrobbleTsSOCDateWithContext(play);
|
||||
|
||||
if(useDuration && play.data.duration !== undefined) {
|
||||
if(SOT === SCROBBLE_TS_SOC_END) {
|
||||
endRange = play.data.playDate.add(bufferTime, 's');
|
||||
startRange = play.data.playDate.subtract(play.data.duration + bufferTime,'s')
|
||||
} else {
|
||||
endRange = play.data.playDate.add(play.data.duration + bufferTime, 's');
|
||||
startRange = play.data.playDate.subtract(bufferTime,'s')
|
||||
}
|
||||
} else if(play.data.playDateCompleted !== undefined && useCompleted) {
|
||||
startRange = play.data.playDate.subtract(bufferTime, 's');
|
||||
// this will be present if source reports it
|
||||
// or we tracked it live with MemorySource
|
||||
endRange = play.data.playDateCompleted.add(bufferTime, 's');
|
||||
} else {
|
||||
endRange = play.data.playDate.add(bufferTime, 's');
|
||||
startRange = sotPlayDate.subtract(bufferTime, 's');
|
||||
endRange = sotPlayDate.add(bufferTime, 's');
|
||||
}
|
||||
return {
|
||||
type: 'between',
|
||||
range: [play.data.playDate.subtract(bufferTime, 's'), endRange]
|
||||
range: [startRange, endRange]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +902,12 @@ export const buildPlayWith = (args: WithPlayRelation[] | undefined): FindWith<'p
|
||||
case 'queues':
|
||||
qWith.queueStates = true;
|
||||
break;
|
||||
case 'events':
|
||||
qWith.events = true;
|
||||
break;
|
||||
case 'children':
|
||||
qWith.children = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown relation ${w}`);
|
||||
}
|
||||
@@ -829,14 +957,26 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
|
||||
// or else assigning an array to OR using only `typeof where.queueStates` causes a type error
|
||||
const queueWhere: typeof where.queueStates.OR[0][] = [];
|
||||
for(const q of queues) {
|
||||
queueWhere.push(
|
||||
{
|
||||
queueName: q.queueName,
|
||||
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
in: q.queueStatus
|
||||
}
|
||||
const qWhereCriteria: typeof where.queueStates.OR[0] = {
|
||||
queueName: q.queueName,
|
||||
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
in: q.queueStatus
|
||||
}
|
||||
)
|
||||
};
|
||||
if(q.retries !== undefined) {
|
||||
qWhereCriteria.retries = {
|
||||
lte: q.retries
|
||||
}
|
||||
}
|
||||
queueWhere.push(qWhereCriteria);
|
||||
// queueWhere.push(
|
||||
// {
|
||||
// queueName: q.queueName,
|
||||
// queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
|
||||
// in: q.queueStatus
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
}
|
||||
if(queueWhere.length === 1) {
|
||||
where.queueStates = queueWhere[0];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq, and, lte, inArray } from "drizzle-orm";
|
||||
import { eq, and, gte, lte, inArray } from "drizzle-orm";
|
||||
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
|
||||
import type {DbConcrete} from "../drizzleUtils.ts";
|
||||
import type {QueueStateSelect} from "../drizzleTypes.ts";
|
||||
import { queueStates } from "../schema/schema.ts";
|
||||
import { CLIENT_DEAD_QUEUE } from "../../../../../core/Atomic.ts";
|
||||
import type {PlaySelect, QueueStateSelect} from "../drizzleTypes.ts";
|
||||
import { playEvents, queueStates } from "../schema/schema.ts";
|
||||
import { DEAD_QUEUE, INGRESS_QUEUE } from "../../../../../core/Atomic.ts";
|
||||
import { queueStateToPlayEvent } from "../entityUtils.ts";
|
||||
export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'> {
|
||||
|
||||
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
|
||||
@@ -17,7 +18,7 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
|
||||
eq(queueStates.componentId, componentId),
|
||||
lte(queueStates.retries, retries),
|
||||
eq(queueStates.queueStatus, 'failed'),
|
||||
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
|
||||
eq(queueStates.queueName, INGRESS_QUEUE)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -27,15 +28,56 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
|
||||
}).where(and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
eq(queueStates.queueStatus, 'queued'),
|
||||
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
|
||||
eq(queueStates.queueName, DEAD_QUEUE)
|
||||
));
|
||||
}
|
||||
|
||||
public getQueueCount = async (componentId: number, queueNames: string[], queueStatus: QueueStateSelect['queueStatus'][] = ['queued']): Promise<number> => {
|
||||
public getQueueCount = async (componentId: number, queueNames: string[], opts: {
|
||||
queueStatus?: QueueStateSelect['queueStatus'][],
|
||||
retries?: number
|
||||
retryEq?: 'lte' | 'gte'
|
||||
} = {}): Promise<number> => {
|
||||
const {
|
||||
queueStatus = ['queued'],
|
||||
retries,
|
||||
retryEq = 'lte'
|
||||
} = opts
|
||||
if(retries === undefined) {
|
||||
return await this.db.$count(queueStates, and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
inArray(queueStates.queueName, queueNames),
|
||||
inArray(queueStates.queueStatus, queueStatus)
|
||||
));
|
||||
}
|
||||
return await this.db.$count(queueStates, and(
|
||||
eq(queueStates.componentId, componentId),
|
||||
inArray(queueStates.queueName, queueNames),
|
||||
inArray(queueStates.queueStatus, queueStatus)
|
||||
));
|
||||
inArray(queueStates.queueStatus, queueStatus),
|
||||
retryEq === 'lte' ? lte(queueStates.retries, retries) : gte(queueStates.retries, retries)
|
||||
));
|
||||
}
|
||||
|
||||
async create(data: typeof this.table.$inferInsert & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.create(data) as QueueStateSelect;
|
||||
if(data.event === true && data.playId !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for new queue creation on Play ${data.playId}`));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateById(id: number, data: Partial<typeof this.table.$inferInsert> & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
|
||||
const res = await super.updateById(id, data) as QueueStateSelect;
|
||||
if(data.event === true && data.playId !== undefined) {
|
||||
try {
|
||||
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error(`Failed to create Play Event for queue ${res.queueName} on Play ${data.playId}`));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { integer, sqliteTable, text, index, uniqueIndex, customType, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { defineRelations } from 'drizzle-orm';
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject } from "../../../../../core/Atomic.ts";
|
||||
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject, type QueueContext } from "../../../../../core/Atomic.ts";
|
||||
import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.ts";
|
||||
import type {ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm} from "../../../../../core/Transform.ts";
|
||||
import type {JobRangeCount, JobRangeTime} from "../../../infrastructure/Job.ts";
|
||||
@@ -94,6 +94,7 @@ export const playInputs = sqliteTable("play_inputs", {
|
||||
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
|
||||
data: text({ mode: 'json' }).$type<object>(),
|
||||
play: PlayJson('play').notNull(),//text({ mode: 'json' }).notNull().$type<PlayObject>(),
|
||||
playHash: text(),
|
||||
createdAt: DayjsTimestamp('createdAt').$defaultFn(() => dayjs())
|
||||
}, (table) => [
|
||||
uniqueIndex('play_input_id_idx').on(table.playId)
|
||||
@@ -143,6 +144,7 @@ export const queueStates = sqliteTable("play_queue_states", {
|
||||
queueStatus: text({enum: ['queued','completed','failed']}).notNull().default('queued'),
|
||||
retries: integer().notNull().default(0),
|
||||
error: ErrorLikeJson('error'),
|
||||
context: text({mode: 'json'}).$type<QueueContext & {isRetry?: boolean}>(),
|
||||
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
|
||||
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs())
|
||||
}, (table) => [
|
||||
@@ -211,7 +213,18 @@ export const jobs = sqliteTable("jobs", {
|
||||
completedAt: DayjsTimestamp('completedAt')
|
||||
});
|
||||
|
||||
const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
|
||||
export const playEvents = sqliteTable("play_events", {
|
||||
id: integer({ mode: 'number' }).primaryKey(),
|
||||
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
|
||||
eventName: text({length: 50}).notNull(),
|
||||
data: text({ mode: 'json' }),
|
||||
error: ErrorLikeJson('error'),
|
||||
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
|
||||
}, (table) => [
|
||||
index('play_event_id_idx').on(table.playId)
|
||||
]);
|
||||
|
||||
const playRelations = defineRelations({ plays, queueStates, playEvents, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
|
||||
plays: {
|
||||
queueStates: r.many.queueStates(),
|
||||
input: r.one.playInputs({
|
||||
@@ -233,7 +246,8 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
|
||||
from: r.plays.jobId,
|
||||
to: r.jobs.id,
|
||||
optional: true
|
||||
})
|
||||
}),
|
||||
events: r.many.playEvents(),
|
||||
},
|
||||
playsHistorical: {
|
||||
component: r.one.components({
|
||||
@@ -265,7 +279,13 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
|
||||
},
|
||||
jobs: {
|
||||
plays: r.many.plays()
|
||||
}
|
||||
},
|
||||
playEvents: {
|
||||
play: r.one.plays({
|
||||
from: r.playEvents.playId,
|
||||
to: r.plays.id
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
export const relations = playRelations;
|
||||
@@ -280,6 +300,8 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
|
||||
return components;
|
||||
case 'playInputs':
|
||||
return playInputs;
|
||||
case 'playEvents':
|
||||
return playEvents;
|
||||
case 'queueStates':
|
||||
return queueStates;
|
||||
case 'componentMigrations':
|
||||
@@ -289,7 +311,7 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
|
||||
export const schema = {playInputs, plays, playEvents, components, componentMigrations, queueStates, jobs};
|
||||
|
||||
export type TSchema = typeof relations;
|
||||
export type Schema = typeof schema;
|
||||
|
||||
@@ -33,16 +33,38 @@ export class ConnectionCheckError extends StageError {
|
||||
}
|
||||
addKnownErrorConstructor(ConnectionCheckError);
|
||||
|
||||
export class AuthError extends NamedError {
|
||||
override name = 'Authentication Error';
|
||||
unrecoverable: boolean = false;
|
||||
|
||||
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
|
||||
super(msg, opts);
|
||||
this.unrecoverable = opts.unrecoverable ?? false;
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(AuthError);
|
||||
export const isAuthError = (e: Error): e is AuthError => e.name === 'Authentication Error' || e instanceof AuthError;
|
||||
export class AuthCheckError extends StageError {
|
||||
name = 'Authentication Check';
|
||||
override name = 'Authentication Check';
|
||||
unrecoverable?: boolean;
|
||||
|
||||
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
|
||||
super(msg, opts);
|
||||
this.unrecoverable = opts.unrecoverable;
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(AuthCheckError);
|
||||
export const isAuthCheckError = (e: Error): e is AuthCheckError => e.name === 'Authentication Check' || e instanceof AuthCheckError;
|
||||
|
||||
export class PostInitError extends StageError {
|
||||
name = 'Post Initialization';
|
||||
}
|
||||
addKnownErrorConstructor(PostInitError);
|
||||
|
||||
export class StageChangeError extends NamedError {
|
||||
override name = 'Stage Change';
|
||||
}
|
||||
|
||||
const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/);
|
||||
|
||||
export class SimpleError extends Error implements HasSimpleError {
|
||||
@@ -200,3 +222,33 @@ export class InvalidRegexError extends SimpleError {
|
||||
}
|
||||
}
|
||||
addKnownErrorConstructor(InvalidRegexError, () => new InvalidRegexError(new RegExp(/1/)));
|
||||
|
||||
export type AuthErrorMap = {
|
||||
root: AuthError
|
||||
check: AuthCheckError
|
||||
auth: AuthError | AuthCheckError
|
||||
parent: Error
|
||||
}
|
||||
|
||||
export const findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(err: Error, opts: { type?: T, unrecoverable?: boolean } = {}): AuthErrorMap[T] | undefined => {
|
||||
const {
|
||||
type = 'auth',
|
||||
unrecoverable
|
||||
} = opts;
|
||||
const authError = findCauseByFunc(err, (e) => {
|
||||
if (['check','auth'].includes(type) && isAuthCheckError(e)) {
|
||||
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
|
||||
}
|
||||
if (['root','auth'].includes(type) && isAuthError(e)) {
|
||||
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (authError !== undefined) {
|
||||
if (type === 'parent') {
|
||||
return err as AuthErrorMap[T];
|
||||
}
|
||||
return authError as AuthErrorMap[T];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NamedError } from "./MSErrors.ts";
|
||||
import type { PlayProcessingResult } from "../infrastructure/PlayProcessing.ts";
|
||||
|
||||
export interface PlayProcessingErrorData extends PlayProcessingResult {
|
||||
showStopping: boolean
|
||||
}
|
||||
|
||||
export class PlayProcessingError extends NamedError {
|
||||
override name = 'Play Processing Error';
|
||||
result: PlayProcessingResult
|
||||
showStopping: boolean
|
||||
|
||||
constructor(error: Error, data: PlayProcessingErrorData, opts: ErrorOptions = {}) {
|
||||
super('Error occured while processing Play', {...opts, cause: error});
|
||||
this.result = data;
|
||||
this.showStopping = data.showStopping;
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,7 @@ export interface MSBackendEventMap extends Omit<MSCoreEvents, 'playerUpdate'> {
|
||||
scrobbleTo: string[]
|
||||
}
|
||||
}>]
|
||||
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[] } },{},SourceType>]
|
||||
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[], wasMonitored?: boolean } },{},SourceType>]
|
||||
configError: [Error | string]
|
||||
instantiateError: [Error]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PlayEvent } from "../../../core/PlayEvent.ts"
|
||||
import type { PlaySelectWithQueueStates, QueueStateSelect } from "../database/drizzle/drizzleTypes.ts"
|
||||
|
||||
export interface PlayProcessingResult {
|
||||
playEntity: PlaySelectWithQueueStates,
|
||||
events: Omit<PlayEvent, 'playId'>[],
|
||||
queue: QueueStateSelect
|
||||
}
|
||||
@@ -1,21 +1,39 @@
|
||||
import * as z from "zod";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import { transformSplitMaybeString } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const statusTypeSchema = z.union([z.literal("online"), z.literal("idle"), z.literal("dnd"), z.literal("invisible")]);
|
||||
|
||||
export type StatusType = z.infer<typeof statusTypeSchema>;
|
||||
|
||||
const ipcLocationTupleSchema = z.custom<[number, string]>((val) => Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'string');
|
||||
const HOST_PORT_LOOSE_REGEX = new RegExp(/.+:\d+/i)
|
||||
|
||||
const ipcTransform = z.transform((val: string, ctx) => {
|
||||
const sp = val.split(':');
|
||||
if(sp.length === 1) {
|
||||
// assume its a path
|
||||
return val;
|
||||
}
|
||||
if(!HOST_PORT_LOOSE_REGEX.test(val)) {
|
||||
ctx.issues.push({
|
||||
code: 'custom',
|
||||
message: "Not shaped like host:port",
|
||||
input: val
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return [parseInt(sp[1]), sp[0]]
|
||||
})
|
||||
|
||||
export const discordDataSchema = z.object({
|
||||
token: z.string().optional(),
|
||||
applicationId: z.string().optional(),
|
||||
artwork: z.union([z.boolean(), z.string(), z.array(z.string())]).optional(),
|
||||
artworkDefaultUrl: z.string().optional(),
|
||||
statusOverrideAllow: z.union([z.string(), z.array(statusTypeSchema)]).optional(),
|
||||
statusOverrideAllow: z.array(statusTypeSchema).optional(),
|
||||
listeningActivityAllow: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
ipcLocations: z.union([z.string(), z.array(z.union([z.string(), ipcLocationTupleSchema]))]).optional()
|
||||
ipcLocations: z.array(z.string().pipe(ipcTransform)).optional()
|
||||
});
|
||||
|
||||
export type DiscordData = z.infer<typeof discordDataSchema>;
|
||||
@@ -24,10 +42,10 @@ const envDataSchema = z.object({
|
||||
DISCORD_TOKEN: discordDataSchema.shape.token,
|
||||
DISCORD_ARTWORK: discordDataSchema.shape.artwork,
|
||||
DISCORD_APPLICATION_ID: discordDataSchema.shape.applicationId,
|
||||
DISCORD_IPC_LOCATIONS: discordDataSchema.shape.ipcLocations,
|
||||
DISCORD_IPC_LOCATIONS: z.string().optional().pipe(transformSplitMaybeString),
|
||||
DISCORD_ARTWORK_DEFAULT_URL: discordDataSchema.shape.artworkDefaultUrl,
|
||||
DISCORD_STATUS_OVERRIDE_ALLOW: discordDataSchema.shape.statusOverrideAllow,
|
||||
DISCORD_LISTENING_ACTIVITY_ALLOW: discordDataSchema.shape.listeningActivityAllow,
|
||||
DISCORD_STATUS_OVERRIDE_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.statusOverrideAllow.meta()),
|
||||
DISCORD_LISTENING_ACTIVITY_ALLOW: z.string().optional().pipe(transformSplitMaybeString).meta(discordDataSchema.shape.listeningActivityAllow.meta()),
|
||||
});
|
||||
|
||||
export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConfig> = {
|
||||
@@ -40,7 +58,7 @@ export const envSchemas: EnvClientSchema<typeof envDataSchema, DiscordClientConf
|
||||
applicationId: partial.DISCORD_APPLICATION_ID,
|
||||
ipcLocations: partial.DISCORD_IPC_LOCATIONS,
|
||||
artworkDefaultUrl: partial.DISCORD_ARTWORK_DEFAULT_URL,
|
||||
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW,
|
||||
statusOverrideAllow: partial.DISCORD_STATUS_OVERRIDE_ALLOW as StatusType[],
|
||||
listeningActivityAllow: partial.DISCORD_LISTENING_ACTIVITY_ALLOW
|
||||
}
|
||||
})
|
||||
@@ -85,7 +103,7 @@ export const discordStrongDataSchema = discordDataSchema.extend({
|
||||
artwork: z.union([z.boolean(), z.array(z.string())]).optional(),
|
||||
statusOverrideAllow: z.array(statusTypeSchema).optional(),
|
||||
listeningActivityAllow: z.array(z.string()).optional(),
|
||||
ipcLocations: z.array(z.union([z.string(), ipcLocationTupleSchema])).optional()
|
||||
//ipcLocations: z.array(z.union([z.string(), z.tuple([z.number(),z.string()])])).optional()
|
||||
});
|
||||
|
||||
export type DiscordStrongData = z.infer<typeof discordStrongDataSchema>;
|
||||
@@ -101,7 +119,7 @@ export const discordIPCDataSchema = discordStrongDataSchema.extend({
|
||||
//ipcLocations: (string | [number, string])[]
|
||||
});
|
||||
|
||||
export type DiscordIPCData = z.infer<typeof discordIPCDataSchema>;
|
||||
export type DiscordIPCData = Omit<z.infer<typeof discordIPCDataSchema>, 'ipcLocations'> & {ipcLocations: (string | [number, string])[]};
|
||||
|
||||
export interface ActivityAssets {
|
||||
largeImage?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
|
||||
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape} from "../common.ts";
|
||||
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape, deadLetterOptionsSchema} from "../common.ts";
|
||||
import {retentionConfigDurationValueSchema} from "../database.ts";
|
||||
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
@@ -124,6 +124,7 @@ export const commonClientOptionsSchema = z.object({
|
||||
...monitorOptionsSchema.shape,
|
||||
...requestRetryOptionsSchema.shape,
|
||||
...upstreamRefreshOptionsSchema.shape,
|
||||
...deadLetterOptionsSchema.shape,
|
||||
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
@@ -143,17 +144,6 @@ export const commonClientOptionsSchema = z.object({
|
||||
}).optional().meta({
|
||||
description: "Options used for increasing verbosity of logging in MS (used for debugging)"
|
||||
}),
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 3
|
||||
* @examples [3]
|
||||
* */
|
||||
deadLetterRetries: z.number().optional().meta({
|
||||
description: "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
default: 3,
|
||||
examples: [3]
|
||||
}),
|
||||
|
||||
/** Enhance/correct Play data by applying a transform pipeline */
|
||||
playTransform: playTransformOptionsSchema.optional().meta({description: 'Enhance/correct Play data by applying a transform pipeline'}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {UnixTimestamp} from "../../../../../core/Atomic.ts";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
@@ -54,7 +55,7 @@ export const koitoDataSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:4110"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL for the Koito server",
|
||||
examples: ["http://192.168.0.100:4110"]
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import {lastfmClientOptionsSchema} from "./lastfm.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const librefmDataSchema = z.object({
|
||||
...commonClientDataSchema.shape,
|
||||
@@ -43,7 +44,7 @@ export const librefmDataSchema = z.object({
|
||||
*
|
||||
* @default 'https://libre.fm/2.0/'
|
||||
*/
|
||||
urlBase: z.string().optional().meta({
|
||||
urlBase: httpUrl.optional().meta({
|
||||
description: "(Optional) The host and path prefix for your Libre.fm instance",
|
||||
default: "https://libre.fm/2.0/"
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const listenBrainzDataSchema = z.object({
|
||||
...requestRetryOptionsSchema.shape,
|
||||
@@ -11,7 +12,7 @@ export const listenBrainzDataSchema = z.object({
|
||||
* @examples ["https://api.listenbrainz.org/"]
|
||||
* @default "https://api.listenbrainz.org/"
|
||||
* */
|
||||
url: z.string().optional().meta({
|
||||
url: httpUrl.optional().meta({
|
||||
description: "URL for the ListenBrainz server, if not using the default",
|
||||
default: "https://api.listenbrainz.org/",
|
||||
examples: ["https://api.listenbrainz.org/"]
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
import {componentTypeSchema} from "../../../../../core/Atomic.ts";
|
||||
import {requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonClientConfigSchema, commonClientDataSchema, type EnvClientSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const malojaDataSchema = z.object({
|
||||
...requestRetryOptionsSchema.shape,
|
||||
@@ -10,7 +11,7 @@ export const malojaDataSchema = z.object({
|
||||
*
|
||||
* @examples ["http://localhost:42010"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL for maloja server",
|
||||
examples: ["http://localhost:42010"]
|
||||
}),
|
||||
|
||||
@@ -34,7 +34,7 @@ export const rockSkyDataSchema = z.object({
|
||||
*
|
||||
* */
|
||||
handle: z.string().meta({
|
||||
description: "The **fully-qualified** handle for your ATPRoto/Bluesky account, like:"
|
||||
description: "The **fully-qualified** handle for your ATPRoto/Bluesky account"
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -155,6 +155,21 @@ export const monitorOptionsSchema = z.object({
|
||||
})
|
||||
export type MonitorOptions = z.infer<typeof monitorOptionsSchema>;
|
||||
|
||||
export const deadLetterOptionsSchema = z.object({
|
||||
/**
|
||||
* Number of times MS should automatically retry Plays in dead letter queue
|
||||
*
|
||||
* @default 3
|
||||
* @examples [3]
|
||||
* */
|
||||
deadLetterRetries: z.number().optional().meta({
|
||||
description: "Number of times MS should automatically retry Plays in dead letter queue",
|
||||
default: 3,
|
||||
examples: [3]
|
||||
})
|
||||
})
|
||||
export type DeadLetterOptions = z.infer<typeof deadLetterOptionsSchema>;
|
||||
|
||||
export type UnparsedConfig<T extends (SourceType | ClientType)> = {config: object, type: T, source?: 'file' | 'aio' | 'env', pos: string};
|
||||
|
||||
export const generateConfigLocation = (configType: string, config: UnparsedConfig<any>): string => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as z from "zod";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface WebhookPayload {
|
||||
title?: string
|
||||
@@ -96,7 +97,7 @@ export const ntfyConfigSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "The URL of the Ntfy server",
|
||||
examples: ["http://192.168.0.100:8078"]
|
||||
}),
|
||||
@@ -149,7 +150,7 @@ export const appriseConfigSchema = z.object({
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
host: z.string().meta({
|
||||
host: httpUrl.meta({
|
||||
description: "The URL of the apprise-api server",
|
||||
examples: ["http://192.168.0.100:8078"]
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,9 @@ export const appleMusicDataSchema = z.object({
|
||||
*
|
||||
* @examples ["https://music.apple.com"]
|
||||
*/
|
||||
origin: z.string().optional().meta({
|
||||
origin: z.url({
|
||||
protocol: /^https$/,
|
||||
}).optional().meta({
|
||||
description: "Origin header to include in every Apple Music API request.",
|
||||
examples: ["https://music.apple.com"]
|
||||
}),
|
||||
@@ -88,7 +90,7 @@ const envDataSchema = z.object({
|
||||
APPLEMUSIC_TEAM_ID: appleMusicKeySchema.shape.teamId.optional(),
|
||||
APPLEMUSIC_KEY_P8: appleMusicKeySchema.shape.p8.optional(),
|
||||
APPLEMUSIC_MEDIA_USER_TOKEN: appleMusicDataSchema.shape.mediaUserToken,
|
||||
APPLEMUSIC_TOKEN: appleMusicDataSchema.shape.token,
|
||||
APPLEMUSIC_TOKEN: appleMusicDataSchema.shape.token.optional(),
|
||||
APPLEMUSIC_ORIGIN_HEADER: appleMusicDataSchema.shape.origin,
|
||||
APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY: z.stringbool().optional().meta(appleMusicOptions.shape.recoverUnchangedTopHistory.meta()),
|
||||
APPLEMUSIC_NORMALIZE_ALBUM: z.stringbool().optional().meta(appleMusicOptions.shape.normalizeAlbum.meta())
|
||||
@@ -104,7 +106,7 @@ export const envSchemas: EnvSourceSchema<typeof envDataSchema, AppleMusicSourceC
|
||||
if(envKeyKeys.some(x => partial[x] !== undefined)) {
|
||||
for(const k of envKeyKeys) {
|
||||
if(partial[k] === undefined) {
|
||||
throw new SimpleError(`ENV ${partial[k]} is not defined but when providing auth via MusicKit Key you must provide all of these: ${envKeyKeys.join(', ')}`);
|
||||
throw new SimpleError(`ENV ${k} is not defined but when providing auth via MusicKit Key you must provide all of these: ${envKeyKeys.join(', ')}`);
|
||||
}
|
||||
appleMusicKey = {
|
||||
id: partial.APPLEMUSIC_KEY_ID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { parseBoolStrict } from "../../../../utils.ts";
|
||||
import { wsUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const azuraStationInfoResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -71,7 +72,7 @@ export const azuracastDataSchema = z.object({
|
||||
*
|
||||
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: wsUrl.meta({
|
||||
description: "Base URL of the Azuracast instance",
|
||||
examples: ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,7 @@ export const chromecastDeviceInfoSchema = z.object({
|
||||
*
|
||||
* @examples ["192.168.0.115"]
|
||||
* */
|
||||
address: z.string().meta({
|
||||
address: z.ipv4().meta({
|
||||
description: "The IP address of the device",
|
||||
examples: ["192.168.0.115"]
|
||||
}),
|
||||
|
||||
@@ -81,6 +81,14 @@ export type DeezerInternalData = z.infer<typeof deezerInternalDataSchema>;
|
||||
|
||||
export const deezerInternalSourceOptionsSchema = z.object({
|
||||
...commonSourceOptionsSchema.shape,
|
||||
/**
|
||||
* Set the heuristic level of duplicate/repeated track pruning from listening history.
|
||||
*
|
||||
* Some third party integrations (Sonos) cause Deezer to report the same track many times in listening history
|
||||
*
|
||||
* * `true` => detect plays with play times at exact, close to, or fuzzy (beginning/end of predicated play time + duration)
|
||||
* * `aggressive` => above AND check for plays during previous play times that occurred during the track (50% +/- duration plus playedAt date)
|
||||
*/
|
||||
fuzzyDiscoveryIgnore: z.union([z.boolean(), z.literal('aggressive')]).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as z from "zod";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export interface IcecastMetadata {
|
||||
icy?: {
|
||||
@@ -37,7 +38,7 @@ export const icecastDataSchema = z.object({
|
||||
/**
|
||||
* The Icecast stream URL
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "The Icecast stream URL"
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod";
|
||||
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema} from "../common.ts";
|
||||
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema, deadLetterOptionsSchema} from "../common.ts";
|
||||
import {retentionConfigDurationValueSchema} from "../database.ts";
|
||||
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
|
||||
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
|
||||
@@ -73,6 +73,7 @@ export const fileLogOptionsSchema = z.object({
|
||||
export const commonSourceOptionsSchema = z.object({
|
||||
...monitorOptionsSchema.shape,
|
||||
...sourceRetryOptionsSchema.shape,
|
||||
...deadLetterOptionsSchema.shape,
|
||||
/**
|
||||
* * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output
|
||||
* * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema, requestRetryOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const jRiverDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -24,7 +25,10 @@ export const jRiverDataSchema = z.object({
|
||||
* @examples ["http://localhost:52199/MCWS/v1/"]
|
||||
* @default "http://localhost:52199/MCWS/v1/"
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: z.union([
|
||||
httpUrl,
|
||||
z.ipv4()
|
||||
]).meta({
|
||||
description: "URL of the JRiver HTTP server to connect to",
|
||||
default: "http://localhost:52199/MCWS/v1/",
|
||||
examples: ["http://localhost:52199/MCWS/v1/"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const kodiDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
@@ -23,7 +24,7 @@ export const kodiDataSchema = z.object({
|
||||
* @examples ["http://localhost:8080/jsonrpc"]
|
||||
* @default "http://localhost:8080/jsonrpc"
|
||||
* */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL of the Kodi HTTP server to connect to",
|
||||
default: "http://localhost:8080/jsonrpc",
|
||||
examples: ["http://localhost:8080/jsonrpc"]
|
||||
|
||||
@@ -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"
|
||||
}),
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as z from "zod";
|
||||
import {pollingOptionsSchema} from "../common.ts";
|
||||
import {commonSourceConfigSchema, commonSourceDataSchema, type EnvSourceSchema} from "./index.ts";
|
||||
import { httpUrl } from "../../../../utils/ZodUtils.ts";
|
||||
|
||||
export const yandexMusicBridgeDataSchema = z.object({
|
||||
...commonSourceDataSchema.shape,
|
||||
...pollingOptionsSchema.shape,
|
||||
/** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */
|
||||
url: z.string().meta({
|
||||
url: httpUrl.meta({
|
||||
description: "URL of the local Python bridge, for example http://yandex-music-bridge:9980"
|
||||
}),
|
||||
/** Optional API key sent as X-API-Key to the bridge */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { childLogger, type Logger } from "@foxxmd/logging";
|
||||
import type { PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
|
||||
import type { OptionalCacheUsage, PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
|
||||
import { isStageTyped, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
|
||||
import AbstractInitializable from "../AbstractInitializable.ts";
|
||||
import type { StageConfig } from "../../../core/Transform.ts";
|
||||
@@ -61,13 +61,16 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
|
||||
protected abstract doParseConfig(data: StageConfig): Y;
|
||||
|
||||
public async handle(data: Y, play: PlayObject): Promise<PlayObject> {
|
||||
public async handle(data: Y, play: PlayObject, opts?: OptionalCacheUsage): Promise<PlayObject> {
|
||||
const {
|
||||
useCachedResult = true,
|
||||
} = (opts ?? {});
|
||||
const cacheKey = `transformResult-${this.configHash}-${hashObject(data)}-${hashObject(playContentInvariantTransform(play))}`
|
||||
try {
|
||||
const cachedTransformData = await this.cache.get<T>(cacheKey);
|
||||
const cachedTransformData = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
|
||||
if(cachedTransformData !== undefined) {
|
||||
this.logger.debug('Transform cache hit');
|
||||
const transformed = await this.doHandle(data, play, cachedTransformData);
|
||||
const transformed = await this.doHandle(data, play, cachedTransformData, opts);
|
||||
return transformed;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -82,7 +85,7 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handlePreFetch(play, data);
|
||||
await this.handlePreFetch(play, data, opts);
|
||||
} catch (e) {
|
||||
if(e instanceof SkipTransformStageError) {
|
||||
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
|
||||
@@ -93,13 +96,13 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
let transformData: T;
|
||||
let fetchedTransformData: any;
|
||||
try {
|
||||
fetchedTransformData = await this.getTransformerData(play, data);
|
||||
fetchedTransformData = await this.getTransformerData(play, data, opts);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not fetch transformer data`, { cause: e });
|
||||
}
|
||||
|
||||
try {
|
||||
transformData = await this.handlePostFetch(play, fetchedTransformData, data);
|
||||
transformData = await this.handlePostFetch(play, fetchedTransformData, data, opts);
|
||||
} catch (e) {
|
||||
if(e instanceof StagePrerequisiteError) {
|
||||
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
|
||||
@@ -107,22 +110,22 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
|
||||
throw new Error('postFetch did not pass', { cause: e });
|
||||
}
|
||||
|
||||
const transformed = await this.doHandle(data, play, transformData);
|
||||
const transformed = await this.doHandle(data, play, transformData, opts);
|
||||
await this.cache.set(cacheKey, transformData, this.config.options?.ttl ?? '15s');
|
||||
return transformed;
|
||||
}
|
||||
|
||||
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T): Promise<PlayObject>;
|
||||
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T, opts?: OptionalCacheUsage): Promise<PlayObject>;
|
||||
|
||||
public async getTransformerData(play: PlayObject, stageConfig: Y): Promise<any> {
|
||||
public async getTransformerData(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<any> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y): Promise<T> {
|
||||
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y, opts?: OptionalCacheUsage): Promise<T> {
|
||||
return transformData;
|
||||
}
|
||||
|
||||
public async handlePreFetch(play: PlayObject, stageConfig: Y): Promise<void> {
|
||||
public async handlePreFetch(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
|
||||
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
|
||||
import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
|
||||
import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";
|
||||
import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts";
|
||||
@@ -429,7 +429,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts?: OptionalCacheUsage): Promise<IRecordingMSList> {
|
||||
|
||||
const {
|
||||
// preserve order of search from before searchOrder
|
||||
@@ -443,25 +443,25 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
try {
|
||||
switch(searchType) {
|
||||
case 'isrc':
|
||||
results = await this.searchByIsrc(play, stageConfig);
|
||||
results = await this.searchByIsrc(play, stageConfig, opts);
|
||||
break;
|
||||
case 'album':
|
||||
results = await this.searchByAlbum(play, stageConfig);
|
||||
results = await this.searchByAlbum(play, stageConfig, opts);
|
||||
break;
|
||||
case 'artist':
|
||||
results = await this.searchByArtist(play, stageConfig);
|
||||
results = await this.searchByArtist(play, stageConfig, opts);
|
||||
break;
|
||||
case 'basic':
|
||||
results = await this.searchByBasicFields(play, stageConfig);
|
||||
results = await this.searchByBasicFields(play, stageConfig, opts);
|
||||
break;
|
||||
case 'freetext':
|
||||
results = await this.searchByFreeText(play, stageConfig);
|
||||
results = await this.searchByFreeText(play, stageConfig, opts);
|
||||
break;
|
||||
case 'basicorids':
|
||||
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig);
|
||||
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig, opts);
|
||||
break;
|
||||
case 'mbidrecording':
|
||||
results = await this.searchByRecordingMbid(play, stageConfig);
|
||||
results = await this.searchByRecordingMbid(play, stageConfig, opts);
|
||||
break;
|
||||
}
|
||||
queries.push({type: `mbQuery-${searchType}${results.recordings.length === 0 ? '-empty' : ''}`, input: results.requestQuery});
|
||||
@@ -486,12 +486,12 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
return {...results, requestQueries: queries};
|
||||
}
|
||||
|
||||
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
this.logger.debug({labels: ['Basic Search']}, 'Searching by artist/album/track');
|
||||
return await this.api.searchByRecording(play);
|
||||
return await this.api.searchByRecording(play, opts);
|
||||
}
|
||||
|
||||
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
const using: UsingTypes[] = [];
|
||||
const {
|
||||
data: {
|
||||
@@ -512,31 +512,31 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
using.push((brainz.artist ?? []).length > 0 ? 'mbidartist' : 'artist');
|
||||
|
||||
this.logger.debug({labels: ['Basic Or MBID Search']}, `Searching using ${using.join(', ')}}`);
|
||||
return await this.api.searchByRecording(play, {using});
|
||||
return await this.api.searchByRecording(play, {using, ...opts});
|
||||
}
|
||||
|
||||
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
if(play.data.isrc !== undefined) {
|
||||
this.logger.debug({labels: ['ISRC Search']},'Searching with ISRC');
|
||||
return await this.api.searchByRecording(play, {using: ['isrc']});
|
||||
return await this.api.searchByRecording(play, {using: ['isrc'], ...opts});
|
||||
}
|
||||
throw new SearchPrerequisiteError('Play does not have ISRC');
|
||||
}
|
||||
|
||||
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
if(play.data.meta?.brainz?.recording !== undefined) {
|
||||
this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID');
|
||||
return await this.api.searchByRecording(play, {using: ['mbidrecording']});
|
||||
return await this.api.searchByRecording(play, {using: ['mbidrecording'], ...opts});
|
||||
}
|
||||
throw new SearchPrerequisiteError('Play does not have recording MBID');
|
||||
}
|
||||
|
||||
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
// possibly the artist is incorrect (may be combined as one string)
|
||||
// if we have an album we can likely still get a decent hit w/o using artist
|
||||
if(play.data.album !== undefined && play.data.artists !== undefined && play.data.artists.length > 0) {
|
||||
this.logger.debug({labels: ['Album Search']},'Searching with only track+album');
|
||||
return await this.api.searchByRecording(play, {using: ['title','album']});
|
||||
return await this.api.searchByRecording(play, {using: ['title','album'], ...opts});
|
||||
}
|
||||
if(play.data.album === undefined) {
|
||||
throw new SearchPrerequisiteError('Play does not have an album');
|
||||
@@ -549,7 +549,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
const {
|
||||
searchArtistMethod = this.defaults.searchArtistMethod,
|
||||
} = stageConfig;
|
||||
@@ -582,7 +582,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
// so we split out all artists by all found delimiters
|
||||
const nativePlay = nativeParse(play, {titleClean: true, delimiters: DELIMITERS});
|
||||
this.logger.debug({labels: ['Parsed Artist Search']},'Searching with aggressive native parsing');
|
||||
return await this.api.searchByRecording(nativePlay, {using: ['title','artist']});
|
||||
return await this.api.searchByRecording(nativePlay, {using: ['title','artist'], ...opts});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,9 +594,9 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
||||
}
|
||||
}
|
||||
|
||||
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
|
||||
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
|
||||
this.logger.debug({labels: ['Freetext Search']},'Trying freetext search');
|
||||
const results = await this.api.searchByRecording(play, {freetext: true}) as IRecordingMSList;
|
||||
const results = await this.api.searchByRecording(play, {freetext: true, ...opts}) as IRecordingMSList;
|
||||
results.freeText = true;
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { childLogger, type Logger } from "@foxxmd/logging";
|
||||
import type AbstractTransformer from "./AbstractTransformer.ts";
|
||||
import type {TransformerCommonConfig} from "../../../core/Atomic.ts";
|
||||
import type {OptionalCacheUsage, TransformerCommonConfig} from "../../../core/Atomic.ts";
|
||||
import UserTransformer from "./UserTransformer.ts";
|
||||
import type {StageConfig} from "../../../core/Transform.ts";
|
||||
import type {PlayObject} from "../../../core/Atomic.ts";
|
||||
@@ -146,10 +146,10 @@ export default class TransformerManager {
|
||||
return config;
|
||||
}
|
||||
|
||||
public async handleStage(data: StageConfig, play: PlayObject, asyncId: string = nanoid(6)): Promise<[PlayObject, string]> {
|
||||
public async handleStage(data: StageConfig, play: PlayObject, opts: {asyncId?: string} & OptionalCacheUsage): Promise<[PlayObject, string]> {
|
||||
const t: AbstractTransformer = this.getTransformerByStage(data);
|
||||
try {
|
||||
const transformedPlay = await this.asyncStore.run(asyncId, async () => {
|
||||
const transformedPlay = await this.asyncStore.run(opts.asyncId ?? nanoid(6), async () => {
|
||||
return await t.handle(data, play);
|
||||
});
|
||||
return [transformedPlay, t.name];
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import xml2js from 'xml2js';
|
||||
import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../infrastructure/Atomic.ts";
|
||||
import type {JRiverData} from "../infrastructure/config/source/jriver.ts";
|
||||
import AbstractApiClient from "./AbstractApiClient.ts";
|
||||
import { AuthError } from '../errors/MSErrors.ts';
|
||||
|
||||
const parser = new xml2js.Parser({'async': true});
|
||||
|
||||
@@ -155,7 +156,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
throw new Error(msg, {cause: e});
|
||||
throw new AuthError(msg, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import type {KodiData} from "../infrastructure/config/source/kodi.ts";
|
||||
import AbstractApiClient from "./AbstractApiClient.ts";
|
||||
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
|
||||
import { artistNamesToCredits } from "../../../core/StringUtils.ts";
|
||||
import { AuthError } from "../errors/MSErrors.ts";
|
||||
|
||||
interface KodiDuration {
|
||||
hours: number
|
||||
@@ -150,7 +151,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
throw new Error(msg, {cause: e});
|
||||
throw new AuthError(msg, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+113
-18
@@ -13,14 +13,16 @@ import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjec
|
||||
import type {LastfmData} from "../infrastructure/config/client/lastfm.ts";
|
||||
import AbstractApiClient from "./AbstractApiClient.ts";
|
||||
import { normalizeStr, parseArtistCredits } from "../../utils/StringUtils.ts";
|
||||
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams } from 'lastfm-ts-api';
|
||||
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams, LastFMResponseError } from 'lastfm-ts-api';
|
||||
import clone from 'clone';
|
||||
import type { IncomingMessage } from "http";
|
||||
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
|
||||
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
|
||||
import { AuthError, ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
|
||||
import { redactString } from "@foxxmd/redact-string";
|
||||
import dns from 'node:dns/promises';
|
||||
import xml2js from 'xml2js';
|
||||
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
|
||||
import * as z from 'zod';
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -30,6 +32,12 @@ const badErrors = [
|
||||
'invalid parameters'
|
||||
];
|
||||
|
||||
const unrecoverableAuthErrorCodes = [
|
||||
10, // invalid api key
|
||||
13, // invalid method signature
|
||||
26, // api key suspended
|
||||
]
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
@@ -247,7 +255,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
if (this.sessionKey === undefined) {
|
||||
this.logger.warn('No session key found. User interaction for authentication required.');
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
throw new SimpleError('No session key found. User interaction for authentication required.');
|
||||
throw new AuthError('No session key found. User interaction for authentication required.', {unrecoverable: false});
|
||||
}
|
||||
try {
|
||||
// existing lastfm clients are ok with getting user from getInfo
|
||||
@@ -269,11 +277,32 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
this.logger.info(`Client authorized for user ${this.user}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e instanceof AuthError) {
|
||||
throw e;
|
||||
}
|
||||
this.logger.error('Testing auth failed');
|
||||
if(isNodeNetworkException(e)) {
|
||||
this.logger.error(`Could not communicate with ${this.upstreamName} API`);
|
||||
throw new AuthError('Testing auth failed', {cause: e, unrecoverable: false});
|
||||
}
|
||||
throw e;
|
||||
let unrecoverable: boolean;
|
||||
if(e instanceof LastFMResponseError) {
|
||||
try {
|
||||
const errorContent = JSON.parse(e.content);
|
||||
if(`error` in errorContent) {
|
||||
unrecoverable = unrecoverableAuthErrorCodes.includes(errorContent.error);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
if(unrecoverable === undefined) {
|
||||
const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined;
|
||||
if(errorWithMessage !== undefined) {
|
||||
unrecoverable = [401,403].includes(errorWithMessage.response.statusCode);
|
||||
}
|
||||
}
|
||||
throw new AuthError('Testing auth failed', {cause: e, unrecoverable});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +570,7 @@ export class LastFMIgnoredScrobble extends UpstreamError {
|
||||
|
||||
}
|
||||
|
||||
export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayObject => {
|
||||
export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObject => {
|
||||
const {
|
||||
artist,
|
||||
track,
|
||||
@@ -812,6 +841,72 @@ export interface LastFMScrobblePayload {
|
||||
mbid?: string
|
||||
}
|
||||
|
||||
// bodies arrive as urlencoded form data, so numeric fields are always strings (or string[] for batch keys) on the wire
|
||||
const lastfmNumericStringSchema = z.coerce.number().int().positive();
|
||||
|
||||
export const lastfmSubmitPayloadSchema = z.object({
|
||||
artist: z.string(),
|
||||
track: z.string(),
|
||||
album: z.string().optional(),
|
||||
albumArtist: z.string().optional(),
|
||||
mbid: z.string().optional(),
|
||||
duration: lastfmNumericStringSchema.optional(),
|
||||
sk: z.string().optional(),
|
||||
api_key: z.string().optional()
|
||||
});
|
||||
|
||||
export const lastfmSubmitMultiPayloadSchema = z.object({
|
||||
artist: z.array(z.string()),
|
||||
track: z.array(z.string()),
|
||||
album: z.array(z.string()).optional(),
|
||||
albumArtist: z.array(z.string()).optional(),
|
||||
mbid: z.array(z.string()).optional(),
|
||||
duration: z.array(lastfmNumericStringSchema).optional(),
|
||||
sk: z.string().optional(),
|
||||
api_key: z.string().optional()
|
||||
});
|
||||
|
||||
export const lastfmScrobblePayloadSchema = z.object({
|
||||
method: z.literal('track.scrobble'),
|
||||
...lastfmSubmitPayloadSchema.shape,
|
||||
timestamp: lastfmNumericStringSchema,
|
||||
}).meta({title: 'Single Scrobble'});
|
||||
export type LastfmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
|
||||
export const lastfmScrobbleMultiPayloadSchema = z.object({
|
||||
method: z.literal('track.scrobble'),
|
||||
...lastfmSubmitMultiPayloadSchema.shape,
|
||||
timestamp: z.array(lastfmNumericStringSchema),
|
||||
}).meta({title: 'Bulk Scrobbles'});
|
||||
export const lastfmScrobbleMaybeMultiPayloadSchema = z.union([lastfmScrobblePayloadSchema,lastfmScrobbleMultiPayloadSchema]).meta({title: 'Single or Bulk Scrobble'});
|
||||
export type LastfmScrobbleMaybeMultiPayload = z.infer<typeof lastfmScrobbleMaybeMultiPayloadSchema>;
|
||||
|
||||
export type LastFmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
|
||||
|
||||
export const lastfmNowPlayingPayloadSchema = z.object({
|
||||
method: z.literal('track.updateNowPlaying'),
|
||||
...lastfmSubmitPayloadSchema.shape,
|
||||
}).meta({title: 'Update Now Playing'});
|
||||
export type LastFmNowPlayingPayload = z.infer<typeof lastfmNowPlayingPayloadSchema>;
|
||||
|
||||
export type LastFmSubmitPayload = LastfmScrobbleMaybeMultiPayload | LastFmNowPlayingPayload;
|
||||
export type LastFmSingleSubmitPayload = LastfmScrobblePayload | LastFmNowPlayingPayload;
|
||||
|
||||
export const lastfmAuthRequestPayloadSchema = z.object({
|
||||
method: z.literal('auth.getMobileSession'),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
}).meta({title: 'Get Mobile Auth Session'});
|
||||
|
||||
export const lastfmRequestSchema = z.union([
|
||||
lastfmScrobbleMaybeMultiPayloadSchema,
|
||||
z.discriminatedUnion("method", [
|
||||
lastfmNowPlayingPayloadSchema,
|
||||
lastfmAuthRequestPayloadSchema
|
||||
]).meta({title: 'Update Now Playing or Mobile Auth Session'})
|
||||
]);
|
||||
export type LastFmRequest = z.infer<typeof lastfmRequestSchema>;
|
||||
|
||||
export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload {
|
||||
method: string
|
||||
}
|
||||
@@ -821,7 +916,7 @@ const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist'];
|
||||
//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid'];
|
||||
//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional];
|
||||
|
||||
export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMScrobbleRequestPayload[] => {
|
||||
export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScrobblePayload[] => {
|
||||
const keys = Object.keys(obj);
|
||||
let allObject = true;
|
||||
for(const k of lfmPayloadKeysRequired) {
|
||||
@@ -834,10 +929,10 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
|
||||
throw new Error('Payload is an unexpected mix of arrays and objects');
|
||||
}
|
||||
}
|
||||
const payloads: LastFMScrobbleRequestPayload[] = [];
|
||||
const payloads: LastfmScrobblePayload[] = [];
|
||||
|
||||
if(allObject) {
|
||||
payloads.push(obj as LastFMScrobbleRequestPayload);
|
||||
payloads.push(obj as LastfmScrobblePayload);
|
||||
} else {
|
||||
let index = 0;
|
||||
for(const t of (obj.track as string[])) {
|
||||
@@ -849,14 +944,14 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
|
||||
mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined,
|
||||
duration: obj.duration !== undefined ? obj.duration[index] : undefined,
|
||||
albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined,
|
||||
method: obj.method as string
|
||||
method: obj.method
|
||||
})
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return payloads.map(x => {
|
||||
const cleaned: LastFMScrobbleRequestPayload = x;
|
||||
const cleaned: LastfmScrobblePayload = x;
|
||||
if(typeof cleaned.duration === 'string') {
|
||||
cleaned.duration = Number.parseInt(cleaned.duration);
|
||||
}
|
||||
@@ -946,19 +1041,19 @@ export const playToScrobbleApiResponseXml = (play: PlayObject) => {
|
||||
scrobble: {
|
||||
track: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.track
|
||||
_: play.data.track ?? ''
|
||||
},
|
||||
artist: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.artists?.join(',')
|
||||
_: play.data.artists?.join(',') ?? ''
|
||||
},
|
||||
album: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.album
|
||||
_: play.data.album ?? ''
|
||||
},
|
||||
albumArtist: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.albumArtists?.join(',')
|
||||
_: play.data.albumArtists?.join(',') ?? ''
|
||||
},
|
||||
timestamp: {
|
||||
_: dayjs().unix(),
|
||||
@@ -981,19 +1076,19 @@ export const playToNowPlayingApiResponseXml = (play: PlayObject) => {
|
||||
nowplaying: {
|
||||
track: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.track
|
||||
_: play.data.track ?? ''
|
||||
},
|
||||
artist: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.artists?.join(',')
|
||||
_: play.data.artists?.join(',') ?? ''
|
||||
},
|
||||
album: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.album
|
||||
_: play.data.album ?? ''
|
||||
},
|
||||
albumArtist: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.albumArtists?.join(',')
|
||||
_: play.data.albumArtists?.join(',') ?? ''
|
||||
},
|
||||
ignoredMessage: {
|
||||
$: { code: 0 }
|
||||
|
||||
+11
-3
@@ -23,12 +23,13 @@ import { unique } from '../../utils.ts';
|
||||
import { removeUndefinedKeys } from '../../../core/DataUtils.ts';
|
||||
import type {ListenPayload, ListenResponse, ListenType, SubmitPayload} from '../../../core/vendor/listenbrainz/interfaces.ts';
|
||||
import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.ts';
|
||||
import { ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
|
||||
import { AuthError, ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
|
||||
import pRetry from 'p-retry';
|
||||
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 {
|
||||
@@ -183,8 +187,12 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
try {
|
||||
const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} catch (err) {
|
||||
const cause = findCauseByFunc<request.ResponseError>(err, (e) => isSuperAgentResponseError(e));
|
||||
if(cause !== undefined && [401,403,400].includes(cause.status)) {
|
||||
throw new AuthError('Failed to validate token', {cause: err, unrecoverable: true});
|
||||
}
|
||||
throw new AuthError('Failed to validate token due to non-auth error', {cause: err, unrecoverable: false});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -14,7 +14,7 @@ import type {RockskyScrobble} from './rocksky/interfaces.ts';
|
||||
import type {Handle} from "@atcute/lexicons";
|
||||
import { getATProtoIdentifier, identifierToAtProtoHandle } from './atproto/atUtils.ts';
|
||||
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
|
||||
import { ScrobbleSubmitError } from "../errors/MSErrors.ts";
|
||||
import { AuthError, ScrobbleSubmitError } from "../errors/MSErrors.ts";
|
||||
import { tryApiCall } from "../../utils/RequestUtils.ts";
|
||||
import { type CreateScrobbleInput, RockskyClient } from "@rocksky/sdk";
|
||||
import { getRoot } from "../../ioc.ts";
|
||||
@@ -23,6 +23,8 @@ import type {HandleData} from "../infrastructure/config/client/atproto.ts";
|
||||
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
|
||||
import { removeUndefinedKeys } from "../../../core/DataUtils.ts";
|
||||
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
|
||||
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
|
||||
import { isSuperAgentResponseError } from "../errors/ErrorUtils.ts";
|
||||
|
||||
interface SubmitOptions {
|
||||
log?: boolean
|
||||
@@ -166,7 +168,8 @@ export class RockSkyApiClient extends AbstractApiClient {
|
||||
const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
const cause = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
|
||||
throw new AuthError('Failed to validate token', {cause: e, unrecoverable: cause !== undefined && [401,403].includes(cause.status)});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
@@ -174,7 +177,9 @@ export class RockSkyApiClient extends AbstractApiClient {
|
||||
await req;
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new UpstreamError('Failed to get /profile with given token', {cause: e});
|
||||
const upstreamErr = new UpstreamError('Failed to get /profile with given token', {cause: e});
|
||||
const cause = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
|
||||
throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause !== undefined && [401,403].includes(cause.status)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export abstract class AbstractATProtoApiClient extends AbstractApiClient {
|
||||
|
||||
cache: MSCache;
|
||||
|
||||
resetMissing: boolean = false;
|
||||
|
||||
constructor(name: any, config: ATProtoUserIdentifierData & {handleData?: HandleData}, options: AbstractApiOptions) {
|
||||
super('atproto', name, config, options);
|
||||
this.cache = getRoot().items.cache();
|
||||
@@ -76,7 +78,6 @@ export abstract class AbstractATProtoApiClient extends AbstractApiClient {
|
||||
throw new UpstreamError(`Failed to fetch repo CAR file. Response was ${resp.status} with response ${text}`, {responseBody: text});
|
||||
}
|
||||
|
||||
resp.headers
|
||||
return await streamBodyProgress(resp.data, {
|
||||
logger: this.logger,
|
||||
chunkDefaultSize: 1024 * 1024 * 5, // report progress every 5 MB
|
||||
@@ -128,8 +129,12 @@ export abstract class AbstractATProtoApiClient extends AbstractApiClient {
|
||||
}
|
||||
try {
|
||||
const info = parseRateLimitHeaders(headers);
|
||||
const secUntilReset = dayjs(info.reset).diff(dayjs(), 's');
|
||||
if(info === null && this.resetMissing === false) {
|
||||
this.logger.warn({headers}, 'PDS does not implement rate limiting or is using some non-standard rate limit headers. Rate limiting will be ignored. Expected headers: ratelimit-limit, ratelimit-remaining, ratelimit-reset');
|
||||
this.resetMissing = true;
|
||||
}
|
||||
if (info !== null) {
|
||||
const secUntilReset = dayjs(info.reset).diff(dayjs(), 's');
|
||||
await this.cache.cacheAuth.set<RateLimitInfo>(`${this.getAuthCacheKey()}-rateLimitInfo`, { limit: info.limit, remaining: info.remaining, reset: info.reset.valueOf() }, Math.min(secUntilReset, 3600));
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,10 +21,14 @@ import {
|
||||
import assert from "node:assert";
|
||||
import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.ts";
|
||||
import { isNodeNetworkException } from "../../errors/NodeErrors.ts";
|
||||
import * as z from 'zod';
|
||||
|
||||
export const HANDLE_REGEX = new RegExp(/.+\..+/);
|
||||
export const HandleZodType = z.stringFormat('atproto-handle', HANDLE_REGEX)
|
||||
export const ATSIGN_REGEX = new RegExp(/^@(.+)/);
|
||||
export const AtSignZodType = z.stringFormat('atproto-atsign-handle', ATSIGN_REGEX);
|
||||
export const DID_REGEX = new RegExp(/did:(?:plc|web):.+/);
|
||||
export const DidZodType = z.stringFormat('did', DID_REGEX);
|
||||
|
||||
export interface HandleOptions {
|
||||
logger?: Logger
|
||||
|
||||
+2
-1
@@ -185,7 +185,8 @@ export const configToIPCConfig = (data: DiscordStrongData): DiscordIPCData => {
|
||||
const parsedPaths: (string | [number, string])[] = [];
|
||||
for(const p of ipcLocations) {
|
||||
if(Array.isArray(p)) {
|
||||
parsedPaths.push(p)
|
||||
if(typeof p[0] === 'number' && typeof p[1] === 'string')
|
||||
parsedPaths.push([p[0],p[1]]);
|
||||
} else {
|
||||
const sp = p.split(':');
|
||||
if(sp.length > 1) {
|
||||
|
||||
+5
-2
@@ -11,10 +11,12 @@ import { playToListenPayload } from '../listenbrainz/lzUtils.ts';
|
||||
import type {SubmitPayload} from '../../../../core/vendor/listenbrainz/interfaces.ts';
|
||||
import type {ListenType} from '../../../../core/vendor/listenbrainz/interfaces.ts';
|
||||
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
|
||||
import { ScrobbleSubmitError } from "../../errors/MSErrors.ts";
|
||||
import { AuthError, ScrobbleSubmitError } from "../../errors/MSErrors.ts";
|
||||
import { tryApiCall } from "../../../utils/RequestUtils.ts";
|
||||
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
|
||||
import { artistNamesToCredits } from "../../../../core/StringUtils.ts";
|
||||
import { findCauseByFunc } from "../../../utils/ErrorUtils.ts";
|
||||
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts";
|
||||
|
||||
interface SubmitOptions {
|
||||
log?: boolean
|
||||
@@ -133,7 +135,8 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa
|
||||
const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error('Could not validate Koito API Key', { cause: e });
|
||||
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
|
||||
throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -16,8 +16,9 @@ import { getMalojaResponseError, isMalojaAPIErrorBody, type MalojaResponseV3Comm
|
||||
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.ts';
|
||||
import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.ts';
|
||||
import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts';
|
||||
import { ScrobbleSubmitError } from '../../errors/MSErrors.ts';
|
||||
import { AuthError, ScrobbleSubmitError } from '../../errors/MSErrors.ts';
|
||||
import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.ts';
|
||||
import { findCauseByFunc } from '../../../utils/ErrorUtils.ts';
|
||||
|
||||
|
||||
|
||||
@@ -175,10 +176,11 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
},'Maloja API Response');
|
||||
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
throw new UpstreamError('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new UpstreamError(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
|
||||
throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -1,5 +1,5 @@
|
||||
import type { Response } from 'superagent';
|
||||
import type {ArtistCredit, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
|
||||
import type {ArtistCredit, OptionalCacheUsage, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
|
||||
import { UpstreamError } from "../../errors/UpstreamError.ts";
|
||||
import { type AbstractApiOptions, type FormatPlayObjectOptions, MUSICBRAINZ_URL, type MusicbrainzApiConfigData } from "../../infrastructure/Atomic.ts";
|
||||
import AbstractApiClient from "../AbstractApiClient.ts";
|
||||
@@ -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) => {
|
||||
@@ -114,17 +112,18 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
return 'API';
|
||||
}
|
||||
|
||||
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string }): Promise<T> => {
|
||||
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string } & OptionalCacheUsage): Promise<T> => {
|
||||
|
||||
let apiConfig = this.rrApis.next().value;
|
||||
|
||||
const {
|
||||
timeout = 30000,
|
||||
cacheKey
|
||||
cacheKey,
|
||||
useCachedResult = true
|
||||
} = options || {};
|
||||
|
||||
try {
|
||||
const cachedTransform = await this.cache.get<T>(cacheKey);
|
||||
const cachedTransform = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
|
||||
if(cachedTransform !== undefined) {
|
||||
const cacheUrl = await this.cache.get<string>(`${cacheKey}-url`);
|
||||
const cacheQs = await this.cache.get<string>(`${cacheKey}-qs`);
|
||||
@@ -218,13 +217,14 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise<IRecordingMSList | undefined> => {
|
||||
searchByRecording = async(play: PlayObject, options?: SearchOptions & OptionalCacheUsage): Promise<IRecordingMSList | undefined> => {
|
||||
|
||||
const {
|
||||
escapeCharacters = true,
|
||||
removeCharacters = false,
|
||||
using = ['album','artist','title'],
|
||||
freetext
|
||||
freetext,
|
||||
useCachedResult
|
||||
} = options || {};
|
||||
|
||||
const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`;
|
||||
@@ -347,7 +347,8 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
query: q
|
||||
});
|
||||
}, {
|
||||
cacheKey
|
||||
cacheKey,
|
||||
useCachedResult
|
||||
});
|
||||
|
||||
if(res === undefined) {
|
||||
|
||||
+92
-43
@@ -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, type PlayObjectMinimal, type BrainzMeta, type MBID, type ScrobbleActionResult, PARSED_FROM} 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,29 +55,30 @@ 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 {
|
||||
const res = await this.client.post((client) => {
|
||||
return client.post('com.atproto.repo.createRecord', {
|
||||
const res = await this.client.post((client) => client.post('com.atproto.repo.createRecord', {
|
||||
input,
|
||||
params: {}
|
||||
});
|
||||
});
|
||||
}));
|
||||
if(!res.ok) {
|
||||
throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { payload: input, responseBody: {status: res.status, body: res.data } });
|
||||
}
|
||||
return {payload: input, response: res.data, createdAt: dayjs().toISOString()};
|
||||
} catch (e) {
|
||||
throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
@@ -74,9 +87,12 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
input,
|
||||
params: {}
|
||||
}));
|
||||
if(!res.ok) {
|
||||
throw new ScrobbleSubmitError(`Failed to update status record`, { payload: input, responseBody: {status: res.status, body: res.data } });
|
||||
}
|
||||
return {payload: input, response: res.data, createdAt: dayjs().toISOString()};
|
||||
} catch (e) {
|
||||
throw new ScrobbleSubmitError(`Failed to update status record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
|
||||
throw new ScrobbleSubmitError(`Failed to update status record`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,37 +108,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,
|
||||
@@ -130,11 +163,12 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
},
|
||||
meta: {
|
||||
source: 'tealfm',
|
||||
parsedFrom: 'history',
|
||||
musicService: record.musicServiceBaseDomain,
|
||||
parsedFrom: PARSED_FROM.history,
|
||||
musicService,
|
||||
playId: options.playId,
|
||||
url: {
|
||||
web: options.web
|
||||
web: options.web,
|
||||
origin
|
||||
},
|
||||
user: options.user
|
||||
}
|
||||
@@ -143,7 +177,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 +187,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 +215,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 +234,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 +255,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 +265,4 @@ export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main
|
||||
}
|
||||
return recordToPlay(listRecord.value, opts);
|
||||
};
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/);
|
||||
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal(?:\.alpha)?\.feed.play\/(?<tid>.*))/);
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ export default defineLexiconConfig({
|
||||
files: ['lexicons/**/*.json'],
|
||||
outdir: 'lexicons/',
|
||||
imports: ['@atcute/bluesky'],
|
||||
modules: { importSuffix: '.ts' },
|
||||
},
|
||||
pull: {
|
||||
outdir: path.resolve(__dirname, 'lexicons/'),
|
||||
@@ -19,8 +20,8 @@ export default defineLexiconConfig({
|
||||
type: 'git',
|
||||
remote: 'https://github.com/teal-fm/teal.git',
|
||||
ref: 'main',
|
||||
pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'],
|
||||
pattern: ['lexicons/fm.teal/feed/*.json','lexicons/fm.teal/actor/*.json'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
this directory contains lexicon documents pulled from the following sources:
|
||||
|
||||
- https://github.com/teal-fm/teal.git (ref: main)
|
||||
- commit: f661868af2a56199f1b3cae1ad20cd5831ec4212
|
||||
- commit: 82ea66cadbde3f1aee9c72b05f372c6543a4b3f6
|
||||
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
export * as FmTealActorDefs from "./types/fm/teal/actor/defs.ts";
|
||||
export * as FmTealActorGetProfile from "./types/fm/teal/actor/getProfile.ts";
|
||||
export * as FmTealActorGetProfiles from "./types/fm/teal/actor/getProfiles.ts";
|
||||
export * as FmTealActorProfile from "./types/fm/teal/actor/profile.ts";
|
||||
export * as FmTealActorProfileStatus from "./types/fm/teal/actor/profileStatus.ts";
|
||||
export * as FmTealActorSearchActors from "./types/fm/teal/actor/searchActors.ts";
|
||||
export * as FmTealActorStatus from "./types/fm/teal/actor/status.ts";
|
||||
export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts";
|
||||
export * as FmTealFeedGetActorFeed from "./types/fm/teal/feed/getActorFeed.ts";
|
||||
export * as FmTealFeedGetPlay from "./types/fm/teal/feed/getPlay.ts";
|
||||
export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts";
|
||||
export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.ts";
|
||||
export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.ts";
|
||||
export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.ts";
|
||||
@@ -8,4 +19,4 @@ export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.ts";
|
||||
export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts";
|
||||
export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts";
|
||||
export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
import * as FmTealActorProfile from "./profile.ts";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _miniProfileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#miniProfileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()),
|
||||
});
|
||||
const _profileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#profileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* IPLD of the banner image
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(FmTealActorProfile.featuredItemSchema);
|
||||
},
|
||||
get status() {
|
||||
return /*#__PURE__*/ v.optional(statusViewSchema);
|
||||
},
|
||||
});
|
||||
const _statusViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#statusView"),
|
||||
),
|
||||
/**
|
||||
* The datetime after which the status is no longer current.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return /*#__PURE__*/ v.optional(FmTealFeedDefs.playViewSchema);
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
});
|
||||
|
||||
type miniProfileView$schematype = typeof _miniProfileViewSchema;
|
||||
type profileView$schematype = typeof _profileViewSchema;
|
||||
type statusView$schematype = typeof _statusViewSchema;
|
||||
|
||||
export interface miniProfileViewSchema extends miniProfileView$schematype {}
|
||||
export interface profileViewSchema extends profileView$schematype {}
|
||||
export interface statusViewSchema extends statusView$schematype {}
|
||||
|
||||
export const miniProfileViewSchema =
|
||||
_miniProfileViewSchema as miniProfileViewSchema;
|
||||
export const profileViewSchema = _profileViewSchema as profileViewSchema;
|
||||
export const statusViewSchema = _statusViewSchema as statusViewSchema;
|
||||
|
||||
export interface MiniProfileView extends v.InferInput<
|
||||
typeof miniProfileViewSchema
|
||||
> {}
|
||||
export interface ProfileView extends v.InferInput<typeof profileViewSchema> {}
|
||||
export interface StatusView extends v.InferInput<typeof statusViewSchema> {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfile", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The actor's DID or handle
|
||||
*/
|
||||
actor: /*#__PURE__*/ v.actorIdentifierString(),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actor() {
|
||||
return FmTealActorDefs.profileViewSchema;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfiles", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Array of actor DIDs or handles
|
||||
* @minLength 1
|
||||
*/
|
||||
actors: /*#__PURE__*/ v.constrain(
|
||||
/*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()),
|
||||
[/*#__PURE__*/ v.arrayLength(1)],
|
||||
),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfiles": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
|
||||
const _featuredItemSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.profile#featuredItem"),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the item, formatted as mbid:<uuid>
|
||||
*/
|
||||
mbid: /*#__PURE__*/ v.genericUriString(),
|
||||
/**
|
||||
* The MusicBrainz entity type of the item.
|
||||
*/
|
||||
type: /*#__PURE__*/ v.string<
|
||||
"artist" | "recording" | "release" | (string & {})
|
||||
>(),
|
||||
});
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profile"),
|
||||
/**
|
||||
* Small image to be displayed next to posts from account. AKA, 'profile picture'
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Larger horizontal image to display behind profile view.
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
* @maxLength 2560
|
||||
* @maxGraphemes 256
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 2560),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 256),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc).
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 64
|
||||
*/
|
||||
displayName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 64),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(featuredItemSchema);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type featuredItem$schematype = typeof _featuredItemSchema;
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface featuredItemSchema extends featuredItem$schematype {}
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const featuredItemSchema = _featuredItemSchema as featuredItemSchema;
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface FeaturedItem extends v.InferInput<typeof featuredItemSchema> {}
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profileStatus"),
|
||||
/**
|
||||
* The onboarding completion status
|
||||
*/
|
||||
completedOnboarding: /*#__PURE__*/ v.string<
|
||||
| "complete"
|
||||
| "none"
|
||||
| "playOnboarding"
|
||||
| "profileOnboarding"
|
||||
| (string & {})
|
||||
>(),
|
||||
/**
|
||||
* The timestamp when this status was created
|
||||
*/
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The timestamp when this status was last updated
|
||||
*/
|
||||
updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profileStatus": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.searchActors", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The maximum number of actors to return
|
||||
* @minimum 1
|
||||
* @maximum 25
|
||||
*/
|
||||
limit: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
|
||||
/*#__PURE__*/ v.integerRange(1, 25),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The search query
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 128
|
||||
*/
|
||||
q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 128),
|
||||
]),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.searchActors": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.status"),
|
||||
/**
|
||||
* The datetime after which the status is no longer current. If unavailable, default to 10 minutes after the start time.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return FmTealFeedDefs.playViewSchema;
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.datetimeString(),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.status": mainSchema;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user