Compare commits

...
Author SHA1 Message Date
FoxxMD 1c78da7985 chore(release): Bump verion for release 2026-04-16 12:26:39 +00:00
FoxxMD 457279465c ci: Add build date to image build workflow 2026-04-16 02:05:53 +00:00
FoxxMD d551c8563f feat(docker): Add proper opencontainer labels 2026-04-16 02:05:38 +00:00
FoxxMD 75bd4a04d6 fix(transform): Fix uninitialized logger 2026-04-15 20:34:40 +00:00
FoxxMD 339232c368 perf(transform): Defer logger creation until after it is determined a hook will be used 2026-04-15 18:40:21 +00:00
Matt Foxx 8741ff25ea Merge pull request #557 from Druidblack/master
update for Yandex music
2026-04-11 00:04:24 -04:00
Druidblack 8a952cb576 error correction at startup. 2026-04-09 22:32:19 +03:00
Druidblack eeb55aa02e added multiPlatform
added multiPlatform and support for receiving data from Yandex smart speakers
2026-04-09 22:31:18 +03:00
Druidblack 6a81fc1be2 Update ymbridge.json.example 2026-04-09 22:28:32 +03:00
FoxxMD a47e2a6242 docs: Update editUrl 2026-04-09 15:45:29 +00:00
FoxxMD dadaa6df35 ci: reduce dependabot npm updates to security only
Version updates are too big and unhelpful
2026-04-09 14:35:51 +00:00
FoxxMD fb19191209 ci: Update node version for sanity test 2026-04-09 14:08:58 +00:00
Matt Foxx 2f53a60da1 Merge pull request #548 from FoxxMD/express5
chore: Update to expressjs 5
2026-04-09 09:34:29 -04:00
FoxxMD ab2f397fab refactor: update express types related packages 2026-04-09 13:21:50 +00:00
FoxxMD db2dd8fb8b refactor: Refactor wildcard path usage for express5 2026-04-09 13:16:56 +00:00
FoxxMD 135439f509 fix(scrobbler): Fix stagger map generation variable usage 2026-04-09 13:16:33 +00:00
FoxxMD d743b849fe refactor: replace awaitjs/express with express usage 2026-04-09 13:04:47 +00:00
FoxxMD 9885768cad refactor: Install expressjs@5 2026-04-09 12:58:09 +00:00
FoxxMD c6e6bbb87b chore: Override awaitjs/express types to fix peer dependency conflict 2026-04-09 12:53:53 +00:00
FoxxMD bbc2351ebc ci: Fix npm groups to apply only by version/security
Some @types dev packages are tied to prod packages, cant really spearate them.
2026-04-09 12:30:52 +00:00
FoxxMD b547db9045 ci: group gha version updates 2026-04-09 12:25:09 +00:00
Matt Foxx 28c2e9873a Merge pull request #537 from FoxxMD/dependabot/github_actions/actions/download-artifact-8.0.1
chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1
2026-04-09 08:20:39 -04:00
Matt Foxx 6bcd3f9516 Merge pull request #536 from FoxxMD/dependabot/github_actions/actions/upload-artifact-7.0.0
chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.0
2026-04-09 08:16:49 -04:00
Matt Foxx 11b2632d02 Merge pull request #538 from FoxxMD/dependabot/github_actions/docker/metadata-action-6.0.0
chore(deps): bump docker/metadata-action from 5.9.0 to 6.0.0
2026-04-08 23:51:10 -04:00
Matt Foxx fb6dbe307d Merge pull request #539 from FoxxMD/dependabot/github_actions/marocchino/sticky-pull-request-comment-3.0.3
chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.4 to 3.0.3
2026-04-08 23:50:13 -04:00
FoxxMD 2474b792c3 ci: try wildcard on github actions 2026-04-09 03:39:12 +00:00
FoxxMD 3007c949a0 ci: Group github action dependabot updates 2026-04-09 03:35:21 +00:00
dependabot[bot] 63533534df chore(deps): bump marocchino/sticky-pull-request-comment
Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2.9.4 to 3.0.3.
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/773744901bac0e8cbb5a0dc842800d45e9b2b405...d4d6b0936434b21bc8345ad45a440c5f7d2c40ff)

---
updated-dependencies:
- dependency-name: marocchino/sticky-pull-request-comment
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:57 +00:00
dependabot[bot] 5c03062976 chore(deps): bump docker/metadata-action from 5.9.0 to 6.0.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.9.0 to 6.0.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/318604b99e75e41977312d83839a89be02ca4893...030e881283bb7a6894de51c315a6bfe6a94e05cf)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:53 +00:00
dependabot[bot] dbe41fa5ab chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:49 +00:00
dependabot[bot] 5454f817e5 chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:44 +00:00
FoxxMD 8d8f8260b5 ci: Back to one object with multiple directories 2026-04-09 03:31:53 +00:00
FoxxMD 62bb00571f ci: Group dependabot updates 2026-04-09 03:12:32 +00:00
FoxxMD c10537d658 sync package-lock.json 2026-04-09 02:55:47 +00:00
FoxxMD e9ccc319e9 try to get dependabot to use a specific npm version 2026-04-09 02:55:02 +00:00
FoxxMD 54803c6ef9 feat(docker): Make colored console output the default
Can be turned off by setting COLORED_STD=false in any environment
2026-04-09 02:35:40 +00:00
FoxxMD a3d37a864a chore: Update tsx dependency 2026-04-09 02:34:48 +00:00
Matt Foxx 394570e5a3 Merge pull request #529 from FoxxMD/fontendDevPackages
refactor: Reduce docker image size by moving frontend packages to devDependencies
2026-04-08 22:33:37 -04:00
FoxxMD 2bbfdf424f refactor: further removal of frontend package to devDependencies 2026-04-09 02:20:01 +00:00
FoxxMD 9785ad6ba7 refactor: Remove unused react-dom imports 2026-04-09 02:19:20 +00:00
FoxxMD fe7d77c03d fix(docker): Ignore dist folder 2026-04-09 02:19:00 +00:00
FoxxMD 0e7aa92f5b refactor: Reduce docker image by moving frontend packages to devDependencies
They are bundled by vite/esbuild into /dist folder, shouldn't need to keep them in node_modules
2026-04-09 01:48:08 +00:00
FoxxMD 2ff80283b9 fix: Build vite-express config based on production env to avoid making vite fallback to development (somehow) 2026-04-09 01:27:22 +00:00
FoxxMD 4f34b1a055 feat(docker): use prune-mod to remove unneeded files in image 2026-04-08 20:58:18 +00:00
FoxxMD 4effdd6df5 refactor(docker): Improve image size by removing some icon folders 2026-04-08 20:39:56 +00:00
FoxxMD af3ce9dea7 fix(docker): Fix missing patches/tsconfig for docs on docker build 2026-04-08 20:39:56 +00:00
Matt Foxx f0c5cb524f Merge pull request #527 from FoxxMD/docusaurusSocialCardNew
docs: Generate social cards per page
2026-04-08 15:48:04 -04:00
FoxxMD 46883384b6 docs: Use netlify deploy url if present 2026-04-08 19:46:00 +00:00
FoxxMD 7bc5ab3d0f docs: Specify source for ambiguous components 2026-04-08 19:31:50 +00:00
FoxxMD fd15d15124 dcs: First pass at more descriptive social card text 2026-04-08 19:30:02 +00:00
FoxxMD 14da0795f1 docs: Finalize a working social card strategy
* Helper script to build a static font
* Move all image rendering code into own folder
* Simplify generated text to description OR title
* Use text-wrap balance to make it look prettier
* Add tagline and increase font sizes to fill card dimensions
2026-04-08 19:14:43 +00:00
FoxxMD 3faf0ee7f9 social cards working but need descriptions in each front matter 2026-04-07 15:13:23 +00:00
FoxxMD 78ddb15dcf almost working
svg still doesn't render
2026-04-07 14:14:25 +00:00
FoxxMD 760373c227 docs: Replace instances of foxxmd.github.io 2026-04-07 12:44:41 +00:00
FoxxMD 6bb7b17e97 docs: Update readme with new doc site base url 2026-04-07 12:39:41 +00:00
FoxxMD 231156d452 docs: fix base site env usage 2026-04-07 12:32:31 +00:00
FoxxMD d85628b4c9 docs: Add BASE_SITE env 2026-04-07 12:27:33 +00:00
FoxxMD db0e74aec3 feat(client): Strict ENV types 2026-04-06 20:10:25 +00:00
FoxxMD a918a96d34 feat(sources): Better type enforcement 2026-04-06 20:07:30 +00:00
FoxxMD bb51915085 feat: Increase transform play concurrency
Since MB rate limiting is done by api calient we can safely increase number of plays transformed without risking MB errors from rate limits
2026-04-06 19:12:19 +00:00
Matt Foxx 7c177dfe03 Merge pull request #521 from FoxxMD/processingOverhaul
refactor: Overhaul long-running processes
2026-04-06 15:11:11 -04:00
Matt Foxx cf5e7ca1fe Merge pull request #523 from Druidblack/master
feat: Add Yandex Music source
2026-04-06 14:58:11 -04:00
Druidblack fd43ff86c8 added NO_DEVICE and NO_USER 2026-04-06 21:36:21 +03:00
FoxxMD 194a0e78ff ci: Update checkout and setup-node versions due to node deprecation 2026-04-06 18:28:00 +00:00
FoxxMD 04027e87e7 ci: fix step if statement 2026-04-06 18:04:22 +00:00
FoxxMD a998060bca ci: Add docs repo dispatch 2026-04-06 18:01:10 +00:00
Druidblack 5b16e8857e Removed optional settings 2026-04-06 20:57:36 +03:00
FoxxMD c53ce7c517 docs: Update analytics 2026-04-06 17:03:43 +00:00
FoxxMD 7897dc9503 docs: Add Yandex Music Source docs 2026-04-06 15:04:56 +00:00
Matt Foxx 160ddac3e4 Merge pull request #524 from FoxxMD/docusaursUpdateAndFix
Docusaurs update and fix
2026-04-06 10:35:58 -04:00
FoxxMD 59b4f27294 fix dep changes in wrong file 2026-04-06 14:34:33 +00:00
FoxxMD 21f42bf45b try to trigger netlify 2026-04-06 14:28:08 +00:00
FoxxMD 3e8f170f9a chore(docs): Move markdown link warn into non-deprecated setting 2026-04-06 14:24:27 +00:00
FoxxMD 15fbed09a5 fix(docs): Override lodash to fix assignWith error
https://github.com/lodash/lodash/issues/6167#issuecomment-4172977775
2026-04-06 14:24:10 +00:00
FoxxMD 52b301e729 feat(docs): Update docusaurus to 3.9.2 2026-04-06 14:23:40 +00:00
FoxxMD 0bef197521 feat(yandex): Add env source config 2026-04-06 14:06:05 +00:00
FoxxMD f16750609a feat(yandex): Handle bridge errors as upstream with some response context 2026-04-06 13:58:43 +00:00
FoxxMD c39ef29597 fix: Add missing type to sourcestatusdata 2026-04-06 13:48:49 +00:00
FoxxMD 72639616b7 fix(jellyfin): Fix typo on devicesBlock env assignment #522 2026-04-06 12:53:04 +00:00
Druidblack effe870104 fix time track
fixed the calculation of the track playback if it has ended and the new ones have not started.
2026-04-05 20:56:18 +03:00
Druidblack ee753389f7 Add files via upload 2026-04-05 19:36:13 +03:00
Druidblack 5f4a5cb815 add yandex music 2026-04-05 19:35:00 +03:00
FoxxMD 0607aedf04 feat(musicbrainz): Handle rate limiting within api calls
Instead of using staggerMap with MB transformer settings, handle at api call level for each host
2026-04-02 21:23:26 +00:00
FoxxMD 1f165096b3 feat(client): make scrobble processing abortable 2026-04-02 19:42:59 +00:00
FoxxMD fb6f6a18c8 feat(source): Make discover abortable 2026-04-02 18:41:45 +00:00
FoxxMD 03f8b20704 refactor: Implement abortable source polling
Use abort-controller-x with signals to interrupt polling process

#483
2026-04-02 18:36:20 +00:00
102 changed files with 6634 additions and 2562 deletions
+1
View File
@@ -13,6 +13,7 @@ config/*.json
config/*.bak
config/*.p8
/docs
dist
/logs
.flatpak-builder
**/generated-sources.*
+18
View File
@@ -6,6 +6,11 @@
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
groups:
sec-updates:
applies-to: "security-updates"
patterns:
- "*"
directories:
- "/"
- "/docsite"
@@ -17,3 +22,16 @@ updates:
# Check for updates once a week
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
groups:
sec-updates:
applies-to: "security-updates"
patterns:
- "*"
version-updates:
applies-to: "version-updates"
patterns:
- "*"
schedule:
interval: "weekly"
+20 -7
View File
@@ -12,12 +12,12 @@ jobs:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
with:
node-version: 20
node-version: 24
cache: npm
- name: Install app dev dependencies
@@ -28,13 +28,11 @@ jobs:
- name: Install docusaurus dependencies
run: npm ci
working-directory: ./docsite
- name: Build website
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
RY_ANALYTICS: ${{ vars.RY_ANALYTICS }}
RY_ANALYTICS_SITEID: ${{ vars.RY_ANALYTICS_SITEID }}
RY_ANALYTICS_REPLAY: ${{ vars.RY_ANALYTICS_REPLAY }}
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
@@ -43,7 +41,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@373f7f263a76c20808c831209c920827a82a2847 #3.9.3
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e #4.0.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Build output to publish to the `gh-pages` branch:
@@ -55,3 +53,18 @@ jobs:
# You can swap them out with your own user credentials.
user_name: github-actions[bot]
user_email: 41898282+github-actions[bot]@users.noreply.github.com
- name: Trigger dependent build
if: ${{ vars.DOCS_REPO != ''}}
run: |
# Trigger builds in dependent repositories
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
"https://api.github.com/repos/${{ vars.DOCS_REPO }}/dispatches" \
-d "{
\"event_type\": \"release\",
\"client_payload\": {
\"commit\": \"${{ github.sha }}\"
}
}"
+8 -5
View File
@@ -51,7 +51,9 @@ jobs:
strings=("${VARS_DOCKERHUB_SLUG}" "${VARS_GHCR_SLUG}")
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "BUILD_DATE=$date" >> $GITHUB_ENV
env:
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
@@ -104,6 +106,7 @@ jobs:
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
BUILD_DATE=${{ env.BUILD_DATE }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
@@ -117,7 +120,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -136,7 +139,7 @@ jobs:
- test
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
with:
path: /tmp/digests
pattern: digests-*
@@ -162,7 +165,7 @@ jobs:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
with:
images: |
${{ vars.DOCKERHUB_SLUG }}
@@ -220,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@773744901bac0e8cbb5a0dc842800d45e9b2b405 #2.9.4
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff #3.0.3
with:
recreate: true
header: "pr-release"
+7 -4
View File
@@ -66,13 +66,15 @@ jobs:
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "BUILD_DATE=$date" >> $GITHUB_ENV
env:
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
- name: Check out the repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
@@ -126,6 +128,7 @@ jobs:
with:
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
BUILD_DATE=${{ env.BUILD_DATE }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
@@ -140,7 +143,7 @@ jobs:
env:
VARS_DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -159,7 +162,7 @@ jobs:
- test
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
with:
path: /tmp/digests
pattern: digests-*
@@ -185,7 +188,7 @@ jobs:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
with:
images: |
${{ vars.DOCKERHUB_SLUG }}
+3 -3
View File
@@ -6,7 +6,7 @@ on:
node-version:
description: "Node version"
required: false
default: '20.x'
default: '24.x'
type: string
ref:
description: "Ref to checkout"
@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
+1
View File
@@ -151,3 +151,4 @@ tmp-*
*storybook.log
storybook-static
lib
+24 -4
View File
@@ -65,7 +65,8 @@ FROM base AS build
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
COPY --chown=abc:abc docsite/patches ./docsite/patches
COPY --chown=abc:abc docsite/package*.json docsite/tsconfig* ./docsite/
# for debugging, so the build fails faster when timing out (arm64)
#RUN npm config set fetch-retries 1 && \
@@ -92,18 +93,21 @@ RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
FROM base AS app
LABEL org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler"
COPY --chown=abc:abc *.json *.js *.ts index.html ./
COPY --chown=abc:abc patches ./patches
# frontend build from vite/esbuild
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
# backend
COPY --from=build --chown=abc:abc /app/src/backend /app/src/backend
COPY --from=build --chown=abc:abc /app/src/core /app/src/core
# docusaurus docs
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
COPY --from=base /usr/bin /usr/bin
COPY --from=base /usr/lib /usr/lib
ENV NODE_ENV=production
ENV IS_DOCKER=true
ENV COLORED_STD=true
# https://stackoverflow.com/a/63640896/1469797
ARG APP_BUILD_VERSION
@@ -114,8 +118,24 @@ RUN npm ci --omit=dev --no-audit \
&& npm uninstall ts-json-schema-generator \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
# added superflously https://github.com/Borewit/rate-limit-threshold/issues/110
&& rm -R node_modules/@biomejs \
&& npx @usex/prune-mod -w \
&& rm -rf /root/.cache
ARG BUILD_DATE=0
LABEL org.opencontainers.image.version="$APP_BUILD_VERSION" \
org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler" \
org.opencontainers.image.documentation="https://docs.multi-scrobbler.app" \
org.opencontainers.image.description="Scrobble from multiple sources to multiple clients" \
org.opencontainers.image.title="Multi-Scrobbler" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.authors="FoxxMD" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.url="https://docs.multi-scrobbler.app" \
maintainer="FoxxMD" \
build_version="$APP_BUILD_VERSION"
ARG webPort=9078
ENV PORT=$webPort
+54 -54
View File
@@ -3,7 +3,7 @@
[![Latest Release](https://img.shields.io/github/v/release/foxxmd/multi-scrobbler)](https://github.com/FoxxMD/multi-scrobbler/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
[![Docs](https://img.shields.io/badge/Read%20The%20Docs-1082c2)](https://foxxmd.github.io/multi-scrobbler/)
[![Docs](https://img.shields.io/badge/Read%20The%20Docs-1082c2)](https://docs.multi-scrobbler.app/)
<img src="/assets/icon.png" align="right"
@@ -11,56 +11,56 @@ alt="multi-scrobbler logo" width="180" height="180">
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
* Supports scrobbling from many [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources)
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/azuracast)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/configuration/sources/deezer)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/google-cast)
* [Icecast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/icecast)
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jellyfin)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jriver)
* [Kodi](https://foxxmd.github.io/multi-scrobbler/configuration/sources/kodi)
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/sources/koito-source)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-endpoint)
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/librefm-source)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-source)
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-endpoint)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/sources/maloja)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mopidy)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpd)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpris)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/configuration/sources/musikcube)
* [Music Assistant](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-endpoint#music-assistant)
* [Plex](https://foxxmd.github.io/multi-scrobbler/configuration/sources/plex)
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/sources/rocksky-source)
* [Sonos](https://foxxmd.github.io/multi-scrobbler/configuration/sources/sonos)
* [Spotify](https://foxxmd.github.io/multi-scrobbler/configuration/sources/spotify)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/tealfm-source)
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/configuration/sources/webscrobbler)
* [VLC](https://foxxmd.github.io/multi-scrobbler/configuration/sources/vlc)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/yamaha-musiccast)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients)
* [Discord](https://foxxmd.github.io/multi-scrobbler/configuration/clients/discord) (Now Playing)
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/clients/koito)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/lastfm)
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/librefm)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/clients/listenbrainz)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/clients/maloja)
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/clients/rocksky)
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/tealfm)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/configuration#webhook-configurations), [healthcheck endpoints](https://foxxmd.github.io/multi-scrobbler/configuration#health-endpoint), or [prometheus metrics](https://foxxmd.github.io/multi-scrobbler/configuration/#prometheus).
* Supports [Now Playing](https://foxxmd.github.io/multi-scrobbler/configuration/clients#now-playing) for scrobble Clients
* Supports scrobbling from many [**Sources**](https://docs.multi-scrobbler.app/configuration/sources)
* [Azuracast](https://docs.multi-scrobbler.app/configuration/sources/azuracast)
* [Deezer](https://docs.multi-scrobbler.app/configuration/sources/deezer)
* [Google Cast (Chromecast)](https://docs.multi-scrobbler.app/configuration/sources/google-cast)
* [Icecast](https://docs.multi-scrobbler.app/configuration/sources/icecast)
* [Jellyfin](https://docs.multi-scrobbler.app/configuration/sources/jellyfin)
* [JRiver](https://docs.multi-scrobbler.app/configuration/sources/jriver)
* [Kodi](https://docs.multi-scrobbler.app/configuration/sources/kodi)
* [Koito](https://docs.multi-scrobbler.app/configuration/sources/koito-source)
* [Last.fm](https://docs.multi-scrobbler.app/configuration/sources/lastfm-source)
* [Last.fm (Endpoint)](https://docs.multi-scrobbler.app/configuration/sources/lastfm-endpoint)
* [Libre.fm](https://docs.multi-scrobbler.app/configuration/sources/librefm-source)
* [ListenBrainz](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-source)
* [ListenBrainz (Endpoint)](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-endpoint)
* [Maloja](https://docs.multi-scrobbler.app/configuration/sources/maloja)
* [Mopidy](https://docs.multi-scrobbler.app/configuration/sources/mopidy)
* [MPD (Music Player Daemon)](https://docs.multi-scrobbler.app/configuration/sources/mpd)
* [MPRIS (Linux Desktop)](https://docs.multi-scrobbler.app/configuration/sources/mpris)
* [Musikcube](https://docs.multi-scrobbler.app/configuration/sources/musikcube)
* [Music Assistant](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-endpoint#music-assistant)
* [Plex](https://docs.multi-scrobbler.app/configuration/sources/plex)
* [Rocksky](https://docs.multi-scrobbler.app/configuration/sources/rocksky-source)
* [Sonos](https://docs.multi-scrobbler.app/configuration/sources/sonos)
* [Spotify](https://docs.multi-scrobbler.app/configuration/sources/spotify)
* [Subsonic-compatible APIs](https://docs.multi-scrobbler.app/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [teal.fm](https://docs.multi-scrobbler.app/configuration/sources/tealfm-source)
* [WebScrobbler](https://docs.multi-scrobbler.app/configuration/sources/webscrobbler)
* [VLC](https://docs.multi-scrobbler.app/configuration/sources/vlc)
* [Yamaha MusicCast](https://docs.multi-scrobbler.app/configuration/sources/yamaha-musiccast)
* [Youtube Music](https://docs.multi-scrobbler.app/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](https://docs.multi-scrobbler.app/configuration/clients)
* [Discord](https://docs.multi-scrobbler.app/configuration/clients/discord) (Now Playing)
* [Koito](https://docs.multi-scrobbler.app/configuration/clients/koito)
* [Last.fm](https://docs.multi-scrobbler.app/configuration/clients/lastfm)
* [Libre.fm](https://docs.multi-scrobbler.app/configuration/clients/librefm)
* [ListenBrainz](https://docs.multi-scrobbler.app/configuration/clients/listenbrainz)
* [Maloja](https://docs.multi-scrobbler.app/configuration/clients/maloja)
* [Rocksky](https://docs.multi-scrobbler.app/configuration/clients/rocksky)
* [teal.fm](https://docs.multi-scrobbler.app/configuration/clients/tealfm)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://docs.multi-scrobbler.app/configuration#webhook-configurations), [healthcheck endpoints](https://docs.multi-scrobbler.app/configuration#health-endpoint), or [prometheus metrics](https://docs.multi-scrobbler.app/configuration/#prometheus).
* Supports [Now Playing](https://docs.multi-scrobbler.app/configuration/clients#now-playing) for scrobble Clients
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through [ENVs or JSON](ttps://foxxmd.github.io/multi-scrobbler/configuration#configuration-types)
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/configuration/transforms)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/installation#nodejs)
* Modify data before scrobbling with [regular expression or search patterns](https://docs.multi-scrobbler.app/configuration/transforms)
* Install using [Docker images for x86/ARM](https://docs.multi-scrobbler.app/installation#docker) or [locally with NodeJS](https://docs.multi-scrobbler.app/installation#nodejs)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler//quickstart)
[**Quick Start Guide**](https://docs.multi-scrobbler.app//quickstart)
<img src="/docsite/static/img/status-ui.png" width="800">
@@ -73,15 +73,15 @@ A dockerized app that monitors your music listening activity from *everywhere* a
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
Yes! You can use [Last.fm as a **Source**](https://docs.multi-scrobbler.app/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
## How Does multi-scrobbler (MS) Work?
You set up [configurations](https://foxxmd.github.io/multi-scrobbler/configuration) for one or more [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) and one or more [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
You set up [configurations](https://docs.multi-scrobbler.app/configuration) for one or more [**Sources**](https://docs.multi-scrobbler.app/configuration/sources) and one or more [**Clients**](https://docs.multi-scrobbler.app/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
### Source
A [**Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
A [**Source**](https://docs.multi-scrobbler.app/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
Source configurations consist of:
@@ -91,7 +91,7 @@ Source configurations consist of:
### Client
A [**Client**](https://foxxmd.github.io/multi-scrobbler/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
A [**Client**](https://docs.multi-scrobbler.app/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
Client configurations consist of:
@@ -100,15 +100,15 @@ Client configurations consist of:
## Quick Start
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/quickstart)
[See the **Quick Start Guide**](https://docs.multi-scrobbler.app/quickstart)
## Installation
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/installation)
[See the **Installation** documentation](https://docs.multi-scrobbler.app/installation)
## Configuration
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/configuration)
[See the **Configuration** documentation](https://docs.multi-scrobbler.app/configuration)
## Usage
@@ -123,11 +123,11 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
## Help/FAQ
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/FAQ) before creating an issue!
Having issues with connections or configuration? Check the [FAQ](https://docs.multi-scrobbler.app/FAQ) before creating an issue!
## Development
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/development/dev-common)
[Detailed architecture and development guides for Sources/Clients](https://docs.multi-scrobbler.app/development/dev-common)
## License
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "Yandex Music",
"type": "ymbridge",
"data": {
"url": "http://192.168.1.161:9980",
"apiKey": "change-me",
"interval": 3
}
}
]
+2
View File
@@ -4,6 +4,8 @@ services:
multi-scrobbler:
build:
context: .
args:
BUILD_DATE: "${BUILD_DATE}"
env_file:
- path: ./.env
required: false
+1
View File
@@ -1,6 +1,7 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
title: FAQ
---
## Connection Issues
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Scrobble Clients
toc_max_heading_level: 4
---
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Discord
description: Discord Client Overview
toc_max_heading_level: 4
---
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Configuration
toc_max_heading_level: 3
---
import Tabs from '@theme/Tabs';
@@ -1,6 +1,7 @@
---
sidebar_position: 3
title: Kitchen Sink
description: Kitchen Sink Example
---
# Example Complex Scenario
@@ -2,6 +2,7 @@
title: Icecast
toc_min_heading_level: 2
toc_max_heading_level: 5
description: Icecast Source Configuration
---
import Tabs from '@theme/Tabs';
@@ -1,5 +1,6 @@
---
title: Koito
description: Koito (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Last.fm
description: Last.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Libre.fm
description: Libre.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Listenbrainz
description: Listenbrainz (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Rocksky
description: Rocksky (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Scrobble Sources
toc_max_heading_level: 4
---
@@ -40,6 +41,7 @@ A **Source** is a data source that contains information about tracks you are pla
| [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
| [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
| [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
| [Yandex Music](/configuration/sources/yandex-music) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | | ✅ | ✅ | ❌ |
| [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
## Features
@@ -1,5 +1,6 @@
---
title: Teal.fm
description: Teal.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -0,0 +1,79 @@
---
title: Yandex Music
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import JsonConfig from '!!raw-loader!@site/../config/ymbridge.json.example';
Monitor your [**Yandex Music**](https://music.yandex.com) listening activity in real-time.
This Source requires a third party docker container to communicate with Yandex Music, [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge).
<details>
<summary>Example Docker Compose with `yandex-music-bridge`</summary>
```yaml title="~/msData/docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT
# ...your other sources/clients here
# add for yandex music
// highlight-start
- YMBRIDGE_URL=http://yandex-music-bridge:9980
- YMBRIDGE_API_KEY=change-me
// highlight-end
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
// highlight-start
yandex-music-bridge:
image: ghcr.io/druidblack/yandex-music-bridge:latest
environment:
- TZ=Europe/Moscow
- YM_TOKEN=AgAAAAACO3_345345
- YM_API_KEY=change-me
- YM_PORT=9980
- YM_LANGUAGE=ru
- YM_ENABLE_YNISON=true
- YM_PUSH_TTL=45
- YM_QUEUE_CACHE_TTL=15
- YM_LOG_LEVEL=INFO
ports:
- "9980:9980"
restart: unless-stopped
// highlight-end
```
You will need to acquire your own token for `YM_TOKEN` used in [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) using the instructions provided there.
</details>
<DetailsAdmo type="tip" summary="Need Help?">
If your issue is specifically related to [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) (errors in the container, usage instructions, general Yandex setup like token etc...) please open an issue on **that repository** instead of Multi-Scrobbler's.
If you have issues specifically with the MS Source please mention [**Druidblack**](https://github.com/Druidblack) (`@Druidblack`) when opening an [issue](https://github.com/FoxxMD/multi-scrobbler/issues/new/choose) or [discussion](https://github.com/FoxxMD/multi-scrobbler/discussions/new/choose). Yandex Music is [not available](https://en.wikipedia.org/wiki/Yandex_Music) in the country of the main Multi-Scrobbler developer (FoxxMD) so they can only help troubleshooting general MS issues related to Yandex Music.
</DetailsAdmo>
## Configuration
<Config config="YandexMusicBridgeSourceConfig" fileContent={JsonConfig} name="ymbridge">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | :------------------------------------------------------------------------ |
| `YMBRIDGE_URL` | Yes | | `http://URL:PORT` for the `yandex-music-bridge` container |
| `YMBRIDGE_API_KEY` | No | | The same key used for `YM_API_KEY` on the `yandex-music-bridge` container |
</Config>
@@ -1,5 +1,6 @@
---
title: Musicbrainz Stage
description: Enhancing Scrobbles with Musicbrainz
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -139,7 +140,7 @@ Example:
### Rules and Hooks
[Add your Stage](https://foxxmd.github.io/multi-scrobbler/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook):
[Add your Stage](/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook):
```json5 title="subsonic.json"
[
@@ -1,5 +1,6 @@
---
title: Native Stage
description: Enhancing Scrobbles with Built-in Parsing
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: User Stage
description: Enhancing Scrobbles with Regex Parsing
toc_min_heading_level: 2
toc_max_heading_level: 5
---
-8
View File
@@ -1,8 +0,0 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 3
title: Client Development/Tutorial
---
To do...
+2 -2
View File
@@ -3,7 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 1
title: Common Development
description: Start here for MS development
description: Common Development Knowledge
---
# Development
@@ -177,7 +177,7 @@ For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](htt
## Creating Clients and Sources
* [Source Development and Tutorial](dev-source.md)
* [Client Development and Tutorial](dev-client.md)
* Client Development and Tutorial (TBD)
## Profiling
+1
View File
@@ -3,6 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 2
title: Source Development/Tutorial
description: Developing a new Source
---
This document will provide a step-by-step guide for creating a (trivial) new Source in MS alongside describing what aspects of the Source need to be implemented based on the service you use. Before using this document you should review [Common Development](dev-common.md#common-development).
+1 -1
View File
@@ -3,7 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 4
title: Flatpak
description: Building Flatpak App locally
description: Building Flatpak App
---
## Install Requirements
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: 'Help'
title: Help
---
## Getting Useful Data
+1
View File
@@ -41,6 +41,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [WebScrobbler](/configuration/sources/webscrobbler)
* [VLC](/configuration/sources/vlc)
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
* [Yandex Music](/configuration/sources/yandex-music)
* [Youtube Music](/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](/configuration/clients)
* [Discord](/configuration/clients/discord) (Now Playing)
+2 -1
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 1
title: 'Overview'
title: Overview
description: Installation
---
import Tabs from '@theme/Tabs';
+2 -1
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: 'As a Service'
title: As a Service
description: Local Install as a Service
---
If you have multi-scrobbler installed [locally](installation.mdx#nodejs) you can enable it to run as a background service when you login.
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: 'Quickstart'
title: Quickstart
sidebar_position: 2
---
+1
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 1
title: 'Overview'
description: Updating
---
import Tabs from '@theme/Tabs';
+56 -30
View File
@@ -1,8 +1,22 @@
import type * as Preset from '@docusaurus/preset-classic';
import * as Preset from '@docusaurus/preset-classic';
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 sidebars from './sidebars';
let baseSite = 'https://foxxmd.github.io';
// https://docs.netlify.com/build/configure-builds/environment-variables/#deploy-urls-and-metadata
const netlifyDeploy = process.env.DEPLOY_PRIME_URL;
if(netlifyDeploy !== undefined && netlifyDeploy !== null && netlifyDeploy.trim() !== '') {
baseSite = netlifyDeploy;
} else {
const baseSiteEnv = process.env.BASE_SITE;
if(baseSiteEnv !== undefined && baseSiteEnv !== null && baseSiteEnv.trim() !== '') {
baseSite = baseSiteEnv;
}
}
const config: Config = {
future: {
v4: {
@@ -19,7 +33,7 @@ const config: Config = {
favicon: 'img/favicon.ico',
// Set the production url of your site here
url: 'https://foxxmd.github.io',
url: baseSite,
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: process.env.DOCS_BASE !== undefined && process.env.DOCS_BASE !== '' ? process.env.DOCS_BASE : '/docs',
@@ -31,8 +45,12 @@ const config: Config = {
trailingSlash: true,
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
}
},
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
@@ -52,8 +70,15 @@ const config: Config = {
routeBasePath: '/',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
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`;
}
},
// blog: {
// showReadingTime: true,
@@ -122,6 +147,16 @@ const config: Config = {
},
]
}
],
[
'@bony_chops/docusaurus-og',
{
path: './preview-images', // relative to the build directory
imageRenderers: {
'docusaurus-plugin-content-docs': Renderers.docs,
'docusaurus-plugin-content-pages': Renderers.docs,
},
},
]
],
themeConfig:
@@ -154,7 +189,7 @@ const config: Config = {
position: 'right',
},
{
href: 'https://foxxmd.github.io/multi-scrobbler/',
href: 'https://multi-scrobbler.app',
label: 'Website',
position: 'right',
},
@@ -193,7 +228,7 @@ const config: Config = {
},
{
label: 'Website',
href: 'https://foxxmd.github.io/multi-scrobbler/',
href: 'https://multi-scrobbler.app',
},
],
},
@@ -216,28 +251,22 @@ const config: Config = {
showRunmeLink: false,
runmeLinkLabel: 'Checkout via Runme'
}
} satisfies Preset.ThemeConfig,
headTags: [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: process.env.GSITEVERIFICATION ?? 'none'
}
}
]
} satisfies Preset.ThemeConfig
};
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
const script = {
src: process.env.ANALYTICS,
defer: true
}
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
}
config.scripts.push(script)
const gverify = process.env.GSITEVERIFICATION;
if(gverify !== undefined && gverify !== null && gverify.trim() !== '') {
config.headTags = [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: gverify
}
}
]
}
if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
const script = {
src: process.env.RY_ANALYTICS,
@@ -246,9 +275,6 @@ if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
if (process.env.RY_ANALYTICS_SITEID !== undefined && process.env.RY_ANALYTICS_SITEID !== '') {
script['data-site-id'] = process.env.RY_ANALYTICS_SITEID;
}
if (process.env.RY_ANALYTICS_REPLAY !== undefined && process.env.RY_ANALYTICS_REPLAY !== '') {
script['data-session-replay'] = process.env.RY_ANALYTICS_REPLAY;
}
config.scripts.push(script)
}
+3878 -1413
View File
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -1,10 +1,10 @@
{
"name": "my-website",
"version": "0.0.0",
"name": "multi-scrobbler-docs",
"version": "0.12.1",
"private": true,
"scripts": {
"start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build",
"build": "npm run -s prebuild && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@@ -12,13 +12,16 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"postinstall": "patch-package"
"postinstall": "patch-package",
"previews:build-renderer": "tsc --project tsconfig.client.json || exit 0",
"prebuild": "npm run -s previews:build-renderer"
},
"dependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/faster": "3.8.1",
"@docusaurus/plugin-client-redirects": "^3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@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",
"@easyops-cn/docusaurus-search-local": "0.51.1",
"@mdx-js/react": "^3.0.0",
"ajv": "^8.18.0",
@@ -39,10 +42,10 @@
"sass": "^1.97.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
"@docusaurus/tsconfig": "^3.6.3",
"@docusaurus/types": "^3.6.3",
"@types/react": "^18.2.29",
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"@types/react": "^19.2.4",
"typescript": "~5.6.2"
},
"browserslist": {
@@ -60,5 +63,9 @@
"engines": {
"node": ">=24.14.0",
"npm": ">=11.12.1"
},
"overrides": {
"lodash": "4.18.1",
"satori": "0.25.0"
}
}
@@ -0,0 +1,42 @@
diff --git a/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js b/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
index b308c02..e0d291a 100644
--- a/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
+++ b/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
@@ -93,7 +93,16 @@ class DocsPlugin {
await document.write();
}
};
- getHtmlPath = (doc) => doc.metadata?.permalink &&
- path.join(this.context.outDir, doc.metadata.permalink, 'index.html');
+ getHtmlPath = (doc) => {
+ if(doc.metadata?.permalink !== undefined) {
+ let relativePerma = doc.metadata.permalink;
+ if(this.context.baseUrl !== undefined && this.context.baseUrl !== '/') {
+ relativePerma = path.join('/', relativePerma.replace(this.context.baseUrl, ''));
+ }
+ const p = path.join(this.context.outDir, relativePerma, 'index.html');
+ return p;
+ }
+ return undefined;
+ }
}
exports.DocsPlugin = DocsPlugin;
diff --git a/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js b/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
index 4676f56..03f03a5 100644
--- a/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
+++ b/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
@@ -101,6 +101,13 @@ class PagesPlugin {
await document.write();
}
};
- getHtmlPath = (permalink) => path.join(this.context.outDir, permalink, 'index.html');
+ getHtmlPath = (permalink) => {
+ let relativePerma = permalink;
+ if(this.context.baseUrl !== undefined && this.context.baseUrl !== '/') {
+ relativePerma = path.join('/', relativePerma.replace(this.context.baseUrl, ''));
+ }
+ const p = path.join(this.context.outDir, relativePerma, 'index.html');
+ return p;
+ }
}
exports.PagesPlugin = PagesPlugin;
-3
View File
@@ -2,9 +2,6 @@ import React, { Fragment } from "react"
import CodeBlock from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
export interface AIOProps {
@@ -2,9 +2,6 @@ import React, { Fragment, useMemo } from "react"
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
import FileExample, { FileProps } from "./FileExample";
-3
View File
@@ -1,9 +1,6 @@
import React, { Fragment, useMemo } from "react"
import CodeBlock, { Props as CodeBlockProps } from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import { useTypedLocalStorage } from "./useLocalStorage";
import ButtonGroup from "./ButtonGroup";
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
+6 -7
View File
@@ -6,10 +6,10 @@ import CodeBlock from '@theme/CodeBlock';
import Schema from "@site/static/schemas/aio.json";
import ConfigExample from "@site/static/configExample.json";
import './Playground.scss';
import { JsonSchemaViewer } from 'cf-json-schema-viz';
import { JSONSchema, JsonSchemaViewer } from 'cf-json-schema-viz';
import "./json-schema-viewer-styles.css";
import "./modern-json-react-styles.css";
import { ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import f from "ajv-formats"
import DetailsAdmo from "./AdmonitionDetails";
@@ -47,10 +47,9 @@ function PlaygroundInner(): JSX.Element {
<p>Use this to understand how to write a valid config.</p>
</DetailsAdmo>
<JsonSchemaViewer
name="Todos Model"
schema={Schema}
expanded={false}
hideTopBar={false}
schema={Schema as JSONSchema}
// expanded={false}
// hideTopBar={false}
renderRootTreeLines={true}
emptyText="No schema defined"
defaultExpandedDepth={0}
@@ -93,7 +92,7 @@ function PlaygroundInner(): JSX.Element {
validator={validator}
theme={colorMode}
content={data}
mode="text"
mode={"text" as Mode.text}
style={{height: 'initial'}}
/>
)}
@@ -1,7 +0,0 @@
import Schema from "@site/static/aio.json";
import Explorer from './Explorer';
export default function ExplorerSSR(): JSX.Element {
return <Explorer schema={Schema}/>
}
@@ -4,7 +4,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, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import f from "ajv-formats"
import json5 from 'json5';
@@ -58,7 +58,7 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
<ReactJsonEditor
validator={validator}
theme={colorMode}
mode="text"
mode={"text" as Mode.text}
content={initialData}
style={{height: 'initial'}}
/>
@@ -1,50 +0,0 @@
import { useContext, createContext } from "react"
// Type I need for useRef
import type { MonacoEditorTypes } from "@theme/MonacoEditor"
export type State = {
// The full schema
// We might need to scope it with a JSON Pointer
fullSchema: unknown
// The current schema displayed (after the json pointer)
userSchema: unknown
// The current json pointer
jsonPointer: string
// Schema editor ref
schemaRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
// Data editor ref
editorRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
// Data on the data editor
// When using "Infer schema from data", it would be annoying to lose our written
data: string
// Errors in schema
schemaErrors: MonacoEditorTypes.IMarkerData[]
// Errors in data
dataErrors: MonacoEditorTypes.IMarkerData[]
}
export type Playground = {
// state
state: State
// update function
updateState: (_: Partial<State>) => void
}
export const PlaygroundContext = createContext<Playground>({
state: {
fullSchema: {},
userSchema: {},
jsonPointer: "",
data: "{}",
schemaRef: undefined,
editorRef: undefined,
schemaErrors: [],
dataErrors: [],
},
updateState: () => {},
})
export const usePlaygroundContext = () => useContext(PlaygroundContext)
export const PlaygroundContextProvider = PlaygroundContext.Provider
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Playground(): JSX.Element {
return (
<Layout
title={`Playground`}
description="Config Playground"
description="Playground"
>
<PlaygroundComponent />
</Layout>
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
import type { DocsPageData, ImageRenderer } from '@bony_chops/docusaurus-og'
import { readFileSync } from 'fs'
import { join } from 'path'
// const logo = (<svg
// id="Layer_1"
// data-name="Layer 1"
// xmlns="http://www.w3.org/2000/svg"
// width="128"
// height="128"
// viewBox="0 0 128 128"
// fill="#c34a36"
// >
// <path className="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z" />
// <path className="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z" />
// </svg>);
const DocContent = (props: { data: DocsPageData }) => {
const { data } = props;
return (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
textAlign: 'center',
alignItems: 'flex-start',
backgroundColor: "#1b1b1d",
color: 'white',
justifyContent: 'flex-start',
flexDirection: 'column',
flexWrap: 'wrap',
padding: '3rem 3rem 0 3rem',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '30px'
}}>
<img src="https://docs.multi-scrobbler.app/img/icon.png" width={120} height={120} />
<div style={{
fontSize: 125,
//fontWeight: 600
}}>Multi-Scrobbler</div>
</div>
<div style={{
fontSize: 50,
color: "#c8c8c8",
marginTop: '1rem'
}}
>Listen everywhere, Scrobble anywhere</div>
<div style={{
marginTop: '4rem',
display: 'flex',
alignContent: 'flex-start',
justifyContent: 'flex-start',
flexDirection: 'column',
gap: '10px',
paddingLeft: '0.5em',
fontSize: 75
}}>
<div style={{
display: 'flex',
textWrap: 'balance',
textAlign: 'left'
}}>{data.metadata.frontMatter?.description ?? data.metadata.title}</div>
</div>
</div>
);
}
export const docs: ImageRenderer<DocsPageData> = (data, context) => [
<DocContent data={data} />,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: readFileSync(
// must be a STATIC (not variable) font due to a bug in satori
// https://github.com/vercel/satori/issues/162
// use static-font.sh to convert a variable font to static
join(__dirname, '../../src/socialCard/AdwaitaSans-Regular-StaticSubset.ttf'),
),
weight: 400,
style: 'normal',
},
],
},
]
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# based on https://github.com/Kikobeats/og.kikobeats.com/blob/master/static-font.sh
# 1) Freeze SF Pro to Bold (700) and make it fully static
fonttools varLib.instancer AdwaitaSans-Regular.ttf \
wght=400 \
--static \
-o AdwaitaSans-Regular-Static.ttf
# 2) Subset to only the glyphs you actually need (Satori-friendly)
fonttools subset AdwaitaSans-Regular-Static.ttf \
--text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#\$%^&*()-_=+[]{};:'\",.<>/? " \
--no-hinting \
--name-legacy \
--output-file=AdwaitaSans-Regular-StaticSubset.ttf
+1 -1
View File
@@ -1,4 +1,4 @@
Sitemap: https://foxxmd.github.io/multi-scrobbler/sitemap.xml
Sitemap: https://docs.multi-scrobbler.app/sitemap.xml
User-agent: *
Allow: /
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"noEmit": false,
"composite": true,
"incremental": true,
"esModuleInterop": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo-client",
"rootDir": "src",
"outDir": "lib",
"module": "CommonJS",
"target": "esnext",
"jsx": "react-jsx",
"types": ["node"],
"baseUrl": "./docs",
"lib": ["DOM"]
},
"include": ["src/socialCard/ImageRenderers.tsx"]
}
+1
View File
@@ -2,6 +2,7 @@
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": ".",
"resolveJsonModule": true,
"types": ["node", "@docusaurus/module-type-aliases", "@docusaurus/theme-classic","docusaurus-plugin-sass"]
+1081 -520
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.12.1",
"version": "0.13.0",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -16,7 +16,7 @@
"build:backend": "tsc -p src/backend && npm run -s schema:app",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
"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 ci --no-audit",
"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:netlify": "npm run -s docs:install && npm run -s docs:build",
@@ -34,7 +34,7 @@
},
"engines": {
"node": ">=24.14.0",
"npm": ">=11.12.1"
"npm": "^11.12.1"
},
"repository": {
"type": "git",
@@ -52,15 +52,8 @@
"@atcute/identity-resolver-node": "^1.0.3",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@awaitjs/express": "^0.6.3",
"@chakra-ui/react": "^3.34.0",
"@curvenote/ansi-to-react": "^7.0.0",
"@donedeal0/superdiff": "^1.1.1",
"@emotion/react": "^11.14.0",
"@ewanc26/tid": "^1.0.2",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.6",
@@ -71,24 +64,19 @@
"@kenyip/backoff-strategies": "^1.0.4",
"@keyv/valkey": "^1.0.8",
"@lukehagar/plexjs": "^0.39.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
"@svrooij/sonos": "^2.5.0",
"@tailwindcss/vite": "^4.1.16",
"@types/express-serve-static-core": "^4.19.6",
"@xhayper/discord-rpc": "^1.3.0",
"abort-controller-x": "^0.5.0",
"address": "^1.2.2",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"autoprefixer": "^10.4.17",
"avahi-browse": "^1.1.4",
"better-sse": "^0.8.0",
"body-parser": "^2.2.2",
"cacheable": "^1.10.4",
"castv2": "^0.1.10",
"clone": "^2.1.2",
"clsx": "^2.0.0",
"common-tags": "^1.8.2",
"compare-versions": "^4.1.2",
"concat-stream": "^2.0.0",
@@ -97,7 +85,7 @@
"dbus-ts": "^0.0.7",
"discord.js": "^14.26.0",
"dotenv": "^10.0.0",
"express": "^4.22.1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"fast-deep-equal": "^3.1.3",
"fixed-size-list": "^0.3.0",
@@ -113,7 +101,6 @@
"json-diff-ts": "^5.0.0-alpha.2",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"jsondiffpatch-react": "^1.0.8",
"keyv": "^5.5.0",
"kodi-api": "^0.2.1",
"lastfm-ts-api": "^2.6.0",
@@ -122,7 +109,6 @@
"mpd-api": "^1.1.2",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"next-themes": "^0.4.6",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.15.2",
@@ -133,41 +119,40 @@
"passport-deezer": "^0.2.0",
"patch-package": "^8.0.1",
"prom-client": "^15.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.30.3",
"react-virtuoso": "^4.18.3",
"round-robin-js": "^3.0.10",
"sass": "^1.93.3",
"serialize-error": "^13.0.1",
"shiki": "^4.0.2",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^4.1.16",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"tsx": "^4.21.0",
"typeson": "^9.0.4",
"typeson-registry": "^11.1.1",
"undici": "7.24.6",
"vite-express": "^0.16.0",
"vite-express": "^0.22.1",
"vlc-client": "^1.1.1",
"xml2js": "0.6.2",
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@chakra-ui/react": "^3.34.0",
"@chromatic-com/storybook": "^5.0.1",
"@curvenote/ansi-to-react": "^7.0.0",
"@dbus-types/notifications": "^0.0.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@gr2m/net-interceptor": "^1.0.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.7",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-docs": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@tailwindcss/vite": "^4.2.2",
"@tsconfig/node18": "^1.0.1",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.5",
@@ -175,8 +160,9 @@
"@types/common-tags": "^1.8.1",
"@types/concat-stream": "^2.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/express": "^5.0.6",
"@types/express-serve-static-core": "^5.1.1",
"@types/express-session": "^1.18.2",
"@types/formidable": "^3.4.5",
"@types/jest": "^27.5.2",
"@types/jscodeshift": "^0.11.6",
@@ -192,20 +178,34 @@
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.27",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"chai-as-promised": "^8.0.2",
"clsx": "^2.1.1",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-storybook": "10.1.11",
"git-cliff": "^2.12.0",
"jsondiffpatch-react": "^1.0.8",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.12.10",
"neotraverse": "^0.6.18",
"next-themes": "^0.4.6",
"nodemon": "^3.0.3",
"playwright": "^1.58.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-redux": "^8.1.3",
"react-router-dom": "^6.30.3",
"react-virtuoso": "^4.18.4",
"sass": "^1.99.0",
"shiki": "^4.0.2",
"sinon": "^21.0.2",
"storybook": "10.2.0",
"tailwindcss": "^4.2.2",
"ts-essentials": "^10.1.1",
"typescript": "^5.9.3",
"typescript-eslint": "^7.0.1",
+10 -5
View File
@@ -3,7 +3,6 @@ import {
cacheFunctions,
} from "@foxxmd/regex-buddy-core";
import deepEqual from 'fast-deep-equal';
import { Simulate } from "react-dom/test-utils";
import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
@@ -167,9 +166,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
const asyncId = nanoid(6);
let logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
let logger: Logger;
try {
let hook: StageConfig[];
@@ -193,6 +190,9 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return play;
}
const asyncId = nanoid(6);
logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
@@ -317,7 +317,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return transformedPlay;
} catch (e) {
logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
const err = new Error(`Unexpected error occurred, returning original play.`, {cause: e});
if(logger === undefined) {
this.logger.warn(err);
} else {
logger.warn(err);
}
return play;
}
}
@@ -1,9 +1,7 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { Simulate } from "react-dom/test-utils";
import {truncateStringToLength } from "../../core/StringUtils.js";
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
import { hasUpstreamError } from "./errors/UpstreamError.js";
import play = Simulate.play;
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js";
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
+32 -8
View File
@@ -1,7 +1,8 @@
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import mergeErrorCause from 'merge-error-cause';
import { findCauseByFunc, findCauseByReference } from "../../utils/ErrorUtils.js";
import { findCauseByFunc, findCauseByReference, isAbortReasonErrorLike } from "../../utils/ErrorUtils.js";
import { UpstreamError, UpstreamErrorOptions } from "./UpstreamError.js";
import { isAbortError } from "abort-controller-x";
export abstract class NamedError extends Error {
public abstract name: string;
@@ -39,6 +40,19 @@ export class SimpleError extends Error implements HasSimpleError {
simple: boolean;
name = 'Error';
stackShortened: boolean = false;
shortenStack() {
const atIndex = parseRegexSingle(STACK_AT_REGEX,this.stack);
if(atIndex !== undefined) {
const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length);
if(firstn !== -1) {
this.stack = this.stack.slice(0, firstn);
this.stackShortened = true;
}
}
}
public constructor(msg: string, options?: ErrorOptions & { simple?: boolean, shortStack?: boolean }) {
super(msg, options);
const {
@@ -46,14 +60,9 @@ export class SimpleError extends Error implements HasSimpleError {
shortStack = false
} = options || {};
this.simple = simple;
Error.captureStackTrace(this, this.constructor);
if(shortStack) {
const atIndex = parseRegexSingle(STACK_AT_REGEX,this.stack);
if(atIndex !== undefined) {
const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length);
if(firstn !== -1) {
this.stack = this.stack.slice(0, firstn);
}
}
this.shortenStack();
}
}
}
@@ -104,4 +113,19 @@ export class ScrobbleSubmitError<T extends (object | string) = object> extends U
super(message, options);
this.payload = options?.payload;
}
}
export class AbortedError extends SimpleError {
name = 'Aborted Operation';
}
export const generateLoggableAbortReason = (msg: string, signal: AbortSignal): AbortedError => {
const reason = signal.reason;
let err: AbortedError;
if(isAbortReasonErrorLike(signal)) {
err = new AbortedError(msg, {cause: reason});
} else {
err = new AbortedError(`${msg} => ${reason ?? 'No Reason Given'}`, {simple: true, shortStack: true});
}
Error.captureStackTrace(err, generateLoggableAbortReason);
return err;
}
+11 -3
View File
@@ -1,7 +1,7 @@
import { Logger } from '@foxxmd/logging';
import { Dayjs, ManipulateType } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { type NextFunction, type ParamsDictionary, type Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { isPlayObject, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
@@ -66,10 +66,19 @@ export const CALCULATED_PLAYER_STATUSES = {
orphaned: 'orphaned' as CalculatedPlayerStatus,
}
export type ConfigureAsSource = 'source';
export type ConfigureAsClient = 'client';
export type ConfigureAs = ConfigureAsSource | ConfigureAsClient;
export const CONFIGURE_AS = {
source: 'source',
client: 'client'
} as const satisfies Record<string, ConfigureAs>
export interface ConfigMeta {
source: string
mode?: string
configureAs: string
configureAs: ConfigureAs
}
export type SourceData = (PlayObject | PlayerStateData);
@@ -291,7 +300,6 @@ export interface CacheConfigOptions {
export interface MusicbrainzApiConfigData {
url?: string
rateLimit?: [number, number]
contact: string,
apiKey?: string
requestTimeout?: number
@@ -28,7 +28,7 @@ export interface MatchLoggingOptions {
confidenceBreakdown?: boolean
}
export interface CommonClientData extends CommonData {
export interface CommonClientData {
}
export interface UpstreamRefreshOptions {
@@ -12,7 +12,7 @@ export interface CommonConfig {
enable?: boolean
}
export type CommonData = keyOmit<{ [key: string]: any }, "options">
export type CommonData = Record<string, any>; // keyOmit<{ [key: string]: any }, "options">
export interface RequestRetryOptions {
/**
@@ -22,6 +22,8 @@ export interface DeezerData extends CommonSourceData, PollingOptions {
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri?: string
accessToken?: string
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
@@ -59,6 +61,8 @@ export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
export type DeezerAIOCompatConfig = DeezerSourceAIOConfig | DeezerInternalAIOConfig;
export interface DeezerInternalTrackData {
/** Song Id */
SNG_ID: string
@@ -119,7 +119,7 @@ export interface ManualListeningOptions {
systemScrobble?: boolean
}
export interface CommonSourceData extends CommonData {
export interface CommonSourceData {
}
@@ -1,7 +1,7 @@
import { PollingOptions } from "../common.js";
import { PollingOptions, RequestRetryOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface JRiverData extends CommonSourceData, PollingOptions {
export interface JRiverData extends CommonSourceData, PollingOptions, RequestRetryOptions {
/**
* URL of the JRiver HTTP server to connect to
*
@@ -13,7 +13,7 @@ export interface KoitoSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: KoitoSourceData
}
@@ -11,7 +11,7 @@ export interface LastfmSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: LastFmSourceData
}
@@ -12,7 +12,7 @@ export interface ListenBrainzSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: ListenBrainzSourceData
}
@@ -12,7 +12,7 @@ export interface MalojaSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: MalojaSourceData
}
@@ -1,6 +1,7 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface MPDData extends CommonSourceData {
export interface MPDData extends CommonSourceData, PollingOptions {
/**
* URL:PORT of the MPD server to connect to
*
@@ -16,7 +16,7 @@ export interface RockskySourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: RockskySourceData
options?: RockskySourceOptions
}
@@ -2,7 +2,7 @@ import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig, DeezerCompatConfig } from "./deezer.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig, DeezerCompatConfig, DeezerAIOCompatConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
@@ -19,6 +19,7 @@ import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig } from "./ymbridge.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
@@ -31,8 +32,7 @@ import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js";
export type SourceConfig =
SpotifySourceConfig
| PlexApiSourceConfig
| DeezerSourceConfig
| DeezerInternalSourceConfig
| DeezerCompatConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
@@ -40,6 +40,7 @@ export type SourceConfig =
| LastfmSourceConfig
| LibrefmSourceConfig
| YTMusicSourceConfig
| YandexMusicBridgeSourceConfig
| MPRISSourceConfig
| MopidySourceConfig
| ListenBrainzSourceConfig
@@ -62,8 +63,7 @@ export type SourceConfig =
export type SourceAIOConfig =
SpotifySourceAIOConfig
| PlexApiSourceAIOConfig
| DeezerSourceAIOConfig
| DeezerInternalAIOConfig
| DeezerAIOCompatConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
@@ -71,6 +71,7 @@ export type SourceAIOConfig =
| LastFmSouceAIOConfig
| LibrefmSouceAIOConfig
| YTMusicSourceAIOConfig
| YandexMusicBridgeSourceAIOConfig
| MPRISSourceAIOConfig
| MopidySourceAIOConfig
| ListenBrainzSourceAIOConfig
@@ -108,6 +109,7 @@ export type JellyApiSourceConfigs = JellyApiSourceConfig[];
export type LastfmSourceConfigs = LastfmSourceConfig[];
export type LibrefmSourceConfigs = LibrefmSourceConfig[];
export type YTMusicSourceConfigs = YTMusicSourceConfig[];
export type YandexMusicBridgeSourceConfigs = YandexMusicBridgeSourceConfig[];
export type MPRISSourceConfigs = MPRISSourceConfig[];
export type MopidySourceConfigs = MopidySourceConfig[];
export type ListenBrainzSourceConfigs = ListenBrainzSourceConfig[];
@@ -139,6 +141,7 @@ export type SourceType =
| 'endpointlz'
| 'endpointlfm'
| 'ytmusic'
| 'ymbridge'
| 'mpris'
| 'mopidy'
| 'musiccast'
@@ -169,6 +172,7 @@ export const sourceTypes: SourceType[] = [
'endpointlz',
'endpointlfm',
'ytmusic',
'ymbridge',
'mpris',
'mopidy',
'musiccast',
@@ -201,6 +205,7 @@ export const atomicSourceInterfaces = [
'LastfmSourceConfig',
'LibrefmSourceConfig',
'YTMusicSourceConfig',
'YandexMusicBridgeSourceConfig',
'MalojaSourceConfig',
'MPRISSourceConfig',
'MopidySourceConfig',
@@ -15,7 +15,7 @@ export interface TealSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: TealSourceData
options?: TealSourceOptions
}
@@ -6,7 +6,7 @@ export interface VLCData extends CommonSourceData, PollingOptions {
/**
* URL:PORT of the VLC server to connect to
*
* To use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc
* To use this you must have the Web (http) interface module enabled and a password set
*
* @examples ["localhost:8080"]
* @default "localhost:8080"
@@ -0,0 +1,17 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface YandexMusicBridgeData extends CommonSourceData, PollingOptions {
/** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */
url: string
/** Optional API key sent as X-API-Key to the bridge */
apiKey?: string
}
export interface YandexMusicBridgeSourceConfig extends CommonSourceConfig {
data?: YandexMusicBridgeData
}
export interface YandexMusicBridgeSourceAIOConfig extends YandexMusicBridgeSourceConfig {
type: 'ymbridge'
}
@@ -356,8 +356,8 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
super(config, options);
this.clientCache = options.clientCache;
this.staggerOpts = {
initialInterval: 300,
maxRandomStagger: 300
initialInterval: 0,
maxRandomStagger: 100
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ export class RockSkyApiClient extends AbstractApiClient {
doCallLZApi = async <T = Response>(req: Request, retries = 0): Promise<T> => {
try {
req.set('Authorization', `Token ${this.config.key ?? this.config.token}`);
req.set('Authorization', `Token ${this.config.key}`);
return await req as T;
} catch (e) {
const {
@@ -18,9 +18,9 @@ import { nanoid } from "nanoid";
import { stripIndents } from "common-tags";
import { getNodeNetworkException, hasNodeNetworkException, isNodeNetworkException } from '../../errors/NodeErrors.js';
import { SimpleError } from '../../errors/MSErrors.js';
import { resourceLimits } from 'worker_threads';
import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js';
import { IRecordingMSList } from '../../transforms/MusicbrainzTransformer.js';
import dayjs, { Dayjs } from 'dayjs';
export interface SubmitResponse {
payload?: {
@@ -33,6 +33,8 @@ export interface SubmitResponse {
export interface MusicbrainzApiConfig extends MusicbrainzApiConfigData {
api: MusicBrainzApi,
hostname: string
minRequestIntervalDuration: number
lastRequest: Dayjs
}
export interface MusicbrainzApiClientConfig {
@@ -66,13 +68,18 @@ export class MusicbrainzApiClient extends AbstractApiClient {
for(const mbConfig of this.config.apis) {
const u = normalizeWebAddress(mbConfig.url ?? MUSICBRAINZ_URL);
let mb = mbMap.get(u.url.hostname);
const mbApiConfig: Omit<MusicbrainzApiConfig, 'api'> = {
...mbConfig,
hostname: u.url.hostname,
minRequestIntervalDuration: 1000,
lastRequest: dayjs().subtract(1000 + 1000, 'ms')
}
if(mb === undefined) {
const api = new MusicBrainzApi({
appName: 'multi-scrobbler',
appVersion: version,
appContactInfo: mbConfig.contact,
baseUrl: u.url.toString(),
rateLimit: mbConfig.rateLimit ?? [1,1],
preRequest: options.logUrl === true || isDebugMode() ? (method, url, headers) => {
const cacheKey = this.asyncStore.getStore() ?? nanoid();
this.cache.set(`${cacheKey}-url`, `${method} - ${url}`);
@@ -84,11 +91,17 @@ export class MusicbrainzApiClient extends AbstractApiClient {
requestTimeout: mbConfig.requestTimeout ?? 6000,
retryLimit: 2
});
mbApis[u.url.hostname] = {api, ...mbConfig, hostname: u.url.hostname};
mbApis[u.url.hostname] = {
...mbApiConfig,
api,
};
mbMap.set(u.url.hostname, api);
mb = api;
} else if(mbApis[u.url.hostname] === undefined) {
mbApis[u.url.hostname] = {api: mb, ...mbConfig, hostname: u.url.hostname};
mbApis[u.url.hostname] = {
...mbApiConfig,
api: mb,
};
}
}
@@ -127,6 +140,17 @@ export class MusicbrainzApiClient extends AbstractApiClient {
const triedHosts: string[] = [];
while(!triedHosts.includes(apiConfig.hostname)) {
// keep track of last request init at and wait until at least 1 second since that
// to help prevent rate limiting
let waitTime = 0;
const sinceLast = dayjs().diff(apiConfig.lastRequest, 'ms');
waitTime = Math.max(0, apiConfig.minRequestIntervalDuration - sinceLast);
apiConfig.lastRequest = dayjs().add(waitTime, 'ms');
//this.logger.trace(`Waiting ${waitTime}ms to call ${apiConfig.hostname} request at ${apiConfig.lastRequest.toISOString()}`)
if(waitTime > 0) {
await sleep(waitTime);
}
try {
const res = await this.callApiEndpoint(apiConfig.api, func, options);
if(cacheKey !== undefined) {
+1 -1
View File
@@ -114,7 +114,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
initServer(logger, appLoggerStream, output, scrobbleSources, scrobbleClients);
if(process.env.IS_LOCAL === 'true') {
logger.info('multi-scrobbler can be run as a background service! See: https://foxxmd.github.io/multi-scrobbler/docs/installation/service');
logger.info('multi-scrobbler can be run as a background service! See: https://docs.multi-scrobbler.app/installation/service');
}
if(appConfigFail !== undefined) {
+174 -127
View File
@@ -64,9 +64,10 @@ import { comparePlayArtistsNormalized, comparePlayTracksNormalized, existingScro
import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js";
import { normalizeStr } from "../utils/StringUtils.js";
import prom, { Counter, Gauge } from 'prom-client';
import { ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js";
import { generateLoggableAbortReason, ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js";
import {serializeError} from 'serialize-error';
import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.js";
import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x';
type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>;
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
@@ -95,9 +96,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
scrobbleDelay: number = 1000;
scrobbleSleep: number = 2000;
scrobbleWaitStopInterval: number = 2000;
protected scrobbleQueueAbortController: AbortController | undefined;
protected scrobbleQueuePromise: Promise<void> | undefined;
scrobbleRetries: number = 0;
scrobbling: boolean = false;
userScrobblingStopSignal: undefined | any;
queuedScrobbles: QueuedScrobble<PlayObject>[] = [];
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
@@ -227,23 +229,23 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
if(preCompare.length > 0) {
let pcInits: number[] = [0],
pcMaxStagger: number[] = [];
for(const hook of this.transformRules.preCompare) {
for(const hook of preCompare) {
const t = this.transformManager.getTransformerByStage({type: hook.type, name: hook.name});
pcInits.push(t.staggerOpts?.initialInterval ?? 0);
pcMaxStagger.push(t.staggerOpts?.maxRandomStagger ?? 0)
}
this.staggerMappers.preCompare = staggerMapper<PlayObject, PlayObject>({initialInterval: Math.max(...pcInits), maxRandomStagger: Math.max(...pcMaxStagger), concurrency: 2});
this.staggerMappers.preCompare = staggerMapper<PlayObject, PlayObject>({initialInterval: Math.max(...pcInits), maxRandomStagger: Math.max(...pcMaxStagger), concurrency: 3});
}
if(existing.length > 0) {
let eInits: number[] = [0],
eMaxStagger: number[] = [];
for(const hook of this.transformRules.postCompare) {
for(const hook of existing) {
const t = this.transformManager.getTransformerByStage({type: hook.type, name: hook.name});
eInits.push(t.staggerOpts?.initialInterval ?? 0);
eMaxStagger.push(t.staggerOpts?.maxRandomStagger ?? 0)
}
this.staggerMappers.existing = staggerMapper<ScrobbledPlayObject, ScrobbledPlayObject>({initialInterval: Math.max(...eInits), maxRandomStagger: Math.max(...eMaxStagger), concurrency: 2});
this.staggerMappers.existing = staggerMapper<ScrobbledPlayObject, ScrobbledPlayObject>({initialInterval: Math.max(...eInits), maxRandomStagger: Math.max(...eMaxStagger), concurrency: 3});
}
}
@@ -503,7 +505,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate);
const dtInvariantMatches = (await pMap(this.scrobbledPlayObjs.data, this.staggerMappers.existing(async x => ({...x, play: await this.transformPlay(x.play, TRANSFORM_HOOK.existing)})), {concurrency: 2}))
const dtInvariantMatches = (await pMap(this.scrobbledPlayObjs.data, this.staggerMappers.existing(async x => ({...x, play: await this.transformPlay(x.play, TRANSFORM_HOOK.existing)})), {concurrency: 3}))
.filter(x => playObjDataMatch(playObj, x.play));
if (dtInvariantMatches.length === 0) {
@@ -518,15 +520,20 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
return [matchPlayDate, dtInvariantMatches];
}
public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false }): Promise<PlayObject> => {
const {delay} = opts || {};
const scrobbleDelay = delay === undefined ? this.scrobbleDelay : (delay === false ? 0 : delay);
public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false, signal?: AbortSignal }): Promise<PlayObject> => {
const {delay: delayDuration, signal} = opts || {};
const scrobbleDelay = delayDuration === undefined ? this.scrobbleDelay : (delayDuration === false ? 0 : delayDuration);
if (scrobbleDelay !== 0) {
const lastScrobbleDiff = dayjs().diff(this.lastScrobbleAttempt, 'ms');
const remainingDelay = scrobbleDelay - lastScrobbleDiff;
if (remainingDelay > 0) {
this.logger.debug(`Waiting ${remainingDelay}ms to scrobble so time passed since previous scrobble is at least ${scrobbleDelay}ms`);
await sleep(scrobbleDelay);
if(signal !== undefined) {
await delay(signal, scrobbleDelay);
} else {
await sleep(scrobbleDelay);
}
}
}
try {
@@ -566,39 +573,57 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
}
this.startScrobbling().catch((e) => {
// do nothing, should have already been caught and logged
this.scrobbleQueueAbortController = new AbortController();
this.scrobbleQueuePromise = spawn(this.scrobbleQueueAbortController.signal, async (signal, { defer, fork }) => {
defer(async () => {
this.scrobbling = false;
this.emitEvent('statusChange', {status: 'Idle'});
});
await this.startScrobbling(signal);
}).catch((e) => {
if (isAbortError(e)) {
const err = generateLoggableAbortReason('Scrobble processing stopped', this.scrobbleQueueAbortController.signal);
this.logger.info(err);
this.logger.trace(e)
} else {
this.logger.warn(new Error('Scrobble processing stopped with error', { cause: e }));
}
}).finally(() => {
this.scrobbleQueueAbortController = undefined;
this.scrobbleQueuePromise = undefined;
});
return;
}
startScrobbling = async () => {
startScrobbling = async (signal: AbortSignal) => {
signal.throwIfAborted();
// reset poll attempts if already previously run
this.scrobbleRetries = 0;
const {
data: {
maxPollRetries = 5,
options: {
maxRequestRetries = 5,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER,
} = {},
} = this.config;
// can't have negative retries!
const maxRetries = Math.max(0, maxPollRetries);
const maxRetries = Math.max(0, maxRequestRetries);
if(this.scrobbling === true) {
this.logger.warn(`Already scrobble processing! Processing needs to be stopped before it can be started`);
return;
}
let pollRes: boolean | undefined = undefined;
while (pollRes === undefined && this.scrobbleRetries <= maxRetries) {
while (this.scrobbleRetries <= maxRetries) {
try {
pollRes = await this.doProcessing();
if(pollRes === true) {
break;
}
await this.doProcessing(signal);
} catch (e) {
if(isAbortError(e)) {
throw e;
}
if(!this.isUsable()) {
this.logger.warn('Stopping scrobble processing due to client no longer usable.');
await this.notify({title: `${this.getIdentifier()} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer usable, stopping processing!. | Error: ${e.message}`, priority: 'error'});
@@ -621,38 +646,31 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
}
tryStopScrobbling = async () => {
tryStopScrobbling = async (reason?: string | Error) => {
if(this.scrobbling === false) {
this.logger.warn(`Polling is already stopped!`);
return;
}
this.userScrobblingStopSignal = true;
if(this.scrobbleQueueAbortController === undefined) {
this.logger.error('No abort controller found! Nothing to stop.');
return false;
}
this.scrobbleQueueAbortController.abort(reason)
let timePasssed = 0;
while(this.userScrobblingStopSignal !== undefined && timePasssed < (this.scrobbleWaitStopInterval * 10)) {
while(this.scrobbling === true && timePasssed < (this.scrobbleWaitStopInterval * 10)) {
await sleep(this.scrobbleWaitStopInterval);
timePasssed += this.scrobbleWaitStopInterval;
this.logger.verbose(`Waiting for scrobble processing stop signal to be acknowledged (waited ${timePasssed}ms)`);
}
if(this.userScrobblingStopSignal !== undefined) {
if(this.scrobbling === true) {
this.logger.warn('Could not stop scrobble processing! Or signal was lost :(');
return false;
}
return true;
}
protected doStopScrobbling = (reason: string = 'system') => {
this.scrobbling = false;
this.userScrobblingStopSignal = undefined;
this.emitEvent('statusChange', {status: 'Idle'});
this.logger.info(`Stopped scrobble processing due to: ${reason}`);
}
protected shouldStopScrobbleProcessing = () => this.scrobbling === false || this.userScrobblingStopSignal !== undefined;
protected doProcessing = async (): Promise<true | undefined> => {
if (this.scrobbling === true) {
return true;
}
protected doProcessing = async (signal: AbortSignal): Promise<true | undefined> => {
signal.throwIfAborted();
this.logger.info('Scrobble processing started');
this.emitEvent('statusChange', {status: 'Running'});
@@ -661,105 +679,134 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
if(!this.upstreamRefresh.refreshEnabled) {
this.logger.verbose('Scrobble refresh is DISABLED. All queued scrobbles will likely always be scrobbled (nothing to check duplicates against).');
}
while (!this.shouldStopScrobbleProcessing()) {
while (true) {
signal.throwIfAborted();
let queueEmpty = this.queuedScrobbles.length === 0;
while (this.queuedScrobbles.length > 0) {
this.handleQueuedScrobbleRanges();
if(!this.upstreamRefresh.refreshEnabled) {
this.logger.trace('Scrobble refresh is DISABLED.');
}
const currQueuedPlay = this.queuedScrobbles.shift();
let historicalPlays: PlayObject[] = [];
let historicalError: Error | undefined;
if(this.upstreamRefresh.refreshEnabled) {
try {
historicalPlays = await this.getSOTScrobblesForPlay(currQueuedPlay.play);
} catch (e) {
historicalError = e;
if(e.message === 'Cannot get historical plays due to cached error') {
this.logger.warn(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => Previous error while getting historical scrobbles means this scrobble cannot be compared, will queue as dead for now.`);
this.logger.trace(e);
} else {
this.logger.warn(new SimpleError(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => cannot get historical scrobbles, will queue as dead for now.`, {cause: e, shortStack: true}));
}
this.addDeadLetterScrobble(currQueuedPlay, e);
}
}
if(historicalError === undefined) {
const {summary, ...matchResult} = await this.existingScrobble(currQueuedPlay.play, historicalPlays);
const {
scrobble = {},
...lifeRest
} = currQueuedPlay.play.meta.lifecycle ?? {steps: [], original: currQueuedPlay.play};
currQueuedPlay.play.meta.lifecycle = {
...lifeRest,
scrobble: {
...scrobble,
match: matchResult
}
}
if(!matchResult.match) {
const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare);
if(transformedScrobble.meta.lifecycle === undefined) {
transformedScrobble.meta.lifecycle = {
original: transformedScrobble,
steps: []
};
}
try {
const scrobbledPlay = await this.scrobble(transformedScrobble);
this.emitEvent('scrobble', {play: transformedScrobble});
this.addScrobbledTrack(scrobbledPlay, scrobbledPlay.meta.lifecycle.scrobble.mergedScrobble ?? scrobbledPlay);
} catch (e) {
currQueuedPlay.play.meta.lifecycle.scrobble = {
};
const submitError = findCauseByReference(e, ScrobbleSubmitError);
if(submitError !== undefined) {
currQueuedPlay.play.meta.lifecycle.scrobble.payload = submitError.payload;
currQueuedPlay.play.meta.lifecycle.scrobble.response = submitError.responseBody;
currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(submitError);
} else {
currQueuedPlay.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble);
currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(e);
}
if (hasUpstreamError(e, false)) {
this.addDeadLetterScrobble(currQueuedPlay, e);
this.logger.warn(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e}));
} else {
this.queuedScrobbles.unshift(currQueuedPlay);
this.updateQueuedScrobblesCache();
throw new Error('Error occurred while trying to scrobble', {cause: e});
}
}
}
}
this.updateQueuedScrobblesCache();
this.queuedGauge.labels(this.getPrometheusLabels()).set(this.queuedScrobbles.length);
this.emitEvent('scrobbleDequeued', {queuedScrobble: currQueuedPlay})
await this.processQueueCurrentScrobble(signal);
}
if(!queueEmpty) {
this.emitEvent('queueEmptied', {});
}
await sleep(this.scrobbleSleep);
}
if (this.shouldStopScrobbleProcessing()) {
this.doStopScrobbling(this.userScrobblingStopSignal !== undefined ? 'user input' : undefined);
return true;
await delay(signal, this.scrobbleSleep);
}
} catch (e) {
this.logger.error('Scrobble processing interrupted');
this.logger.error(e);
if(!isAbortError(e)) {
this.logger.error('Scrobble processing interrupted');
this.logger.error(e);
}
this.emitEvent('statusChange', {status: 'Idle'});
this.scrobbling = false;
throw e;
}
}
protected processQueueCurrentScrobble = async (signal: AbortSignal) => {
signal.throwIfAborted();
if (this.queuedScrobbles.length === 0) {
return;
}
this.handleQueuedScrobbleRanges();
if (!this.upstreamRefresh.refreshEnabled) {
// TODO add signal for this to scrobble match
this.logger.trace('Scrobble refresh is DISABLED.');
}
let handledShiftedPlay = false;
const currQueuedPlay = this.queuedScrobbles.shift();
let historicalPlays: PlayObject[] = [];
let historicalError: Error | undefined;
try {
if (this.upstreamRefresh.refreshEnabled) {
try {
historicalPlays = await this.getSOTScrobblesForPlay(currQueuedPlay.play);
} catch (e) {
historicalError = e;
if (e.message === 'Cannot get historical plays due to cached error') {
this.logger.warn(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => Previous error while getting historical scrobbles means this scrobble cannot be compared, will queue as dead for now.`);
this.logger.trace(e);
} else {
this.logger.warn(new SimpleError(`${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' => cannot get historical scrobbles, will queue as dead for now.`, { cause: e, shortStack: true }));
}
this.addDeadLetterScrobble(currQueuedPlay, e);
handledShiftedPlay = true;
}
signal.throwIfAborted();
}
if (historicalError === undefined) {
const { summary, ...matchResult } = await this.existingScrobble(currQueuedPlay.play, historicalPlays);
signal.throwIfAborted();
const {
scrobble = {},
...lifeRest
} = currQueuedPlay.play.meta.lifecycle ?? { steps: [], original: currQueuedPlay.play };
currQueuedPlay.play.meta.lifecycle = {
...lifeRest,
scrobble: {
...scrobble,
match: matchResult
}
}
if (!matchResult.match) {
const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare);
signal.throwIfAborted();
if (transformedScrobble.meta.lifecycle === undefined) {
transformedScrobble.meta.lifecycle = {
original: transformedScrobble,
steps: []
};
}
try {
const scrobbledPlay = await this.scrobble(transformedScrobble, {signal});
this.emitEvent('scrobble', { play: transformedScrobble });
this.addScrobbledTrack(scrobbledPlay, scrobbledPlay.meta.lifecycle.scrobble.mergedScrobble ?? scrobbledPlay);
handledShiftedPlay = true;
signal.throwIfAborted();
} catch (e) {
currQueuedPlay.play.meta.lifecycle.scrobble = {
};
const submitError = findCauseByReference(e, ScrobbleSubmitError);
if (submitError !== undefined) {
currQueuedPlay.play.meta.lifecycle.scrobble.payload = submitError.payload;
currQueuedPlay.play.meta.lifecycle.scrobble.response = submitError.responseBody;
currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(submitError);
} else {
currQueuedPlay.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble);
currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(e);
}
if (hasUpstreamError(e, false)) {
this.addDeadLetterScrobble(currQueuedPlay, e);
handledShiftedPlay = true;
this.logger.warn(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, { cause: e }));
} else {
this.queuedScrobbles.unshift(currQueuedPlay);
handledShiftedPlay = true;
this.updateQueuedScrobblesCache();
throw new Error('Error occurred while trying to scrobble', { cause: e });
}
}
}
}
this.updateQueuedScrobblesCache();
this.queuedGauge.labels(this.getPrometheusLabels()).set(this.queuedScrobbles.length);
this.emitEvent('scrobbleDequeued', { queuedScrobble: currQueuedPlay })
signal.throwIfAborted();
// reset retries if we've made this far
this.scrobbleRetries = 0;
} catch (e) {
if(!handledShiftedPlay) {
this.queuedScrobbles.unshift(currQueuedPlay);
}
throw e;
}
}
processDeadLetterQueue = async (attemptWithRetries?: number) => {
if (this.deadLetterScrobbles.length === 0) {
@@ -903,7 +950,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
queueScrobble = async (data: PlayObject | PlayObject[], source: string) => {
const plays = (Array.isArray(data) ? data : [data]).map(x => ({...x, meta: {...x.meta, seenAt: dayjs()}}));
for await(const play of pMapIterable(plays, this.staggerMappers.preCompare(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 2})) {
for await(const play of pMapIterable(plays, this.staggerMappers.preCompare(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 3})) {
try {
const existingQueued = await this.existingScrobble(play, this.queuedScrobbles.map(x => x.play), false);
// want to be very confident of this
+20 -20
View File
@@ -6,23 +6,23 @@ import { ConfigMeta, InternalConfig, InternalConfigOptional, isClientType, Sourc
import { clientTypes, ClientType } from '../common/infrastructure/config/client/clients.js';
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
import { LastfmClientConfig, LastfmData } from "../common/infrastructure/config/client/lastfm.js";
import { ListenBrainzClientConfig, ListenBrainzData } from "../common/infrastructure/config/client/listenbrainz.js";
import { MalojaClientConfig, MalojaData } from "../common/infrastructure/config/client/maloja.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { isDebugMode, parseBool } from "../utils.js";
import { readJson } from '../utils/DataUtils.js';
import { validateJson } from "../utils/ValidationUtils.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { KoitoClientConfig } from '../common/infrastructure/config/client/koito.js';
import { TealClientConfig } from '../common/infrastructure/config/client/tealfm.js';
import { RockSkyClientConfig } from '../common/infrastructure/config/client/rocksky.js';
import { KoitoClientConfig, KoitoData } from '../common/infrastructure/config/client/koito.js';
import { TealClientConfig, TealData } from '../common/infrastructure/config/client/tealfm.js';
import { RockSkyClientConfig, RockSkyData } from '../common/infrastructure/config/client/rocksky.js';
import { CommonClientOptions } from '../common/infrastructure/config/client/index.js';
import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js';
import { LibrefmClientConfig } from '../common/infrastructure/config/client/librefm.js';
import { LibrefmClientConfig, LibrefmData } from '../common/infrastructure/config/client/librefm.js';
import clone from 'clone';
import { DiscordClientConfig } from '../common/infrastructure/config/client/discord.js';
import { DiscordClientConfig, DiscordData } from '../common/infrastructure/config/client/discord.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -173,22 +173,23 @@ export default class ScrobbleClients {
const url = process.env.MALOJA_URL;
const apiKey = process.env.MALOJA_API_KEY;
if (url !== undefined || apiKey !== undefined) {
const malojaData: MalojaData = {
url,
apiKey
}
configs.push({
type: 'maloja',
name: 'unnamed-mlj',
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: {
url,
apiKey
},
data: malojaData,
options: transformPresetEnv('MALOJA')
})
}
break;
case 'lastfm':
const lfm = {
const lfm: LastfmData = {
apiKey: process.env.LASTFM_API_KEY,
secret: process.env.LASTFM_SECRET,
redirectUri: process.env.LASTFM_REDIRECT_URI,
@@ -208,7 +209,7 @@ export default class ScrobbleClients {
break;
case 'librefm': {
const shouldUse = parseBool(process.env.LIBRFM_ENABLE)
const libre = {
const libre: LibrefmData = {
apiKey: process.env.LIBREFM_API_KEY,
secret: process.env.LIBREFM_SECRET,
redirectUri: process.env.LIBREFM_REDIRECT_URI,
@@ -228,7 +229,7 @@ export default class ScrobbleClients {
}
} break;
case 'listenbrainz':
const lz = {
const lz: ListenBrainzData = {
url: process.env.LZ_URL,
token: process.env.LZ_TOKEN,
username: process.env.LZ_USER
@@ -246,7 +247,7 @@ export default class ScrobbleClients {
}
break;
case 'koito':
const koit = {
const koit: KoitoData = {
url: process.env.KOITO_URL,
token: process.env.KOITO_TOKEN,
username: process.env.KOITO_USER
@@ -264,10 +265,9 @@ export default class ScrobbleClients {
}
break;
case 'tealfm':
const teal = {
const teal: TealData = {
identifier: process.env.TEALFM_IDENTIFIER,
appPassword: process.env.TEALFM_APP_PW,
pds: process.env.TEALFM_PDS
};
if (!Object.values(teal).every(x => x === undefined)) {
configs.push({
@@ -282,7 +282,7 @@ export default class ScrobbleClients {
}
break;
case 'rocksky':
const rocksky = {
const rocksky: RockSkyData = {
key: process.env.ROCKSKY_KEY,
handle: process.env.ROCKSKY_HANDLE
};
@@ -299,7 +299,7 @@ export default class ScrobbleClients {
}
break;
case 'discord': {
const discord = {
const discord: DiscordData = {
token: process.env.DISCORD_TOKEN,
artwork: process.env.DISCORD_ARTWORK,
applicationId: process.env.DISCORD_APPLICATION_ID,
+24 -23
View File
@@ -1,5 +1,5 @@
import { ExpressWithAsync } from "@awaitjs/express";
import { LogDataPretty, Logger, LogLevel } from "@foxxmd/logging";
import { Express } from 'express';
import bsseDef from 'better-sse';
import bodyParser from "body-parser";
import { FixedSizeList } from 'fixed-size-list';
@@ -33,6 +33,7 @@ import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js";
import ScrobbleSources from "../sources/ScrobbleSources.js";
import ScrobbleClients from "../scrobblers/ScrobbleClients.js";
import prom from 'prom-client';
import { SimpleError } from "../common/errors/MSErrors.js";
const maxBufferSize = 300;
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
@@ -54,7 +55,7 @@ const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' |
return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit);
}
export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
for(const level of Object.keys(logger.levels.labels)) {
output[level] = new FixedSizeList<LeveledLogData>(maxBufferSize);
}
@@ -154,17 +155,17 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
setupLastfmEndpointRoutes(app, logger, scrobbleSources);
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
app.put('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
logger.info(req.body);
res.sendStatus(200);
});
app.getAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
logger.info(req.body);
res.sendStatus(200);
});
app.getAsync('/api/status', async (req, res, next) => {
app.get('/api/status', async (req, res, next) => {
const sourceData = scrobbleSources.sources.map((x) => {
const {
@@ -256,7 +257,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.json({sources: sourceData, clients: clientData});
});
app.getAsync('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
app.get('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
@@ -284,7 +285,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.json(result);
});
app.getAsync('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => {
app.get('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource,
@@ -312,7 +313,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
}
});
app.getAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
app.get('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
@@ -323,7 +324,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.json(result);
});
app.putAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
app.put('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
@@ -336,7 +337,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000));
});
app.putAsync('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
app.put('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
@@ -365,7 +366,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.json(dead);
});
app.deleteAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
app.delete('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
@@ -378,7 +379,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.json([]);
});
app.deleteAsync('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
app.delete('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
@@ -402,7 +403,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
return res.status(200).send();
});
app.getAsync('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
app.get('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
const {
// @ts-expect-error scrobbleClient not part of req
scrobbleClient: client,
@@ -417,7 +418,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
});
app.use('/api/source/init', sourceRequiredMiddle);
app.postAsync('/api/source/init', async (req, res) => {
app.post('/api/source/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const source = req.scrobbleSource as AbstractSource;
@@ -435,7 +436,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
if(source.polling) {
source.logger.info('Source is already polling! Restarting polling...');
const stopRes = await source.tryStopPolling();
const stopRes = await source.tryStopPolling(new SimpleError('user initiated', {simple: true, shortStack: true}));
if(stopRes === true) {
source.poll({force, notify: false}).catch(e => source.logger.error(e));
}
@@ -445,7 +446,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
});
app.use('/api/source/listen', sourceRequiredMiddle);
app.postAsync('/api/source/listen', async (req, res) => {
app.post('/api/source/listen', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const source = req.scrobbleSource as AbstractSource;
@@ -473,7 +474,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
});
app.use('/api/client/init', clientRequiredMiddle);
app.postAsync('/api/client/init', async (req, res) => {
app.post('/api/client/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const client = req.scrobbleClient as AbstractScrobbleClient;
@@ -488,7 +489,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
client.logger.verbose(`User requested${force ? ' a FORCED' :''} (re)init via API call`);
client.logger.info('Checking (and trying) to stop scrobbler if already running...');
if(false === (await client.tryStopScrobbling())) {
if(false === (await client.tryStopScrobbling(new SimpleError('user initiated', {simple: true, shortStack: true})))) {
return res.status(500).send();
}
@@ -497,8 +498,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
res.status(200).send('OK');
});
app.getAsync('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
app.getAsync('/api/health', async (req, res) => {
app.get('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
app.get('/api/health', async (req, res) => {
const {
type,
name
@@ -548,7 +549,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
prom.collectDefaultMetrics();
}
app.getAsync('/api/metrics', async (req, res) => {
app.get('/api/metrics', async (req, res) => {
const metricsString = await prom.register.metrics();
return res
@@ -558,11 +559,11 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
});
app.getAsync('/api/version', async (req, res) => {
app.get('/api/version', async (req, res) => {
return res.json({version: root.get('version')});
});
app.useAsync('/api/*', async (req, res) => {
app.use('/api/*path', async (req, res) => {
const remote = req.connection.remoteAddress;
const proxyRemote = req.headers["x-forwarded-for"];
const ua = req.headers["user-agent"];
+6 -6
View File
@@ -1,4 +1,4 @@
import { ExpressWithAsync } from "@awaitjs/express";
import { Express } from 'express';
import { Logger } from "@foxxmd/logging";
import passport from "passport";
import { ExpressHandler } from "../common/infrastructure/Atomic.js";
@@ -18,9 +18,9 @@ import LibrefmSource from "../sources/LibrefmSource.js";
import e from "express";
import AbstractSource from "../sources/AbstractSource.js";
export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
app.use('/api/client/auth', clientMiddle);
app.getAsync('/api/client/auth', async (req, res) => {
app.get('/api/client/auth', async (req, res) => {
const {
scrobbleClient,
} = req as any;
@@ -40,7 +40,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
});
app.use('/api/source/auth', sourceMiddle);
app.getAsync('/api/source/auth', async (req, res, next) => {
app.get('/api/source/auth', async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
@@ -74,7 +74,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
}
});
app.getAsync(/.*callback$/, async (req, res, next) => {
app.get(/.*callback$/, async (req, res, next) => {
if(req.url.indexOf('/api') !== 0) {
return res.redirect(307, `/api${req.url}`);
}
@@ -155,7 +155,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
}
});
app.getAsync(/(\/api\/tealfm\/.*)/, async function (req, res) {
app.get(/(\/api\/tealfm\/.*)/, async function (req, res) {
const clients = scrobbleClients.getByType('tealfm') as TealScrobbler[];
if (clients.length === 0) {
+3 -3
View File
@@ -1,11 +1,11 @@
import { ExpressWithAsync } from "@awaitjs/express";
import { Express } from 'express';
import { Logger } from "@foxxmd/logging";
import passport from "passport";
import DeezerSource from "../sources/DeezerSource.js";
import ScrobbleSources from "../sources/ScrobbleSources.js";
import { sleep } from "../utils.js";
export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
export const setupDeezerRoutes = (app: Express, logger: Logger, scrobbleSources: ScrobbleSources) => {
// initialize deezer strategies
// const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
@@ -15,7 +15,7 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
app.getAsync(/.*deezer\/callback*$/, (req, res, next) => {
app.get(/.*deezer\/callback*$/, (req, res, next) => {
if(req.url.indexOf('/api') !== 0) {
return res.redirect(307, `/api${req.url}`);
}
+3 -3
View File
@@ -1,5 +1,5 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import { ExpressWithAsync } from "@awaitjs/express";
import { Express } from 'express';
import { childLogger, Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import ScrobbleSources from "../sources/ScrobbleSources.js";
@@ -8,14 +8,14 @@ import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNoti
import { EndpointLastfmSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.js";
import { LastFMScrobbleRequestPayload } from "../common/vendor/LastfmApiClient.js";
export const setupLastfmEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'LFM']);
const nonEmptyCheck = nonEmptyBody(logger, 'LFM Endpoint');
const webhookIngress = new LFMEndpointNotifier(logger);
app.useAsync(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
app.use(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
@@ -1,5 +1,5 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import { ExpressWithAsync } from "@awaitjs/express";
import { Express } from 'express';
import { childLogger, Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import { EndpointListenbrainzSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.js";
@@ -10,7 +10,7 @@ import { isDebugMode } from "../utils.js";
const TEXT_WILDCARD_REGEX = new RegExp(/text\/.+/);
export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']);
@@ -30,7 +30,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge
const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint');
const webhookIngress = new LZEndpointNotifier(logger);
app.useAsync(/(\/api\/listenbrainz.*)|(\/1\/submit-listens\/?$)/,
app.use(/(\/api\/listenbrainz.*)|(\/1\/submit-listens\/?$)/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
@@ -67,7 +67,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge
await source.handle(playerState);
}
});
app.getAsync('/1/validate-token', async function (req, res) {
app.get('/1/validate-token', async function (req, res) {
//https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token
logger.info('Validated token');
return res.status(200).json({
@@ -77,7 +77,7 @@ export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logge
user_name: "Multi-Scrobbler"
})
});
app.useAsync(/\/1\/.*/, async function (req, res) {
app.use(/\/1\/.*/, async function (req, res) {
logger.warn(`Received what looks like a Listenbrainz Endpoint request but it was to an invalid URL route: ${req.originalUrl}\nMake sure base URL path to MS endpoint is correct.`);
res.status(404);
});
+27 -9
View File
@@ -1,8 +1,7 @@
import { addAsync, Router } from '@awaitjs/express';
import express, { Router } from 'express';
import { childLogger, LogDataPretty, Logger } from "@foxxmd/logging";
import bodyParser from 'body-parser';
import { stripIndents } from "common-tags";
import express from 'express';
import session from 'express-session';
import { PassThrough } from "node:stream";
import passport from 'passport';
@@ -16,7 +15,7 @@ import { setupApi } from "./api.js";
import ScrobbleSources from '../sources/ScrobbleSources.js';
import ScrobbleClients from '../scrobblers/ScrobbleClients.js';
const app = addAsync(express());
const app = express();
const router = Router();
export const initServer = async (parentLogger: Logger, appLoggerStream: PassThrough, initialOutput: LogDataPretty[] = [], sources: ScrobbleSources, clients: ScrobbleClients) => {
@@ -59,7 +58,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
dockerHint = stripIndents`
--- HINT ---
MS is likely being run in a container with BRIDGE networking which means the above addresses are not accessible from outside this container.
To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://foxxmd.github.io/multi-scrobbler/docs/installation#networking
To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://docs.multi-scrobbler.app/installation/?dockerSetting=networking#recommended-settings
The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT${localDefined ? ` (or ${local} since you defined this!)` : ''}
--- HINT ---
`;
@@ -83,16 +82,24 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
process.env.USE_HASH_ROUTER = root.get('isSubPath').toString();
}
const conf = await ViteExpress.getViteConfig();
ViteExpress.config({
const viteExpressOptions: Parameters<typeof ViteExpress.config>[0] = {
mode: isProd ? 'production' : 'development',
inlineViteConfig: {
...conf,
base: localDefined && local.pathname !== '/' ? local.toString() : '/'
}
});
ViteExpress.getViteConfig
};
if(!isProd) {
const conf = await ViteExpress.getViteConfig();
viteExpressOptions.inlineViteConfig = {
...viteExpressOptions.inlineViteConfig,
...conf
};
}
ViteExpress.config(viteExpressOptions);
try {
ViteExpress.listen(app, port, () => {
const start = stripIndents`\n
@@ -115,3 +122,14 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
throw new Error('Server crashed with uncaught exception', {cause: e});
}
}
type ViteConfig = {
root: string;
base: string;
build: {
outDir: string;
};
server?: {
hmr?: boolean;
};
};
+4 -4
View File
@@ -1,4 +1,4 @@
import { ExpressWithAsync } from "@awaitjs/express";
import { Express } from 'express';
import { childLogger, Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import cors from 'cors';
@@ -12,7 +12,7 @@ const corsOpts: cors.CorsOptions = {
methods: ['POST']
}
export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
export const setupWebscrobblerRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'WebScrobbler']);
@@ -25,13 +25,13 @@ export const setupWebscrobblerRoutes = (app: ExpressWithAsync, parentLogger: Log
// }
});
const webhookIngress = new WebhookNotifier(logger);
app.options('/api/webscrobbler*', async (req, res, next) => {
app.options('/api/webscrobbler*path', async (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
},
cors(corsOpts));
app.postAsync('/api/webscrobbler*',
app.post('/api/webscrobbler*path',
async (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
+113 -65
View File
@@ -38,12 +38,14 @@ import { todayAwareFormat } from "../../core/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { isAbortReasonErrorLike, messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
import { findAsync, staggerMapper, StaggerOptions } from '../utils/AsyncUtils.js';
import pMap, {pMapIterable} from 'p-map';
import prom, { Counter, Gauge } from 'prom-client';
import { normalizeStr } from '../utils/StringUtils.js';
import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x';
import { AbortedError, generateLoggableAbortReason } from '../common/errors/MSErrors.js';
export interface RecentlyPlayedOptions {
limit?: number
@@ -71,7 +73,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
canPoll: boolean = false;
polling: boolean = false;
canBacklog: boolean = false;
userPollingStopSignal: undefined | any;
protected abortController: AbortController | undefined;
protected pollingPromise: Promise<void> | undefined;
stopPollingWaitInterval: number = 200;
pollRetries: number = 0;
tracksDiscovered: number = 0;
@@ -119,7 +123,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
async [Symbol.asyncDispose]() {
if(this.canPoll) {
await this.tryStopPolling();
await this.tryStopPolling('Source is being disposed');
}
}
@@ -261,11 +265,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
return existing !== undefined;
}
discover = async (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): Promise<PlayObject[]> => {
discover = async (plays: PlayObject[], options: { checkAll?: boolean, signal?: AbortSignal, [key: string]: any } = {}): Promise<PlayObject[]> => {
const newDiscoveredPlays: PlayObject[] = [];
for await(const play of pMapIterable(plays, this.staggerMappers.preCompare(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 2})) {
for await(const play of pMapIterable(plays, this.staggerMappers.preCompare(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 3})) {
options.signal?.throwIfAborted();
if(!(await this.alreadyDiscovered(play, options))) {
options.signal?.throwIfAborted()
this.addPlayToDiscovered(play);
newDiscoveredPlays.push(play);
}
@@ -296,7 +302,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
newDiscoveredPlays.sort(sortByOldestPlayDate);
this.emitter.emit('discoveredToScrobble', {
data: await pMap(newDiscoveredPlays, this.staggerMappers.postCompare(async (x) => await this.transformPlay(x, TRANSFORM_HOOK.postCompare)), {concurrency: 2}),
data: await pMap(newDiscoveredPlays, this.staggerMappers.postCompare(async (x) => await this.transformPlay(x, TRANSFORM_HOOK.postCompare)), {concurrency: 3}),
options: {
...options,
checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate.add(2, 'second'),
@@ -307,7 +313,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
}
protected processBacklog = async () => {
protected processBacklog = async (signal: AbortSignal) => {
if (this.canBacklog) {
const {
@@ -334,14 +340,16 @@ export default abstract class AbstractSource extends AbstractComponent implement
try {
this.logger.verbose(`Fetching the last ${backlogLimit}${backlogLimit === this.SCROBBLE_BACKLOG_COUNT ? ' (max) ' : ''} listens to check for backlogging...`);
backlogPlays = await this.getBackloggedPlays({limit: backlogLimit});
signal.throwIfAborted();
} catch (e) {
throw new Error('Error occurred while fetching backlogged plays', {cause: e});
}
const discovered = await this.discover(backlogPlays, {discoverLocation: 'backlog'});
const discovered = await this.discover(backlogPlays, {discoverLocation: 'backlog', signal});
if (scrobbleBacklog) {
if (discovered.length > 0) {
this.logger.info('Scrobbling backlogged tracks...');
signal.throwIfAborted();
await this.scrobble(discovered);
this.logger.info('Backlog scrobbling complete.');
} else {
@@ -351,6 +359,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
this.logger.info('Backlog scrobbling is disabled by config, skipping...');
}
}
return;
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions): Promise<PlayObject[]> => {
@@ -369,6 +378,11 @@ export default abstract class AbstractSource extends AbstractComponent implement
poll = async (options: {force?: boolean, notify?: boolean} = {}) => {
const {force = false, notify = false} = options;
if(this.polling) {
this.logger.error('Already polling!');
return;
}
// TODO refactor to only use tryInitialize
if(!this.isReady() || force) {
try {
@@ -387,22 +401,45 @@ export default abstract class AbstractSource extends AbstractComponent implement
if(!(await this.onPollPostAuthCheck())) {
return;
}
try {
await this.processBacklog();
} catch (e) {
this.logger.error(new Error('Cannot start polling because error occurred while processing backlog', {cause: e}));
await this.notify({
title: `${this.getIdentifier()} - Polling Error`,
message: 'Cannot start polling because error occurred while processing backlog.',
priority: 'error'
});
return;
}
await this.startPolling();
this.abortController = new AbortController();
this.pollingPromise = spawn(this.abortController.signal, async (signal, { defer, fork }) => {
defer(async () => {
this.polling = false;
this.isSleeping = false;
this.emitEvent('statusChange', {status: 'Idle'});
});
fork(async (fSignal) => {
try {
await this.processBacklog(fSignal);
} catch (e) {
throwIfAborted(fSignal);
await this.notify({
title: `${this.getIdentifier()} - Polling Error`,
message: 'Polling interrupted because error occurred while processing backlog.',
priority: 'error'
});
throw new Error('Polling interrupted because error occurred while processing backlog', { cause: e });
}
});
await this.startPolling(signal);
}).catch((e) => {
if (isAbortError(e)) {
const err = generateLoggableAbortReason('Polling stopped', this.abortController.signal);
this.logger.info(err);
this.logger.trace(e)
} else {
this.logger.warn(new Error('Polling stopped with error', { cause: e }));
}
}).finally(() => {
this.abortController = undefined;
this.pollingPromise = undefined;
});
}
startPolling = async () => {
startPolling = async (signal: AbortSignal) => {
signal.throwIfAborted();
// reset poll attempts if already previously run
this.pollRetries = 0;
@@ -421,8 +458,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
return;
}
let pollRes: boolean | undefined = undefined;
while (pollRes === undefined && this.pollRetries <= maxRetries) {
while (this.pollRetries <= maxRetries) {
try {
if(!this.isReady() && this.buildOK) {
this.logger.verbose(`Source is no longer ready! Will attempt to reinitialize => Connection OK: ${this.connectionOK} | Auth OK: ${this.authed}`);
@@ -430,71 +466,75 @@ export default abstract class AbstractSource extends AbstractComponent implement
if(init === false) {
throw new Error('Source failed reinitialization');
}
signal.throwIfAborted();
}
pollRes = await this.doPolling();
if(pollRes === true) {
break;
}
await this.doPolling(signal);
} catch (e) {
if(isAbortError(e)) {
throw e;
}
if (this.pollRetries < maxRetries) {
const delayFor = pollingBackoff(this.pollRetries + 1, retryMultiplier);
this.logger.info(`Poll retries (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
await this.notify({title: `${this.getIdentifier()} - Polling Retry`, message: `Encountered error while polling but retries (${this.pollRetries}) are less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'});
await sleep((delayFor) * 1000);
this.pollRetries++;
} else {
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
await this.notify({title: `${this.getIdentifier()} - Polling Error`, message: `Encountered error while polling and retries (${this.pollRetries}) are equal to max poll retries (${maxRetries}), stopping polling!. | Error: ${e.message}`, priority: 'error'});
throw e;
}
this.pollRetries++;
}
}
}
tryStopPolling = async () => {
tryStopPolling = async (reason?: string | Error) => {
if(this.polling === false) {
this.logger.warn(`Polling is already stopped!`);
return;
return true;
}
this.userPollingStopSignal = true;
let secsPassed = 0;
while(this.userPollingStopSignal !== undefined && secsPassed < 10) {
await sleep(2000);
secsPassed += 2;
this.logger.verbose(`Waiting for polling stop signal to be acknowledged (waited ${secsPassed}s)`);
if(this.abortController === undefined) {
this.logger.error('No abort controller found! Nothing to stop.');
return false;
}
if(this.userPollingStopSignal !== undefined) {
this.abortController.abort(reason);
let elapsed = 0;
let lastlog: Dayjs;
while(this.polling && elapsed < 10) {
if(lastlog === undefined || dayjs().diff(lastlog, 's') >= 2) {
this.logger.verbose(`Waiting for polling stop signal to be acknowledged (waited ${formatNumber(elapsed/1000)}s)`);
}
await sleep(this.stopPollingWaitInterval);
elapsed += this.stopPollingWaitInterval;
}
if(this.polling) {
this.logger.warn('Could not stop polling! Or polling signal was lost :(');
return false;
}
return true;
}
protected doStopPolling = (reason: string = 'system') => {
this.polling = false;
this.userPollingStopSignal = undefined;
this.emitEvent('statusChange', {status: 'Idle'});
this.logger.info(`Stopped polling due to: ${reason}`);
}
protected doPolling = async (signal: AbortSignal): Promise<true | undefined> => {
signal.throwIfAborted();
protected shouldStopPolling = () => this.polling === false || this.userPollingStopSignal !== undefined;
protected doPolling = async (): Promise<true | undefined> => {
if (this.polling === true) {
return true;
}
this.logger.info('Polling started');
this.emitEvent('statusChange', {status: 'Running'});
await this.notify({title: `${this.getIdentifier()} - Polling Started`, message: 'Polling Started', priority: 'info'});
this.lastActivityAt = dayjs();
let checkCount = 0;
let checksOverThreshold = 0;
let checkActiveFor = 120;
let maxInterval = DEFAULT_POLLING_MAX_INTERVAL;
const {checkActiveFor = 120, maxInterval = DEFAULT_POLLING_MAX_INTERVAL} = this.config.data;
if('maxInterval' in this.config.data) {
maxInterval = this.config.data.maxInterval;
}
let isInactive = false;
try {
this.polling = true;
while (!this.shouldStopPolling()) {
while (true) {
signal.throwIfAborted();
const pollFrom = dayjs();
let lastActivityLogLevel: LogLevel = 'trace';
@@ -503,6 +543,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
playObjs = await this.getRecentlyPlayed({formatted: true});
} catch (e) {
throw new Error('Error occurred while refreshing recently played', {cause: e});
} finally {
signal.throwIfAborted();
}
@@ -525,7 +567,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
this.logger.info(`Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by ${maxDelay} seconds so other clients have time to scrobble first`);
await sleep(maxDelay * 1000);
}
newDiscovered = await this.discover(playObjs);
newDiscovered = await this.discover(playObjs, {signal});
signal.throwIfAborted();
this.scrobble(newDiscovered,
{
forceRefresh: closeToInterval
@@ -571,25 +614,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
this.logger[lastActivityLogLevel](activityMsgs.join(' | '));
this.setWakeAt(pollFrom.add(sleepTime, 'seconds'));
this.setIsSleeping(true);
while(!this.shouldStopPolling() && dayjs().isBefore(this.getWakeAt())) {
while(dayjs().isBefore(this.getWakeAt())) {
// check for polling status every half second and wait till wake up time
await sleep(500);
await delay(signal, 500);
}
this.setIsSleeping(false);
}
if(this.shouldStopPolling()) {
this.doStopPolling(this.userPollingStopSignal !== undefined ? 'user input' : undefined);
return true;
// if we have made it this far in the loop we can reset poll retries
this.pollRetries = 0;
}
} catch (e) {
this.logger.error(new Error('Error occurred while polling', {cause: e}));
if(!isAbortError(e)) {
this.logger.error(new Error('Error occurred while polling', {cause: e}));
}
if(e.message.includes('Status code: 401')) {
this.authed = false;
this.authFailure = true;
}
this.emitEvent('statusChange', {status: 'Idle'});
this.polling = false;
throw e;
} finally {
this.setIsSleeping(false);
@@ -613,13 +653,21 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
protected getInterval() {
const {interval = DEFAULT_POLLING_INTERVAL} = this.config.data;
let interval = DEFAULT_POLLING_INTERVAL;
if('interval' in this.config.data) {
interval = this.config.data.interval;
}
return interval;
}
protected getMaxBackoff() {
const {interval = DEFAULT_POLLING_INTERVAL, maxInterval = DEFAULT_POLLING_MAX_INTERVAL} = this.config.data;
return maxInterval - interval;
let maxInterval = DEFAULT_POLLING_MAX_INTERVAL;
if('maxInterval' in this.config.data) {
maxInterval = this.config.data.maxInterval;
}
return maxInterval - this.getInterval();
}
public emitEvent = (eventName: string, payload: object = {}) => {
-3
View File
@@ -42,9 +42,6 @@ import {
from "@jellyfin/sdk/lib/index.js";
import dayjs from "dayjs";
import EventEmitter from "events";
import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
+95 -60
View File
@@ -1,13 +1,13 @@
/* eslint-disable no-case-declarations */
import { childLogger, Logger } from '@foxxmd/logging';
import EventEmitter from "events";
import { ConfigMeta, InternalConfig, InternalConfigOptional } from "../common/infrastructure/Atomic.js";
import { ConfigMeta, ConfigureAs, ConfigureAsSource, InternalConfig, InternalConfigOptional } from "../common/infrastructure/Atomic.js";
import { isSourceType } from '../common/infrastructure/config/source/sources.js';
import { sourceTypes } from '../common/infrastructure/config/source/sources.js';
import { SourceType } from '../common/infrastructure/config/source/sources.js';
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { ChromecastData, ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
@@ -25,14 +25,15 @@ import { IcecastData, IcecastSourceConfig, IcecastSourceOptions } from "../commo
import { MPDData, MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { PlexApiData, PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js";
import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js";
import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js";
import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js";
import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js";
import { WebScrobblerData, WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js";
import { YTMusicData, YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js";
import { YandexMusicBridgeData, YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js";
import { SonosData, SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { parseBool } from "../utils.js";
@@ -46,6 +47,13 @@ import { RockskySourceConfig } from '../common/infrastructure/config/source/rock
import { CommonSourceOptions } from '../common/infrastructure/config/source/index.js';
import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js';
import { LibrefmSourceConfig } from '../common/infrastructure/config/source/librefm.js';
import { LastfmData } from '../common/infrastructure/config/client/lastfm.js';
import { MalojaData } from '../common/infrastructure/config/client/maloja.js';
import { LibrefmData } from '../common/infrastructure/config/client/librefm.js';
import { ListenBrainzData } from '../common/infrastructure/config/client/listenbrainz.js';
import { KoitoData } from '../common/infrastructure/config/client/koito.js';
import { TealData } from '../common/infrastructure/config/client/tealfm.js';
import { RockSkyData } from '../common/infrastructure/config/client/rocksky.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -128,6 +136,8 @@ export default class ScrobbleSources {
return "LibrefmSourceConfig";
case 'ytmusic':
return "YTMusicSourceConfig";
case 'ymbridge':
return "YandexMusicBridgeSourceConfig";
case 'maloja':
return "MalojaSourceConfig";
case 'mpris':
@@ -260,16 +270,14 @@ export default class ScrobbleSources {
}
for (const sourceType of sourceTypes) {
let defaultConfigureAs = 'source';
let defaultConfigureAs: ConfigureAsSource = 'source';
// env builder for single user mode
switch (sourceType) {
case 'spotify':
const s = {
accessToken: process.env.SPOTIFY_ACCESS_TOKEN,
clientId: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
const s: SpotifySourceData = {
clientId: process.env.SPOTIFY_CLIENT_ID as string,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET as string,
redirectUri: process.env.SPOTIFY_REDIRECT_URI,
refreshToken: process.env.SPOTIFY_REFRESH_TOKEN,
};
if (!Object.values(s).every(x => x === undefined && x !== null)) {
configs.push({
@@ -278,20 +286,19 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: s as SpotifySourceData,
data: s,
options: transformPresetEnv('SPOTIFY')
})
}
break;
case 'plex':
const p = {
user: process.env.PLEX_USER,
const p: PlexApiData = {
url: process.env.PLEX_URL,
token: process.env.PLEX_TOKEN,
usersAllow: process.env.PLEX_USERS_ALLOW,
usersBlock: process.env.PLEX_USERS_BLOCK,
devicesAllow: process.env.PLEX_DEVICES_ALLOW,
deviceBlock: process.env.PLEX_DEVICES_BLOCK,
devicesBlock: process.env.PLEX_DEVICES_BLOCK,
librariesAllow: process.env.PLEX_LIBRARIES_ALLOW,
librariesBlock: process.env.PLEX_LIBRARIES_BLOCK
};
@@ -308,7 +315,7 @@ export default class ScrobbleSources {
}
break;
case 'subsonic':
const sub = {
const sub: SubsonicData = {
user: process.env.SUBSONIC_USER,
password: process.env.SUBSONIC_PASSWORD,
url: process.env.SUBSONIC_URL,
@@ -334,7 +341,7 @@ export default class ScrobbleSources {
usersAllow: process.env.JELLYFIN_USERS_ALLOW,
usersBlock: process.env.JELLYFIN_USERS_BLOCK,
devicesAllow: process.env.JELLYFIN_DEVICES_ALLOW,
deviceBlock: process.env.JELLYFIN_DEVICES_BLOCK,
devicesBlock: process.env.JELLYFIN_DEVICES_BLOCK,
librariesAllow: process.env.JELLYFIN_LIBRARIES_ALLOW,
librariesBlock: process.env.JELLYFIN_LIBRARIES_BLOCK,
frontendUrlOverride: process.env.JELLYFIN_FRONTEND_URL_OVERRIDE,
@@ -354,7 +361,7 @@ export default class ScrobbleSources {
break;
case 'lastfm':
{
const lfm = {
const lfm: LastfmData = {
apiKey: process.env.SOURCE_LASTFM_API_KEY,
secret: process.env.SOURCE_LASTFM_SECRET,
redirectUri: process.env.SOURCE_LASTFM_REDIRECT_URI,
@@ -389,14 +396,14 @@ export default class ScrobbleSources {
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: d as DeezerData,
data: d,
options: transformPresetEnv('DEEZER')
});
}
break;
case 'mpris':
const shouldUse = parseBool(process.env.MPRIS_ENABLE);
const mp = {
const mp: MPRISData = {
blacklist: process.env.MPRIS_BLACKLIST,
whitelist: process.env.MPRIS_WHITELIST
}
@@ -417,16 +424,17 @@ export default class ScrobbleSources {
const url = process.env.SOURCE_MALOJA_URL;
const apiKey = process.env.SOURCE_MALOJA_API_KEY;
if (url !== undefined || apiKey !== undefined) {
const malojaData: MalojaData = {
url,
apiKey
}
configs.push({
type: 'maloja',
name: 'unnamed-mlj-source',
source: 'ENV',
mode: 'single',
configureAs: 'source',
data: {
url,
apiKey
},
data: malojaData,
options: transformPresetEnv('SOURCE_MALOJA')
})
}
@@ -434,7 +442,7 @@ export default class ScrobbleSources {
break;
case 'librefm':{
const shouldUse = parseBool(process.env.LIBRFM_ENABLE)
const libre = {
const libre: LibrefmData = {
apiKey: process.env.SOURCE_LIBREFM_API_KEY,
secret: process.env.SOURCE_LIBREFM_SECRET,
redirectUri: process.env.SOURCE_LIBREFM_REDIRECT_URI,
@@ -455,7 +463,7 @@ export default class ScrobbleSources {
}
break;
case 'listenbrainz': {
const lz = {
const lz: ListenBrainzData = {
url: process.env.SOURCE_LZ_URL,
token: process.env.SOURCE_LZ_TOKEN,
username: process.env.SOURCE_LZ_USER
@@ -474,7 +482,7 @@ export default class ScrobbleSources {
}
break;
case 'koito': {
const koit = {
const koit: KoitoData = {
url: process.env.SOURCE_KOITO_URL,
token: process.env.SOURCE_KOITO_TOKEN,
username: process.env.SOURCE_KOITO_USER
@@ -493,10 +501,9 @@ export default class ScrobbleSources {
}
break;
case 'tealfm': {
const teal = {
const teal: TealData = {
identifier: process.env.SOURCE_TEALFM_IDENTIFIER,
appPassword: process.env.SOURCE_TEALFM_APP_PW,
pds: process.env.SOURCE_TEALFM_PDS
};
if (!Object.values(teal).every(x => x === undefined)) {
configs.push({
@@ -512,7 +519,7 @@ export default class ScrobbleSources {
}
break;
case 'rocksky': {
const rocksky = {
const rocksky: RockSkyData = {
key: process.env.SOURCE_ROCKSKY_KEY,
handle: process.env.SOURCE_ROCKSKY_HANDLE
};
@@ -531,7 +538,7 @@ export default class ScrobbleSources {
break;
case 'endpointlz':
const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE);
const lze = {
const lze: ListenbrainzEndpointData = {
slug: process.env.LZE_SLUG,
token: process.env.LZE_TOKEN
}
@@ -549,7 +556,7 @@ export default class ScrobbleSources {
break;
case 'endpointlfm':
const lfmShouldUse = parseBool(process.env.LFMENDPOINT_ENABLE);
const lfme = {
const lfme: LastFMEndpointData = {
slug: process.env.LFM_SLUG,
}
if (!Object.values(lfme).every(x => x === undefined) || lfmShouldUse) {
@@ -564,10 +571,10 @@ export default class ScrobbleSources {
});
}
break;
case 'icecast':
const icecast = {
case 'icecast': {
const scrobbleStart = parseBool(process.env.ICECAST_SCROBBLE_START);
const icecast: IcecastData = {
url: process.env.ICECAST_URL,
scrobbleOnStart: process.env.ICECAST_SCROBBLE_START,
}
if (!Object.values(icecast).every(x => x === undefined)) {
configs.push({
@@ -578,13 +585,13 @@ export default class ScrobbleSources {
configureAs: defaultConfigureAs,
data: icecast as IcecastData,
options: transformPresetEnv<IcecastSourceOptions>('ICECAST', {
systemScrobble: parseBool(icecast.scrobbleOnStart)
systemScrobble: scrobbleStart
})
});
}
break;
} break;
case 'jriver':
const jr = {
const jr: JRiverData = {
url: process.env.JRIVER_URL,
username: process.env.JRIVER_USER,
password: process.env.JRIVER_PASSWORD
@@ -602,7 +609,7 @@ export default class ScrobbleSources {
}
break;
case 'kodi':
const ko = {
const ko: KodiData = {
url: process.env.KODI_URL,
username: process.env.KODI_USER,
password: process.env.KODI_PASSWORD
@@ -621,7 +628,7 @@ export default class ScrobbleSources {
break;
case 'webscrobbler':
const wsShouldUse = parseBool(process.env.WS_ENABLE);
const ws = {
const ws: WebScrobblerData = {
blacklist: process.env.WS_BLACKLIST,
whitelist: process.env.WS_WHITELIST
}
@@ -633,8 +640,8 @@ export default class ScrobbleSources {
mode: 'single',
configureAs: defaultConfigureAs,
data: {
blacklist: ws.blacklist !== undefined ? ws.blacklist.split(',') : [],
whitelist: ws.whitelist !== undefined ? ws.whitelist.split(',') : [],
blacklist: ws.blacklist !== undefined ? (ws.blacklist as string).split(',') : [],
whitelist: ws.whitelist !== undefined ? (ws.whitelist as string).split(',') : [],
},
options: transformPresetEnv('WS')
});
@@ -642,7 +649,7 @@ export default class ScrobbleSources {
break;
case 'chromecast':
const ccShouldUse = parseBool(process.env.CC_ENABLE);
const cc = {
const cc: ChromecastData = {
blacklistDevices: process.env.CC_BLACKLIST_DEVICES,
whitelistDevices: process.env.CC_WHITELIST_DEVICES,
blacklistApps: process.env.CC_BLACKLIST_APPS,
@@ -656,17 +663,17 @@ export default class ScrobbleSources {
mode: 'single',
configureAs: defaultConfigureAs,
data: {
blacklistDevices: cc.blacklistDevices !== undefined ? cc.blacklistDevices.split(',') : [],
whitelistDevices: cc.whitelistDevices !== undefined ? cc.whitelistDevices.split(',') : [],
blacklistApps: cc.blacklistApps !== undefined ? cc.blacklistApps.split(',') : [],
whitelistApps: cc.whitelistApps !== undefined ? cc.whitelistApps.split(',') : [],
blacklistDevices: cc.blacklistDevices !== undefined ? (cc.blacklistDevices as string).split(',') : [],
whitelistDevices: cc.whitelistDevices !== undefined ? (cc.whitelistDevices as string).split(',') : [],
blacklistApps: cc.blacklistApps !== undefined ? (cc.blacklistApps as string).split(',') : [],
whitelistApps: cc.whitelistApps !== undefined ? (cc.whitelistApps as string).split(',') : [],
},
options: transformPresetEnv('CC')
});
}
break;
case 'musiccast':
const musecase = {
const musecase: MusicCastData = {
url: process.env.MCAST_URL,
}
if (!Object.values(musecase).every(x => x === undefined)) {
@@ -682,7 +689,7 @@ export default class ScrobbleSources {
}
break;
case 'musikcube':
const mc = {
const mc: MusikcubeData = {
url: process.env.MC_URL,
password: process.env.MC_PASSWORD
}
@@ -699,7 +706,7 @@ export default class ScrobbleSources {
}
break;
case 'mpd':
const mpd = {
const mpd: MPDData = {
url: process.env.MPD_URL,
password: process.env.MPD_PASSWORD
}
@@ -716,7 +723,7 @@ export default class ScrobbleSources {
}
break;
case 'vlc':
const vlc = {
const vlc: VLCData = {
url: process.env.VLC_URL,
password: process.env.VLC_PASSWORD
}
@@ -733,7 +740,7 @@ export default class ScrobbleSources {
}
break;
case 'ytmusic':
const ytm = {
const ytm: YTMusicData = {
redirectUri: process.env.YTM_REDIRECT_URI,
clientId: process.env.YTM_CLIENT_ID,
clientSecret: process.env.YTM_CLIENT_SECRET,
@@ -751,14 +758,20 @@ export default class ScrobbleSources {
});
}
break;
case 'azuracast':
const azura = {
case 'azuracast': {
const azura: AzuracastData = {
station: process.env.AZURA_STATION,
url: process.env.AZURA_URL,
monitorWhenListeners: process.env.AZURA_LISTENERS_NUM,
monitorWhenLive: process.env.AZURA_LIVE,
apiKey: process.env.AZURA_KEY
}
const listenerNum = process.env.AZURA_LISTENERS_NUM ?? '';
if(listenerNum.trim() !== '') {
azura.monitorWhenListeners = !isNaN(Number.parseInt(listenerNum)) ? Number.parseInt(listenerNum) : parseBool(listenerNum);
}
const live = process.env.AZURA_LIVE ?? '';
if(live.trim() !== '') {
azura.monitorWhenLive = parseBool(live);
}
if (!Object.values(azura).every(x => x === undefined)) {
configs.push({
type: 'azuracast',
@@ -770,14 +783,14 @@ export default class ScrobbleSources {
options: transformPresetEnv('AZURA')
});
}
break;
} break;
case 'sonos': {
const sonos = {
const sonos: SonosData = {
host: process.env.SONOS_HOST,
devicesAllow: process.env.SONOS_DEVICES_ALLOW,
devicesBlock: process.env.SONOS_DEVICES_BLOCK,
groupsAllow: process.env.SONOS_GROUPS_ALLOW,
groupsBlocks: process.env.SONOS_GROUPS_BLOCK
groupsBlock: process.env.SONOS_GROUPS_BLOCK
}
if (!Object.values(sonos).every(x => x === undefined)) {
configs.push({
@@ -790,7 +803,24 @@ export default class ScrobbleSources {
options: transformPresetEnv('SONOS')
});
}
} break;
} break;
case 'ymbridge': {
const yandex: YandexMusicBridgeData = {
url: process.env.YMBRIDGE_URL,
apiKey: process.env.YMBRIDGE_API_KEY,
}
if (!Object.values(yandex).every(x => x === undefined)) {
configs.push({
type: 'ymbridge',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: yandex,
options: transformPresetEnv('YMBRIDGE')
});
}
} break;
default:
break;
}
@@ -901,6 +931,7 @@ export default class ScrobbleSources {
}
// add defaults
// @ts-expect-error idk why this has so many issues
const compositeConfig: SourceConfig = {...clientConfig, data: d, options: {...defaults, ...clientOptions}};
this.logger.debug({labels: [`${type} - ${name}`]},`Constructing Source from ${source}...`);
@@ -932,7 +963,7 @@ export default class ScrobbleSources {
break;
case 'deezer':
const deezerConfig = compositeConfig as DeezerCompatConfig;
if(deezerConfig.data.arl !== undefined) {
if('arl' in deezerConfig.data && deezerConfig.data.arl !== undefined) {
const DeezerInternalSource = (await import('./DeezerInternalSource.js')).default;
newSource = await new DeezerInternalSource(name, compositeConfig as DeezerInternalSourceConfig, this.internalConfig, this.emitter);
} else {
@@ -944,6 +975,10 @@ export default class ScrobbleSources {
const YTMusicSource = (await import('./YTMusicSource.js')).default;
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter);
break;
case 'ymbridge':
const YandexMusicBridgeSource = (await import('./YandexMusicBridgeSource.js')).default;
newSource = await new YandexMusicBridgeSource(name, compositeConfig as YandexMusicBridgeSourceConfig, this.internalConfig, this.emitter);
break;
case 'mpris':
const {MPRISSource} = (await import('./MPRISSource.js'));
newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, this.internalConfig, this.emitter);
@@ -0,0 +1,458 @@
import { EventEmitter } from "events";
import request from 'superagent';
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js";
import {
InternalConfig,
NO_DEVICE,
NO_USER,
PlayerStateData,
PlayerStateDataMaybePlay,
REPORTED_PLAYER_STATUSES,
} from "../common/infrastructure/Atomic.js";
import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js";
import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
interface BridgeTrackData {
title?: string
artists?: string
artists_list?: string[]
album?: string
track_id?: string
cover?: string
duration_ms?: number
progress_ms?: number
paused?: boolean
explicit?: boolean
context_type?: string
queue_id?: string
player_id?: string
device_id?: string
device_name?: string
platform?: string
source?: string
timestamp?: number
}
interface BridgePlayersResponse {
ok: boolean
source?: string
fresh?: boolean
players_count?: number
primary?: BridgeTrackData | null
data?: BridgeTrackData[] | BridgeTrackData | null
}
interface SyntheticPlaybackState {
key: string
lastSeenAtMs: number
lastPositionSec: number
durationSec?: number
reachedTrackEndAtMs?: number
}
const normalizeBridgeData = (obj: BridgeTrackData): BridgeTrackData => {
const normalized: BridgeTrackData = { ...obj };
if (normalized.progress_ms !== undefined && normalized.progress_ms !== null) {
normalized.progress_ms = Math.max(0, Number(normalized.progress_ms) || 0);
}
if (normalized.duration_ms !== undefined && normalized.duration_ms !== null) {
const dur = Number(normalized.duration_ms);
normalized.duration_ms = Number.isFinite(dur) && dur > 0 ? dur : undefined;
}
if (normalized.duration_ms !== undefined && normalized.progress_ms !== undefined) {
normalized.progress_ms = Math.min(normalized.progress_ms, normalized.duration_ms);
}
return normalized;
}
const getSafePositionSec = (obj: BridgeTrackData): number | undefined => {
if (obj.progress_ms === undefined || obj.progress_ms === null) {
return undefined;
}
const progressSec = Math.max(0, obj.progress_ms / 1000);
const durationSec = obj.duration_ms !== undefined && obj.duration_ms !== null && obj.duration_ms > 0
? obj.duration_ms / 1000
: undefined;
if (obj.source === 'station-local') {
return durationSec !== undefined ? Math.min(progressSec, durationSec) : 0;
}
return durationSec !== undefined ? Math.min(progressSec, durationSec) : progressSec;
}
export default class YandexMusicBridgeSource extends MemoryPositionalSource {
declare config: YandexMusicBridgeSourceConfig;
urlData!: URLData;
private syntheticPlaybackByPlayer = new Map<string, SyntheticPlaybackState>();
private lastBridgeDataByPlayer = new Map<string, BridgeTrackData>();
private lastPlayByPlayer = new Map<string, PlayObject>();
private lastBridgeSeenAtMsByPlayer = new Map<string, number>();
private keepAliveMinSec = 90;
private keepAlivePaddingSec = 120;
private keepAliveHardCapSec = 600;
private postEndStopGraceSec = 20;
constructor(name: string, config: YandexMusicBridgeSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('ymbridge', name, config, internal, emitter);
this.requiresAuth = false;
this.canPoll = true;
this.multiPlatform = true;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const { data: { url } = {} } = this.config;
if (url === null || url === undefined || url === '') {
throw new Error('data.url must be defined');
}
this.urlData = normalizeWebAddress(url, { defaultPath: '/' });
this.logger.verbose(`Config URL: '${url}' => Normalized: '${this.urlData.normal}'`);
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname });
const healthUrl = joinedUrl(this.urlData.url, 'health').toString();
const req = request.get(healthUrl).timeout({ response: 5000, deadline: 10000 });
const apiKey = this.config.data?.apiKey;
if (apiKey !== undefined && apiKey.trim() !== '') {
req.set('X-API-Key', apiKey);
}
const resp = await req;
if (resp.body !== undefined && typeof resp.body === 'object') {
this.logger.info(`Yandex Music bridge is reachable at ${this.urlData.url.host}`);
return true;
}
throw new UpstreamError('Bridge health endpoint did not return JSON', { responseBody: resp.body, showStopper: true });
} catch (e: any) {
const hint = e?.response?.text ?? e?.message ?? undefined;
throw new UpstreamError(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e, responseBody: e?.response?.text, showStopper: true });
}
}
private async callBridge(): Promise<BridgePlayersResponse> {
const apiKey = this.config.data?.apiKey;
const makeReq = (path: string) => {
const req = request.get(joinedUrl(this.urlData.url, path).toString()).timeout({ response: 5000, deadline: 10000 });
if (apiKey !== undefined && apiKey.trim() !== '') {
req.set('X-API-Key', apiKey);
}
return req;
};
try {
const resp = await makeReq('players');
if (resp.body === undefined || typeof resp.body !== 'object') {
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
}
return resp.body as BridgePlayersResponse;
} catch (e: any) {
if (e?.status !== 404) {
throw new UpstreamError('Bridge did not return an expected response from /players', { responseBody: e?.response?.text, cause: e, showStopper: true });
}
const resp = await makeReq('now-playing');
if (resp.body === undefined || typeof resp.body !== 'object') {
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
}
const body = resp.body as any;
const single = body?.data ?? null;
return {
ok: body?.ok ?? true,
source: body?.source,
fresh: body?.fresh,
players_count: single ? 1 : 0,
primary: single,
data: single ? [single] : [],
};
}
}
private getPlayerId(bridgeData: BridgeTrackData): string {
const raw = bridgeData.player_id ?? bridgeData.device_id ?? bridgeData.queue_id ?? bridgeData.track_id;
return raw !== undefined && raw !== null && `${raw}`.trim() !== '' ? `${raw}` : NO_DEVICE;
}
private isStationLocal(bridgeData: BridgeTrackData): boolean {
return bridgeData.source === 'station-local';
}
private removePlayerState(playerId: string): void {
this.syntheticPlaybackByPlayer.delete(playerId);
this.lastBridgeDataByPlayer.delete(playerId);
this.lastPlayByPlayer.delete(playerId);
this.lastBridgeSeenAtMsByPlayer.delete(playerId);
}
private clearSyntheticPlayback(playerId: string): void {
this.syntheticPlaybackByPlayer.delete(playerId);
}
private getSyntheticKey(bridgeData: BridgeTrackData, play: PlayObject): string {
const artists = Array.isArray(bridgeData.artists_list) && bridgeData.artists_list.length > 0
? bridgeData.artists_list.join(',')
: (bridgeData.artists ?? play.data.artists?.join(',') ?? '');
return [
this.getPlayerId(bridgeData),
bridgeData.track_id ?? '',
bridgeData.title ?? play.data.track ?? '',
artists,
bridgeData.album ?? play.data.album ?? '',
].join('::');
}
private getPlaybackState(playerId: string, bridgeData: BridgeTrackData, play: PlayObject): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } {
const now = Date.now();
const reportedPositionSec = bridgeData.progress_ms !== undefined && bridgeData.progress_ms !== null
? Math.max(0, bridgeData.progress_ms / 1000)
: undefined;
const durationSec = play.data.duration;
if (this.isStationLocal(bridgeData)) {
const position = durationSec !== undefined && durationSec > 0
? Math.min(reportedPositionSec ?? play.meta.trackProgressPosition ?? 0, durationSec)
: (reportedPositionSec ?? play.meta.trackProgressPosition ?? 0);
this.clearSyntheticPlayback(playerId);
return {
status: REPORTED_PLAYER_STATUSES.playing,
position: Math.max(0, position),
};
}
const key = this.getSyntheticKey(bridgeData, play);
const current = this.syntheticPlaybackByPlayer.get(playerId);
if (current === undefined || current.key !== key) {
const initialPosition = reportedPositionSec ?? play.meta.trackProgressPosition ?? 0;
this.syntheticPlaybackByPlayer.set(playerId, {
key,
lastSeenAtMs: now,
lastPositionSec: initialPosition,
durationSec,
reachedTrackEndAtMs: durationSec !== undefined && durationSec > 0 && initialPosition >= durationSec ? now : undefined,
});
return {
status: REPORTED_PLAYER_STATUSES.playing,
position: durationSec !== undefined && durationSec > 0 ? Math.min(initialPosition, durationSec) : initialPosition,
};
}
const elapsedSec = Math.max(0, (now - current.lastSeenAtMs) / 1000);
const startingPoint = reportedPositionSec !== undefined
? Math.max(reportedPositionSec, current.lastPositionSec)
: current.lastPositionSec;
let nextPosition = startingPoint + elapsedSec;
if (durationSec !== undefined && durationSec > 0 && nextPosition >= durationSec) {
nextPosition = durationSec;
if (current.reachedTrackEndAtMs === undefined) {
current.reachedTrackEndAtMs = now;
}
}
current.lastSeenAtMs = now;
current.lastPositionSec = nextPosition;
current.durationSec = durationSec;
this.syntheticPlaybackByPlayer.set(playerId, current);
return {
status: REPORTED_PLAYER_STATUSES.playing,
position: nextPosition,
};
}
private shouldKeepAliveSynthetic(playerId: string, nowMs: number = Date.now()): boolean {
const lastBridgeData = this.lastBridgeDataByPlayer.get(playerId);
if (lastBridgeData !== undefined && this.isStationLocal(lastBridgeData)) {
return false;
}
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
const lastPlay = this.lastPlayByPlayer.get(playerId);
const lastSeen = this.lastBridgeSeenAtMsByPlayer.get(playerId);
if (synthetic === undefined || lastPlay === undefined || lastBridgeData === undefined || lastSeen === undefined) {
return false;
}
const silenceSec = Math.max(0, (nowMs - lastSeen) / 1000);
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
const currentPosSec = synthetic.lastPositionSec ?? lastPlay.meta.trackProgressPosition ?? 0;
let allowedSilenceSec = this.keepAliveMinSec;
if (durationSec !== undefined && durationSec > 0) {
const remainingSec = Math.max(0, durationSec - currentPosSec);
allowedSilenceSec = Math.max(this.keepAliveMinSec, remainingSec + this.keepAlivePaddingSec);
}
allowedSilenceSec = Math.min(allowedSilenceSec, this.keepAliveHardCapSec);
if (silenceSec <= allowedSilenceSec) {
return true;
}
this.logger.debug(`Dropping synthetic keepalive for player ${playerId} after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`);
return false;
}
private shouldFinalizeSyntheticTrack(playerId: string, nowMs: number = Date.now()): boolean {
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
const lastPlay = this.lastPlayByPlayer.get(playerId);
if (synthetic === undefined || lastPlay === undefined) {
return false;
}
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
if (durationSec === undefined || durationSec <= 0) {
return false;
}
if (synthetic.lastPositionSec < durationSec) {
return false;
}
if (synthetic.reachedTrackEndAtMs === undefined) {
synthetic.reachedTrackEndAtMs = nowMs;
this.syntheticPlaybackByPlayer.set(playerId, synthetic);
return false;
}
const sinceEndSec = Math.max(0, (nowMs - synthetic.reachedTrackEndAtMs) / 1000);
return sinceEndSec >= this.postEndStopGraceSec;
}
private buildStoppedState(playerId: string): PlayerStateDataMaybePlay | undefined {
const bridgeData = this.lastBridgeDataByPlayer.get(playerId);
if (bridgeData === undefined) {
return undefined;
}
return {
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.stopped,
};
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const payload = await this.callBridge();
const rawData = payload.data;
const players = Array.isArray(rawData)
? rawData.filter((x): x is BridgeTrackData => x !== null && x !== undefined && typeof x === 'object')
: (rawData !== undefined && rawData !== null && typeof rawData === 'object' ? [rawData as BridgeTrackData] : []);
const nowMs = Date.now();
const seenPlayerIds = new Set<string>();
const states: PlayerStateDataMaybePlay[] = [];
for (const rawBridgeData of players) {
const bridgeData = normalizeBridgeData(rawBridgeData);
if (!payload.ok || bridgeData.title === undefined || bridgeData.title === null || `${bridgeData.title}`.trim() === '') {
continue;
}
const playerId = this.getPlayerId(bridgeData);
seenPlayerIds.add(playerId);
const play = formatPlayObj(bridgeData, playerId);
this.lastBridgeDataByPlayer.set(playerId, bridgeData);
this.lastPlayByPlayer.set(playerId, play);
this.lastBridgeSeenAtMsByPlayer.set(playerId, nowMs);
if (this.isStationLocal(bridgeData) && bridgeData.paused === true) {
this.clearSyntheticPlayback(playerId);
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.paused,
play,
position: getSafePositionSec(bridgeData) ?? play.meta.trackProgressPosition ?? 0,
});
continue;
}
const playbackState = this.getPlaybackState(playerId, bridgeData, play);
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? play.data.duration ?? 0;
this.logger.info(`Synthetic playback exceeded track duration for player ${playerId} '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState(playerId);
this.removePlayerState(playerId);
if (stoppedState !== undefined) {
states.push(stoppedState);
}
continue;
}
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: playbackState.status,
play,
position: playbackState.position,
});
}
for (const [playerId, lastPlay] of Array.from(this.lastPlayByPlayer.entries())) {
if (seenPlayerIds.has(playerId)) {
continue;
}
if (this.shouldKeepAliveSynthetic(playerId, nowMs)) {
const bridgeDataForKeepAlive = this.lastBridgeDataByPlayer.get(playerId)!;
const playbackState = this.getPlaybackState(playerId, bridgeDataForKeepAlive, lastPlay);
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? lastPlay.data.duration ?? 0;
this.logger.info(`Synthetic keepalive exceeded track duration for player ${playerId} '${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState(playerId);
this.removePlayerState(playerId);
if (stoppedState !== undefined) {
states.push(stoppedState);
}
continue;
}
this.logger.trace(`Bridge returned no current track for player ${playerId}; keeping synthetic playback alive for ${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}`);
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.playing,
play: lastPlay,
position: playbackState.position,
});
continue;
}
this.removePlayerState(playerId);
}
return await this.processRecentPlays(states);
}
}
const formatPlayObj = (obj: BridgeTrackData, playerId: string): PlayObject => {
const artists = Array.isArray(obj.artists_list) && obj.artists_list.length > 0
? obj.artists_list
: (obj.artists ? obj.artists.split(/\s*,\s*/).filter(x => x.trim() !== '') : []);
const play: PlayObjectLifecycleless = {
data: {
artists,
album: obj.album ?? undefined,
track: obj.title ?? undefined,
duration: obj.duration_ms !== undefined && obj.duration_ms !== null
? obj.duration_ms / 1000
: undefined,
},
meta: {
trackProgressPosition: getSafePositionSec(obj),
deviceId: playerId || NO_DEVICE,
mediaPlayerName: obj.device_name ?? (obj.source === 'station-local' ? 'Yandex Station' : 'Yandex Music'),
mediaPlayerVersion: obj.platform ?? (obj.source === 'station-local' ? 'station-local' : 'bridge'),
comment: obj.track_id !== undefined ? `Yandex Track ${obj.track_id}` : undefined,
art: obj.cover !== undefined && obj.cover !== null && obj.cover !== '' ? { album: obj.cover } : undefined,
}
}
return baseFormatPlayObj(obj, play);
}
@@ -0,0 +1,54 @@
import chai from 'chai';
import asPromised from 'chai-as-promised';
import { describe, it } from 'mocha';
import { sleep } from "../../utils.js";
import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever } from 'abort-controller-x';
chai.should();
chai.use(asPromised);
const expect = chai.expect;
describe('#Abortable', function () {
it('Executes defer on non-abort error', async function () {
const controller = new AbortController();
let didUseDefer = false;
try {
await spawn(controller.signal, async (signal, { defer }) => {
defer(async () => {
didUseDefer = true;
});
await delay(signal, 100);
throw new Error('Not an abort signal');
})
} catch (e) {
rethrowAbortError(e);
}
expect(didUseDefer).is.true;
});
it('Executes defer on abort error', async function () {
const controller = new AbortController();
let didUseDefer = false;
const spawnPromise = spawn(controller.signal, async (signal, { defer }) => {
defer(async () => {
didUseDefer = true;
});
await forever(signal);
}).catch(catchAbortError);
await sleep(50);
controller.abort();
await spawnPromise.should.be.fulfilled;
expect(didUseDefer).is.true;
});
});
@@ -525,7 +525,7 @@ describe('Upstream Scrobbles', function() {
const play = generatePlay({playDate: dayjs().subtract(60, 's')});
await scrobbler.queueScrobble(play, 'test');
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling().then(() => null);
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
scrobbler.tryStopScrobbling().then(() => null);
expect(sp.called).is.true;
@@ -545,7 +545,7 @@ describe('Upstream Scrobbles', function() {
const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
await scrobbler.queueScrobble([play1, play2], 'test');
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling().then(() => null);
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
scrobbler.tryStopScrobbling().then(() => null);
expect(sp.callCount).to.eq(1);
@@ -566,7 +566,7 @@ describe('Upstream Scrobbles', function() {
const play3 = generatePlay({playDate: dayjs().subtract(DEFAULT_CONSOLIDATE_DURATION.add(4, 'm'))});
await scrobbler.queueScrobble([play1, play2, play3], 'test');
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling().then(() => null);
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
scrobbler.tryStopScrobbling().then(() => null);
expect(sp.callCount).to.eq(2);
@@ -586,7 +586,7 @@ describe('Upstream Scrobbles', function() {
const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
await scrobbler.queueScrobble([play1], 'test');
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling().then(() => null);
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
expect(sp.calledOnce).is.true;
+4 -1
View File
@@ -1,3 +1,4 @@
import { isAbortError } from "abort-controller-x";
import { truncateStringToLength } from "../../core/StringUtils.js";
/**
@@ -116,4 +117,6 @@ export const messageWithCausesTruncated = (length: number) => {
return (err: Error) => messageWithCauses(err, t);
}
export const messageWithCausesTruncatedDefault = messageWithCausesTruncated(100);
export const messageWithCausesTruncatedDefault = messageWithCausesTruncated(100);
export const isAbortReasonErrorLike = (signal: AbortSignal) => signal.aborted && signal.reason !== undefined && (isAbortError(signal.reason) || signal.reason instanceof Error);
+2 -3
View File
@@ -6,9 +6,8 @@ import { ErrorAlert } from "./ErrorAlert";
import { IoMdCodeDownload } from "react-icons/io";
import { BiWrench } from "react-icons/bi";
import { IoMusicalNoteOutline } from "react-icons/io5";
import { PiMagnifyingGlassDuotone } from "react-icons/pi";
import { HiMiniMagnifyingGlass } from "react-icons/hi2";
import { TbDatabaseEdit } from "react-icons/tb";
import { MdFiberNew } from "react-icons/md";
import { capitalize } from "../../core/StringUtils";
import { shortTodayAwareFormat, todayAwareFormat } from "../../core/TimeUtils";
import dayjs from "dayjs";
@@ -156,7 +155,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => {
<Timeline.Separator />
<Timeline.Indicator>
<Icon fontSize="lg" color={`${match.match ? 'orange' : 'green'}.focusRing`}>
<PiMagnifyingGlassDuotone />
<HiMiniMagnifyingGlass />
</Icon>
</Timeline.Indicator>
</Timeline.Connector>

Some files were not shown because too many files have changed in this diff Show More