mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c78da7985 | ||
|
|
457279465c | ||
|
|
d551c8563f | ||
|
|
75bd4a04d6 | ||
|
|
339232c368 | ||
|
|
8741ff25ea | ||
|
|
8a952cb576 | ||
|
|
eeb55aa02e | ||
|
|
6a81fc1be2 | ||
|
|
a47e2a6242 | ||
|
|
dadaa6df35 | ||
|
|
fb19191209 | ||
|
|
2f53a60da1 | ||
|
|
ab2f397fab | ||
|
|
db2dd8fb8b | ||
|
|
135439f509 | ||
|
|
d743b849fe | ||
|
|
9885768cad | ||
|
|
c6e6bbb87b | ||
|
|
bbc2351ebc | ||
|
|
b547db9045 | ||
|
|
28c2e9873a | ||
|
|
6bcd3f9516 | ||
|
|
11b2632d02 | ||
|
|
fb6dbe307d | ||
|
|
2474b792c3 | ||
|
|
3007c949a0 | ||
|
|
63533534df | ||
|
|
5c03062976 | ||
|
|
dbe41fa5ab | ||
|
|
5454f817e5 | ||
|
|
8d8f8260b5 | ||
|
|
62bb00571f | ||
|
|
c10537d658 | ||
|
|
e9ccc319e9 | ||
|
|
54803c6ef9 | ||
|
|
a3d37a864a | ||
|
|
394570e5a3 | ||
|
|
2bbfdf424f | ||
|
|
9785ad6ba7 | ||
|
|
fe7d77c03d | ||
|
|
0e7aa92f5b | ||
|
|
2ff80283b9 | ||
|
|
4f34b1a055 | ||
|
|
4effdd6df5 | ||
|
|
af3ce9dea7 | ||
|
|
f0c5cb524f | ||
|
|
46883384b6 | ||
|
|
7bc5ab3d0f | ||
|
|
fd15d15124 | ||
|
|
14da0795f1 | ||
|
|
3faf0ee7f9 | ||
|
|
78ddb15dcf | ||
|
|
760373c227 | ||
|
|
6bb7b17e97 | ||
|
|
231156d452 | ||
|
|
d85628b4c9 | ||
|
|
db0e74aec3 | ||
|
|
a918a96d34 | ||
|
|
bb51915085 | ||
|
|
7c177dfe03 | ||
|
|
cf5e7ca1fe | ||
|
|
fd43ff86c8 | ||
|
|
194a0e78ff | ||
|
|
04027e87e7 | ||
|
|
a998060bca | ||
|
|
5b16e8857e | ||
|
|
c53ce7c517 | ||
|
|
7897dc9503 | ||
|
|
160ddac3e4 | ||
|
|
59b4f27294 | ||
|
|
21f42bf45b | ||
|
|
3e8f170f9a | ||
|
|
15fbed09a5 | ||
|
|
52b301e729 | ||
|
|
0bef197521 | ||
|
|
f16750609a | ||
|
|
c39ef29597 | ||
|
|
72639616b7 | ||
|
|
effe870104 | ||
|
|
ee753389f7 | ||
|
|
5f4a5cb815 | ||
|
|
0607aedf04 | ||
|
|
1f165096b3 | ||
|
|
fb6f6a18c8 | ||
|
|
03f8b20704 |
@@ -13,6 +13,7 @@ config/*.json
|
||||
config/*.bak
|
||||
config/*.p8
|
||||
/docs
|
||||
dist
|
||||
/logs
|
||||
.flatpak-builder
|
||||
**/generated-sources.*
|
||||
|
||||
@@ -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"
|
||||
@@ -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 }}\"
|
||||
}
|
||||
}"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -151,3 +151,4 @@ tmp-*
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
lib
|
||||
+24
-4
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/FoxxMD/multi-scrobbler/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
[](https://foxxmd.github.io/multi-scrobbler/)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "Yandex Music",
|
||||
"type": "ymbridge",
|
||||
"data": {
|
||||
"url": "http://192.168.1.161:9980",
|
||||
"apiKey": "change-me",
|
||||
"interval": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -4,6 +4,8 @@ services:
|
||||
multi-scrobbler:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
BUILD_DATE: "${BUILD_DATE}"
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 3
|
||||
title: Client Development/Tutorial
|
||||
---
|
||||
|
||||
To do...
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
---
|
||||
title: 'Help'
|
||||
title: Help
|
||||
---
|
||||
|
||||
## Getting Useful Data
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
title: Overview
|
||||
description: Installation
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
---
|
||||
title: 'Quickstart'
|
||||
title: Quickstart
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
description: Updating
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Generated
+3878
-1413
File diff suppressed because it is too large
Load Diff
+19
-12
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
Executable
+18
@@ -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,4 +1,4 @@
|
||||
Sitemap: https://foxxmd.github.io/multi-scrobbler/sitemap.xml
|
||||
Sitemap: https://docs.multi-scrobbler.app/sitemap.xml
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
Generated
+1081
-520
File diff suppressed because it is too large
Load Diff
+34
-34
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user