mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b27890254 | ||
|
|
8747c8375e | ||
|
|
50f27c110c | ||
|
|
c1f8997c01 | ||
|
|
12ed767961 | ||
|
|
335808217b | ||
|
|
b5bb7024fc | ||
|
|
ac2806dc66 | ||
|
|
9d149dab49 | ||
|
|
451404c0f9 | ||
|
|
a3f1f077ce | ||
|
|
dcd13833ae | ||
|
|
78b732b75c | ||
|
|
3152933937 | ||
|
|
6c6126bb27 | ||
|
|
ad13b7687d | ||
|
|
633e366e82 | ||
|
|
69b1786859 | ||
|
|
41839a961a | ||
|
|
ccb50c64bb | ||
|
|
d88ac1b424 | ||
|
|
c807611e55 | ||
|
|
155ec1403e | ||
|
|
ecde657c3e | ||
|
|
6aa8374f25 | ||
|
|
c2f01473fd | ||
|
|
41e607c995 | ||
|
|
fee754f15f | ||
|
|
d62d18232d | ||
|
|
a1acab0fed | ||
|
|
d39a7099c7 | ||
|
|
7b2c4f99f3 | ||
|
|
b941ff08dc | ||
|
|
210a74c75f | ||
|
|
eafce71cd0 | ||
|
|
d5777b501d | ||
|
|
dd60e5eb21 | ||
|
|
c57dbf23e1 | ||
|
|
cb52adb4de | ||
|
|
a75b0fc549 | ||
|
|
1a546b72d2 | ||
|
|
fd13d268d0 | ||
|
|
5837fc11a5 | ||
|
|
efdbbe40e8 | ||
|
|
5103cf1c12 | ||
|
|
e855a3de7d | ||
|
|
b90e38d595 | ||
|
|
1864f39be0 | ||
|
|
89e80b6f8b | ||
|
|
564295ee64 | ||
|
|
61c7f26007 | ||
|
|
54b9ece5b6 | ||
|
|
335136561a | ||
|
|
56f1cacd07 | ||
|
|
558e358d0b | ||
|
|
290e6eecc4 | ||
|
|
f512f55782 | ||
|
|
ad6eba58d2 | ||
|
|
b675aee8ff | ||
|
|
c29ea9c89c | ||
|
|
50016a31a8 | ||
|
|
1cecffc30f | ||
|
|
833273c19d | ||
|
|
35603d0caf | ||
|
|
de1377afdd | ||
|
|
a22ddd10cc | ||
|
|
280add7107 | ||
|
|
b34abcab56 | ||
|
|
788b26949e | ||
|
|
c11da3df3d | ||
|
|
0d68fea0eb | ||
|
|
c98672bcef | ||
|
|
bb0c079757 | ||
|
|
891144546e | ||
|
|
86611d764d | ||
|
|
e604268004 | ||
|
|
396c3da277 | ||
|
|
0591b90089 | ||
|
|
6763be53ac | ||
|
|
aee23f7550 | ||
|
|
57b5528d43 | ||
|
|
bd8cf18a1a | ||
|
|
84acdd901a | ||
|
|
e213a95996 | ||
|
|
78cd2eecf9 | ||
|
|
ebfeea1087 | ||
|
|
ddca623792 | ||
|
|
f0da35d115 | ||
|
|
10eb355f92 | ||
|
|
85702c534a | ||
|
|
e2dfe317a9 | ||
|
|
7008ae8672 | ||
|
|
10d9fa3581 | ||
|
|
2b85927739 | ||
|
|
8c402238ed | ||
|
|
758770d278 | ||
|
|
ee7ebb159b | ||
|
|
f47a66060f | ||
|
|
8929a630ed | ||
|
|
077f5d7547 | ||
|
|
369ad42946 | ||
|
|
4bd996eb46 | ||
|
|
a6ddc2b9f5 | ||
|
|
afb2e76b2d | ||
|
|
47812546e3 | ||
|
|
5ab6b85b30 | ||
|
|
9709d6348a | ||
|
|
10fea9b23d | ||
|
|
051dd5d128 | ||
|
|
369e18b1c2 | ||
|
|
fd49d17de4 | ||
|
|
e0f034fa99 | ||
|
|
80bc629ac2 | ||
|
|
1a90c23e76 | ||
|
|
48ddd4272f | ||
|
|
ea408ac7a4 | ||
|
|
79c48af3e9 | ||
|
|
12c2c0a132 | ||
|
|
78abfc2168 | ||
|
|
0f721b94c2 | ||
|
|
f126bf0ec0 | ||
|
|
064a435c79 | ||
|
|
c0e5bb872a | ||
|
|
e9561b5783 | ||
|
|
77c3a2f0c7 | ||
|
|
db4e64dc2a | ||
|
|
fcc05a8b19 | ||
|
|
c37d94e71b | ||
|
|
aa59bc4607 | ||
|
|
a993df4f0b | ||
|
|
7e83e0f085 | ||
|
|
5b2449adb0 | ||
|
|
5811912745 | ||
|
|
b7d6e9e497 | ||
|
|
5fd0cc3fe4 | ||
|
|
6e2e6f4492 | ||
|
|
ed6f7cfe5f | ||
|
|
6b17babd7a | ||
|
|
c77e4355fc | ||
|
|
25de65f41f | ||
|
|
af037ee215 | ||
|
|
39c4764b40 | ||
|
|
ec28d2aeb5 | ||
|
|
baf8635587 | ||
|
|
a3839a28a2 | ||
|
|
4247f545c5 | ||
|
|
c54c3ff5de | ||
|
|
1d84f07a40 | ||
|
|
b47c80dc11 | ||
|
|
75e2b2b337 | ||
|
|
08ca7fff35 | ||
|
|
3140df28b7 | ||
|
|
14546b8d74 | ||
|
|
341e896eca | ||
|
|
b3b4636e7f | ||
|
|
43f5aeb5dc | ||
|
|
d38aabf7b8 | ||
|
|
c64b8d16e6 | ||
|
|
1938fc3a27 | ||
|
|
36181396c7 | ||
|
|
bb76a3b214 | ||
|
|
339bb7fcd2 | ||
|
|
3a25057d05 | ||
|
|
14ab6032f5 | ||
|
|
3e1f785437 | ||
|
|
91bb9d6089 | ||
|
|
9cf373e9f4 | ||
|
|
9ec22e9000 | ||
|
|
9616860e23 | ||
|
|
0f6638dfdf | ||
|
|
fe9f49779c | ||
|
|
3aab6a207b | ||
|
|
49e9fd4b3b | ||
|
|
0143ae62aa | ||
|
|
2882f65b69 | ||
|
|
62e9f7f620 | ||
|
|
57f2ebf2b5 | ||
|
|
884134cab9 | ||
|
|
0c90de78ab | ||
|
|
1c5530ee2a | ||
|
|
fbb67a8fe6 | ||
|
|
c8c5f5dcb7 | ||
|
|
eb449eeca6 | ||
|
|
7cfdb10813 | ||
|
|
be599b5562 | ||
|
|
07d97a4937 | ||
|
|
7037d6f833 | ||
|
|
2deca322e7 | ||
|
|
ab84cb396d | ||
|
|
bb63c61ca8 | ||
|
|
f4256d1084 | ||
|
|
fc87b86f52 | ||
|
|
5dff617ed6 | ||
|
|
f2a171c596 | ||
|
|
336970c1f7 | ||
|
|
d04bc038d4 | ||
|
|
1ab12355f3 | ||
|
|
fb5d9fb7b0 | ||
|
|
9aae57f966 | ||
|
|
fefc82c521 | ||
|
|
43ebdcb10c | ||
|
|
adfb166320 | ||
|
|
1c54ae6465 | ||
|
|
9309f7d8cf | ||
|
|
8717fad08a | ||
|
|
983b4c7417 | ||
|
|
a663e9af14 | ||
|
|
249a606bf5 | ||
|
|
0ec8d8814d | ||
|
|
f0cee72f43 | ||
|
|
a82aafa6b8 | ||
|
|
3b624205ad | ||
|
|
c6d38e40d0 | ||
|
|
1ca7834feb | ||
|
|
77c9977d27 | ||
|
|
521130562d | ||
|
|
9512ca7e84 | ||
|
|
159901f79e | ||
|
|
9758568ded | ||
|
|
a953f83d07 | ||
|
|
2bbe2e6ba0 | ||
|
|
ec4ab8c078 | ||
|
|
7885c4ca9c | ||
|
|
8371ec3a01 | ||
|
|
43cbadcd2d | ||
|
|
6e1e2c3534 | ||
|
|
a46389e745 | ||
|
|
ce5f8d5fca | ||
|
|
bdbbce4cb0 | ||
|
|
034cb321ef | ||
|
|
ac2d453aa2 | ||
|
|
860d2d953f | ||
|
|
a486102747 | ||
|
|
735264cb2b | ||
|
|
7dece447df | ||
|
|
3fb5d6aae9 | ||
|
|
d5dfc33fce | ||
|
|
6dc40e50ad | ||
|
|
b8ebd8ca7e | ||
|
|
e9eb7d357f | ||
|
|
3f2cb8ac0c | ||
|
|
8e36c27ec9 | ||
|
|
fa90a3b821 | ||
|
|
2d2319fa27 | ||
|
|
ba161b7b92 | ||
|
|
d438c8b256 | ||
|
|
bbb4555c0e | ||
|
|
6e8ea96f32 | ||
|
|
b06de77f42 | ||
|
|
3aa8279914 | ||
|
|
a64724e114 | ||
|
|
052234a3d9 | ||
|
|
aa2c565afd | ||
|
|
d0fd225d99 | ||
|
|
c40acdd7c7 | ||
|
|
590610022d | ||
|
|
f7dae32f50 | ||
|
|
3cbbc0837f | ||
|
|
0197e70a1a | ||
|
|
40a7d9e398 | ||
|
|
c7b2dcbbbe | ||
|
|
a524e18820 | ||
|
|
81894d691b | ||
|
|
0fb192dd41 | ||
|
|
3f608c6c8e | ||
|
|
dcff9ee73d | ||
|
|
f1cd66d3e8 | ||
|
|
d95fef09a3 | ||
|
|
d1060e724e | ||
|
|
a7faaaac86 | ||
|
|
3762f95bae | ||
|
|
b9f04deb28 | ||
|
|
9cc820c76e | ||
|
|
f7195fb40c | ||
|
|
9c0f029992 | ||
|
|
f370b8ad1e | ||
|
|
7c962ec43a | ||
|
|
2013a6692e | ||
|
|
4a5eb91f16 |
@@ -10,6 +10,7 @@ Dockerfile
|
||||
.idea
|
||||
config/*.json
|
||||
config/*.bak
|
||||
config/*.p8
|
||||
/docs
|
||||
/logs
|
||||
.flatpak-builder
|
||||
@@ -20,3 +21,4 @@ docsite/node_modules
|
||||
docsite/.docusaurus
|
||||
docsite/.cache-loader
|
||||
build
|
||||
*.secrets
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
## Checklist before requesting a review
|
||||
|
||||
- [ ] **I am opening this PR for the [`develop` branch](https://github.com/FoxxMD/multi-scrobbler/tree/develop) and NOT `master`.**
|
||||
- [ ] I have read the [contributing guidelines.](../CONTRIBUTING.md)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Describe your changes
|
||||
|
||||
|
||||
|
||||
## Issue number and link, if applicable
|
||||
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,24 +15,36 @@ jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile
|
||||
suffix: ''
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
- dockerfile: ./debian.Dockerfile
|
||||
suffix: '-debian'
|
||||
# can't build arm64 due to a TLS issue when running npm install??
|
||||
# https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
platforms: 'linux/amd64'
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -40,30 +52,32 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}},suffix=${{ matrix.suffix }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
|
||||
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platforms }}
|
||||
|
||||
@@ -125,8 +125,10 @@ src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
*.p8
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
*.secrets
|
||||
|
||||
build
|
||||
!setupProxy.js
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"require": ["./register.js", "source-map-support/register"],
|
||||
"reporter": "dot"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Creating a Pull Request
|
||||
|
||||
Please follow these guidelines when contributing code to this repository:
|
||||
|
||||
* The PR **must be for the [`develop` branch.](https://github.com/FoxxMD/multi-scrobbler/tree/develop)** The `master` branch is for releases only.
|
||||
* Use [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format when creating commits.
|
||||
* Preferably, please use a [feature branch](https://stackoverflow.com/a/39586780/1469797) instead of committing directly to `develop`.
|
||||
* Ensure that if your code is covered by [an existing test](./src/backend/tests) that you have updated the test accordingly
|
||||
+10
-7
@@ -1,4 +1,4 @@
|
||||
FROM lsiobase/alpine:3.17 as base
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
@@ -6,6 +6,8 @@ RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
alpine-base \
|
||||
avahi \
|
||||
avahi-tools \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
@@ -24,7 +26,8 @@ ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
RUN npm install -g patch-package
|
||||
RUN npm install -g patch-package \
|
||||
&& chown -R root:root /usr/local/lib/node_modules/patch-package
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -39,7 +42,8 @@ COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
|
||||
RUN npm install
|
||||
RUN npm install \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
@@ -55,7 +59,8 @@ FROM base as app
|
||||
#COPY --chown=abc:abc package.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/build /app/build
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
|
||||
@@ -72,9 +77,7 @@ ENV IS_DOCKER=true
|
||||
|
||||
RUN npm install --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown abc:abc node_modules \
|
||||
&& rm -rf node_modules/ts-node \
|
||||
&& rm -rf node_modules/typescript \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
ARG webPort=9078
|
||||
|
||||
@@ -12,8 +12,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
|
||||
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
|
||||
* [WebScrobbler](/docsite/docs/configuration/configuration.md#webscrobbler)
|
||||
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz--source-)
|
||||
@@ -22,6 +23,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Mopidy](/docsite/docs/configuration/configuration.md#mopidy)
|
||||
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
|
||||
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
|
||||
* [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
|
||||
@@ -29,6 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
|
||||
* 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
|
||||
* Install using [Docker images for x86/ARM](/docsite/docs/installation/installation.md#docker), [flatpak](/docsite/docs/installation/installation.md#flatpak), or [locally with NodeJS](/docsite/docs/installation/installation.md#nodejs)
|
||||
@@ -44,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#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.
|
||||
|
||||
<img src="/assets/status-ui.jpg" width="800">
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
@@ -77,7 +77,7 @@ export default function transformer(
|
||||
if(hasNoExt) {
|
||||
const dirFiles = fs.readdirSync(dir)
|
||||
filenameFromDir = dirFiles.find(x => path.parse(x).name === pathInfo.name);
|
||||
if(filenameFromDir !== undefined) {
|
||||
if(filenameFromDir === undefined) {
|
||||
fileExists = false;
|
||||
isRelative = false;
|
||||
} else {
|
||||
@@ -159,7 +159,7 @@ export default function transformer(
|
||||
}
|
||||
break;
|
||||
default:
|
||||
transformedImport = combineDirFile(pathInfo.dir, `.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
|
||||
transformedImport = combineDirFile(pathInfo.dir, `${path.parse(filenameFromDir).name}.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"data": {
|
||||
"blacklistedDevices": [],
|
||||
"whitelistedDevices": [],
|
||||
"blacklistedApps": [],
|
||||
"whitelistedApps": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -21,6 +21,7 @@
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
"enable": true,
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
@@ -33,6 +34,7 @@
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"enable": true,
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJriver",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "0.0.0.0",
|
||||
"username": "auser",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyKodi",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:8080/jsonrpc",
|
||||
"username": "myUser",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "brainz",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "myMaloja",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyMopidy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "localhost",
|
||||
"uriBlacklist": [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"whitelist": ["vlc", "mpd"],
|
||||
"blacklist": ["spotify"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MySpotify",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
"password": "yourPassword",
|
||||
"password": "yourPassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyTautuilli",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyWebScrobbler",
|
||||
"data": {
|
||||
"slug": null,
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyYTMusic",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#FROM ghcr.io/linuxserver/baseimage-ubuntu:jammy as base
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
ENV NODE_VERSION 18.19.0
|
||||
|
||||
# borrowing openssl header removal trick from offical docker-node
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
|
||||
RUN \
|
||||
ARCH= OPENSSL_ARCH= && dpkgArch="$(dpkg --print-architecture)" \
|
||||
&& case "${dpkgArch##*-}" in \
|
||||
amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \
|
||||
ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \
|
||||
s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \
|
||||
arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \
|
||||
armhf) ARCH='armv7l' OPENSSL_ARCH='linux-armv4';; \
|
||||
i386) ARCH='x86' OPENSSL_ARCH='linux-elf';; \
|
||||
*) echo "unsupported architecture"; exit 1 ;; \
|
||||
esac && \
|
||||
set -ex && \
|
||||
echo "**** install build packages ****" && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
#ca-certificates \
|
||||
xz-utils \
|
||||
curl && \
|
||||
echo "**** Fetch and install node****" && \
|
||||
# get node/npm directly from nodejs dist \
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L41
|
||||
curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr --strip-components=1 --no-same-owner && \
|
||||
rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
ln -s /usr/bin/node /usr/bin/nodejs && \
|
||||
# curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
|
||||
# apt-get install --no-install-recommends -y nodejs && \
|
||||
npm update -g npm && \
|
||||
echo "**** cleanup ****" && \
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L49
|
||||
# Remove unused OpenSSL headers to save ~34MB
|
||||
# (does not affect arm64 issue below)
|
||||
find /usr/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && \
|
||||
apt-get purge --auto-remove -y perl xz-utils && \
|
||||
apt-get autoclean && \
|
||||
apt-get autoremove && \
|
||||
rm -rf \
|
||||
/config/.cache \
|
||||
/root/cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/* \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root /
|
||||
|
||||
RUN npm install -g \
|
||||
patch-package \
|
||||
&& chown -R root:root /usr/lib/node_modules/patch-package
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
# npm config set fetch-retry-mintimeout 5000 && \
|
||||
# npm config set fetch-retry-maxtimeout 5000
|
||||
|
||||
# https://www.npmjs.com/package/tls-test
|
||||
# used to test that the OS supports downloading packages over HTTPS with TLS 1.2 enforced
|
||||
# -- this always succeeds but a good sanity check
|
||||
#RUN npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
|
||||
|
||||
# This FAILS when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
|
||||
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
RUN npm ci \
|
||||
--verbose \
|
||||
# --no-audit \
|
||||
&& chown -R root:root node_modules
|
||||
# running with --no-audit does not have any affect even though the error only logs with prefix 'npm verb audit error'
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build && rm -rf node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=base /usr/bin /usr/bin
|
||||
COPY --from=base /usr/lib /usr/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types \
|
||||
/root/.cache
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
@@ -13,6 +13,10 @@ services:
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- /path_on_host/multi-scrobbler-config:/config
|
||||
#networks:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
# NODE_ARGS can be passed by ENV in docker command like "docker run foxxmd/multi-scrobbler -e NODE_ARGS=--optimize_for_size"
|
||||
echo -e "\nmulti-scrobbler is starting!"
|
||||
export NODE_ENV=production
|
||||
cd /app || exit
|
||||
exec \
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/build/server.js
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/node_modules/.bin/tsx /app/src/backend/index.ts
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<!-- TOC -->
|
||||
* [Connection Issues](#connection-issues)
|
||||
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
|
||||
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
|
||||
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
|
||||
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
|
||||
* [Sporadic network problems on docker](#sporadic-network-problems-on-docker)
|
||||
* [Configuration Issues](#configuration-issues)
|
||||
* [Config could not be parsed](#config-could-not-be-parsed)
|
||||
* [Scrobbling Issues](#scrobbling-issues)
|
||||
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
* [Google Cast track information is missing/incorrect or MS player has weird times](#google-cast-track-information-is-missingincorrect-or-ms-player-has-weird-times)
|
||||
* [Google Cast device does not track media](#google-cast-device-does-not-track-media)
|
||||
<!-- TOC -->
|
||||
|
||||
# Connection Issues
|
||||
|
||||
@@ -95,6 +101,22 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
## Jellyfin has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
```
|
||||
[API] Jellyfin is not sending a request with valid headers...
|
||||
```
|
||||
|
||||
A workaround that may fix this:
|
||||
|
||||
* In Webhook settings:
|
||||
* [In the webhook you have already configured...](configuration/configuration.md#jellyfin)
|
||||
* Add Request Header...
|
||||
* **Key:** `Content-Type`
|
||||
* **Value:** `application/json`
|
||||
* Then Save
|
||||
|
||||
## Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
@@ -109,6 +131,16 @@ If multi-scrobbler is not running on the same machine your browser is on then th
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
## Sporadic network problems on docker
|
||||
|
||||
If you encounter networking issues like:
|
||||
|
||||
* sporadic timeouts (`ETIMEDOUT`) without a pattern
|
||||
* DNS errors (`EAI_AGAIN`) that do no occur consistently
|
||||
* Failures to reach a host that was previously fine (`EHOSTUNREACH`)
|
||||
|
||||
there may be an issue with the underlying docker image OS (alpine) that may be solved by switching to a different image. Try switching to a `*-debian` variant tag (only available for ARM hosts) to see if this resolves your issue. IE `multi-scrobbler:latest-debian` or `multi-scrobbler:develop-debian`
|
||||
|
||||
# Configuration Issues
|
||||
|
||||
## Config could not be parsed
|
||||
@@ -134,3 +166,27 @@ Multi-scrobbler works the same was the official Spotify-Last.fm integration work
|
||||
## Jellyfin does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
|
||||
## Google Cast track information is missing/incorrect or MS player has weird times
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
|
||||
To diagnose these issues you [**must enable payload logging**](configuration/configuration.md#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
|
||||
## Google Cast device does not track media
|
||||
|
||||
It is likely the app playing on the cast device is incorrectly reporting the media type as **not music**.
|
||||
|
||||
MS logs will tell you what type the media is reported as with lines like:
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
|
||||
```
|
||||
|
||||
Refer to [Allow Unknown Media Type](configuration/configuration.md#allow-unknown-media-type) section to fix this
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'movie' media type so will not track
|
||||
```
|
||||
|
||||
Refer to [Force Media Tracking](configuration/configuration.md#forcing-media-tracking) section to fix this
|
||||
|
||||
@@ -5,11 +5,14 @@ title: Overview
|
||||
|
||||
# Configuration
|
||||
|
||||
<!-- TOC -->
|
||||
* [Overview](#overview)
|
||||
* [ENV-Based Configuration](#env-based-configuration)
|
||||
* [File-Based Configuration](#file-based-configuration)
|
||||
* [All-in-One File Configuration](#all-in-one-file-configuration)
|
||||
* [Specific File Configuration](#specific-file-configuration)
|
||||
* [Application Options](#application-options)
|
||||
* [Base URL](#base-url)
|
||||
* [Source Configurations](#source-configurations)
|
||||
* [Spotify](#spotify)
|
||||
* [Plex](#plex)
|
||||
@@ -20,17 +23,26 @@ title: Overview
|
||||
* [Listenbrainz (Source)](#listenbrainz--source-)
|
||||
* [Deezer](#deezer)
|
||||
* [Youtube Music](#youtube-music)
|
||||
* [MPRIS (Linux Desktop)](#mpris)
|
||||
* [MPRIS](#mpris)
|
||||
* [Mopidy](#mopidy)
|
||||
* [JRiver](#jriver)
|
||||
* [Kodi](#kodi)
|
||||
* [WebScrobbler](#webscrobbler)
|
||||
* [Multiple Users](#multiple-users)
|
||||
* [Google Cast (Chromecast)](#google-cast--chromecast-)
|
||||
* [Connecting Devices](#connecting-devices)
|
||||
* [What Media Does MS Scrobble?](#what-media-does-ms-scrobble)
|
||||
* [Cast Troubleshooting](#cast-troubleshooting)
|
||||
* [Client Configurations](#client-configurations)
|
||||
* [Maloja](#maloja)
|
||||
* [Last.fm](#lastfm)
|
||||
* [Listenbrainz](#listenbrainz)
|
||||
* [Monitoring](#monitoring)
|
||||
* [Webhooks](#webhook-configurations)
|
||||
* [Webhook Configurations](#webhook-configurations)
|
||||
* [Gotify](#gotify)
|
||||
* [Ntfy](#ntfy)
|
||||
* [Health Endpoint](#health-endpoint)
|
||||
<!-- TOC -->
|
||||
|
||||
# Overview
|
||||
|
||||
@@ -80,7 +92,7 @@ There is also a [**kitchensink example**](kitchensink.md) that provides examples
|
||||
|
||||
### All-in-One File Configuration
|
||||
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
Example directory structure:
|
||||
|
||||
@@ -168,6 +180,21 @@ Example directory structure:
|
||||
|
||||
See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
|
||||
|
||||
# Application Options
|
||||
|
||||
These options affect multi-scrobbler's behavior and are not specific to any source/client.
|
||||
|
||||
#### Base URL
|
||||
|
||||
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
|
||||
|
||||
* Default => `http://localhost`
|
||||
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
|
||||
|
||||
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
|
||||
|
||||
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
|
||||
|
||||
# Source Configurations
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
@@ -175,6 +202,8 @@ See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config)
|
||||
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a
|
||||
Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
|
||||
|
||||
**NOTE:** If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-is-Automix/ta-p/5257278) enabled and Spotify uses it for your playlist/queue then MS cannot accurately determine when a track will end. This is because the track is "mixed" in your queue with a shorter play time than its actual length and [Spotify does not report this modified play time in its API.](https://community.spotify.com/t5/Spotify-for-Developers/Wrong-duration-ms-of-track-with-Automix/m-p/5429147) This **does not affect MS's ability to scrobble** from Spotify but it will affect the accuracy of the duration MS reports was played.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
@@ -185,7 +214,7 @@ Client ID/Secret. Make sure to also whitelist your redirect URI in the applicati
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Plex](https://plex.tv)
|
||||
|
||||
@@ -199,7 +228,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Tautulli](https://tautulli.com)
|
||||
|
||||
@@ -213,11 +242,11 @@ Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Subsonic](http://www.subsonic.org/)
|
||||
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
|
||||
**Known Issues:**
|
||||
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
|
||||
@@ -236,7 +265,7 @@ Can use this source for any application that implements the [Subsonic API](http:
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
@@ -255,6 +284,8 @@ Must be using Jellyfin 10.7 or greater
|
||||
* Check `Send All Properties`
|
||||
* Save
|
||||
|
||||
If you see errors in the MS logs regarding `missing headers` when using Jellyfin [see this workaround.](../FAQ.md#jellyfin-has-warnings-about-missing-headers)
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
@@ -264,7 +295,7 @@ Must be using Jellyfin 10.7 or greater
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
@@ -276,7 +307,7 @@ No support for ENV based for Last.fm as a client (only source)
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Listenbrainz (Source)](https://listenbrainz.org)
|
||||
|
||||
@@ -288,7 +319,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
**Change `configureAs` to `source`**
|
||||
|
||||
@@ -320,7 +351,7 @@ After starting multi-scrobbler with credentials in-place open the dashboard (`ht
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Youtube Music](https://music.youtube.com)
|
||||
|
||||
@@ -346,7 +377,7 @@ NOTES:
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
|
||||
|
||||
@@ -370,7 +401,7 @@ multi-scrobbler can listen to this interface and scrobble tracks played by **any
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Mopidy](https://mopidy.com/)
|
||||
|
||||
@@ -380,7 +411,7 @@ multi-scrobbler can scrobble tracks played from any Mopidy backend source, regar
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
Configuration Options:
|
||||
|
||||
@@ -537,7 +568,7 @@ MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Kodi](https://kodi.tv/)
|
||||
|
||||
@@ -605,7 +636,195 @@ MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [WebScrobbler](https://web-scrobbler.com/)
|
||||
|
||||
After installing the extension open the preferences/settings for it:
|
||||
|
||||
* Under **Accounts**
|
||||
* **Add Webhook**
|
||||
* API URL: `http://localhost:9078/api/webscrobbler`
|
||||
* Application name: `(whatever you want)`
|
||||
|
||||
Reload the extension after adding the webhook.
|
||||
|
||||
* **On Firefox** - Only FQNs (domain.tld), `localhost`, and `127.0.0.1` are supported for API URL due to [firefox requiring https](https://github.com/web-scrobbler/web-scrobbler/issues/4183#issuecomment-1749222006)
|
||||
* **On Chromium-based Browsers** - Any domain will work for API URL
|
||||
* All Other browsers are untested
|
||||
|
||||
#### Multiple Users
|
||||
|
||||
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](#file-based-configuration)
|
||||
|
||||
Example:
|
||||
|
||||
In `webscrobbler.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "aUserWS",
|
||||
"clients": [
|
||||
"client1Maloja"
|
||||
],
|
||||
"data": {
|
||||
"slug": "usera"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bUserWS",
|
||||
"clients": [
|
||||
"client2Maloja"
|
||||
],
|
||||
"data": {
|
||||
"slug": "userb"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
* To use `aUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/usera`
|
||||
* To use `bUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/userb`
|
||||
|
||||
Note: `http://localhost:9078/api/webscrobbler` is matched with the first source that _that does not have a slug defined._
|
||||
|
||||
##### Connectors Black/Whitelist
|
||||
|
||||
MS can be configured to only scrobble, or NOT scrobble, from some WS connectors. Use the name of the website from the [supported websites](https://web-scrobbler.com/) or from the **Connectors** tab in the extension. Note that this **only** affects MS's behavior and does not affect the general connector behavior you have configured within the WebScrobbler extension.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------------------------------------------|
|
||||
| WS_ENABLE | No | | Set to 'true' to enable WS without needing to define other ENVs |
|
||||
| WS_WHITELIST | No | | Only scrobble from these WebScrobbler Connectors. Comma-delimited list |
|
||||
| WS_BLACKLIST | No | | Do not scrobble from these WebScrobbler Connectors. Comma-delimited list |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/webscrobbler.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FWebScrobblerSourceConfig/%23%2Fdefinitions%2FWebScrobblerData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Google Cast (Chromecast)](https://www.google.com/chromecast/built-in/)
|
||||
|
||||
**NOTE:** Google Cast support is **experimental**. You may experience crashes and errors while using this Source. Please open an issue if you experience problems and include all information detailed in the issue template to help debug your issue.
|
||||
|
||||
If your media device can be **Cast** to using this button  on your phone/computer then multi-scrobbler can monitor it in order to scrobble music you play.
|
||||
|
||||
**Note:** This source relies on common, **basic** music data provided by the cast device which will always be less exhaustive than data parsed from full source integrations. If there is an existing [Source](#source-configurations) it is recommended to configure for it and blacklist the app on Google Cast, rather than relying solely on Google Cast for scrobbling.
|
||||
|
||||
### Connecting Devices
|
||||
|
||||
Cast devices can be manually configured using [File-based configuration](#file-based-14) OR automatically discovered using **mDNS.**
|
||||
|
||||
##### mDNS Discovery
|
||||
|
||||
The host machine running multi-scrobbler must be configured to allow [mDNS traffic on port 5353/UDP](https://book.hacktricks.xyz/network-services-pentesting/5353-udp-multicast-dns-mdns).
|
||||
|
||||
##### Linux
|
||||
|
||||
**Docker**
|
||||
|
||||
The host machine must have [avahi-daemon](https://avahi.org/) running to circumvent limitations with DNS resolution due to musl in Alpine. Most major linux distributions package avahi and many have it built-in. Once avahi is running you must pass D-Bus and the avahi daemon socket to your container like so:
|
||||
|
||||
```
|
||||
docker run ... -v /var/run/dbus:/var/run/dbus -v /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket ... foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
**Flatpak/Nodejs**
|
||||
|
||||
No additional steps are required.
|
||||
|
||||
##### Windows
|
||||
|
||||
**Docker**
|
||||
|
||||
Unsupported at this time.
|
||||
|
||||
**Nodejs**
|
||||
|
||||
No additional steps are required.
|
||||
|
||||
### What Media Does MS Scrobble?
|
||||
|
||||
Cast devices report what type of media the current activity is [(see `metadata` property here)](https://developers.google.com/cast/docs/media/messages#MediaInformation). The reported type is dependent on the application playing the media to correctly report it, the cast device does not magically know what the media is. If an application does not report a type it is always classified as `unknown`.
|
||||
|
||||
**By default, MS will only track media that is reported as `MusicTrack`.**
|
||||
|
||||
#### Allow Unknown Media Type
|
||||
|
||||
Media with an Unknown (`Generic`) media type can be explicitly allowed by setting `"allowUnknownMedia": true` in the [file-based configuration.](#file-based-14) This can also be configured to only allow unknown media types for specific applications by using a list of application names like:
|
||||
|
||||
```json5
|
||||
// in chromecast.json or config.json sources
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
// only allow unknown if app name contains any of these phrases
|
||||
"allowUnknownMedia": ["smarttube", "default media receiver"]
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Forcing Media Tracking
|
||||
|
||||
MS can be forced to track media from an application regardless of media type. This is useful if an application incorrectly reports a media type you are sure should be music. Set `"forceMediaRecognitionOn"` in the [file-based configuration.](#file-based-14) to a list of application names that should always be tracked like:
|
||||
|
||||
```json5
|
||||
// in chromecast.json or config.json sources
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
// media from applications that contains these phrases will always be tracked, regardless of media type reported
|
||||
"forceMediaRecognitionOn": ["smarttube", "default media receiver"]
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
### Cast Troubleshooting
|
||||
|
||||
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
|
||||
|
||||
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue:
|
||||
|
||||
```json5
|
||||
// in chromecast.json or config.json sources
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"type": "chromecast",
|
||||
"data": {
|
||||
//...
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### ENV-Based
|
||||
|
||||
Note: [Manually configuring cast device connections](#connecting-devices) is only available through [File-based config.](#file-based-14)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|--------------------------------------------------------------------------------------|
|
||||
| CC_ENABLE | No | | Set to 'true' to enable Cast monitoring without needing to define other ENVs |
|
||||
| CC_WHITELIST_DEVICES | No | | Only scrobble from these Cast devices. Comma-delimited list. EX mini-home, family-tv |
|
||||
| CC_BLACKLIST_DEVICES | No | | Do not scrobble from these Cast devices. Comma-delimited list |
|
||||
| CC_WHITELIST_APPS | No | | Only scrobble from these casted Apps. Comma-delimited list. EX spotify, pandora |
|
||||
| CC_BLACKLIST_APPS | No | | Do not scrobble from these casted Apps. Comma-delimited list |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`chromecast.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FChromecastSourceConfig/%23%2Fdefinitions%2FChromecastData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
# Client Configurations
|
||||
|
||||
@@ -620,7 +839,7 @@ See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [Last.fm](https://www.last.fm)
|
||||
|
||||
@@ -643,7 +862,7 @@ or replace `localhost:9078` with your own base URL
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [Listenbrainz](https://listenbrainz.org)
|
||||
|
||||
@@ -662,7 +881,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
# Monitoring
|
||||
|
||||
@@ -711,7 +930,7 @@ Webhooks are configured in the main [config.json](#all-in-one-file-configuration
|
||||
|
||||
### [Gotify](https://gotify.net/)
|
||||
|
||||
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
@@ -733,7 +952,7 @@ EX
|
||||
|
||||
### [Ntfy](https://ntfy.sh/)
|
||||
|
||||
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
|
||||
@@ -22,6 +22,25 @@ npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
* The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
@@ -46,6 +65,11 @@ You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
**Note:** Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
|
||||
|
||||
* to use a [Docker](#docker) installation if possible or
|
||||
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
@@ -84,7 +108,7 @@ Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise these will be lost when the container is updated.
|
||||
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
|
||||
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
|
||||
@@ -98,6 +122,20 @@ The default container port is `9078`. To map container to host port:
|
||||
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
|
||||
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
|
||||
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
@@ -118,8 +156,20 @@ To get the UID and GID for the current user run these commands from a terminal:
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
### Network Issues
|
||||
|
||||
If you encounter networking issues like:
|
||||
|
||||
* sporadic timeouts (`ETIMEDOUT`) without a pattern
|
||||
* DNS errors (`EAI_AGAIN`) that do no occur consistently
|
||||
* Failures to reach a host that was previously fine (`EHOSTUNREACH`)
|
||||
|
||||
there may be an issue with the underlying docker image OS (alpine) that may be solved by switching to a different image. Try switching to a `*-debian` variant tag (only available for ARM hosts) to see if this resolves your issue. IE `multi-scrobbler:latest-debian` or `multi-scrobbler:develop-debian`
|
||||
|
||||
## Docker Usage Examples
|
||||
|
||||
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
|
||||
|
||||
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,8 +15,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz--source-)
|
||||
@@ -25,6 +26,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
@@ -32,6 +34,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* 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
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
@@ -47,7 +50,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#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.
|
||||
|
||||
<img src={require('/img/status-ui.jpg').default} width="800"/>
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"baseUrl": "http://localhost",
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
+1
-1
@@ -36,7 +36,7 @@ From MS project root:
|
||||
|
||||
```shell
|
||||
cd flatpak
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
|
||||
```
|
||||
Add `--install --user` to have the app installed immediately.
|
||||
# 5. Run (Locally)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
|
||||
<name>multi-scrobbler</name>
|
||||
<summary>Scrobbles music from many sources to many clients</summary>
|
||||
<url type="homepage">https://github.com/FoxxMD/multi-scrobbler</url>
|
||||
<url type="help">https://github.com/FoxxMD/multi-scrobbler/blob/flatpak-clean/docs/configuration.md</url>
|
||||
<url type="homepage">https://foxxmd.github.io/multi-scrobbler</url>
|
||||
<url type="help">https://foxxmd.github.io/multi-scrobbler/docs/configuration</url>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<description>
|
||||
@@ -20,11 +20,15 @@
|
||||
<li>Plex (Tautulli)</li>
|
||||
<li>Any subsonic-based music player (Airsonic)</li>
|
||||
<li>Jellyfin</li>
|
||||
<li>WebScrobbler</li>
|
||||
<li>Mopidy</li>
|
||||
<li>Deezer</li>
|
||||
<li>JRiver</li>
|
||||
<li>Kodi</li>
|
||||
<li>Webscrobbler</li>
|
||||
<li>Youtube Music</li>
|
||||
<li>MPRIS (linux desktop)</li>
|
||||
<li>Google Cast (Chromecast)</li>
|
||||
</ul>
|
||||
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
|
||||
<ul>
|
||||
@@ -37,10 +41,17 @@
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Browser-based UI</caption>
|
||||
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/7b0f5801a1aa728986f440f4c67fe158cecd763d/assets/status-ui.jpg</image>
|
||||
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/f4256d1084f4bdea2945c03e523af6d0ed89b3ae/assets/status-ui.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.6.5" date="2024-02-05"/>
|
||||
<release version="0.6.4" date="2024-01-29"/>
|
||||
<release version="0.6.3" date="2024-01-10"/>
|
||||
<release version="0.6.2" date="2023-11-29"/>
|
||||
<release version="0.6.1" date="2023-10-24"/>
|
||||
<release version="0.6.0" date="2023-10-04"/>
|
||||
<release version="0.5.2" date="2023-09-21"/>
|
||||
<release version="0.5.0" date="2023-08-29"/>
|
||||
<release version="0.4.6" date="2023-06-07"/>
|
||||
<release version="0.4.4" date="2023-03-20"/>
|
||||
|
||||
@@ -27,7 +27,7 @@ modules:
|
||||
- NODE_ENV=production npm run build
|
||||
|
||||
# makes node and npm available
|
||||
- mkdir -p /app/bin /app/lib /app/lib/build
|
||||
- mkdir -p /app/bin /app/lib /app/lib/dist /app/lib/src /app/lib/assets
|
||||
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
|
||||
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
|
||||
- rm -r /app/lib/node_modules/npm/{docs,man}
|
||||
@@ -36,11 +36,18 @@ modules:
|
||||
- npm prune --production
|
||||
|
||||
# copy node_modules needed to run app
|
||||
- cp -r node_modules/* /app/lib/node_modules
|
||||
- cp -r node_modules/. /app/lib/node_modules
|
||||
|
||||
# copy app files to runtime dir
|
||||
- cp -r build/* /app/lib/build
|
||||
- cp -r dist/. /app/lib/dist
|
||||
- cp -r src/. /app/lib/src
|
||||
- cp -r config /app/lib/config
|
||||
- cp -r assets /app/lib/assets
|
||||
- cp -r public /app/lib/public
|
||||
- cp vite.config.ts /app/lib/vite.config.ts
|
||||
- cp tsconfig.json /app/lib/tsconfig.json
|
||||
- cp tailwind.config.js /app/lib/tailwind.config.js
|
||||
- cp postcss.config.js /app/lib/postcss.config.js
|
||||
- cp flatpak/defaultConfig.json /app/lib/config/config.json
|
||||
|
||||
- install -Dm644 flatpak/${FLATPAK_ID}.desktop /app/share/applications/${FLATPAK_ID}.desktop
|
||||
@@ -51,13 +58,16 @@ modules:
|
||||
- install -Dm755 ../run.sh /app/bin/multiscrobbler
|
||||
subdir: main
|
||||
sources:
|
||||
# use for official releases
|
||||
- type: git
|
||||
url: https://github.com/FoxxMD/multi-scrobbler
|
||||
branch: develop
|
||||
# in official builds use tag/commit instead of branch
|
||||
# tag: 0.4.6
|
||||
# commit: ed6bcd60b3448397424ed7c14075479452ff566f
|
||||
tag: 0.6.3
|
||||
commit: 4bd996eb466137ffc5c3f48305afabde03720525
|
||||
dest: main
|
||||
# use if developing locally
|
||||
# - type: dir
|
||||
# path: /home/yourUser/multi-scrobbler
|
||||
# dest: main
|
||||
# Wrapper to launch the app
|
||||
- type: script
|
||||
dest-filename: run.sh
|
||||
@@ -72,6 +82,6 @@ modules:
|
||||
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
|
||||
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
|
||||
fi
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node build/server.js
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME LOG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node_modules/.bin/tsx src/backend/index.ts
|
||||
|
||||
- generated-sources.json
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Dashboard for Multi Scrobbler"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"ext": "js"
|
||||
}
|
||||
Generated
+2796
-16326
File diff suppressed because it is too large
Load Diff
+45
-33
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
|
||||
@@ -11,18 +12,13 @@
|
||||
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"typedoc": "typedoc",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"dev": "concurrently -p name -c \"yellow,magenta,blue\" -n \"webpack-server,nodemon-server,CRA\" \"npm run dev:server:webpack\" \"npm run dev:server:nodemon\" \"npm run dev:client\"",
|
||||
"dev:client": "BROWSER=none react-scripts start",
|
||||
"dev:server": "concurrently -p name -c \"yellow,magenta\" -n \"webpack-server,nodemon-server\" \"npm run dev:server:webpack\" \"npm run dev:server:nodemon\"",
|
||||
"dev:server:webpack": "webpack --config webpack.config.server.js",
|
||||
"dev:server:nodemon": "rm -f build/server.js && wait-on -l build/server.js && nodemon build/server.js",
|
||||
"build": "npm run -s build:client && npm run -s build:server",
|
||||
"build:client": "react-scripts build",
|
||||
"build:server": "webpack --config webpack.config.server.js",
|
||||
"start": "node build/server.js",
|
||||
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend"
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "NODE_OPTIONS='--loader tsx' mocha --extension ts --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend",
|
||||
"dev": "APP_VERSION=$npm_package_version nodemon -w src/backend -x tsx src/backend/index.ts",
|
||||
"start": "APP_VERSION=$npm_package_version NODE_ENV=production tsx src/backend/index.ts",
|
||||
"build": "APP_VERSION=$npm_package_version vite build",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -46,22 +42,32 @@
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@astronautlabs/mdns": "^1.0.7",
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@foxxmd/string-sameness": "^0.1.3",
|
||||
"@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/string-sameness": "^0.2.0",
|
||||
"@foxxmd/winston": "3.3.31",
|
||||
"@foxxmd/youtube-music-ts-api": "1.5.1",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^7.2.4",
|
||||
"ajv": "^8.12.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"avahi-browse": "^1.1.4",
|
||||
"better-sse": "^0.8.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"castv2": "^0.1.10",
|
||||
"clsx": "^2.0.0",
|
||||
"common-tags": "^1.8.2",
|
||||
"compare-versions": "^4.1.2",
|
||||
"concat-stream": "^2.0.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"dbus-next": "0.10.2",
|
||||
"dotenv": "^10.0.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
@@ -73,31 +79,38 @@
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"nanoid": "^3.3.1",
|
||||
"normalize-url": "^6.1.0",
|
||||
"ntfy": "1.0.5",
|
||||
"object-hash": "^3.0.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"p-event": "^4.2.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
"pony-cause": "^1.1.1",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"sass": "^1.70.0",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"triple-beam": "^1.3.0",
|
||||
"wait-on": "^7.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"tsx": "^4.7.0",
|
||||
"vite-express": "^0.13.0",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-duplex": "0.1.3",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"xml2js": "^0.4.23"
|
||||
"xml2js": "0.5.0",
|
||||
"youtube-music-ts-api": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -105,6 +118,7 @@
|
||||
"@tsconfig/node18": "^1.0.1",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/clone": "^2.1.2",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
@@ -114,6 +128,7 @@
|
||||
"@types/jscodeshift": "^0.11.6",
|
||||
"@types/mocha": "^9.1.0",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/object-hash": "^3.0.0",
|
||||
"@types/passport": "^1.0.12",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
@@ -122,25 +137,22 @@
|
||||
"@types/superagent": "^4.1.16",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"concurrently": "^8.2.0",
|
||||
"clone": "^2.1.2",
|
||||
"jscodeshift": "^0.15.0",
|
||||
"mocha": "^9.2.1",
|
||||
"nodemon": "^3.0.1",
|
||||
"mocha": "10.1.0",
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"nyc": "^15.1.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths": "^3.13.0",
|
||||
"typedoc": "^0.23.28",
|
||||
"typescript": "^4.9.5",
|
||||
"typedoc": "^0.25",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-json-schema": "~0.55",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
"vite": "^5.0.11",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"wtfnode": "^0.9.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/apps/application.js b/node_modules/chromecast-client/dist/cjs/src/apps/application.js
|
||||
index d91a6ff..dfc417e 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/apps/application.js
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/apps/application.js
|
||||
@@ -4,7 +4,9 @@ exports.join = exports.launchAndJoin = void 0;
|
||||
const receiver_1 = require("../controllers/receiver");
|
||||
const utils_1 = require("../utils");
|
||||
const _getJoinableTransportId = (status) => {
|
||||
- const app = status.applications.find(a => a.namespaces.map(e => e.name).includes('urn:x-cast:com.google.cast.media'));
|
||||
+ const app = status.applications === undefined
|
||||
+ ? undefined
|
||||
+ : status.applications.find(a => a.namespaces.map(e => e.name).includes('urn:x-cast:com.google.cast.media'));
|
||||
return app === undefined ? utils_1.Result.Err(new Error('failed to find joinable application')) : utils_1.Result.Ok(app.transportId);
|
||||
};
|
||||
const _join = async (status, factory) => {
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
|
||||
index cbc2af8..e0f60dc 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
|
||||
@@ -63,7 +63,7 @@ export declare const Application$: z.ZodObject<{
|
||||
}>;
|
||||
export type Application = z.infer<typeof Application$>;
|
||||
export declare const ReceiverStatus$: z.ZodObject<{
|
||||
- applications: z.ZodArray<z.ZodObject<{
|
||||
+ applications: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
appId: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
iconUrl: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
||||
@@ -103,7 +103,7 @@ export declare const ReceiverStatus$: z.ZodObject<{
|
||||
}[];
|
||||
sessionId: string;
|
||||
transportId: string;
|
||||
- }>, "many">;
|
||||
+ }>, "many">>;
|
||||
volume: z.ZodObject<{
|
||||
controlType: z.ZodEffects<z.ZodEnum<["ATTENUATION", "FIXED", "MASTER"]>, "ATTENUATION" | "FIXED" | "MASTER", unknown>;
|
||||
level: z.ZodNullable<z.ZodOptional<z.ZodNumber>>;
|
||||
@@ -121,13 +121,7 @@ export declare const ReceiverStatus$: z.ZodObject<{
|
||||
stepInterval: number;
|
||||
}>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
- volume: {
|
||||
- level?: number | null | undefined;
|
||||
- muted?: boolean | null | undefined;
|
||||
- controlType: "ATTENUATION" | "FIXED" | "MASTER";
|
||||
- stepInterval: number;
|
||||
- };
|
||||
- applications: {
|
||||
+ applications?: {
|
||||
iconUrl?: string | null | undefined;
|
||||
isIdleScreen?: boolean | null | undefined;
|
||||
launchedFromCloud?: boolean | null | undefined;
|
||||
@@ -139,15 +133,15 @@ export declare const ReceiverStatus$: z.ZodObject<{
|
||||
}[];
|
||||
sessionId: string;
|
||||
transportId: string;
|
||||
- }[];
|
||||
-}, {
|
||||
+ }[] | undefined;
|
||||
volume: {
|
||||
level?: number | null | undefined;
|
||||
muted?: boolean | null | undefined;
|
||||
- controlType?: unknown;
|
||||
+ controlType: "ATTENUATION" | "FIXED" | "MASTER";
|
||||
stepInterval: number;
|
||||
};
|
||||
- applications: {
|
||||
+}, {
|
||||
+ applications?: {
|
||||
iconUrl?: string | null | undefined;
|
||||
isIdleScreen?: boolean | null | undefined;
|
||||
launchedFromCloud?: boolean | null | undefined;
|
||||
@@ -159,6 +153,12 @@ export declare const ReceiverStatus$: z.ZodObject<{
|
||||
}[];
|
||||
sessionId: string;
|
||||
transportId: string;
|
||||
- }[];
|
||||
+ }[] | undefined;
|
||||
+ volume: {
|
||||
+ level?: number | null | undefined;
|
||||
+ muted?: boolean | null | undefined;
|
||||
+ controlType?: unknown;
|
||||
+ stepInterval: number;
|
||||
+ };
|
||||
}>;
|
||||
export type ReceiverStatus = z.infer<typeof ReceiverStatus$>;
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
|
||||
index 33bfb4b..29e79fe 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
|
||||
@@ -51,7 +51,7 @@ exports.Application$ = zod_1.z.object({
|
||||
transportId: zod_1.z.string(),
|
||||
});
|
||||
exports.ReceiverStatus$ = zod_1.z.object({
|
||||
- applications: zod_1.z.array(exports.Application$),
|
||||
+ applications: zod_1.z.array(exports.Application$).optional(),
|
||||
volume: exports.Volume$,
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
|
||||
index 53e42b2..0d77bb2 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
|
||||
@@ -80,7 +80,7 @@ export declare const MediaStatus$: z.ZodObject<{
|
||||
playbackRate: z.ZodNumber;
|
||||
playerState: z.ZodEffects<z.ZodEnum<["IDLE", "PLAYING", "BUFFERING", "PAUSED"]>, "IDLE" | "PLAYING" | "BUFFERING" | "PAUSED", unknown>;
|
||||
supportedMediaCommands: z.ZodNumber;
|
||||
- volume: z.ZodObject<{
|
||||
+ volume: z.ZodNullable<z.ZodOptional<z.ZodObject<{
|
||||
level: z.ZodNullable<z.ZodOptional<z.ZodNumber>>;
|
||||
muted: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
@@ -89,7 +89,7 @@ export declare const MediaStatus$: z.ZodObject<{
|
||||
}, {
|
||||
level?: number | null | undefined;
|
||||
muted?: boolean | null | undefined;
|
||||
- }>;
|
||||
+ }>>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
media?: {
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
@@ -102,15 +102,15 @@ export declare const MediaStatus$: z.ZodObject<{
|
||||
} | null | undefined;
|
||||
customData?: Record<string, unknown> | null | undefined;
|
||||
idleReason?: "CANCELLED" | "INTERRUPTED" | "FINISHED" | "ERROR" | null | undefined;
|
||||
+ volume?: {
|
||||
+ level?: number | null | undefined;
|
||||
+ muted?: boolean | null | undefined;
|
||||
+ } | null | undefined;
|
||||
currentTime: number;
|
||||
mediaSessionId: number;
|
||||
playbackRate: number;
|
||||
playerState: "IDLE" | "PLAYING" | "BUFFERING" | "PAUSED";
|
||||
supportedMediaCommands: number;
|
||||
- volume: {
|
||||
- level?: number | null | undefined;
|
||||
- muted?: boolean | null | undefined;
|
||||
- };
|
||||
}, {
|
||||
media?: {
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
@@ -124,13 +124,13 @@ export declare const MediaStatus$: z.ZodObject<{
|
||||
customData?: Record<string, unknown> | null | undefined;
|
||||
idleReason?: unknown;
|
||||
playerState?: unknown;
|
||||
+ volume?: {
|
||||
+ level?: number | null | undefined;
|
||||
+ muted?: boolean | null | undefined;
|
||||
+ } | null | undefined;
|
||||
currentTime: number;
|
||||
mediaSessionId: number;
|
||||
playbackRate: number;
|
||||
supportedMediaCommands: number;
|
||||
- volume: {
|
||||
- level?: number | null | undefined;
|
||||
- muted?: boolean | null | undefined;
|
||||
- };
|
||||
}>;
|
||||
export type MediaStatus = z.infer<typeof MediaStatus$>;
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
|
||||
index 2af6558..138f550 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
|
||||
@@ -28,6 +28,6 @@ exports.MediaStatus$ = zod_1.z.object({
|
||||
playbackRate: zod_1.z.number(),
|
||||
playerState: exports.PlayerState$,
|
||||
supportedMediaCommands: zod_1.z.number(),
|
||||
- volume: exports.Volume$,
|
||||
+ volume: exports.Volume$.nullish(),
|
||||
});
|
||||
//# sourceMappingURL=media.types.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/chromecast-client/dist/cjs/src/platform.d.ts b/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
|
||||
index 2f9770b..1247d8a 100644
|
||||
--- a/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
|
||||
+++ b/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
|
||||
@@ -65,7 +65,7 @@ export declare const createPlatform: (client: PersistentClient) => {
|
||||
controlType: "ATTENUATION" | "FIXED" | "MASTER";
|
||||
stepInterval: number;
|
||||
};
|
||||
- applications: {
|
||||
+ applications?: {
|
||||
iconUrl?: string | null | undefined;
|
||||
isIdleScreen?: boolean | null | undefined;
|
||||
launchedFromCloud?: boolean | null | undefined;
|
||||
@@ -0,0 +1,38 @@
|
||||
diff --git a/node_modules/lastfm-node-client/lib/ApiRequest.js b/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
index ecd39d0..c8e1cc5 100644
|
||||
--- a/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
+++ b/node_modules/lastfm-node-client/lib/ApiRequest.js
|
||||
@@ -91,7 +91,7 @@ class ApiRequest {
|
||||
const paramsStr = querystring.stringify(paramsObj);
|
||||
const options = {
|
||||
hostname: "ws.audioscrobbler.com",
|
||||
- path: "/2.0"
|
||||
+ path: "/2.0",
|
||||
};
|
||||
|
||||
if (method === "POST") {
|
||||
@@ -113,7 +113,11 @@ class ApiRequest {
|
||||
httpResponse.on("data", chunk => data += chunk);
|
||||
httpResponse.on("end", () => resolve(data));
|
||||
httpResponse.on("error", err => reject(err));
|
||||
- });
|
||||
+ })
|
||||
+ // stop waiting for request if it takes longer than 3 seconds
|
||||
+ .setTimeout(3000, () => {
|
||||
+ httpRequest.destroy(new Error('ETIMEDOUT - socket hang up'));
|
||||
+ });
|
||||
|
||||
httpRequest.on("error", err => reject(err));
|
||||
|
||||
diff --git a/node_modules/lastfm-node-client/lib/LastFm.js b/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
index 2393a4d..75bdf37 100644
|
||||
--- a/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
+++ b/node_modules/lastfm-node-client/lib/LastFm.js
|
||||
@@ -1045,7 +1045,6 @@ class LastFm {
|
||||
* @param {callback} [callback]
|
||||
* @returns {(Promise|LastFm)}
|
||||
*/
|
||||
-
|
||||
trackUpdateNowPlaying(params, callback) {
|
||||
const apiRequest = new ApiRequest()
|
||||
.set(params)
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.ico"/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Dashboard for Multi Scrobbler"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Overrides the tsconfig used for the app.
|
||||
* In the test environment we need some tweaks.
|
||||
*/
|
||||
|
||||
const tsNode = require('ts-node');
|
||||
const tsConfigPaths = require('tsconfig-paths');
|
||||
const mainTSConfig = require('./src/backend/tsconfig.json');
|
||||
|
||||
tsConfigPaths.register({
|
||||
baseUrl: './src/backend/tests',
|
||||
paths: {
|
||||
...mainTSConfig.compilerOptions.paths,
|
||||
}
|
||||
});
|
||||
|
||||
tsNode.register({
|
||||
files: true,
|
||||
transpileOnly: true,
|
||||
project: './src/backend/tsconfig.json'
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {ResponseError} from "superagent";
|
||||
|
||||
export const isSuperAgentResponseError = (e: any): e is ResponseError => {
|
||||
return typeof e === 'object'
|
||||
&& (
|
||||
('timeout' in e && typeof e.timeout === 'boolean')
|
||||
||
|
||||
('status' in e
|
||||
&& (typeof e.status === 'number' || e.status === undefined)
|
||||
&& 'response' in e
|
||||
&& (typeof e.response === 'object' || e.response === undefined))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isArbitraryObject } from "../infrastructure/Atomic.js";
|
||||
import ErrnoException = NodeJS.ErrnoException;
|
||||
import { findCauseByFunc } from "../../utils.js";
|
||||
|
||||
export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH';
|
||||
export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
|
||||
|
||||
export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
|
||||
|
||||
export const isErrnoException = (error: unknown): error is ErrnoException => {
|
||||
return isArbitraryObject(error) &&
|
||||
error instanceof Error &&
|
||||
(typeof error.errno === "number" || typeof error.errno === "undefined") &&
|
||||
(typeof error.code === "string" || typeof error.code === "undefined") &&
|
||||
(typeof error.path === "string" || typeof error.path === "undefined") &&
|
||||
(typeof error.syscall === "string" || typeof error.syscall === "undefined");
|
||||
}
|
||||
|
||||
export interface NodeNetworkException extends ErrnoException {
|
||||
code: NodeNetworkErrorCode
|
||||
}
|
||||
|
||||
export const isNodeNetworkException = (error: unknown): error is NodeNetworkException => {
|
||||
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code as NodeNetworkErrorCode);
|
||||
}
|
||||
|
||||
export const hasNodeNetworkException = (error: unknown): boolean => {
|
||||
return getNodeNetworkException(error) !== undefined;
|
||||
}
|
||||
|
||||
export const getNodeNetworkException = (error: unknown): Error | undefined => {
|
||||
return findCauseByFunc(error, isNodeNetworkException);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { findCauseByFunc } from "../../utils.js";
|
||||
import {Response} from 'superagent';
|
||||
|
||||
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
|
||||
|
||||
showStopper: boolean = false;
|
||||
response?: Response
|
||||
|
||||
constructor(message: string, options?: { cause?: T, showStopper?: boolean, response?: Response } | undefined) {
|
||||
super(message, options);
|
||||
const {showStopper = false, response} = options;
|
||||
this.showStopper = showStopper;
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
export const hasUpstreamError = (err: any, showStopping?: boolean): boolean => {
|
||||
return findCauseByFunc(err, (e) => {
|
||||
if (e instanceof UpstreamError) {
|
||||
if (showStopping === undefined) {
|
||||
return true;
|
||||
} else {
|
||||
return e.showStopper === showStopping;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) !== undefined;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Dayjs} from "dayjs";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import TupleMap from "../TupleMap";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
import {Request, Response} from "express";
|
||||
import {NextFunction, ParamsDictionary, Query} from "express-serve-static-core";
|
||||
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic";
|
||||
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi'];
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast'];
|
||||
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
|
||||
|
||||
@@ -109,7 +109,7 @@ export interface LogConfig {
|
||||
level?: string
|
||||
file?: string | false
|
||||
stream?: string
|
||||
console?: string
|
||||
console?: string | false
|
||||
}
|
||||
|
||||
export interface LogOptions {
|
||||
@@ -132,7 +132,7 @@ export interface LogOptions {
|
||||
/**
|
||||
* Specify the minimum log level streamed to the console (or docker container)
|
||||
* */
|
||||
console?: LogLevel
|
||||
console?: LogLevel | false
|
||||
}
|
||||
|
||||
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
|
||||
@@ -147,7 +147,21 @@ export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
|
||||
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
|
||||
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
|
||||
|
||||
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 30;
|
||||
/**
|
||||
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
* */
|
||||
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 240;
|
||||
/**
|
||||
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
* */
|
||||
export const DEFAULT_SCROBBLE_PERCENT_THRESHOLD: number = 50;
|
||||
|
||||
export const DEFAULT_POLLING_INTERVAL: number = 10;
|
||||
export const DEFAULT_POLLING_MAX_INTERVAL = 30;
|
||||
|
||||
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
|
||||
|
||||
export interface ScrobbleThresholdResult {
|
||||
passes: boolean
|
||||
@@ -192,3 +206,29 @@ export interface numberFormatOptions {
|
||||
}
|
||||
|
||||
export const DELIMITERS = [',','&','\/','\\'];
|
||||
|
||||
export const ARTIST_WEIGHT = 0.3;
|
||||
export const TITLE_WEIGHT = 0.4;
|
||||
export const TIME_WEIGHT = 0.5;
|
||||
export const REFERENCE_WEIGHT = 0.5;
|
||||
export const DUP_SCORE_THRESHOLD = 1;
|
||||
|
||||
// https://stackoverflow.com/a/70887388/1469797
|
||||
export type ArbitraryObject = { [key: string]: unknown; };
|
||||
export function isArbitraryObject(potentialObject: unknown): potentialObject is ArbitraryObject {
|
||||
return typeof potentialObject === "object" && potentialObject !== null;
|
||||
}
|
||||
|
||||
export interface Authenticatable {
|
||||
requiresAuth: boolean
|
||||
requiresAuthInteraction: boolean
|
||||
authed: boolean
|
||||
authFailure?: boolean
|
||||
testAuth: () => Promise<any>
|
||||
}
|
||||
|
||||
export interface MdnsDeviceInfo {
|
||||
name: string
|
||||
type: string
|
||||
addresses: string[]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index";
|
||||
import { RequestRetryOptions } from "./common";
|
||||
import { SourceAIOConfig } from "./source/sources";
|
||||
import { ClientAIOConfig } from "./client/clients";
|
||||
import { WebhookConfig } from "./health/webhooks";
|
||||
import { LogOptions } from "../Atomic";
|
||||
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index.js";
|
||||
import { RequestRetryOptions } from "./common.js";
|
||||
import { SourceAIOConfig } from "./source/sources.js";
|
||||
import { ClientAIOConfig } from "./client/clients.js";
|
||||
import { WebhookConfig } from "./health/webhooks.js";
|
||||
import { LogOptions } from "../Atomic.js";
|
||||
|
||||
export interface SourceDefaults extends SourceRetryOptions {
|
||||
/**
|
||||
@@ -28,6 +28,20 @@ export interface AIOConfig {
|
||||
* */
|
||||
port?: number
|
||||
|
||||
/**
|
||||
* Set the Base URL the application should assume the UI is served from.
|
||||
*
|
||||
* This will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.
|
||||
*
|
||||
* It will NOT set the actual interface/IP that the application is listening on.
|
||||
*
|
||||
* This can also be set using the BASE_URL environmental variable.
|
||||
*
|
||||
* @default "http://localhost"
|
||||
* @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"]
|
||||
* */
|
||||
baseUrl?: string
|
||||
|
||||
logging?: LogOptions
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja";
|
||||
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm";
|
||||
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz";
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
|
||||
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
|
||||
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonConfig, CommonData } from "../common";
|
||||
import { CommonConfig, CommonData } from "../common.js";
|
||||
|
||||
/**
|
||||
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
|
||||
@@ -63,6 +63,14 @@ export interface CommonClientData extends CommonData {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
deadLetterRetries?: number
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index";
|
||||
import { RequestRetryOptions } from "../common";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface LastfmData extends RequestRetryOptions {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index";
|
||||
import { RequestRetryOptions } from "../common";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface ListenBrainzData extends RequestRetryOptions{
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonClientConfig, CommonClientData } from "./index";
|
||||
import { RequestRetryOptions } from "../common";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
data?: CommonData
|
||||
/**
|
||||
* Should MS use this client/source? Defaults to true
|
||||
*
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
enable?: boolean
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
@@ -30,16 +37,16 @@ export interface PollingOptions {
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* @default 10
|
||||
* @examples [10]
|
||||
* */
|
||||
interval?: number
|
||||
|
||||
/**
|
||||
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface ChromecastData extends CommonSourceData {
|
||||
/**
|
||||
* DO NOT scrobble from any cast devices that START WITH these values, case-insensitive
|
||||
*
|
||||
* Useful when used with auto discovery
|
||||
*
|
||||
* @examples [["home-mini","family-tv"]]
|
||||
* */
|
||||
blacklistDevices?: string | string[]
|
||||
|
||||
/**
|
||||
* ONLY scrobble from any cast device that START WITH these values, case-insensitive
|
||||
*
|
||||
* If whitelist is present then blacklist is ignored
|
||||
*
|
||||
* Useful when used with auto discovery
|
||||
*
|
||||
* @examples [["home-mini","family-tv"]]
|
||||
* */
|
||||
whitelistDevices?: string | string[]
|
||||
|
||||
/**
|
||||
* DO NOT scrobble from any application that START WITH these values, case-insensitive
|
||||
*
|
||||
* @examples [["spotify","pandora"]]
|
||||
* */
|
||||
blacklistApps?: string | string[]
|
||||
|
||||
/**
|
||||
* ONLY scrobble from any application that START WITH these values, case-insensitive
|
||||
*
|
||||
* If whitelist is present then blacklist is ignored
|
||||
*
|
||||
* @examples [["spotify","pandora"]]
|
||||
* */
|
||||
whitelistApps?: string | string[]
|
||||
|
||||
/**
|
||||
* Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying
|
||||
*
|
||||
* Useful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
useAvahi?: boolean
|
||||
|
||||
/**
|
||||
* Use mDNS to discovery Google Cast devices on your next automatically?
|
||||
*
|
||||
* If not explicitly set then it is TRUE if `devices` is not set
|
||||
* */
|
||||
useAutoDiscovery?: boolean | undefined
|
||||
|
||||
/**
|
||||
* A list of Google Cast devices to monitor
|
||||
*
|
||||
* If this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set
|
||||
* */
|
||||
devices?: ChromecastDeviceInfo[]
|
||||
|
||||
/**
|
||||
* Chromecast Apps report a "media type" in the status info returned for whatever is currently playing
|
||||
*
|
||||
* * If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS
|
||||
* * If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS
|
||||
* * If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked
|
||||
*
|
||||
* See https://developers.google.com/cast/docs/media/messages#MediaInformation "metadata" property
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
allowUnknownMedia?: boolean | string[]
|
||||
|
||||
/**
|
||||
* Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the "media type" reported
|
||||
*
|
||||
* Apps will be recognized if they CONTAIN any of these values, case-insensitive
|
||||
* */
|
||||
forceMediaRecognitionOn?: string[]
|
||||
}
|
||||
|
||||
export interface ChromecastDeviceInfo {
|
||||
/**
|
||||
* A friendly name to identify this device
|
||||
*
|
||||
* @examples ["MySmartTV"]
|
||||
* */
|
||||
name: string
|
||||
/**
|
||||
* The IP address of the device
|
||||
*
|
||||
* @examples ["192.168.0.115"]
|
||||
* */
|
||||
address: string
|
||||
}
|
||||
|
||||
export interface ChromecastSourceConfig extends CommonSourceConfig {
|
||||
data: ChromecastData
|
||||
}
|
||||
|
||||
export interface ChromecastSourceAIOConfig extends ChromecastSourceConfig {
|
||||
type: 'chromecast'
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface DeezerData extends CommonSourceData {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common";
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
/**
|
||||
@@ -12,18 +12,31 @@ export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
|
||||
export interface ScrobbleThresholds {
|
||||
/**
|
||||
* The number of seconds before a track should be considered scrobbled.
|
||||
* The number of seconds a track has been listened to before it should be considered scrobbled.
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* Set to null to disable.
|
||||
*
|
||||
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* @seehttps://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
*
|
||||
* @default 240
|
||||
* @examples [240]
|
||||
* */
|
||||
duration?: number
|
||||
duration?: number | null
|
||||
/**
|
||||
* The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
|
||||
* The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
|
||||
*
|
||||
* Set to null to disable.
|
||||
*
|
||||
* NOTE: This should be used with care when the Source is a "polling" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not "seen" the track for long enough from first discovery, even if you have been playing the track for longer.
|
||||
*
|
||||
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* @see https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
*
|
||||
* @default 50
|
||||
* @examples [50]
|
||||
* */
|
||||
percent?: number
|
||||
percent?: number | null
|
||||
}
|
||||
|
||||
export interface CommonSourceOptions {
|
||||
@@ -57,6 +70,19 @@ export interface CommonSourceOptions {
|
||||
* @examples [false]
|
||||
* */
|
||||
logPlayerState?: boolean
|
||||
|
||||
/**
|
||||
* If this source
|
||||
*
|
||||
* * supports fetching a listen history
|
||||
* * and this option is enabled
|
||||
*
|
||||
* then on startup MS will attempt to scrobble the recent listens from that history
|
||||
*
|
||||
* @default true
|
||||
* @examples [true, false]
|
||||
* */
|
||||
scrobbleBacklog?: boolean
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData, SourceRetryOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface JellyData extends CommonSourceData {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface JRiverData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
|
||||
export interface KodiData extends CommonSourceData, PollingOptions {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LastfmData } from "../client/lastfm";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { LastfmData } from "../client/lastfm.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
|
||||
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
|
||||
|
||||
export interface LastfmSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { ListenBrainzData } from "../client/listenbrainz";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { ListenBrainzData } from "../client/listenbrainz.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
|
||||
}
|
||||
|
||||
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface MopidyData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export const PLAYBACK_STATUS_PLAYING = 'Playing';
|
||||
export const PLAYBACK_STATUS_PAUSED = 'Paused';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface PlexSourceData extends CommonSourceData {
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex";
|
||||
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic";
|
||||
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin";
|
||||
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris";
|
||||
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy";
|
||||
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver";
|
||||
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi";
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
|
||||
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
|
||||
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
|
||||
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
|
||||
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
|
||||
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
|
||||
import { ChromecastSourceConfig, ChromecastSourceAIOConfig } from "./chromecast.js";
|
||||
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig;
|
||||
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig;
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig;
|
||||
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
@@ -29,10 +29,12 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
* Reading:
|
||||
* * https://developer.spotify.com/documentation/web-api/guides/rate-limits/
|
||||
* * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
|
||||
* * The rate limit is ~180 req/min
|
||||
* * Rate limit may ~180 req/min
|
||||
* * https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930
|
||||
* * Informally indicated as 20 req/sec? Probably for burstiness
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* @default 10
|
||||
* @examples [10]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface SubsonicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlexSourceConfig } from "./plex";
|
||||
import { PlexSourceConfig } from "./plex.js";
|
||||
|
||||
export interface TautulliSourceConfig extends PlexSourceConfig {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface WebScrobblerData extends CommonSourceData {
|
||||
/**
|
||||
* The URL ending that should be used to identify scrobbles for this source
|
||||
*
|
||||
* In WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:
|
||||
*
|
||||
* http://localhost:9078/api/webscrobbler
|
||||
*
|
||||
* If you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* * slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera
|
||||
* * slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb
|
||||
*
|
||||
* If no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used
|
||||
* */
|
||||
slug?: string | null
|
||||
|
||||
/**
|
||||
* Block scrobbling from specific WebScrobbler Connectors
|
||||
*
|
||||
* @examples [["youtube"]]
|
||||
* */
|
||||
blacklist?: string | string[]
|
||||
|
||||
/**
|
||||
* Only allow scrobbling from specific WebScrobbler Connectors
|
||||
*
|
||||
* @examples [["mixcloud","soundcloud","bandcamp"]]
|
||||
* */
|
||||
whitelist?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for WebScrobbler logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
/**
|
||||
* Log raw WebScrobbler webhook payload to debug
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
logPayload?: boolean
|
||||
|
||||
/**
|
||||
* How MS should log when a WebScrobbler event fails a defined filter
|
||||
*
|
||||
* * `false` => do not log
|
||||
* * `debug` => log to DEBUG level
|
||||
* * `warn` => log to WARN level (default)
|
||||
*
|
||||
* @default warn
|
||||
* @examples ["warn"]
|
||||
* */
|
||||
logFilterFailure?: false | 'debug' | 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebScrobblerSourceConfig extends CommonSourceConfig {
|
||||
data?: WebScrobblerData
|
||||
}
|
||||
|
||||
export interface WebScrobblerSourceAIOConfig extends WebScrobblerSourceConfig {
|
||||
type: 'webscrobbler'
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { PollingOptions } from "../common";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
|
||||
export interface YTMusicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,9 @@ declare module 'lastfm-node-client' {
|
||||
album?: string
|
||||
|
||||
albumArtist?: string
|
||||
|
||||
/** MusicBrainz track ID */
|
||||
mbid?: string
|
||||
}
|
||||
|
||||
export interface TrackScrobbleResponse {
|
||||
@@ -47,6 +50,17 @@ declare module 'lastfm-node-client' {
|
||||
}
|
||||
}
|
||||
|
||||
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: number
|
||||
'#text': string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthGetSessionPayload {
|
||||
token: string
|
||||
}
|
||||
@@ -80,10 +94,12 @@ declare module 'lastfm-node-client' {
|
||||
artist: {
|
||||
'#text': string,
|
||||
name: string,
|
||||
mbid: string,
|
||||
},
|
||||
name: string,
|
||||
album: {
|
||||
'#text': string,
|
||||
mbid: string
|
||||
},
|
||||
duration: number,
|
||||
date?: {
|
||||
@@ -100,6 +116,7 @@ declare module 'lastfm-node-client' {
|
||||
export default class LastFM {
|
||||
constructor(apiKey: string, secret?: string, session?: string);
|
||||
|
||||
trackUpdateNowPlaying(params: NowPlayingPayload): Promise<NowPlayingResponse>
|
||||
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
|
||||
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
|
||||
userGetInfo(): Promise<UserGetInfoResponse>
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import path from "path";
|
||||
import { projectDir } from "./index";
|
||||
import winston, {format, Logger} from '@foxxmd/winston';
|
||||
import { projectDir } from "./index.js";
|
||||
import * as winstonNs from '@foxxmd/winston';
|
||||
import winstonDef from '@foxxmd/winston';
|
||||
import {DuplexTransport} from "winston-duplex";
|
||||
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic";
|
||||
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic.js";
|
||||
import process from "process";
|
||||
import { fileOrDirectoryIsWriteable, parseBool } from "../utils";
|
||||
import { fileOrDirectoryIsWriteable, mergeArr, parseBool } from "../utils.js";
|
||||
import {ErrorWithCause, stackWithCauses} from "pony-cause";
|
||||
import {NullTransport} from 'winston-null';
|
||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import dayjs from "dayjs";
|
||||
import stringify from 'safe-stable-stringify';
|
||||
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
|
||||
import { LogInfo, LogLevel } from "../../core/Atomic";
|
||||
import { LogInfo, LogLevel } from "../../core/Atomic.js";
|
||||
import TransportStream from "winston-transport";
|
||||
import {format} from 'logform';
|
||||
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
|
||||
|
||||
const {transports} = winston;
|
||||
//const {transports} = winstonNew;
|
||||
const {loggers, transports} = winstonDef;
|
||||
|
||||
export let logPath = path.resolve(projectDir, `./logs`);
|
||||
if (typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}
|
||||
|
||||
winston.loggers.add('noop', {transports: [new NullTransport()]});
|
||||
loggers.add('noop', {transports: [new NullTransport()]});
|
||||
|
||||
export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
export const getLogger = (config: LogConfig = {}, name = 'app'): winstonNs.Logger => {
|
||||
|
||||
if (!winston.loggers.has(name)) {
|
||||
if (!loggers.has(name)) {
|
||||
const errors: (Error | string)[] = [];
|
||||
|
||||
let options: LogOptions = {};
|
||||
@@ -39,17 +42,22 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
|
||||
const {level: configLevel} = options;
|
||||
const defaultLevel = process.env.LOG_LEVEL || (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'info');
|
||||
let consoleLevel: string | boolean = process.env.CONSOLE_LEVEL || 'debug';
|
||||
if(consoleLevel === 'false') {
|
||||
consoleLevel = false;
|
||||
}
|
||||
let fileLevel: string | boolean = process.env.FILE_LEVEL || defaultLevel;
|
||||
if(fileLevel === 'false') {
|
||||
fileLevel = false;
|
||||
}
|
||||
const {
|
||||
level = configLevel || defaultLevel,
|
||||
file = configLevel || defaultLevel,
|
||||
file = configLevel || fileLevel,
|
||||
stream = configLevel || 'debug',
|
||||
console = configLevel || 'debug'
|
||||
console = configLevel || consoleLevel
|
||||
} = options;
|
||||
|
||||
const consoleTransport = new transports.Console({level: console});
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
const myTransports: TransportStream[] = [
|
||||
new DuplexTransport({
|
||||
stream: {
|
||||
transform(chunk, e, cb) {
|
||||
@@ -58,13 +66,17 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
objectMode: true,
|
||||
},
|
||||
name: 'duplex',
|
||||
handleExceptions: true,
|
||||
handleRejections: true,
|
||||
level: stream,
|
||||
dump: false,
|
||||
}),
|
||||
})
|
||||
];
|
||||
|
||||
if(console !== false) {
|
||||
myTransports.push(new transports.Console({
|
||||
level: console,
|
||||
}));
|
||||
}
|
||||
|
||||
if (file !== false) {
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
dirname: logPath,
|
||||
@@ -73,7 +85,7 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m',
|
||||
level: file
|
||||
level: file,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -86,15 +98,15 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
}
|
||||
}
|
||||
|
||||
const loggerOptions: winston.LoggerOptions = {
|
||||
const loggerOptions: winstonNs.LoggerOptions = {
|
||||
level: level,
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add(name, loggerOptions);
|
||||
loggers.add(name, loggerOptions);
|
||||
|
||||
const logger = winston.loggers.get(name);
|
||||
const logger = loggers.get(name);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) {
|
||||
logger.error(e);
|
||||
@@ -102,7 +114,7 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
return winston.loggers.get(name);
|
||||
return loggers.get(name);
|
||||
}
|
||||
|
||||
const breakSymbol = '<br />';
|
||||
@@ -125,7 +137,7 @@ const s = splat();
|
||||
//const errorsFormat = errors({stack: true});
|
||||
const CWD = process.cwd();
|
||||
|
||||
const causeKeys = ['name', 'cause']
|
||||
const causeKeys = ['name', 'cause', 'showStopper']
|
||||
|
||||
export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
label,
|
||||
@@ -138,10 +150,13 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
durationMs,
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
id,
|
||||
cause,
|
||||
showStopper,
|
||||
...rest
|
||||
}) => {
|
||||
const keys = Object.keys(rest);
|
||||
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify(rest) : '';
|
||||
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify.default(rest) : '';
|
||||
let msg = message;
|
||||
let stackMsg = '';
|
||||
if (stack !== undefined) {
|
||||
@@ -168,6 +183,9 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
// https://knowyourmeme.com/memes/cereal-guy
|
||||
// this number will never overflow
|
||||
let seqId: number = 0;
|
||||
export const labelledFormat = (labelName = 'App') => {
|
||||
const l = label({label: labelName, message: false});
|
||||
return combine(
|
||||
@@ -176,6 +194,13 @@ export const labelledFormat = (labelName = 'App') => {
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
{
|
||||
transform: (info, opts) => {
|
||||
info.id = seqId;
|
||||
seqId++;
|
||||
return info;
|
||||
}
|
||||
},
|
||||
l,
|
||||
s,
|
||||
errorAwareFormat,
|
||||
@@ -215,6 +240,10 @@ export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel)
|
||||
return level <= minLevel;
|
||||
}
|
||||
|
||||
export const isLogLevelMinLevel = (levelStr: LogLevel, minLevelStr: LogLevel): boolean => {
|
||||
return logLevels[levelStr] <= logLevels[minLevelStr];
|
||||
}
|
||||
|
||||
const isProbablyError = (val: any, explicitErrorName?: string) => {
|
||||
if(typeof val !== 'object' || val === null) {
|
||||
return false;
|
||||
@@ -339,3 +368,45 @@ const _transformError = (err: Error, seen: Set<Error>) => {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
export class MaybeLogger {
|
||||
logger?: winstonNs.Logger
|
||||
|
||||
constructor(logger?: winstonNs.Logger, label?: string) {
|
||||
if (logger !== undefined && label !== undefined) {
|
||||
this.logger = logger.child({labels: [label]}, mergeArr);
|
||||
} else {
|
||||
this.logger = logger;
|
||||
}
|
||||
}
|
||||
|
||||
public info(first: any, ...rest: any) {
|
||||
if (this.logger) {
|
||||
this.logger.info(first, ...rest);
|
||||
}
|
||||
}
|
||||
|
||||
public debug(first: any, ...rest: any) {
|
||||
if (this.logger) {
|
||||
this.logger.debug(first, ...rest);
|
||||
}
|
||||
}
|
||||
|
||||
public warn(first: any, ...rest: any) {
|
||||
if (this.logger) {
|
||||
this.logger.warn(first, ...rest);
|
||||
}
|
||||
}
|
||||
|
||||
public verbose(first: any, ...rest: any) {
|
||||
if (this.logger) {
|
||||
this.logger.verbose(first, ...rest);
|
||||
}
|
||||
}
|
||||
|
||||
public error(first: any, ...rest: any) {
|
||||
if (this.logger) {
|
||||
this.logger.error(first, ...rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -101,6 +110,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -203,6 +221,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -249,6 +276,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -319,6 +355,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -373,6 +418,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
|
||||
@@ -1,6 +1,254 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ChromecastData": {
|
||||
"properties": {
|
||||
"allowUnknownMedia": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": false,
|
||||
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
|
||||
"title": "allowUnknownMedia"
|
||||
},
|
||||
"blacklistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "blacklistApps"
|
||||
},
|
||||
"blacklistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "blacklistDevices"
|
||||
},
|
||||
"devices": {
|
||||
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ChromecastDeviceInfo"
|
||||
},
|
||||
"title": "devices",
|
||||
"type": "array"
|
||||
},
|
||||
"forceMediaRecognitionOn": {
|
||||
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "forceMediaRecognitionOn",
|
||||
"type": "array"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"useAutoDiscovery": {
|
||||
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
|
||||
"title": "useAutoDiscovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAvahi": {
|
||||
"default": false,
|
||||
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
|
||||
"title": "useAvahi",
|
||||
"type": "boolean"
|
||||
},
|
||||
"whitelistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "whitelistApps"
|
||||
},
|
||||
"whitelistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "whitelistDevices"
|
||||
}
|
||||
},
|
||||
"title": "ChromecastData",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastDeviceInfo": {
|
||||
"properties": {
|
||||
"address": {
|
||||
"description": "The IP address of the device",
|
||||
"examples": [
|
||||
"192.168.0.115"
|
||||
],
|
||||
"title": "address",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "A friendly name to identify this device",
|
||||
"examples": [
|
||||
"MySmartTV"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"name"
|
||||
],
|
||||
"title": "ChromecastDeviceInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ChromecastData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"chromecast"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"title": "ChromecastSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"CommonSourceOptions": {
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
@@ -33,6 +281,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -139,6 +397,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -166,19 +433,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -265,6 +532,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -417,6 +693,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -444,19 +729,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -545,6 +830,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -601,6 +895,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -635,6 +938,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -733,6 +1054,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -759,6 +1089,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -929,6 +1277,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1075,6 +1432,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1119,6 +1485,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1270,16 +1645,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -1327,6 +1706,12 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/KodiSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WebScrobblerSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ChromecastSourceAIOConfig"
|
||||
}
|
||||
],
|
||||
"title": "SourceAIOConfig"
|
||||
@@ -1384,6 +1769,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1427,19 +1821,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1604,6 +1998,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1648,6 +2051,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1672,6 +2084,172 @@
|
||||
"title": "TautulliSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Block scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"youtube"
|
||||
]
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for WebScrobbler logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw WebScrobbler webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"slug": {
|
||||
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
|
||||
"title": "slug",
|
||||
"type": "string"
|
||||
},
|
||||
"whitelist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"mixcloud",
|
||||
"soundcloud",
|
||||
"bandcamp"
|
||||
]
|
||||
],
|
||||
"title": "whitelist"
|
||||
}
|
||||
},
|
||||
"title": "WebScrobblerData",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/WebScrobblerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"webscrobbler"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "WebScrobblerSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicData": {
|
||||
"properties": {
|
||||
"authUser": {
|
||||
@@ -1688,19 +2266,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1768,6 +2346,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
|
||||
@@ -1,6 +1,254 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ChromecastData": {
|
||||
"properties": {
|
||||
"allowUnknownMedia": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": false,
|
||||
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
|
||||
"title": "allowUnknownMedia"
|
||||
},
|
||||
"blacklistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "blacklistApps"
|
||||
},
|
||||
"blacklistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "blacklistDevices"
|
||||
},
|
||||
"devices": {
|
||||
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ChromecastDeviceInfo"
|
||||
},
|
||||
"title": "devices",
|
||||
"type": "array"
|
||||
},
|
||||
"forceMediaRecognitionOn": {
|
||||
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "forceMediaRecognitionOn",
|
||||
"type": "array"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"useAutoDiscovery": {
|
||||
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
|
||||
"title": "useAutoDiscovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAvahi": {
|
||||
"default": false,
|
||||
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
|
||||
"title": "useAvahi",
|
||||
"type": "boolean"
|
||||
},
|
||||
"whitelistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "whitelistApps"
|
||||
},
|
||||
"whitelistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "whitelistDevices"
|
||||
}
|
||||
},
|
||||
"title": "ChromecastData",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastDeviceInfo": {
|
||||
"properties": {
|
||||
"address": {
|
||||
"description": "The IP address of the device",
|
||||
"examples": [
|
||||
"192.168.0.115"
|
||||
],
|
||||
"title": "address",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "A friendly name to identify this device",
|
||||
"examples": [
|
||||
"MySmartTV"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"name"
|
||||
],
|
||||
"title": "ChromecastDeviceInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ChromecastData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"chromecast"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"title": "ChromecastSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"ClientAIOConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -37,6 +285,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -106,6 +363,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -212,6 +479,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -288,19 +564,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -387,6 +663,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -539,6 +824,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -566,19 +860,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -667,6 +961,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -723,6 +1026,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -757,6 +1069,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -850,6 +1180,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -952,6 +1291,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -998,6 +1346,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -1093,6 +1450,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1119,6 +1485,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -1199,8 +1583,15 @@
|
||||
"LogOptions": {
|
||||
"properties": {
|
||||
"console": {
|
||||
"$ref": "#/definitions/LogLevel",
|
||||
"description": "Specify the minimum log level streamed to the console (or docker container)",
|
||||
"enum": [
|
||||
"debug",
|
||||
"error",
|
||||
false,
|
||||
"info",
|
||||
"verbose",
|
||||
"warn"
|
||||
],
|
||||
"title": "console"
|
||||
},
|
||||
"file": {
|
||||
@@ -1334,6 +1725,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1365,6 +1765,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -1419,6 +1828,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -1624,6 +2042,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1724,6 +2151,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1931,16 +2367,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -1988,6 +2428,12 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/KodiSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WebScrobblerSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ChromecastSourceAIOConfig"
|
||||
}
|
||||
],
|
||||
"title": "SourceAIOConfig"
|
||||
@@ -2054,6 +2500,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2097,19 +2552,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -2274,6 +2729,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2318,6 +2782,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2342,6 +2815,172 @@
|
||||
"title": "TautulliSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Block scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"youtube"
|
||||
]
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for WebScrobbler logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw WebScrobbler webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"slug": {
|
||||
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
|
||||
"title": "slug",
|
||||
"type": "string"
|
||||
},
|
||||
"whitelist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"mixcloud",
|
||||
"soundcloud",
|
||||
"bandcamp"
|
||||
]
|
||||
],
|
||||
"title": "whitelist"
|
||||
}
|
||||
},
|
||||
"title": "WebScrobblerData",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/WebScrobblerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"webscrobbler"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "WebScrobblerSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"WebhookConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2369,19 +3008,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -2449,6 +3088,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2475,6 +3123,17 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"default": "http://localhost",
|
||||
"description": "Set the Base URL the application should assume the UI is served from.\n\nThis will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.\n\nIt will NOT set the actual interface/IP that the application is listening on.\n\nThis can also be set using the BASE_URL environmental variable.",
|
||||
"examples": [
|
||||
"http://localhost",
|
||||
"http://192.168.0.101",
|
||||
"https://ms.myDomain.tld"
|
||||
],
|
||||
"title": "baseUrl",
|
||||
"type": "string"
|
||||
},
|
||||
"clientDefaults": {
|
||||
"$ref": "#/definitions/RequestRetryOptions",
|
||||
"title": "clientDefaults"
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -98,6 +107,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -192,6 +210,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -230,6 +257,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -300,6 +336,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -346,6 +391,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
|
||||
@@ -39,9 +39,255 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/KodiSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/WebScrobblerSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ChromecastSourceConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"ChromecastData": {
|
||||
"properties": {
|
||||
"allowUnknownMedia": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": false,
|
||||
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
|
||||
"title": "allowUnknownMedia"
|
||||
},
|
||||
"blacklistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "blacklistApps"
|
||||
},
|
||||
"blacklistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "blacklistDevices"
|
||||
},
|
||||
"devices": {
|
||||
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ChromecastDeviceInfo"
|
||||
},
|
||||
"title": "devices",
|
||||
"type": "array"
|
||||
},
|
||||
"forceMediaRecognitionOn": {
|
||||
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "forceMediaRecognitionOn",
|
||||
"type": "array"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>",
|
||||
"title": "options"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"useAutoDiscovery": {
|
||||
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
|
||||
"title": "useAutoDiscovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAvahi": {
|
||||
"default": false,
|
||||
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
|
||||
"title": "useAvahi",
|
||||
"type": "boolean"
|
||||
},
|
||||
"whitelistApps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
|
||||
"examples": [
|
||||
[
|
||||
"spotify",
|
||||
"pandora"
|
||||
]
|
||||
],
|
||||
"title": "whitelistApps"
|
||||
},
|
||||
"whitelistDevices": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
|
||||
"examples": [
|
||||
[
|
||||
"home-mini",
|
||||
"family-tv"
|
||||
]
|
||||
],
|
||||
"title": "whitelistDevices"
|
||||
}
|
||||
},
|
||||
"title": "ChromecastData",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastDeviceInfo": {
|
||||
"properties": {
|
||||
"address": {
|
||||
"description": "The IP address of the device",
|
||||
"examples": [
|
||||
"192.168.0.115"
|
||||
],
|
||||
"title": "address",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "A friendly name to identify this device",
|
||||
"examples": [
|
||||
"MySmartTV"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"name"
|
||||
],
|
||||
"title": "ChromecastDeviceInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ChromecastSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ChromecastData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"title": "ChromecastSourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"CommonSourceOptions": {
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
@@ -74,6 +320,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -180,6 +436,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -199,19 +464,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -298,6 +563,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -442,6 +716,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -461,19 +744,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -562,6 +845,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -588,6 +880,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -686,6 +996,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -734,6 +1053,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -752,6 +1080,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -922,6 +1268,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1060,6 +1415,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1096,6 +1460,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1239,16 +1612,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -1276,6 +1653,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1311,19 +1697,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1402,6 +1788,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1524,6 +1919,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1540,6 +1944,162 @@
|
||||
"title": "TautulliSourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Block scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"youtube"
|
||||
]
|
||||
],
|
||||
"title": "blacklist"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for WebScrobbler logging and tuning",
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw WebScrobbler webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "options",
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
},
|
||||
"slug": {
|
||||
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
|
||||
"title": "slug",
|
||||
"type": "string"
|
||||
},
|
||||
"whitelist": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
|
||||
"examples": [
|
||||
[
|
||||
"mixcloud",
|
||||
"soundcloud",
|
||||
"bandcamp"
|
||||
]
|
||||
],
|
||||
"title": "whitelist"
|
||||
}
|
||||
},
|
||||
"title": "WebScrobblerData",
|
||||
"type": "object"
|
||||
},
|
||||
"WebScrobblerSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/WebScrobblerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonSourceOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"title": "WebScrobblerSourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicData": {
|
||||
"properties": {
|
||||
"authUser": {
|
||||
@@ -1556,19 +2116,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1636,6 +2196,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
import { capitalize, mergeArr } from "../../utils";
|
||||
import { mergeArr } from "../../utils.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import winston from '@foxxmd/winston';
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { capitalize } from "../../../core/StringUtils.js";
|
||||
|
||||
export default abstract class AbstractApiClient {
|
||||
name: string;
|
||||
|
||||
+6
-6
@@ -1,8 +1,9 @@
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import {JRiverData} from "../infrastructure/config/source/jriver";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {JRiverData} from "../infrastructure/config/source/jriver.js";
|
||||
import request, {Request, Response} from 'superagent';
|
||||
import xml2js from 'xml2js';
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {DEFAULT_RETRY_MULTIPLIER} from "../infrastructure/Atomic.js";
|
||||
|
||||
const parser = new xml2js.Parser({'async': true});
|
||||
|
||||
@@ -107,7 +108,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
if (this.token !== undefined) {
|
||||
@@ -126,15 +127,14 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
testConnection = async (): Promise<true> => {
|
||||
try {
|
||||
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
|
||||
const {body: { data } = {}} = resp;
|
||||
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error(new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e}));
|
||||
return false;
|
||||
throw new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -1,13 +1,13 @@
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { KodiData } from "../infrastructure/config/source/kodi";
|
||||
import { KodiData } from "../infrastructure/config/source/kodi.js";
|
||||
import { KodiClient } from 'kodi-api'
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {URL} from "url";
|
||||
import { RecentlyPlayedOptions } from "../../sources/AbstractSource";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
|
||||
interface KodiDuration {
|
||||
hours: number
|
||||
@@ -47,7 +47,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
|
||||
token?: string;
|
||||
|
||||
client: KodiClient;
|
||||
declare client: KodiClient;
|
||||
|
||||
constructor(name: any, config: KodiData, options = {}) {
|
||||
super('Kodi', name, config, options);
|
||||
@@ -107,6 +107,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
data: {
|
||||
track: title,
|
||||
album: album,
|
||||
albumArtists: albumartist,
|
||||
artists,
|
||||
duration,
|
||||
playDate: dayjs()
|
||||
|
||||
+130
-20
@@ -1,10 +1,21 @@
|
||||
import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "lastfm-node-client";
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import LastFm, {
|
||||
AuthGetSessionResponse,
|
||||
NowPlayingResponse,
|
||||
TrackObject,
|
||||
TrackScrobblePayload,
|
||||
UserGetInfoResponse
|
||||
} from "lastfm-node-client";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import dayjs from "dayjs";
|
||||
import { readJson, sleep, writeFile } from "../../utils";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import {readJson, removeUndefinedKeys, sleep, writeFile} from "../../utils.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import {getNodeNetworkException, isNodeNetworkException} from "../errors/NodeErrors.js";
|
||||
import {nonEmptyStringOrDefault, splitByFirstFound} from "../../../core/StringUtils.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {getScrobbleTsSOCDate} from "../../utils/TimeUtils.js";
|
||||
import {UpstreamError} from "../errors/UpstreamError.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -25,10 +36,10 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
user?: string;
|
||||
declare config: LastfmData;
|
||||
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string}, options = {}) {
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: string}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config!");
|
||||
}
|
||||
@@ -41,10 +52,12 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
artist: {
|
||||
'#text': artists,
|
||||
name: artistName,
|
||||
mbid: artistMbid,
|
||||
},
|
||||
name: title,
|
||||
album: {
|
||||
'#text': album,
|
||||
mbid: albumMbid,
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
@@ -58,7 +71,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
mbid,
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
|
||||
let artistStrings = splitByFirstFound(artists, [','], [artistName]);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
@@ -66,6 +79,13 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
album,
|
||||
duration,
|
||||
playDate: time !== undefined ? dayjs.unix(time) : undefined,
|
||||
meta: {
|
||||
brainz: {
|
||||
album: nonEmptyStringOrDefault<undefined>(albumMbid),
|
||||
artist: splitByFirstFound<undefined>(artistMbid, [',',';'], undefined),
|
||||
track: nonEmptyStringOrDefault<undefined>(mbid)
|
||||
}
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
nowPlaying: nowplaying === 'true',
|
||||
@@ -81,7 +101,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(func: any, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
@@ -92,15 +112,27 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if (undefined !== retryError) {
|
||||
let networkError = null;
|
||||
if(retryError === undefined) {
|
||||
const nError = getNodeNetworkException(e);
|
||||
if(nError !== undefined) {
|
||||
networkError = nError.message;
|
||||
} else if(message.includes('ETIMEDOUT')) {
|
||||
networkError = 'request timed out after 3 seconds'
|
||||
}
|
||||
}
|
||||
if (undefined !== retryError || networkError !== undefined) {
|
||||
if (retries < maxRequestRetries) {
|
||||
const delay = (retries + 1) * retryMultiplier;
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
if(networkError !== undefined) {
|
||||
this.logger.warn(`API call failed due to network issue (${networkError}), retrying in ${delay} seconds...`);
|
||||
} else {
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
}
|
||||
await sleep(delay * 1000);
|
||||
return this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.warn('Could not recover!');
|
||||
throw e;
|
||||
throw new UpstreamError(`API call failed due -> ${retryError ?? 'API call timed out'} <- after max retries hit ${maxRequestRetries}`, {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +141,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
|
||||
}
|
||||
|
||||
authenticate = async (token: any) => {
|
||||
@@ -128,7 +159,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}));
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
initialize = async (): Promise<true> => {
|
||||
|
||||
try {
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
@@ -138,14 +169,14 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
|
||||
return false;
|
||||
throw new ErrorWithCause('Current lastfm credentials file exists but could not be parsed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
this.logger.warn('No session key found. User interaction for authentication required.');
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -161,8 +192,87 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing auth failed');
|
||||
if(isNodeNetworkException(e)) {
|
||||
this.logger.error('Could not communicate with Last.fm API');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
album,
|
||||
albumArtists = [],
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
track: mbid
|
||||
} = {},
|
||||
} = {}
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
if (albumArtists.length > 0) {
|
||||
rawPayload.albumArtist = albumArtists[0];
|
||||
}
|
||||
|
||||
// I don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
return removeUndefinedKeys(rawPayload);
|
||||
}
|
||||
|
||||
updateNowPlaying = async (play: PlayObject) => {
|
||||
try {
|
||||
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
|
||||
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
|
||||
return client.trackUpdateNowPlaying(rest)
|
||||
});
|
||||
const {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
} = {}
|
||||
} = response;
|
||||
if (ignoreCode > 0) {
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) {
|
||||
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-17
@@ -1,22 +1,22 @@
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import request, {Request} from 'superagent';
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz";
|
||||
import { DELIMITERS, FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import { combinePartsToString } from "../../utils.js";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
containsDelimiters,
|
||||
findDelimiters,
|
||||
normalizeStr,
|
||||
parseArtistCredits,
|
||||
parseCredits,
|
||||
parseStringList,
|
||||
parseTrackCredits,
|
||||
unique,
|
||||
uniqueNormalizedStrArr,
|
||||
} from "../../utils";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import { slice } from "../../../core/StringUtils";
|
||||
} from "../../utils/StringUtils.js";
|
||||
import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -130,7 +130,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
@@ -139,7 +139,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
err,
|
||||
status,
|
||||
response: {
|
||||
body = undefined,
|
||||
text = undefined,
|
||||
} = {}
|
||||
} = e;
|
||||
// TODO check err for network exception
|
||||
if(status !== undefined) {
|
||||
const msgParts = [`(HTTP Status ${status})`];
|
||||
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
|
||||
let showStopper = status !== 400;
|
||||
if(body !== undefined) {
|
||||
if(typeof body === 'object') {
|
||||
if('code' in body) {
|
||||
msgParts.push(`Code ${body.code}`);
|
||||
}
|
||||
if('error' in body) {
|
||||
msgParts.push(`Error => ${body.error}`);
|
||||
}
|
||||
if('message' in body) {
|
||||
msgParts.push(`Message => ${body.error}`);
|
||||
}
|
||||
// if('track_metadata' in body) {
|
||||
// msgParts.push(`Track Metadata => ${JSON.stringify(body.track_metadata)}`);
|
||||
// }
|
||||
} else if(typeof body === 'string') {
|
||||
msgParts.push(`Response => ${body}`);
|
||||
}
|
||||
} else if (text !== undefined) {
|
||||
msgParts.push(`Response => ${text}`);
|
||||
}
|
||||
throw new UpstreamError(`Listenbrainz API Request Failed => ${msgParts.join(' | ')}`, {cause: e, showStopper});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +194,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +218,26 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
deadline: 30000 // wait 30 seconds overall for request to complete
|
||||
}));
|
||||
const {body: {payload}} = resp as any;
|
||||
// const data = payload as ListensResponse;
|
||||
// if(data.listens.length > 0) {}
|
||||
// return data.listens[0];
|
||||
return payload as ListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
@@ -208,20 +261,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
static playToListenPayload = (play: PlayObject): ListenPayload => {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
artists = [],
|
||||
// MB doesn't use this during submission AFAIK
|
||||
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
meta: {
|
||||
brainz = {}
|
||||
} = {}
|
||||
}
|
||||
} = play;
|
||||
return {
|
||||
listened_at: (play.data.playDate ?? dayjs()).unix(),
|
||||
listened_at: getScrobbleTsSOCDate(play).unix(),
|
||||
track_metadata: {
|
||||
artist_name: play.data.artists[0],
|
||||
track_name: play.data.track,
|
||||
artist_name: artists[0],
|
||||
track_name: track,
|
||||
release_name: album,
|
||||
additional_info: {
|
||||
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined,
|
||||
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
|
||||
track_mbid: brainz.track,
|
||||
artist_mbids: brainz.artist !== undefined ? [brainz.artist] : undefined,
|
||||
artist_mbids: brainz.artist,
|
||||
release_mbid: brainz.album,
|
||||
release_group_mbid: brainz.releaseGroup
|
||||
}
|
||||
@@ -464,6 +526,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
recording_mbid: aRecordingMbid,
|
||||
duration: aDuration,
|
||||
duration_ms: aDurationMs,
|
||||
music_service_name,
|
||||
music_service,
|
||||
submission_client,
|
||||
submission_client_version
|
||||
} = {},
|
||||
mbid_mapping: {
|
||||
recording_mbid: mRecordingMbid
|
||||
@@ -509,7 +575,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
meta: {
|
||||
source: 'listenbrainz',
|
||||
trackId,
|
||||
playId
|
||||
playId,
|
||||
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../infrastructure/Atomic.js";
|
||||
import { PlatformApplication, PlatformType } from "./interfaces.js";
|
||||
import {Media, MediaController, Result} from "@foxxmd/chromecast-client";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import objectHash from "object-hash";
|
||||
import { PlayObject } from "../../../../core/Atomic.js";
|
||||
|
||||
export const chromePlayerStateToReported = (state: string): ReportedPlayerStatus => {
|
||||
switch (state) {
|
||||
case 'PLAYING':
|
||||
return REPORTED_PLAYER_STATUSES.playing;
|
||||
case 'PAUSED':
|
||||
case 'BUFFERING':
|
||||
return REPORTED_PLAYER_STATUSES.paused;
|
||||
case 'IDLE':
|
||||
return REPORTED_PLAYER_STATUSES.stopped;
|
||||
default:
|
||||
return REPORTED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentPlatformApplications = async (platform: PlatformType): Promise<PlatformApplication[]> => {
|
||||
|
||||
//https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.Session
|
||||
|
||||
let statusRes: Result<{applications?: PlatformApplication[]}>;
|
||||
try {
|
||||
statusRes = await platform.getStatus()
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
|
||||
}
|
||||
|
||||
let status: {applications?: PlatformApplication[]};
|
||||
|
||||
try {
|
||||
status = statusRes.unwrapAndThrow();
|
||||
if(status.applications === undefined) {
|
||||
return [];
|
||||
}
|
||||
return status.applications;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
export const getMediaStatus = async (controller: MediaController.MediaController) => {
|
||||
let status: Media.MediaStatus;
|
||||
|
||||
try {
|
||||
const statusRes = await controller.getStatus();
|
||||
status = statusRes.unwrapAndThrow();
|
||||
return status;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Unable to fetch media status', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
export const genDeviceId = (deviceName: string, appName: string) => {
|
||||
return `${deviceName.substring(0, 25)}-${appName.substring(0,25)}`;
|
||||
}
|
||||
|
||||
export const genPlayHash = (play: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
track,
|
||||
album,
|
||||
albumArtists
|
||||
} = {},
|
||||
meta: {
|
||||
mediaType
|
||||
}
|
||||
} = play;
|
||||
|
||||
return objectHash.sha1({artists, track, album, albumArtists, mediaType});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {createPlatform, MediaController, PersistentClient} from "@foxxmd/chromecast-client";
|
||||
import { FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
|
||||
import {Dayjs} from "dayjs";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
|
||||
export type PlatformType = ReturnType<typeof createPlatform>;
|
||||
export interface PlatformApplication {
|
||||
iconUrl?: string | null | undefined;
|
||||
isIdleScreen?: boolean | null | undefined;
|
||||
launchedFromCloud?: boolean | null | undefined;
|
||||
statusText?: string | null | undefined;
|
||||
appId: string;
|
||||
displayName: string;
|
||||
namespaces: {
|
||||
name: string;
|
||||
}[];
|
||||
sessionId: string;
|
||||
transportId: string;
|
||||
}
|
||||
|
||||
export interface PlatformApplicationWithContext extends PlatformApplication {
|
||||
filtered: boolean
|
||||
controller?: MediaController.MediaController
|
||||
stale: boolean
|
||||
staleAt?: Dayjs | undefined
|
||||
badData: boolean
|
||||
badDataAt?: Dayjs | undefined
|
||||
validAppType: boolean
|
||||
playerId: string
|
||||
logger: Logger
|
||||
lastPlayHash?: string
|
||||
}
|
||||
|
||||
export interface ChromecastFormatPlayObjectOptions extends FormatPlayObjectOptions {
|
||||
deviceId: string
|
||||
source: string
|
||||
}
|
||||
+55
-3
@@ -1,3 +1,7 @@
|
||||
import {ResponseError} from "superagent";
|
||||
import { findCauseByFunc } from "../../../utils.js";
|
||||
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
|
||||
|
||||
export interface MalojaV2ScrobbleData {
|
||||
artists: string[]
|
||||
title: string
|
||||
@@ -41,19 +45,32 @@ export interface MalojaV3ScrobbleData {
|
||||
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
|
||||
|
||||
export interface MalojaScrobbleRequestData {
|
||||
/** The auth key used to scrobble */
|
||||
key: string
|
||||
/** name of the track */
|
||||
title: string
|
||||
album: string
|
||||
/** name of the album */
|
||||
album?: string
|
||||
/** unix timestamp of when the track was scrobbled*/
|
||||
time: number
|
||||
length: number
|
||||
/** length of the track in seconds */
|
||||
length?: number
|
||||
/** number of seconds the track was listened to */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
|
||||
/** comma-separated list of artists for this track */
|
||||
artist: string
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
|
||||
/** a list of artists for this track */
|
||||
artists: string[]
|
||||
/** a list of artists for the album the track is on */
|
||||
albumartists: string[]
|
||||
/** skip server-side metadata parsing */
|
||||
nofix?: boolean
|
||||
}
|
||||
|
||||
interface MalojaScrobbleWarning {
|
||||
@@ -66,7 +83,7 @@ export interface MalojaResponseV3CommonData {
|
||||
status: 'failure' | 'error' | 'success' | 'ok'
|
||||
error?: {
|
||||
type: string
|
||||
value?: string | object
|
||||
value?: string | MalojaV3ScrobbleData & { [key: string]: any }
|
||||
desc: string
|
||||
}
|
||||
}
|
||||
@@ -80,3 +97,38 @@ export interface MalojaScrobbleV3ResponseData extends MalojaResponseV3CommonData
|
||||
desc: string
|
||||
warnings?: MalojaScrobbleWarning[]
|
||||
}
|
||||
|
||||
export const isMalojaAPIErrorBody = (body: any): body is MalojaResponseV3CommonData => {
|
||||
return typeof body === 'object'
|
||||
&& 'status' in body
|
||||
&& typeof body.status === 'string'
|
||||
&& 'error' in body
|
||||
&& typeof body.error === 'object'
|
||||
&& 'type' in body.error;
|
||||
}
|
||||
|
||||
export const getMalojaResponseError = (e: Error, asObject: boolean = false): ResponseError | undefined => {
|
||||
return findCauseByFunc(e, (err) => {
|
||||
if (!isSuperAgentResponseError(err)) {
|
||||
return false;
|
||||
}
|
||||
return isMalojaAPIErrorBody(err.response.body);
|
||||
}) as ResponseError | undefined;
|
||||
}
|
||||
|
||||
export const getMalojaRawResponse = (e: Error, asObject: boolean = false): string | object | undefined => {
|
||||
const err = getMalojaResponseError(e);
|
||||
if (err === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const {
|
||||
response: {
|
||||
body,
|
||||
text
|
||||
} = {}
|
||||
} = err;
|
||||
if (asObject) {
|
||||
return body as object;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Response} from 'superagent';
|
||||
|
||||
export interface SubsonicResponseCommon {
|
||||
status: 'failed' | 'ok'
|
||||
version: string
|
||||
type: string,
|
||||
serverVersion: string
|
||||
openSubsonic: boolean
|
||||
error?: {
|
||||
code: number,
|
||||
message: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SubsonicResponseBody<T extends SubsonicResponseCommon = SubsonicResponseCommon> {
|
||||
"subsonic-response": T
|
||||
}
|
||||
|
||||
export interface SubsonicResponse extends Response {
|
||||
body: SubsonicResponseBody
|
||||
}
|
||||
|
||||
export interface SubsonicNowPlayingResponse extends SubsonicResponseCommon {
|
||||
nowPlaying: {
|
||||
entry?: EntryData[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface EntryData {
|
||||
id: string,
|
||||
title: string,
|
||||
album?: string,
|
||||
artist: string,
|
||||
/**
|
||||
* Length of track in seconds
|
||||
* */
|
||||
duration: number,
|
||||
/**
|
||||
* When track began playing relative to now
|
||||
*
|
||||
* Returned as *whole* minutes so it isn't very accurate
|
||||
* */
|
||||
minutesAgo: number,
|
||||
playerId: string,
|
||||
username: string,
|
||||
}
|
||||
|
||||
export const asSubsonicResponseCommon = (obj: unknown): obj is SubsonicResponseCommon => {
|
||||
return obj !== null && typeof obj === 'object' && 'status' in obj;
|
||||
}
|
||||
|
||||
export const asSubsonicResponseBody = (obj: unknown): obj is SubsonicResponse => {
|
||||
return obj !== null && typeof obj === 'object' && 'subsonic-response' in obj;
|
||||
}
|
||||
|
||||
export const getSubsonicResponse = (resp: Response): SubsonicResponseCommon | undefined => {
|
||||
if(asSubsonicResponseBody(resp.body) && asSubsonicResponseCommon(resp.body['subsonic-response'])) {
|
||||
return resp.body['subsonic-response'];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type WebScrobblerHookEvent = 'scrobble' | 'paused' | 'resumedplaying' | 'nowplaying' | string;
|
||||
export interface WebScrobblerPayload {
|
||||
eventName: WebScrobblerHookEvent
|
||||
time?: number
|
||||
data: {
|
||||
song: WebScrobblerSong
|
||||
songs?: WebScrobblerSong[]
|
||||
currentlyPlaying?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessedSongData {
|
||||
artist?: string | null;
|
||||
album?: string | null;
|
||||
albumArtist?: string | null;
|
||||
track?: string | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
interface ParsedSongData extends ProcessedSongData {
|
||||
trackArt?: string | null;
|
||||
uniqueID?: string | null;
|
||||
originUrl?: string | null;
|
||||
isPodcast?: boolean | null;
|
||||
isPlaying?: boolean | null;
|
||||
currentTime?: number | null;
|
||||
isScrobblingAllowed?: boolean | null;
|
||||
}
|
||||
|
||||
export type Flags =
|
||||
| {
|
||||
isScrobbled: boolean;
|
||||
isCorrectedByUser: boolean;
|
||||
isRegexEditedByUser: {
|
||||
track: boolean;
|
||||
artist: boolean;
|
||||
album: boolean;
|
||||
albumArtist: boolean;
|
||||
};
|
||||
isAlbumFetched: boolean;
|
||||
isValid: boolean;
|
||||
isMarkedAsPlaying: boolean;
|
||||
isSkipped: boolean;
|
||||
isReplaying: boolean;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
export type Metadata =
|
||||
| {
|
||||
label: string;
|
||||
startTimestamp: number;
|
||||
|
||||
albumMbId?: string;
|
||||
albumUrl?: string;
|
||||
artistUrl?: string;
|
||||
notificationId?: string;
|
||||
trackArtUrl?: string;
|
||||
trackUrl?: string;
|
||||
userPlayCount?: number;
|
||||
userloved?: boolean;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
export interface WebScrobblerSong {
|
||||
controllerTabId: string | number;
|
||||
parsed: ParsedSongData;
|
||||
processed: ProcessedSongData;
|
||||
noRegex: ProcessedSongData;
|
||||
flags: Flags;
|
||||
metadata: Metadata;
|
||||
connectorLabel: string;
|
||||
}
|
||||
+54
-36
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
@@ -5,19 +6,20 @@ import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import timezone from 'dayjs/plugin/timezone.js';
|
||||
import {
|
||||
parseBool,
|
||||
readJson,
|
||||
sleep
|
||||
} from "./utils";
|
||||
import { parseBool, readJson, sleep } from "./utils.js";
|
||||
import * as path from "path";
|
||||
import {projectDir} from "./common/index";
|
||||
import SpotifySource from "./sources/SpotifySource";
|
||||
import { AIOConfig } from "./common/infrastructure/config/aioConfig";
|
||||
import { getRoot } from "./ioc";
|
||||
import {getLogger} from "./common/logging";
|
||||
import {LogInfo} from "../core/Atomic";
|
||||
import {initServer} from "./server/index";
|
||||
import { projectDir } from "./common/index.js";
|
||||
import SpotifySource from "./sources/SpotifySource.js";
|
||||
import { AIOConfig } from "./common/infrastructure/config/aioConfig.js";
|
||||
import { getRoot } from "./ioc.js";
|
||||
import { getLogger } from "./common/logging.js";
|
||||
import { LogInfo } from "../core/Atomic.js";
|
||||
import { initServer } from "./server/index.js";
|
||||
import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
|
||||
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
|
||||
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import LastfmSource from "./sources/LastfmSource.js";
|
||||
|
||||
|
||||
dayjs.extend(utc)
|
||||
@@ -28,6 +30,8 @@ dayjs.extend(timezone);
|
||||
|
||||
(async function () {
|
||||
|
||||
const scheduler = new ToadScheduler()
|
||||
|
||||
let output: LogInfo[] = []
|
||||
|
||||
const initLogger = getLogger({file: false}, 'init');
|
||||
@@ -38,6 +42,15 @@ initLogger.stream().on('log', (log: LogInfo) => {
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
process.on('uncaughtExceptionMonitor', (err, origin) => {
|
||||
const appError = new ErrorWithCause(`Uncaught exception is crashing the app! :( Type: ${origin}`, {cause: err});
|
||||
if(logger !== undefined) {
|
||||
logger.error(appError)
|
||||
} else {
|
||||
initLogger.error(appError);
|
||||
}
|
||||
})
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
|
||||
try {
|
||||
@@ -53,7 +66,6 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
|
||||
const {
|
||||
webhooks = [],
|
||||
port,
|
||||
logging = {},
|
||||
debugMode,
|
||||
} = (config || {}) as AIOConfig;
|
||||
@@ -67,14 +79,15 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
process.env.DEBUG_MODE = b.toString();
|
||||
}
|
||||
|
||||
const root = getRoot(port);
|
||||
const root = getRoot(config);
|
||||
initLogger.info(`Version: ${root.get('version')}`);
|
||||
|
||||
logger = getLogger(logging, 'app');
|
||||
|
||||
initServer(logger, output);
|
||||
|
||||
if(process.env.IS_LOCAL === 'true') {
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://github.com/FoxxMD/multi-scrobbler/blob/develop/docs/service.md');
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://foxxmd.github.io/multi-scrobbler/docs/installation/service');
|
||||
}
|
||||
|
||||
if(appConfigFail !== undefined) {
|
||||
@@ -92,6 +105,11 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
await scrobbleClients.buildClientsFromConfig(notifiers);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
} else {
|
||||
logger.info('Starting scrobble clients...');
|
||||
}
|
||||
for(const client of scrobbleClients.clients) {
|
||||
await client.initScrobbleMonitoring();
|
||||
}
|
||||
|
||||
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
|
||||
@@ -110,34 +128,34 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
let anyNotReady = false;
|
||||
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
|
||||
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if ((source as SpotifySource).spotifyApi !== undefined) {
|
||||
if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) {
|
||||
anyNotReady = true;
|
||||
} else {
|
||||
(source as SpotifySource).poll();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
if(source.initialized === true) {
|
||||
source.poll();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (source.poll !== undefined) {
|
||||
source.poll();
|
||||
}
|
||||
if(source.isReady()) {
|
||||
source.poll();
|
||||
} else {
|
||||
anyNotReady = true;
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
logger.info(`Some sources are not ready, open the dashboard to continue`);
|
||||
}
|
||||
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatClientsTask(scrobbleClients, logger)));
|
||||
logger.info('Scheduler started.');
|
||||
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
const appError = new ErrorWithCause('Exited with uncaught error', {cause: e});
|
||||
if(logger !== undefined) {
|
||||
logger.error(appError);
|
||||
} else {
|
||||
initLogger.error(appError);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}());
|
||||
|
||||
|
||||
+67
-31
@@ -1,53 +1,89 @@
|
||||
import {createContainer} from "iti";
|
||||
import path from "path";
|
||||
import {projectDir} from "./common/index";
|
||||
import ScrobbleClients from "./scrobblers/ScrobbleClients";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources";
|
||||
import {Notifiers} from "./notifier/Notifiers";
|
||||
import { configDir, projectDir } from "./common/index.js";
|
||||
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import { Notifiers } from "./notifier/Notifiers.js";
|
||||
import {EventEmitter} from "events";
|
||||
import {logPath} from "./common/logging";
|
||||
import {Container} from '@foxxmd/winston';
|
||||
import { WildcardEmitter } from "./common/WildcardEmitter";
|
||||
//import ScrobbleClients from "./clients/ScrobbleClients.js";
|
||||
import { logPath } from "./common/logging.js";
|
||||
import { WildcardEmitter } from "./common/WildcardEmitter.js";
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import fs from 'fs';
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
//const port = process.env.PORT ?? 9078;
|
||||
let version = 'Unknown';
|
||||
|
||||
/*let logPath = path.resolve(projectDir, `./logs`);
|
||||
if(typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}*/
|
||||
if(process.env.APP_VERSION === undefined) {
|
||||
if(fs.existsSync('./package.json')) {
|
||||
try {
|
||||
const pkg = fs.readFileSync('./package.json') as unknown as string;
|
||||
try {
|
||||
version = JSON.parse(pkg).version || 'unknown'
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} else if(fs.existsSync('./package-lock.json')) {
|
||||
try {
|
||||
const pkg = fs.readFileSync('./package-lock.json') as unknown as string;
|
||||
try {
|
||||
version = JSON.parse(pkg).version || 'unknown'
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
} catch (e) {
|
||||
// don't care
|
||||
}
|
||||
}
|
||||
process.env.APP_VERSION = version;
|
||||
} else {
|
||||
version = process.env.APP_VERSION;
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
const createRoot = (port: number | string | undefined) => {
|
||||
export interface RootOptions {
|
||||
baseUrl?: string,
|
||||
port?: string | number
|
||||
}
|
||||
|
||||
const createRoot = (options?: RootOptions) => {
|
||||
const {
|
||||
port = 9078,
|
||||
baseUrl = process.env.BASE_URL,
|
||||
} = options || {};
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
return createContainer().add({
|
||||
version,
|
||||
configDir: configDir,
|
||||
logDir: logPath,
|
||||
localUrl: `http://localhost:${port}`,
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
configPort: port,
|
||||
apiPort: process.env.API_PORT ?? 9079,
|
||||
mainPort: process.env.PORT ?? 3000,
|
||||
port: process.env.PORT ?? port,
|
||||
clientEmitter: () => new WildcardEmitter(),
|
||||
sourceEmitter: () => new WildcardEmitter(),
|
||||
notifierEmitter: () => new EventEmitter(),
|
||||
}).add((items) => ({
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
|
||||
port: () => {
|
||||
if(items.configPort !== undefined) {
|
||||
return items.configPort;
|
||||
}
|
||||
return items.isProd ? items.mainPort : items.apiPort;
|
||||
}).add((items) => {
|
||||
const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true});
|
||||
const u = new URL(base);
|
||||
let localUrl = u.toString();
|
||||
if(u.port === '' && u.pathname === '/') {
|
||||
localUrl = `${u.origin}:${items.mainPort}`;
|
||||
}
|
||||
}));
|
||||
return {
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
|
||||
localUrl,
|
||||
hasDefinedBaseUrl: baseUrl !== undefined,
|
||||
isSubPath: u.pathname !== '/' && u.pathname.length > 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getRoot = (port?: number | string) => {
|
||||
export const getRoot = (options?: RootOptions) => {
|
||||
if(root === undefined) {
|
||||
root = createRoot(port);
|
||||
root = createRoot(options);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks";
|
||||
import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { mergeArr } from "../utils";
|
||||
import { mergeArr } from "../utils.js";
|
||||
|
||||
export abstract class AbstractWebhookNotifier {
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier";
|
||||
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {gotify} from 'gotify';
|
||||
import request from 'superagent';
|
||||
import {HTTPError} from "got";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user