mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0f5801a1 | ||
|
|
4dbea91a5e | ||
|
|
548356aaa4 | ||
|
|
8b3f4c636c | ||
|
|
1b0212f3bb | ||
|
|
14e9ec0b45 | ||
|
|
dab113ab76 | ||
|
|
ad729769ec | ||
|
|
bb4ae549a3 | ||
|
|
0786642a38 | ||
|
|
77d5f5884e | ||
|
|
7a0a23b7fd | ||
|
|
1207a5eec1 | ||
|
|
48a0ecad41 | ||
|
|
f792feeb6c | ||
|
|
d6b682a23a | ||
|
|
d77d547c15 | ||
|
|
0dc828fa85 | ||
|
|
bd00a5f414 | ||
|
|
f587ad4bbc | ||
|
|
b8f2b1e3f8 | ||
|
|
bf3b81f1cd | ||
|
|
9375a6fce4 | ||
|
|
ca148f490e | ||
|
|
9ab0468913 | ||
|
|
99a73a80db | ||
|
|
80ba6e6c79 | ||
|
|
7a4f0f0d3f | ||
|
|
a9a6c6ae15 | ||
|
|
c5190fc2a6 | ||
|
|
93b543e1a4 | ||
|
|
4d77ca9c11 | ||
|
|
03cf3d1e3c | ||
|
|
d2134f3467 | ||
|
|
97fe411ac0 | ||
|
|
ff953b4535 | ||
|
|
b68fb9a9f9 | ||
|
|
ad1895d6e1 | ||
|
|
51b4ea3b16 | ||
|
|
3d86a38a2e | ||
|
|
605aad1df3 | ||
|
|
3d2e3534ff | ||
|
|
2a65ffe6c4 | ||
|
|
e3e3e03249 | ||
|
|
6bd8642c91 | ||
|
|
40ae842f82 | ||
|
|
a2ab1aa49e | ||
|
|
6d3cbf32df | ||
|
|
7a8138773c | ||
|
|
5ae79deca4 | ||
|
|
285f593a0f | ||
|
|
e0ae621b62 | ||
|
|
259d4dabc8 | ||
|
|
f7b03d9622 | ||
|
|
bd188a8ca7 | ||
|
|
28910a9332 | ||
|
|
ee38252e1a | ||
|
|
6f3595db31 | ||
|
|
c5c0cd9cd2 | ||
|
|
f5da32d233 | ||
|
|
315869dafe | ||
|
|
330802e29b | ||
|
|
3d5e988ef0 | ||
|
|
9a2b4b3d1e | ||
|
|
4a1ebcdbc6 | ||
|
|
bc66fdb2b6 | ||
|
|
6c2b0557c6 | ||
|
|
c24dc41811 | ||
|
|
0a41d32084 | ||
|
|
b7aa087b74 | ||
|
|
1f511d964d | ||
|
|
14eabecb7d | ||
|
|
8e31aa431d | ||
|
|
e47a636c2d | ||
|
|
bdd14b16de | ||
|
|
8cc5aacb9a | ||
|
|
d8e861dd54 | ||
|
|
e369f9eb21 | ||
|
|
ce6246e479 | ||
|
|
0c58cc6687 | ||
|
|
19687b725d | ||
|
|
02dee5fb6c | ||
|
|
e08a1ad514 | ||
|
|
865f1064f5 | ||
|
|
2b798e5a69 | ||
|
|
da4d2ecade | ||
|
|
89540d8b76 | ||
|
|
931709d277 | ||
|
|
1b199750bd | ||
|
|
4b1fa710f7 | ||
|
|
360b6f4cf0 | ||
|
|
e960438968 | ||
|
|
93230c45c7 | ||
|
|
a3fd30c427 | ||
|
|
c9bac6f7d2 | ||
|
|
06c0b64ee5 | ||
|
|
70a1181ea1 | ||
|
|
be9938fe95 | ||
|
|
27acfa9489 | ||
|
|
fc26125f80 | ||
|
|
193a5c1e4d | ||
|
|
700722627a | ||
|
|
1f6996d881 | ||
|
|
1cbad9ccfd | ||
|
|
04c9de648d | ||
|
|
2b60a0f76f | ||
|
|
176ff80172 | ||
|
|
e2d150ce15 | ||
|
|
636004a1bb | ||
|
|
2258bb36b9 | ||
|
|
0583a59177 | ||
|
|
fbbcfc5a57 | ||
|
|
317da71ec3 | ||
|
|
22bbcfe3e5 | ||
|
|
de3832f3ce | ||
|
|
04b07d6bfb | ||
|
|
fdf00d8acc | ||
|
|
3d2296858f | ||
|
|
b1390cd74a | ||
|
|
216d92cad7 | ||
|
|
dcde41f89a | ||
|
|
1e9518ac54 | ||
|
|
ac2aa8297c | ||
|
|
8611798051 | ||
|
|
a659897d37 | ||
|
|
b4303fa81c | ||
|
|
8f0a75a81b | ||
|
|
e006476b52 | ||
|
|
bb7bfc4a6e | ||
|
|
03c3f6b6a2 | ||
|
|
900f3c2471 | ||
|
|
cd86acdcf0 | ||
|
|
abf338f71a | ||
|
|
91bb0cba45 | ||
|
|
8499e3197d | ||
|
|
bfc3568b00 | ||
|
|
26613f8b68 | ||
|
|
0b2d4b5f89 | ||
|
|
6561ec9404 | ||
|
|
517df8a3ad | ||
|
|
4a0cdc0e87 | ||
|
|
a54bc99925 | ||
|
|
eeae98360f | ||
|
|
c5a938d4eb | ||
|
|
c5b7dbd50f | ||
|
|
26cd478c6f | ||
|
|
670805f18a | ||
|
|
8b851d2220 | ||
|
|
7a1279b02c | ||
|
|
3f2a49dedf | ||
|
|
d0f792c107 | ||
|
|
ff9172001f | ||
|
|
0b1772b30d | ||
|
|
64b77cab6b | ||
|
|
8aecd9dbce | ||
|
|
0bdd0fd801 | ||
|
|
0ede2cd96b | ||
|
|
8219dcecd7 | ||
|
|
42a5fdf9a1 | ||
|
|
9bc6f44af4 | ||
|
|
ccb31c5866 | ||
|
|
30f9ebf900 | ||
|
|
9b4eb2d383 | ||
|
|
6e2f765e69 | ||
|
|
57fd88556d | ||
|
|
034f5b217e | ||
|
|
a5112e0bbb | ||
|
|
f6db5fa14e | ||
|
|
d7de41d642 | ||
|
|
ca0d1a4f90 | ||
|
|
46fc9143b4 | ||
|
|
0800cff6fc | ||
|
|
3106b56dee | ||
|
|
08b1ac04bb | ||
|
|
1a55cc482c | ||
|
|
73aa7ad890 | ||
|
|
95db3da58f | ||
|
|
31926e6ad9 | ||
|
|
71a8ad2418 | ||
|
|
49bd1c8836 | ||
|
|
fc3dd62858 | ||
|
|
ff114a0ae5 | ||
|
|
8bfcb6cd9a | ||
|
|
8154e30939 | ||
|
|
506440825f | ||
|
|
646723fcf8 | ||
|
|
a560a49aac | ||
|
|
de763d68f3 | ||
|
|
f731c25332 | ||
|
|
f738eb92e8 | ||
|
|
b9f4f43d30 | ||
|
|
e42f00604e | ||
|
|
74eed98b9b | ||
|
|
cef6f5864a | ||
|
|
d5e816b1d3 | ||
|
|
dcc4201019 | ||
|
|
9c093a8455 | ||
|
|
9a56a3ee4d | ||
|
|
f2b0714dea | ||
|
|
0a1357acc7 | ||
|
|
403af711eb | ||
|
|
31cd72cd15 | ||
|
|
55afe876e0 | ||
|
|
49fadd8c9a | ||
|
|
58157ddfee | ||
|
|
2a9cd87848 | ||
|
|
7b31285f89 | ||
|
|
89db858289 | ||
|
|
2fec6aff6e | ||
|
|
9beadfaf0f | ||
|
|
dd7e971e71 | ||
|
|
1aee74bfc1 | ||
|
|
123c171d07 | ||
|
|
764b490cfd | ||
|
|
aec3398edb | ||
|
|
e552820b4d | ||
|
|
1bc8edb07c | ||
|
|
085e61db5c | ||
|
|
1df6a8d6f7 | ||
|
|
d0e1e83ebd | ||
|
|
e0a2ada59b | ||
|
|
855d3f6144 | ||
|
|
0508551dc7 | ||
|
|
f4a310cda9 | ||
|
|
6ac8938644 | ||
|
|
f41b3eea55 | ||
|
|
db34d92329 | ||
|
|
a60a0f89ad | ||
|
|
1de9c5cdc1 | ||
|
|
299e37e32a | ||
|
|
4921f963a8 | ||
|
|
22cc300733 | ||
|
|
46ee5b89c6 | ||
|
|
e660b8f835 | ||
|
|
0050d114ce | ||
|
|
e7ed5d9381 | ||
|
|
a1848c0a44 | ||
|
|
49827c80bc | ||
|
|
1c6427b8a7 | ||
|
|
ce35b67f37 | ||
|
|
4c0ff1c9ce | ||
|
|
f29e224631 | ||
|
|
d4bdab1f8d | ||
|
|
8f1808e24f | ||
|
|
1e5a427a3d | ||
|
|
b8ba0fe224 | ||
|
|
058473a595 | ||
|
|
5696d5598b | ||
|
|
937f22e643 | ||
|
|
ec6b952110 | ||
|
|
bbb0adf448 | ||
|
|
67a35cb81a | ||
|
|
44b40cc35f | ||
|
|
8eb4289b18 | ||
|
|
ee614e1a61 | ||
|
|
e0a274a314 | ||
|
|
71c26f1964 | ||
|
|
2c83364b9b | ||
|
|
20da4bd0b8 | ||
|
|
307d52fbf0 | ||
|
|
101e2c8657 | ||
|
|
8bdeca9089 | ||
|
|
5eb82f0da2 | ||
|
|
8c31b65525 | ||
|
|
f0d439a785 | ||
|
|
29cdf34ef8 | ||
|
|
5a1fb1c08e | ||
|
|
66dcdc30d2 | ||
|
|
f74940800b | ||
|
|
8a46890b20 | ||
|
|
a49212ea24 | ||
|
|
ce63a9b775 | ||
|
|
eb1ee5cfed | ||
|
|
0be4fbb8c5 | ||
|
|
3437519ef4 | ||
|
|
02791d7aba | ||
|
|
f8eeebe29f | ||
|
|
aa95604cc3 | ||
|
|
f981f9e207 | ||
|
|
eff9cfbdb8 | ||
|
|
37b4aca6be | ||
|
|
77ffcae41b | ||
|
|
e50623c9d4 | ||
|
|
82f89b51ea | ||
|
|
5b4a53121d | ||
|
|
7c0014b590 | ||
|
|
b22d96bbec | ||
|
|
f2396c7955 | ||
|
|
e4e398ac99 | ||
|
|
433e077eba | ||
|
|
51339cf74d | ||
|
|
eb5ded164c | ||
|
|
c06f7b4695 | ||
|
|
65a211df8b |
+5
-4
@@ -4,10 +4,11 @@ npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.github
|
||||
.git
|
||||
config/currentCreds.json
|
||||
*.log
|
||||
config/maloja.json
|
||||
config/spotify.json
|
||||
config/config.json
|
||||
.idea
|
||||
config/*.json
|
||||
config/*.bak
|
||||
/docs
|
||||
/logs
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
[Please check the FAQ](https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/FAQ.md) before submitting a bug report.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If possible reproduce the issue with [debug logging ON](https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/FAQ.md#turn-on-debug-logging)
|
||||
|
||||
```
|
||||
Copy and paste as much log data as possible related to this issue here.
|
||||
```
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
Provide version information for any related sources/clients.
|
||||
|
||||
- multi-scrobbler: [e.g. 0.4.0 on docker]
|
||||
- maloja [e.g. 3.1.4]
|
||||
- jellyfin [e.g. 10.8.9]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'develop'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@f2a13332ac1ce8c0a71aeac48a150dbb1838ab67
|
||||
with:
|
||||
images: foxxmd/multi-scrobbler
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
+6
-1
@@ -117,6 +117,11 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.json
|
||||
config/*.json
|
||||
*.txt
|
||||
.idea/
|
||||
|
||||
src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
|
||||
+37
-18
@@ -1,35 +1,54 @@
|
||||
FROM node:fermium-alpine3.10
|
||||
FROM lsiobase/alpine:3.17 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
alpine-base \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
openssh && \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
WORKDIR /home/node/app
|
||||
COPY docker/root/ /
|
||||
|
||||
COPY package*.json ./
|
||||
WORKDIR /app
|
||||
|
||||
USER node
|
||||
FROM base as build
|
||||
|
||||
RUN npm install --production
|
||||
# copy NPM dependencies and install
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc tsconfig.json .
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npm install
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL debug
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
ARG config_dir=/home/node/config
|
||||
RUN mkdir -p $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_DIR=$config_dir
|
||||
RUN npm run build && rm -rf node_modules
|
||||
|
||||
ARG log_dir=/home/node/logs
|
||||
RUN mkdir -p $log_dir
|
||||
VOLUME $log_dir
|
||||
ENV LOG_DIR=$log_dir
|
||||
FROM base as app
|
||||
|
||||
COPY --from=build --chown=abc:abc /app /app
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
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
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
|
||||
CMD [ "node", "index.js" ]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 FoxxMD
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -2,139 +2,92 @@
|
||||
|
||||
[](https://github.com/FoxxMD/multi-scrobbler/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/repository/docker/foxxmd/multi-scrobbler)
|
||||
[](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
A single-user, javascript app to scrobble your recent plays to [Maloja](https://github.com/krateng/maloja) (and other clients, eventually)
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
|
||||
|
||||
* Displays running status and buffered log through web server
|
||||
* Spotify - Authorize your app through the web server
|
||||
* Spotify - Persists obtained credentials to file
|
||||
* Spotify - Automatically refreshes authorization for unattended use
|
||||
* Spotify - Implements back off behavior if no listening activity is detected after an interval (after 10 minutes of idle it will back off to a maximum of 5 minutes between checks)
|
||||
* [Tautulli](https://tautulli.com) - Scrobble endpoint using notification agents
|
||||
* [Plex](https://plex.tv) - Scrobble endpoint using [Webhooks](https://support.plex.tv/articles/115002267687-webhooks)
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](/docs/configuration.md#spotify)
|
||||
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Jellyfin](/docs/configuration.md#jellyfin)
|
||||
* [Youtube Music](/docs/configuration.md#youtube-music)
|
||||
* [Last.fm](/docs/configuration.md#lastfm-source)
|
||||
* [ListenBrainz](/docs/configuration.md#listenbrainz--source-)
|
||||
* [Deezer](/docs/configuration.md#deezer)
|
||||
* [MPRIS (Linux Desktop)](/docs/configuration.md#mpris)
|
||||
* [Mopidy](/docs/configuration.md#mopidy)
|
||||
* [JRiver](/docs/configuration.md#jriver)
|
||||
* [Kodi](/docs/configuration.md#kodi)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](/docs/configuration.md#maloja)
|
||||
* [Last.fm](/docs/configuration.md#lastfm)
|
||||
* [ListenBrainz](/docs/configuration.md#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/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
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Docker images for x86/ARM
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
|
||||
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
|
||||
* **Track your activity regardless of where you listen** -- Scrobble from many Sources to one Client with ease and without duplicating tracks.
|
||||
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/docs/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docs/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">
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
You set up configurations for one or more **Sources** and one or more **Clients**. MS monitors all of your configured **Sources**. When new tracks are played by a Source it grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to scrobble to.
|
||||
|
||||
### Source
|
||||
|
||||
A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Source.
|
||||
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
|
||||
|
||||
### Client
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](/docs/installation.md)
|
||||
|
||||
### Locally
|
||||
## Configuration
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### [Dockerhub](https://hub.docker.com/repository/docker/foxxmd/multi-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
All configuration is done through json files or environment variables. Reference the [examples in the config folder](config) more detailed explanations and structure.
|
||||
|
||||
**A property from a json config will override the corresponding environmental variable.**
|
||||
|
||||
### App (General)
|
||||
|
||||
[JSON config example](config/config.json.example)
|
||||
|
||||
These environmental variables do not have a config file equivalent (to make Docker configuration easier)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|--------------|-------------------------------------------------------------------------------------------|
|
||||
| `CONFIG_DIR` | - | `CWD/config` | Directory to look for all other configuration files |
|
||||
| `LOG_PATH` | - | `CWD/logs` | If `false` no logs will be written. If `string` will be the directory logs are written to |
|
||||
| `PORT` | - | 9078 | Port to run web server on |
|
||||
|
||||
**The app must have permission to write to `CONFIG_DIR` in order to store the current spotify access token.**
|
||||
|
||||
|
||||
### Sources (Where Music Plays Come From)
|
||||
|
||||
#### Spotify
|
||||
|
||||
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.
|
||||
|
||||
[Spotify config example](config/spotify.json.example)
|
||||
|
||||
All variables have a config file equivalent which will overwrite the ENV variable if present (so config file is not required if ENVs present)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_ACCESS_TOKEN` | - | | Must include either this token or client id/secret |
|
||||
| `SPOTIFY_REFRESH_TOKEN` | - | | |
|
||||
| `SPOTIFY_REDIRECT_URI` | - | `http://localhost:{PORT}/callback` | URI must end in `callback` |
|
||||
|
||||
The app will automatically obtain new access/refresh token if needed and possible. These will override values from configuration.
|
||||
|
||||
#### [Plex](https://plex.tv)
|
||||
|
||||
Check the [instructions](docs/plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
|
||||
|
||||
[Plex config example](config/plex.json.example)
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| PLEX_USER | - | | The username of the user to scrobble tracks for. No user specified means all tracks by all users will be scrobbled. |
|
||||
|
||||
#### [Tautulli](https://tautulli.com)
|
||||
|
||||
Check the [instructions](docs/plex.md) on how to setup a notification agent to scrobble your plays.
|
||||
|
||||
**Environmental variables and config file is the same as Plex**
|
||||
|
||||
### Scrobble Clients
|
||||
|
||||
At least one client (the only one right now...) must be setup in order for the app to work. Client configurations can alternatively be configured in the main [`config.json`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/config.json.example) configuration
|
||||
|
||||
#### Maloja
|
||||
|
||||
[Maloja config example](config/maloja.json.example)
|
||||
|
||||
All variables have a config file equivalent which will overwrite the ENV variable if present (so config file is not required if ENVs present)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|-------------------------------|
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
[See the **Configuration** documentation](/docs/configuration.md)
|
||||
|
||||
## Usage
|
||||
|
||||
A status page with statistics and recent logs can found at
|
||||
A status page with statistics, recent logs, and some runtime configuration options can be found at
|
||||
|
||||
```
|
||||
https://localhost:9078
|
||||
http://localhost:9078
|
||||
```
|
||||
Output is also provided to stdout/stderr as well as file if specified in configuration.
|
||||
|
||||
On first startup you may need to authorize Spotify by visiting the callback URL (which can also be accessed from the status page)
|
||||
On first startup you may need to authorize Spotify and/or Last.fm by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
|
||||
|
||||
```
|
||||
https://localhost:9078/authSpotify
|
||||
```
|
||||
## Help/FAQ
|
||||
|
||||
### Running Directly
|
||||
|
||||
```
|
||||
node index.js
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
| Environmental Variable | Type | Default |
|
||||
|------------------------|--------|-------------------------|
|
||||
| `CONFIG_DIR` | Volume | `/home/node/app/config` |
|
||||
| `LOG_DIR` | Volume | `/home/node/app/logs` |
|
||||
| `PORT` | Port | 9078 |
|
||||
|
||||
## Examples
|
||||
|
||||
[See minimal configuration in the examples doc](docs/examples.md)
|
||||
Having issues with connections or configuration? Check the [FAQ](/docs/FAQ.md) before creating an issue!
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -1,30 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default class AbstractScrobbleClient {
|
||||
|
||||
name;
|
||||
|
||||
recentScrobbles = [];
|
||||
|
||||
lastScrobbleCheck = dayjs();
|
||||
refreshEnabled;
|
||||
checkExistingScrobbles;
|
||||
|
||||
config;
|
||||
logger;
|
||||
|
||||
constructor(logger, config = {}, options = {}) {
|
||||
this.logger = logger;
|
||||
this.config = config;
|
||||
const {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
} = options;
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
this.checkExistingScrobbles = checkExistingScrobbles;
|
||||
}
|
||||
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
name = 'Maloja';
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
const {url} = this.config;
|
||||
const today = dayjs().format('YYYY/MM/DD');
|
||||
const resp = await request.get(`${url}/apis/mlj_1/scrobbles?since=${today}&to=${today}&max=15`);
|
||||
this.recentScrobbles = resp.body.list.slice(0, 10);
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
alreadyScrobbled = (playObj) => {
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
trackLength,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
const playUnix = playDate.unix();
|
||||
const lowerTitle = track.toLocaleLowerCase();
|
||||
return this.recentScrobbles.some((x) => {
|
||||
const {time: scrobbleTime, title: scrobbleTitle} = x;
|
||||
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase();
|
||||
if (lowerTitle.includes(lowerScrobbleTitle) || lowerScrobbleTitle.includes(lowerTitle)) {
|
||||
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(playUnix - scrobbleTime);
|
||||
if (scrobblePlayDiff < 10) {
|
||||
this.logger.debug(`Scrobble with same name found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`, {label: this.name});
|
||||
return true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track
|
||||
let scrobblePlayStartDiff = Math.abs(playUnix - (scrobbleTime - trackLength));
|
||||
if (scrobblePlayStartDiff < 10) {
|
||||
this.logger.debug(`Scrobble with same name found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`, {label: this.name});
|
||||
return true;
|
||||
}
|
||||
this.logger.debug(`Scrobble with same name found but the start/finish times vs scrobble time diffs were too large to consider dups (Start Diff ${scrobblePlayStartDiff.toFixed(0)}s) (End Diff ${scrobblePlayDiff.toFixed(0)}s)`, {label: this.name});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}
|
||||
|
||||
scrobble = async (playObj) => {
|
||||
const {url, apiKey} = this.config;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
playDate
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
try {
|
||||
await request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send({
|
||||
artist,
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
});
|
||||
this.logger.info('Scrobbled', {label: this.name});
|
||||
} catch (e) {
|
||||
this.logger.error('Error while scrobbling', {label: this.name});
|
||||
this.logger.log(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {readJson} from "../utils.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
|
||||
export default class ScrobbleClients {
|
||||
|
||||
clients;
|
||||
logger;
|
||||
|
||||
constructor(logger, clients = []) {
|
||||
this.logger = logger;
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
buildClients = async (clientConfigs = [], configDir = undefined) => {
|
||||
const clients = [];
|
||||
if (!clientConfigs.every(x => typeof x === 'object')) {
|
||||
throw new Error('All client from config json must be objects');
|
||||
}
|
||||
for (const clientType of ['maloja']) {
|
||||
|
||||
let clientConfig = {};
|
||||
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
clientConfig = clientConfigs.find(x => x.type === 'maloja') || {
|
||||
url: process.env.MALOJA_URL,
|
||||
apiKey: process.env.MALOJA_API_KEY
|
||||
};
|
||||
|
||||
if (Object.values(clientConfig).every(x => x === undefined)) {
|
||||
try {
|
||||
clientConfig = await readJson(`${configDir}/maloja.json`);
|
||||
} catch (e) {
|
||||
// no config exists, skip this client
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
apiKey
|
||||
} = clientConfig;
|
||||
|
||||
if (url === undefined) {
|
||||
this.logger.warn('Maloja url not found in config');
|
||||
continue;
|
||||
}
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn('Maloja api key not found in config');
|
||||
continue;
|
||||
}
|
||||
clients.push(new MalojaScrobbler(this.logger, clientConfig));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
scrobble = async (playObj, options = {}) => {
|
||||
const {
|
||||
forceRefresh = false,
|
||||
checkTime = dayjs(),
|
||||
} = options;
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
await client.refreshScrobbles();
|
||||
}
|
||||
if (!client.alreadyScrobbled(playObj)) {
|
||||
await client.scrobble(playObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
These are **example configurations** for all Source/Client types and AIO config.
|
||||
|
||||
These can be used as-is by renaming them to `.json` and filling or replacing sample data.
|
||||
|
||||
For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
|
||||
These are **NOT** exhaustive examples. You should consult the **configuration** documentation and the **schema explorer links** for each source/config type to see a complete list of options and descriptions for all properties.
|
||||
|
||||
Documentation at
|
||||
|
||||
* [internal docs](/docs/configuration.md)
|
||||
* External Link: https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/configuration.md
|
||||
@@ -1,11 +1,59 @@
|
||||
{
|
||||
"spotify": {}, // optional, may specify config here, or in CONFIG_DIR/spotify.json, or as ENV vars
|
||||
|
||||
"clients": [ // may specify clients as objects, or in CONFIG_DIR/{clientType}.json, or as ENV vars
|
||||
// EX CONFIG_DIR/maloja.json
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "maloja", // client name
|
||||
"data": {} // config data
|
||||
"type": "spotify",
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/callback"
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "FirstGotifyServer",
|
||||
"type": "gotify",
|
||||
"url": "http://localhost:8070",
|
||||
"token": "MyGotifyToken",
|
||||
"priorities": {
|
||||
"info": 5,
|
||||
"warn": 7,
|
||||
"error": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ntfy",
|
||||
"name": "MyNtfyFriendlyNameForLogs",
|
||||
"url": "http://localhost:9991",
|
||||
"topic": "MyMultiScrobblerTopic",
|
||||
"username": "Optional",
|
||||
"password": "Optional",
|
||||
"priorities": {
|
||||
"info": 3,
|
||||
"warn": 4,
|
||||
"error": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logPayload": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJriver",
|
||||
"data": {
|
||||
"url": "0.0.0.0",
|
||||
"username": "auser",
|
||||
"password": "apassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyKodi",
|
||||
"data": {
|
||||
"url": "http://localhost:8080/jsonrpc",
|
||||
"username": "myUser",
|
||||
"password": "myPassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "brainz",
|
||||
"configureAs": "client"
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"url": "https://domain.tld", // the base url of your maloja installation
|
||||
"apiKey": "string" // your maloja api key
|
||||
}
|
||||
[
|
||||
{
|
||||
"name": "myMaloja",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyMopidy",
|
||||
"data": {
|
||||
"url": "localhost",
|
||||
"uriBlacklist": [],
|
||||
"uriWhitelist": [],
|
||||
"albumBlacklist": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"data": {
|
||||
"whitelist": ["vlc", "mpd"],
|
||||
"blacklist": ["spotify"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"user": "username@email.com", // user may be a single username
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"] // or it may be an array of usernames
|
||||
}
|
||||
[
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"clientId": "string", // spotify client id
|
||||
"clientSecret": "string", // spotify client secret
|
||||
"redirectUri": "http://localhost:9078/callback", // optional, spotify redirect URI. Specify only if not the default. URI must end in "callback"
|
||||
"interval": 60 // optional, how long to wait before calling spotify for new tracks
|
||||
}
|
||||
[
|
||||
{
|
||||
"name": "MySpotify",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
"password": "yourPassword",
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyTautuilli",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyYTMusic",
|
||||
"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",
|
||||
"authUser": "0",
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#- SPOTIFY_CLIENT_ID=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- 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
|
||||
volumes:
|
||||
- /path_on_host/multi-scrobbler-config:/config
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI on
|
||||
restart: unless-stopped
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-plex as a template
|
||||
|
||||
POPULATE_EXAMPLES=false
|
||||
|
||||
echo "-------------------------------------"
|
||||
echo -e "Setting up multi-scrobbler config directory based on CONFIG_DIR env: ${CONFIG_DIR}\n"
|
||||
|
||||
# make config folder if it does not exist
|
||||
if [ ! -d "${CONFIG_DIR}" ]; then
|
||||
echo "Directory does not exist! Creating..."
|
||||
POPULATE_EXAMPLES=true
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
else
|
||||
if [ "$(ls -A ${CONFIG_DIR})" ]; then
|
||||
echo "Directory is not empty, not creating examples."
|
||||
else
|
||||
POPULATE_EXAMPLES=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# add example configs
|
||||
if [ "$POPULATE_EXAMPLES" = true ]; then
|
||||
echo "Directory is empty, adding examples..."
|
||||
cp -r /app/config/. "${CONFIG_DIR}"/
|
||||
fi
|
||||
|
||||
# permissions
|
||||
echo "chown'ing directory to ensure correct permissions."
|
||||
chown -R abc:abc "${CONFIG_DIR}"
|
||||
echo "Done!"
|
||||
echo -e "-------------------------------------\n"
|
||||
@@ -0,0 +1 @@
|
||||
oneshot
|
||||
@@ -0,0 +1 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-ms-config/run
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-wikijs/blob/master/root/etc/s6-overlay/s6-rc.d/svc-wikijs/run as a template
|
||||
|
||||
# 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!"
|
||||
exec \
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/src/index.js run
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
* [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)
|
||||
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
|
||||
* [Configuration Issues](#configuration-issues)
|
||||
* [Config could not be parsed](#config-could-not-be-parsed)
|
||||
|
||||
# Connection Issues
|
||||
|
||||
## Plex/Tautulli/Jellyfin don't connect
|
||||
|
||||
These three [sources](/README.md#source) are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server to contact multi-scrobbler, as opposed to multi-scrobbler contacting the server.
|
||||
|
||||
multi-scrobbler will log information about any server that connects to it for these three services. In the logs it looks something like this:
|
||||
|
||||
```
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) Received valid data from server examplePlex for the first time.
|
||||
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
|
||||
```
|
||||
It also logs if a server tries to connect to a URL that it does not recognize:
|
||||
|
||||
```
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
```
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin is not able to connect to your multi-scrobbler instance at all.**
|
||||
|
||||
This is not something multi-scrobbler can fix and means you have an issue in your network.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Check or try all these steps before submitting an issue:
|
||||
|
||||
#### Turn on Debug Logging
|
||||
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
|
||||
|
||||
* using node `LOG_LEVEL=debug ... node src/index.js`
|
||||
* using docker `docker run -e LOG_LEVEL=debug ... foxxmd/multi-scrobbler`
|
||||
|
||||
Check the output for any additional information.
|
||||
|
||||
#### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](/docs/configuration.md) documentation assume you are running Plex/Tautulli/Jellyfin on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
|
||||
|
||||
#### Check Firewall and Port Forwarding
|
||||
|
||||
If the machine multi-scrobbler is running on has a firewall ensure that port **9078** is open. Or if it is in another network entirely make sure your router is forwarding this port and it is open to the correct machine.
|
||||
|
||||
#### Check Source Service Logs
|
||||
|
||||
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
|
||||
|
||||
##### Plex
|
||||
|
||||
Settings -> Manage -> Console
|
||||
|
||||
##### Tautulli
|
||||
|
||||
Check the command-line output of the application or docker logs.
|
||||
|
||||
##### Jellyfin
|
||||
|
||||
Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
## Jellyfin has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](/docs/configuration.md#jellyfin)
|
||||
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
|
||||
You can verify the payload sent from the webhook by modifying your jellyfin configuration to include `logPayload: true` which will output the raw payload to DEBUG level logging:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
|
||||
## Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
Ensure any **client id** or **secrets** are correct in your configuration.
|
||||
|
||||
The callback/redirect URL for these services must be:
|
||||
|
||||
* the same address you would use to access the multi-scrobbler web interface
|
||||
* the web-interface must be accessible from the browser you are completing authentication from.
|
||||
|
||||
If multi-scrobbler is not running on the same machine your browser is on then the default/example addresses (`http://localhost...`) **will not work.** You must determine the address you can reach the web interface at (such as `http://192.168.0.140:9078`) then use that in place of `localhost` in the callback URLs.
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
# Configuration Issues
|
||||
|
||||
## Config could not be parsed
|
||||
|
||||
If you see something like this in your logs:
|
||||
|
||||
```
|
||||
2023-02-19T10:05:42-06:00 warn : [App] App config file exists but could not be parsed!
|
||||
2023-02-19T10:05:42-06:00 error : [App] Exited with uncaught error
|
||||
2023-02-19T10:05:42-06:00 error : [App] Error: config.json could not be parsed
|
||||
```
|
||||
|
||||
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
|
||||
@@ -0,0 +1,761 @@
|
||||
* [Configuration Overview](#configuration-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)
|
||||
* [Source Configurations](#source-configurations)
|
||||
* [Spotify](#spotify)
|
||||
* [Plex](#plex)
|
||||
* [Tautulli](#tautulli)
|
||||
* [Subsonic](#subsonic)
|
||||
* [Jellyfin](#jellyfin)
|
||||
* [Last.fm (Source)](#lastfm--source-)
|
||||
* [Listenbrainz (Source)](#listenbrainz--source-)
|
||||
* [Deezer](#deezer)
|
||||
* [Youtube Music](#youtube-music)
|
||||
* [MPRIS (Linux Desktop)](#mpris)
|
||||
* [Mopidy](#mopidy)
|
||||
* [JRiver](#jriver)
|
||||
* [Kodi](#kodi)
|
||||
* [Client Configurations](#client-configurations)
|
||||
* [Maloja](#maloja)
|
||||
* [Last.fm](#lastfm)
|
||||
* [Listenbrainz](#listenbrainz)
|
||||
* [Monitoring](#monitoring)
|
||||
* [Webhooks](#webhook-configurations)
|
||||
* [Health Endpoint](#health-endpoint)
|
||||
|
||||
# Configuration Overview
|
||||
|
||||
[**Sources** and **Clients**](/README.md#how-does-multi-scrobbler-ms-work) are configured using environmental (ENV) variables and/or json files.
|
||||
|
||||
**MS will parse configuration from both configuration types.** You can mix and match configurations but it is generally better to stick to one or the other.
|
||||
|
||||
TIP: Check the [**FAQ**](/docs/FAQ.md) if you have any issues after configuration!
|
||||
|
||||
## ENV-Based Configuration
|
||||
|
||||
This is done by passing environmental variables and so does not require any files to run MS.
|
||||
|
||||
* Using a docker container EX `docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" ...`
|
||||
* Using a local installations by exporting variables before running MS EX `SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret node index.js`
|
||||
|
||||
Use ENV-based configuration if:
|
||||
|
||||
* You are the only person for whom MS is scrobbling for
|
||||
* You have a very simple setup for MS such as one scrobble [Client](/README.md#client) and one [Source](/README.md#source) IE Plex -> Maloja
|
||||
|
||||
## File-Based Configuration
|
||||
|
||||
MS will parse configuration files located in the directory specified by the `CONFIG_DIR` environmental variable. This variable defaults to:
|
||||
|
||||
* Local installation -> `PROJECT_DIR/config`
|
||||
* Docker -> `/config` (in the container) -- see the [install docs](/docs/installation.md#docker) for how to configure this correctly
|
||||
|
||||
Use File-based configuration if:
|
||||
|
||||
* You have many [Sources](/README.md#source)
|
||||
* You have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
|
||||
Funkwhale...
|
||||
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers
|
||||
* You want only some **Sources** to scrobble to some **Clients** IE Fred's Spotify account scrobbles to Fred's Maloja
|
||||
server, but not Mary's Maloja server
|
||||
|
||||
File-based configurations located in the `CONFIG_DIR` directory can be parsed from
|
||||
|
||||
* an **all-in-one** config file named `config.json` that contains information for all Sources and Clients and/or
|
||||
* many **specific** files named based on the client/source to configure IE `plex.json` `spotify.json`
|
||||
|
||||
There are **example configurations** for all Source/Client types and AIO config located in the [/config](/config) directory of this project. These can be used as-is by renaming them to `.json`.
|
||||
For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
|
||||
There is also a [**kitchensink example**](/docs/kitchensink.md) that provides examples of using all sources/clients in a complex configuration.
|
||||
|
||||
### 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%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
Example directory structure:
|
||||
|
||||
```
|
||||
/CONFIG_DIR
|
||||
config.json
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Config Example</summary>
|
||||
|
||||
```json5
|
||||
// in config.json
|
||||
{
|
||||
//...
|
||||
"sources": [
|
||||
{
|
||||
"name": "myConfig",
|
||||
"type": "spotify",
|
||||
"clients": [
|
||||
"myMalojaClient"
|
||||
],
|
||||
"data": {
|
||||
"clientId": "anExample"
|
||||
//...
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"name": "myFirstMalojaClient",
|
||||
"type": "maloja",
|
||||
"data": {
|
||||
"url": "http://myMalojaServer.example",
|
||||
// ...
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
`config.json` can also be used to set default behavior for all sources/clients using `sourceDefaults` and `clientDefaults` properties.
|
||||
|
||||
See [config.json.example](/config/config.json.example) for an annotated example or check out [the kitchen sink example](kitchensink.md).
|
||||
|
||||
### Specific File Configuration
|
||||
|
||||
Each file is named by the **type** of the Client/Source found in below sections. Each file as an **array** of that type of Client/Source.
|
||||
|
||||
Example directory structure:
|
||||
|
||||
```
|
||||
/CONFIG_DIR
|
||||
plex.json
|
||||
spotify.json
|
||||
maloja.json
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Config Example</summary>
|
||||
|
||||
```json5
|
||||
// in maloja.json
|
||||
[
|
||||
{
|
||||
"name": "myFirstMalojaClient",
|
||||
"data": {
|
||||
"url": "http://myMalojaServer.example",
|
||||
"apiKey": "myKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mySecondMalojaClient",
|
||||
"data": {
|
||||
"url": "http://my2ndMalojaServer.example",
|
||||
"apiKey": "myKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See the [/config](/config) directory of this project for examples of each type of config file or reference specific files below.
|
||||
|
||||
# Source Configurations
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
|
||||
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.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:9078/callback` | URI must end in `callback` |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`spotify.json.example`](/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)
|
||||
|
||||
## [Plex](https://plex.tv)
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `PLEX_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`plex.json.example`](/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)
|
||||
|
||||
## [Tautulli](https://tautulli.com)
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `TAUTULLI_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`tautulli.json.example`](/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)
|
||||
|
||||
## [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/))
|
||||
|
||||
**Known Issues:**
|
||||
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
|
||||
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
|
||||
* "played X minutes ago" sometimes is also not reported correctly
|
||||
* Multiple artists are reported as one value and cannot be separated
|
||||
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SUBSONIC_USER` | Yes | | |
|
||||
| `SUBSONIC_PASSWORD` | Yes | | |
|
||||
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`subsonic.json.example`](/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)
|
||||
|
||||
## [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
Must be using Jellyfin 10.7 or greater
|
||||
|
||||
* In the Jellyfin desktop web UI Navigate to -> Administration -> Dashboard -> Plugins -> Catalog
|
||||
* Under Notifications -> **Webhook** -> Install, then restart your server
|
||||
* Navigate back to -> Administration -> Dashboard -> Plugins -> My Plugins -> Webhook
|
||||
* Click "..." -> Settings
|
||||
* In Webhook settings:
|
||||
* `Add Generic Destination`
|
||||
* In the new `Generic` dropdown:
|
||||
* Webhook Url: `http://localhost:9078/jellyfin`
|
||||
* Notification Type: `Playback Progress`
|
||||
* Item Type: `Songs`
|
||||
* Check `Send All Properties`
|
||||
* Save
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-------------------------------------------------------------------|
|
||||
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
|
||||
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jellyfin.json.example`](/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)
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
See the [Last.fm (Client)](#lastfm) setup for registration instructions. You may need to disable "Hide recent listening information" on your [privacy page](https://www.last.fm/settings/privacy) for this to work.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
No support for ENV based for Last.fm as a client (only source)
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](/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)
|
||||
|
||||
## [Listenbrainz (Source)](https://listenbrainz.org)
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
**NOTE:** You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](/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)
|
||||
|
||||
**Change `configureAs` to `source`**
|
||||
|
||||
## [Deezer](https://deezer.com/)
|
||||
|
||||
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
|
||||
|
||||
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
|
||||
* Redirect URL must end in `deezer/callback`
|
||||
* Default would be `http://localhost:9078/deezer/callback`
|
||||
|
||||
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
|
||||
|
||||
* **Application ID**
|
||||
* **Secret Key**
|
||||
* **Redirect URL** (if not the default)
|
||||
|
||||
**If no access token is provided...**
|
||||
|
||||
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|-----------------------------------------|----------------------------------------------------|
|
||||
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
|
||||
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
|
||||
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`deezer.json.example`](/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)
|
||||
|
||||
## [Youtube Music](https://music.youtube.com)
|
||||
|
||||
Credentials for YT Music are obtained from a browser request to https://music.youtube.com **once you are logged in.** [Specific requirements are here and summarized below:](https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate)
|
||||
|
||||
* Open a new tab
|
||||
* Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
|
||||
* Go to https://music.youtube.com and ensure you are logged in
|
||||
|
||||
Then...
|
||||
|
||||
1. Find and select an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you don’t see the request, try scrolling down a bit or clicking on the library button in the top bar.
|
||||
2. **Make sure **Headers** pane is selected and open
|
||||
3. In the **Request Headers** section find and copy the **entire value** found after `Cookie:` and use this as the `cookie` value in your multi-scrobbler config
|
||||
4. If present, in the **Request Headers** section find and copy the number found in `X-google-AuthUser` and use this as the value for `authUser` in your multi-scrobbler config
|
||||
|
||||

|
||||
|
||||
NOTES:
|
||||
|
||||
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps above to get new credentials.
|
||||
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`ytmusic.json.example`](/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)
|
||||
|
||||
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
|
||||
|
||||
MPRIS is a standard interface for communicating with Music Players on **linux operating systems.**
|
||||
|
||||
If you run Linux and have a notification tray that shows what media you are listening to, you likely have access to MPRIS.
|
||||
|
||||

|
||||
|
||||
multi-scrobbler can listen to this interface and scrobble tracks played by **any media player** that communicates to the operating system with MPRIS.
|
||||
|
||||
**NOTE:** multi-scrobbler needs to be running as a [**Local Installation**](/docs/installation.md#local) in order to use MPRIS. This cannot be used from docker.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|----------------------------------------------------------------------------------|
|
||||
| MPRIS_ENABLE | No | | Use MPRIS as a Source (useful when you don't need any other options) |
|
||||
| MPRIS_BLACKLIST | No | | Comma-delimited list of player names not to scrobble from |
|
||||
| MPRIS_WHITELIST | No | | Comma-delimited list of players names to ONLY scrobble from. Overrides blacklist |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mpris.json.example`](/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)
|
||||
|
||||
## [Mopidy](https://mopidy.com/)
|
||||
|
||||
Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/)
|
||||
|
||||
multi-scrobbler can scrobble tracks played from any Mopidy backend source, regardless of where you listen to them.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mopidy.json.example`](/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)
|
||||
|
||||
Configuration Options:
|
||||
|
||||
##### `url`
|
||||
|
||||
The URL used to connect to the Mopidy server. You MUST have [Mopidy-HTTP extension](https://mopidy.com/ext/http) enabled.
|
||||
|
||||
If no `url` is provided a default is used which assumes Mopidy is installed on the same server as multi-scrobbler: `ws://localhost:6680/mopidy/ws/`
|
||||
|
||||
Make sure the hostname and port number match what is found in the Mopidy configuration file `mopidy.conf`:
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
[http]
|
||||
hostname = localhost
|
||||
port = 6680
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
|
||||
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
Part => Default Value
|
||||
|
||||
* Protocol => `ws://`
|
||||
* Hostname => `localhost`
|
||||
* Port => `6680`
|
||||
* Path => `/mopidy/ws/`
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "mopidy.mydomain.com"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `ws://mopidy.mydomain.com:6680/mopidy/ws/`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "192.168.0.101:3456"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "mopidy.mydomain.com:80/MOPWS"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS`
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
#### URI Blacklist/Whitelist
|
||||
|
||||
If you wish to disallow or only allow scrobbling from some sources played through Mopidy you can specify these using `uriBlacklist` or `uriWhitelist` in your config. multi-scrobbler will check the list to see if any string matches the START of the `uri` on a track. If whitelist is used then blacklist is ignored. All strings are case-insensitive.
|
||||
|
||||
EX:
|
||||
|
||||
```json
|
||||
{
|
||||
"uriBlacklist": ["soundcloud"]
|
||||
}
|
||||
```
|
||||
|
||||
Will prevent multi-scrobbler from scrobbling any Mopidy track that start with a `uri` like `soundcloud:song:MySong-1234`
|
||||
|
||||
#### Album Blacklist
|
||||
|
||||
For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake Album name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. All strings are case-insensitive.
|
||||
|
||||
EX:
|
||||
|
||||
```json
|
||||
{
|
||||
"albumBlacklist": ["SoundCloud", "Mixcloud"]
|
||||
}
|
||||
```
|
||||
|
||||
If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist`
|
||||
then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist`
|
||||
|
||||
## [JRiver](https://jriver.com/)
|
||||
|
||||
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
|
||||
|
||||
* Tools -> Options -> Media Network
|
||||
* Check `Use Media Network to share this library...`
|
||||
* If you have `Authentication` checked you will need to provide the **Username** and **Password** in the ENV/File configuration below.
|
||||
|
||||
#### URL
|
||||
|
||||
If you do not provide a URL then a default is used which assumes JRiver is installed on the same server as multi-scrobbler: `http://localhost:52199/MCWS/v1/`
|
||||
|
||||
* Make sure the port number matches what is found in `Advanced` section in the [Media Network](#jriver) options.
|
||||
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
|
||||
|
||||
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
|
||||
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
Part => Default Value
|
||||
|
||||
* Protocol => `http://`
|
||||
* Hostname => `localhost`
|
||||
* Port => `52199`
|
||||
* Path => `/MCWS/v1/`
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "jriver.mydomain.com"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://jriver.mydomain.com:52199/MCWS/v1/`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "192.168.0.101:3456"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://192.168.0.101:3456/MCWS/v1/`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "mydomain.com:80/jriverReverse/MCWS/v1/"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
|
||||
|
||||
</details>
|
||||
|
||||
### ENV-Based
|
||||
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------------------------------|------------------------------------------------|
|
||||
| JRIVER_URL | Yes | http://localhost:52199/MCWS/v1/ | The URL of the JRiver server |
|
||||
| JRIVER_USERNAME | No | | If authentication is enabled, the username set |
|
||||
| JRIVER_PASSWORD | No | | If authenticated is enabled, the password set |
|
||||
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jriver.json.example`](/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)
|
||||
|
||||
## [Kodi](https://kodi.tv/)
|
||||
|
||||
In order for multi-scrobbler to communicate with Kodi you must have the [Web Interface](https://kodi.wiki/view/Web_interface) enabled. This can can be in the Kodi GUI:
|
||||
|
||||
* Settings -> Services -> Control
|
||||
* Check `Allow remote control via HTTP`
|
||||
* Ensure you have a **Username** and **Password** set, you will need to provide them in the ENV/File configuration below.
|
||||
|
||||
#### URL
|
||||
|
||||
If you do not provide a URL then a default is used which assumes Kodi is installed on the same server as multi-scrobbler: `http://localhost:8080/jsonrpc`
|
||||
|
||||
* Make sure the port number matches what is found in **Port** in the [Control](#kodi) section mentioned above.
|
||||
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
|
||||
|
||||
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
|
||||
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
|
||||
|
||||
Part => Default Value
|
||||
|
||||
* Protocol => `http://`
|
||||
* Hostname => `localhost`
|
||||
* Port => `8080`
|
||||
* Path => `/jsonrpc`
|
||||
|
||||
<details>
|
||||
<summary>URL Transform Examples</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "kodi.mydomain.com"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://kodi.mydomain.com:8080/jsonrpc`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "192.168.0.101:3456"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://192.168.0.101:3456/jsonprc`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "mydomain.com:80/kodiReverse/jsonrpc"
|
||||
}
|
||||
```
|
||||
|
||||
MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
|
||||
|
||||
</details>
|
||||
|
||||
### ENV-Based
|
||||
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|-------------------------------|----------------------------|
|
||||
| KODI_URL | Yes | http://localhost:8080/jsonrpc | The URL of the Kodi server |
|
||||
| KODI_USERNAME | No | | The username set |
|
||||
| KODI_PASSWORD | No | | The password set |
|
||||
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`kodi.json.example`](/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)
|
||||
|
||||
# Client Configurations
|
||||
|
||||
## [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|-------------------------------|
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`maloja.json.example`](/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)
|
||||
|
||||
## [Last.fm](https://www.last.fm)
|
||||
|
||||
[Register for an API account here.](https://www.last.fm/api/account/create)
|
||||
|
||||
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
|
||||
```
|
||||
http://localhost:9078/lastfm/callback
|
||||
```
|
||||
or replace `localhost:9078` with your own base URL
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|-----------------------------------------|-------------------------------|
|
||||
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
|
||||
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
|
||||
| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
|
||||
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](/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)
|
||||
|
||||
## [Listenbrainz](https://listenbrainz.org)
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|-------------------------------|---------------------------------|
|
||||
| LZ_TOKEN | Yes | | User token from your LZ profile |
|
||||
| LZ_USER | Yes | | Your LZ username |
|
||||
| LZ_URL | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](/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)
|
||||
|
||||
# Monitoring
|
||||
|
||||
multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
|
||||
|
||||
## Webhook Configurations
|
||||
|
||||
Webhooks will **push** a notification to your configured servers on these events:
|
||||
|
||||
* Source polling started
|
||||
* Source polling retry
|
||||
* Source polling stopped on error
|
||||
* Scrobble client scrobble failure
|
||||
|
||||
Webhooks are configured in the main [config.json](#all-in-one-file-configuration) file under the `webhook` top-level property. Multiple webhooks may be configured for each webhook type. EX:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
...
|
||||
],
|
||||
"clients": [
|
||||
...
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "FirstGotifyServer",
|
||||
"type": "gotify",
|
||||
"url": "http://192.168.0.100:8070",
|
||||
"token": "abcd"
|
||||
},
|
||||
{
|
||||
"name": "SecondGotifyServer",
|
||||
"type": "gotify",
|
||||
...
|
||||
},
|
||||
{
|
||||
"name": "NtfyServerOne",
|
||||
"type": "ntfy",
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### [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)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
EX
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "gotify",
|
||||
"name": "MyGotifyFriendlyNameForLogs",
|
||||
"url": "http://192.168.0.100:8070",
|
||||
"token": "AQZI58fA.rfSZbm",
|
||||
"priorities": {
|
||||
"info": 5,
|
||||
"warn": 7,
|
||||
"error": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [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)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
EX
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ntfy",
|
||||
"name": "MyNtfyFriendlyNameForLogs",
|
||||
"url": "http://192.168.0.100:9991",
|
||||
"topic": "RvOwKJ1XtIVMXGLR",
|
||||
"username": "Optional",
|
||||
"password": "Optional",
|
||||
"priorities": {
|
||||
"info": 3,
|
||||
"warn": 4,
|
||||
"error": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Endpoint
|
||||
|
||||
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
|
||||
|
||||
* Returns `200 OK` when **everything** is working or `500 Internal Server Error` if **anything** is not
|
||||
* The plain url (`/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
|
||||
* Use query params `type` or `name` to restrict client/sources aggregated IE `/health?type=spotify` or `/health?name=MyMaloja`
|
||||
* On 500 the response returns a JSON payload with `messages` array that describes any issues
|
||||
* For any clients/sources that require authentication `/health` will return 500 if they are **not authenticated**
|
||||
* For sources that poll (spotify, yt music, subsonic) `/health` will 500 if they are **not polling**
|
||||
@@ -1,36 +0,0 @@
|
||||
# Minimal Configuration
|
||||
|
||||
Examples assume you have registered a Spotify application with the default callback url of `http://localhost:9078/callback`.
|
||||
|
||||
If you use another callback url or domain name you will need to specify at a minimum `SPOTIFY_REDIRECT_URI`.
|
||||
|
||||
## Using Environmental Variables
|
||||
|
||||
### Local
|
||||
```
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://domain.tld MALOJA_API_KEY=1234 node index.js
|
||||
```
|
||||
|
||||
### Dockerhub
|
||||
|
||||
Note: I do not recommend running a container without a `config` volume specified or you will need to reauthorize the app everytime the container is rebuilt.
|
||||
|
||||
```
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" foxxmd/spotify-scrobbler
|
||||
```
|
||||
|
||||
## Using Configuration
|
||||
|
||||
Reference the [example json configs.](https://github.com/FoxxMD/multi-scrobbler/tree/master/config)
|
||||
|
||||
### Local
|
||||
|
||||
```
|
||||
node index.js
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```
|
||||
docker run -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,82 @@
|
||||
# Installation
|
||||
|
||||
## Local
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Local Usage Examples
|
||||
|
||||
* The web UI is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
|
||||
## [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM (IE Raspberry Pi)
|
||||
|
||||
```
|
||||
foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
Or use the provided [docker-compose.yml](/docker-compose.yml) after modifying it to fit your configuration.
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
* If you must **bind a host directory into the container for storing configurations and credentials:**
|
||||
* [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`
|
||||
* (Optionally) map the web UI port in the container **9078** to the host
|
||||
* 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"`
|
||||
* (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))
|
||||
|
||||
### Linux Host
|
||||
|
||||
If you are
|
||||
|
||||
* using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_)
|
||||
* running docker on MacOS or Windows
|
||||
|
||||
this **DOES NOT** apply to you.
|
||||
|
||||
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
### Docker Usage Examples
|
||||
|
||||
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
See the [docker-compose.yml](/docker-compose.yml) file for how to use with docker-compose.
|
||||
@@ -0,0 +1,288 @@
|
||||
# Example Config using all Possible Features
|
||||
|
||||
Scenario:
|
||||
|
||||
* You want to scrobble plays for yourself (Foxx), Fred, and Mary
|
||||
* Each person has their own Maloja server
|
||||
* Each person has their own Spotify account
|
||||
* You have your own Airsonic (subsonic) server you to scrobble from
|
||||
* You have your own Youtube Music account you want to scrobble from
|
||||
* Mary has her own Last.fm account she also wants to scrobble to
|
||||
* Fred has his own Spotify application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
|
||||
* Fred has a Plex server and wants to scrobble everything he plays
|
||||
* Mary uses Fred's Plex server but only wants to scrobble her plays from the `podcast` library
|
||||
* The three of you have a shared library on Plex called `party` that you only play when you are hanging out. You want plays from that library to be scrobbled to everyone's servers.
|
||||
* Fred also has his own Jellyfin server and wants to scrobble everything he plays
|
||||
|
||||
### All-in-one Config
|
||||
|
||||
Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
"name": "foxxSpot",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "spotify",
|
||||
"name": "marySpot",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId", // only need one application, it can be used by all users of this multi-scrobbler instance
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "spotify",
|
||||
"name": "fredSpot",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"accessToken": "fredsToken",
|
||||
"refreshToken": "fredsRefreshToken",
|
||||
"interval": 120, // he also wants a slower check interval because his application already has heavy api usage
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "plex",
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "plex",
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
|
||||
"libraries": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "plex",
|
||||
"name": "partyPlex",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"libraries": ["party"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "jellyfin",
|
||||
"name": "FredJelly",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "subsonic",
|
||||
"name": "foxxAirsonic",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"user": "foxx",
|
||||
"password": "foxxPassword",
|
||||
"url": "https://airsonic.foxx.example"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "foxxYoutube",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
"authUser": 1
|
||||
}
|
||||
},
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "foxxMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.foxx.example",
|
||||
"apiKey": "foxxApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "fredMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.fred.example",
|
||||
"apiKey": "fredApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "maryMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.mary.example",
|
||||
"apiKey": "maryApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "lastfm",
|
||||
"name": "maryLFM",
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Separate JSON files
|
||||
|
||||
In `CONFIG_DIR/spotify.json`:
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
// may omit 'type' property since app knows this is file is for spotify configs
|
||||
"name": "foxxSpot",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "marySpot",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fredSpot",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"accessToken": "fredsToken",
|
||||
"refreshToken": "fredsRefreshToken",
|
||||
"interval": 120
|
||||
}
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/plex.json`
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"],
|
||||
"libraries": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "partyPlex",
|
||||
"data": {
|
||||
"libraries": ["party"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/jellyfin.json`
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"name": "FredJelly",
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/ytmusic.json`
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "foxxYoutube",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
"authUser": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/maloja.json`:
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"name": "foxxMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.foxx.example",
|
||||
"apiKey": "foxxApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fredMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.fred.example",
|
||||
"apiKey": "fredApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maryMaloja",
|
||||
"data": {
|
||||
"url": "https://maloja.mary.example",
|
||||
"apiKey": "maryApiKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/lastfm.json`:
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"name": "maryLFM",
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
+11
-2
@@ -40,8 +40,17 @@ Expand the **Watched** dropdown and add the following code block to the **JSON D
|
||||
"album_name": "{album_name}",
|
||||
"media_type": "{media_type}",
|
||||
"title": "{title}",
|
||||
"duration": "{duration}",
|
||||
"username": "{username}"
|
||||
"duration": "{duration_sec}",
|
||||
"username": "{username}",
|
||||
"server": "{server_name}",
|
||||
"version": "{server_version}",
|
||||
"library": "{library_name}",
|
||||
"player": "{player}",
|
||||
"device": "{device}",
|
||||
"platform": "{platform}",
|
||||
"action": "{action}",
|
||||
"machine_id": "{machine_id}",
|
||||
"session_key": "{session_key}"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import {addAsync} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import multer from 'multer';
|
||||
import winston from 'winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import {Writable} from 'stream';
|
||||
import 'winston-daily-rotate-file';
|
||||
import {readJson, writeFile} from "./utils.js";
|
||||
import Clients from './clients/ScrobbleClients.js';
|
||||
import SpotifySource from "./sources/SpotifySource.js";
|
||||
import TautulliSource from "./sources/TautulliSource.js";
|
||||
import PlexSource from "./sources/PlexSource.js";
|
||||
|
||||
const storage = multer.memoryStorage()
|
||||
const upload = multer({storage: storage})
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
const {format, createLogger, transports} = winston;
|
||||
const {combine, printf, timestamp} = format;
|
||||
|
||||
let output = []
|
||||
const stream = new Writable()
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
output.unshift(chunk.toString().replace('\n', ''));
|
||||
output = output.slice(0, 51);
|
||||
next()
|
||||
}
|
||||
const streamTransport = new winston.transports.Stream({
|
||||
stream,
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
})
|
||||
|
||||
const logPath = process.env.LOG_DIR || `${process.cwd()}/logs`;
|
||||
const port = process.env.PORT ?? 9078;
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
|
||||
const myFormat = printf(({level, message, label = 'App', timestamp}) => {
|
||||
return `${timestamp} [${label}] ${level}: ${message}`;
|
||||
});
|
||||
|
||||
const logger = createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: combine(
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
myFormat
|
||||
),
|
||||
transports: [
|
||||
new transports.Console({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
}),
|
||||
streamTransport,
|
||||
]
|
||||
});
|
||||
|
||||
if (typeof logPath === 'string') {
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
}))
|
||||
}
|
||||
|
||||
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
|
||||
const state = 'random';
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
|
||||
const workingCredentialsPath = `${configDir}/currentCreds.json`;
|
||||
|
||||
const app = addAsync(express());
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
try {
|
||||
(async function () {
|
||||
|
||||
// try to read a configuration file
|
||||
let config = {};
|
||||
try {
|
||||
config = await readJson(`${configDir}/config.json`);
|
||||
} catch (e) {
|
||||
logger.info('No config file or could not be read (normal if using ENV vars only)');
|
||||
}
|
||||
|
||||
// setup defaults for other configs and general config
|
||||
const {
|
||||
spotify,
|
||||
plex = {},
|
||||
clients = [],
|
||||
} = config || {};
|
||||
|
||||
/*
|
||||
* setup clients
|
||||
* */
|
||||
const scrobbleClients = new Clients(logger);
|
||||
await scrobbleClients.buildClients(clients, configDir);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured')
|
||||
}
|
||||
|
||||
/*
|
||||
* setup sources
|
||||
* */
|
||||
const spotifySource = new SpotifySource(logger, {configDir, localUrl});
|
||||
await spotifySource.buildSpotifyApi(spotify);
|
||||
|
||||
let plexJson = {};
|
||||
try {
|
||||
plexJson = await readJson(`${configDir}/plex.json`);
|
||||
} catch (e) {
|
||||
// no config exists but that's ok
|
||||
}
|
||||
|
||||
const tautulliSource = await new TautulliSource(logger, scrobbleClients, {...plex, ...plexJson});
|
||||
const plexSource = await new PlexSource(logger, scrobbleClients, {...plex, ...plexJson});
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
res.render('status', {
|
||||
spotify: {
|
||||
status: spotifySource.pollerRunning,
|
||||
discovered: spotifySource.discoveredTracks,
|
||||
},
|
||||
tautulli: {
|
||||
status: tautulliSource.discoveredTracks > 0 ? 'Received Data' : 'Awaiting Data',
|
||||
discovered: tautulliSource.discoveredTracks,
|
||||
},
|
||||
plex: {
|
||||
status: plexSource.discoveredTracks > 0 ? 'Received Data' : 'Awaiting Data',
|
||||
discovered: plexSource.discoveredTracks,
|
||||
},
|
||||
logs: output
|
||||
});
|
||||
})
|
||||
|
||||
app.postAsync('/tautulli', async function (req, res) {
|
||||
await tautulliSource.handle(req);
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.postAsync('/plex', upload.any(), async function (req, res) {
|
||||
const {
|
||||
body: {
|
||||
payload
|
||||
} = {}
|
||||
} = req;
|
||||
if (payload !== undefined) {
|
||||
await plexSource.handle(JSON.parse(payload));
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.getAsync('/authSpotify', async function (req, res) {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
res.redirect(spotifySource.spotifyApi.createAuthorizeURL(scopes, state));
|
||||
});
|
||||
|
||||
app.getAsync('/pollSpotify', async function (req, res) {
|
||||
spotifySource.pollSpotify(scrobbleClients);
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async function (req, res, next) {
|
||||
const {error, code} = req.query;
|
||||
if (error === undefined) {
|
||||
const tokenResponse = await spotifySource.spotifyApi.authorizationCodeGrant(code);
|
||||
spotifySource.spotifyApi.setAccessToken(tokenResponse.body['access_token']);
|
||||
spotifySource.spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
|
||||
await writeFile(workingCredentialsPath, JSON.stringify({
|
||||
token: tokenResponse.body['access_token'],
|
||||
refreshToken: tokenResponse.body['refresh_token']
|
||||
}));
|
||||
logger.info('Got auth code from callback!');
|
||||
spotifySource.pollSpotify(scrobbleClients);
|
||||
return res.send('OK');
|
||||
} else {
|
||||
throw new Error('User denied oauth access');
|
||||
}
|
||||
});
|
||||
|
||||
if (spotifySource.spotifyApi !== undefined) {
|
||||
if (spotifySource.spotifyApi.getAccessToken() === undefined) {
|
||||
logger.info('Spotify Api is not ready');
|
||||
logger.info(`Open ${localUrl}/authSpotify to continue`);
|
||||
} else {
|
||||
spotifySource.pollSpotify(scrobbleClients);
|
||||
}
|
||||
}
|
||||
app.set('views', './views');
|
||||
app.set('view engine', 'ejs');
|
||||
logger.info(`Server started at ${localUrl}`);
|
||||
const server = await app.listen(port)
|
||||
}());
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
}
|
||||
Generated
+3806
-826
File diff suppressed because it is too large
Load Diff
+82
-18
@@ -1,36 +1,100 @@
|
||||
{
|
||||
"name": "maloja-spotify-scrobbler",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.4.0",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"start": "node src/index.js",
|
||||
"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",
|
||||
"schema-client": "typescript-json-schema tsconfig.json ClientConfig --out src/common/schema/client.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-source": "typescript-json-schema tsconfig.json SourceConfig --out src/common/schema/source.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aio": "typescript-json-schema tsconfig.json AIOConfig --out src/common/schema/aio.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema tsconfig.json AIOSourceConfig --out src/common/schema/aio-source.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema tsconfig.json AIOClientConfig --out src/common/schema/aio-client.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/common/infrastructure/typings/lastfm-node-client.d.ts",
|
||||
"import": "./src/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0",
|
||||
"npm": ">=6.0.0"
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.1.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/FoxxMD/maloja-spotify-scrobbler.git"
|
||||
"url": "git+https://github.com/FoxxMD/multi-scrobbler.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/FoxxMD/maloja-spotify-scrobbler/issues"
|
||||
"url": "https://github.com/FoxxMD/multi-scrobbler/issues"
|
||||
},
|
||||
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
|
||||
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"ajv": "^7.2.4",
|
||||
"body-parser": "^1.19.0",
|
||||
"dayjs": "^1.9.6",
|
||||
"ejs": "^3.1.5",
|
||||
"compare-versions": "^4.1.2",
|
||||
"concat-stream": "^2.0.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"dbus-next": "^0.10.2",
|
||||
"ejs": "^3.1.6",
|
||||
"es6-error": "^4.1.1",
|
||||
"express": "^4.17.1",
|
||||
"multer": "^1.4.2",
|
||||
"spotify-web-api-node": "^5.0.0",
|
||||
"superagent": "^6.1.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0"
|
||||
"express-session": "^1.17.2",
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"formidable": "^2.1",
|
||||
"gotify": "^1.1.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"normalize-url": "^6.1.0",
|
||||
"ntfy": "^1.0.5",
|
||||
"p-event": "^4.2.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
"pony-cause": "^1.1.1",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"socket.io": "^4.6.1",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-duplex": "^0.1.1",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"xml2js": "^0.4.23",
|
||||
"youtube-music-ts-api": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node18": "^1.0.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/formidable": "^2.0.5",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/passport": "^1.0.12",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^4.1.16",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths": "^3.13.0",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript-json-schema": "~0.55"
|
||||
},
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, readJson} from "../utils.js";
|
||||
|
||||
export default class PlexSource {
|
||||
|
||||
name = 'Plex';
|
||||
|
||||
logger;
|
||||
clients;
|
||||
user;
|
||||
|
||||
discoveredTracks = 0;
|
||||
|
||||
constructor(logger, clients, { user = process.env.PLEX_USER } = {}) {
|
||||
this.logger = logger;
|
||||
this.clients = clients;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
event,
|
||||
Account: {
|
||||
title: user,
|
||||
} = {},
|
||||
Metadata: {
|
||||
type,
|
||||
title: track,
|
||||
parentTitle: album,
|
||||
grandparentTitle: artist,
|
||||
} = {}
|
||||
} = obj;
|
||||
return {
|
||||
data: {
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
event,
|
||||
mediaType: type,
|
||||
user,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle = async (payload) => {
|
||||
const playObj = PlexSource.formatPlayObj(payload);
|
||||
const {meta: {mediaType, title, event, user}} = playObj;
|
||||
if (event !== 'media.scrobble') {
|
||||
this.logger.debug(`Will not scrobble webhook event because it is not media.scrobble: ${event}`, {
|
||||
event,
|
||||
label: this.name
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (this.user !== undefined && user !== undefined) {
|
||||
if (Array.isArray(this.user)) {
|
||||
if (this.user.includes(user)) {
|
||||
this.logger.debug(`Will not scrobble webhook event because specified user was not part of user array`, {
|
||||
user,
|
||||
label: this.name
|
||||
})
|
||||
return;
|
||||
}
|
||||
} else if (this.user !== user) {
|
||||
this.logger.debug(`Will not scrobble webhook event because specified user was not found`, {
|
||||
user,
|
||||
label: this.name
|
||||
})
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mediaType !== 'track') {
|
||||
this.logger.warn(`Webhook posted a non-music media type (${mediaType}), not scrobbling this. Item: ${title}`, {label: this.name});
|
||||
} else {
|
||||
this.logger.info(`New Track => ${buildTrackString(playObj)}`, {label: this.name});
|
||||
try {
|
||||
await this.clients.scrobble(playObj);
|
||||
// only gets hit if we scrobbled ok
|
||||
this.discoveredTracks++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling', {label: this.name})
|
||||
this.logger.error(e, {label: this.name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {EventEmitter} from "events";
|
||||
import {buildTrackString, readJson, sleep, writeFile, makeSingle} from "../utils.js";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
|
||||
export default class SpotifySource {
|
||||
|
||||
logger;
|
||||
spotifyApi;
|
||||
interval;
|
||||
localUrl;
|
||||
workingCredsPath;
|
||||
configDir;
|
||||
|
||||
spotifyPoller;
|
||||
pollerRunning = false;
|
||||
|
||||
emitter;
|
||||
discoveredTracks = 0;
|
||||
|
||||
constructor(logger, config = {}) {
|
||||
this.logger = logger;
|
||||
const {
|
||||
localUrl,
|
||||
configDir,
|
||||
} = config;
|
||||
|
||||
this.configDir = configDir;
|
||||
this.workingCredsPath = `${configDir}/currentCreds.json`;
|
||||
this.localUrl = localUrl;
|
||||
this.spotifyPoller = makeSingle(pollSpotify);
|
||||
this.emitter = new EventEmitter();
|
||||
this.emitter.addListener('spotifyTrackDiscovered', this.handleDiscoveredTrack);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
track: {
|
||||
artists = [],
|
||||
name,
|
||||
id,
|
||||
duration_ms,
|
||||
} = {},
|
||||
played_at
|
||||
} = obj;
|
||||
let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
|
||||
return {
|
||||
data: {
|
||||
artist: artistString,
|
||||
track: name,
|
||||
playDate: dayjs(played_at),
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration_ms / 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDiscoveredTrack = (e) => {
|
||||
this.discoveredTracks++;
|
||||
}
|
||||
|
||||
buildSpotifyApi = async (spotifyObj) => {
|
||||
|
||||
let spotifyCreds = {};
|
||||
try {
|
||||
spotifyCreds = await readJson(this.workingCredsPath);
|
||||
} catch (e) {
|
||||
this.logger.warn('Current spotify access token was not parsable or file does not exist (this could be normal)');
|
||||
}
|
||||
|
||||
let spotifyConfig = spotifyObj;
|
||||
if (spotifyObj === undefined) {
|
||||
try {
|
||||
spotifyConfig = await readJson(`${this.configDir}/spotify.json`);
|
||||
} catch (e) {
|
||||
this.logger.warn('No spotify config file or could not be read (normal if using ENV vars only)');
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
accessToken = process.env.SPOTIFY_ACCESS_TOKEN,
|
||||
clientId = process.env.SPOTIFY_CLIENT_ID,
|
||||
clientSecret = process.env.SPOTIFY_CLIENT_SECRET,
|
||||
redirectUri = process.env.SPOTIFY_REDIRECT_URI,
|
||||
refreshToken = process.env.SPOTIFY_REFRESH_TOKEN,
|
||||
interval = 60,
|
||||
} = spotifyConfig;
|
||||
|
||||
if (interval < 15) {
|
||||
console.warn('Interval should be above 30 seconds...😬');
|
||||
}
|
||||
this.interval = interval;
|
||||
|
||||
const rdUri = redirectUri || `${this.localUrl}/callback`;
|
||||
|
||||
|
||||
const {token = accessToken, refreshToken: rt = refreshToken} = spotifyCreds;
|
||||
|
||||
const apiConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken: token,
|
||||
redirectUri: rdUri,
|
||||
refreshToken: rt,
|
||||
}
|
||||
|
||||
if (Object.values(apiConfig).every(x => x === undefined)) {
|
||||
this.logger.info('No values found for Spotify configuration, assuming user does not want to set it up', {label: 'Spotify'});
|
||||
} else {
|
||||
if (token === undefined) {
|
||||
let ready = true;
|
||||
if (clientId === undefined) {
|
||||
this.logger.warn('No access token exists and clientId is not defined', {label: 'Spotify'});
|
||||
ready = false;
|
||||
}
|
||||
if (clientSecret === undefined) {
|
||||
this.logger.warn('No access token exists and clientSecret is not defined', {label: 'Spotify'})
|
||||
ready = false;
|
||||
}
|
||||
if (ready === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
||||
}
|
||||
}
|
||||
|
||||
pollSpotify = (clients) => {
|
||||
if (this.spotifyApi === undefined) {
|
||||
this.logger.warn('Cannot poll spotify without valid credentials configuration', {label: 'Spotify'})
|
||||
return;
|
||||
}
|
||||
this.pollerRunning = true;
|
||||
return this.spotifyPoller(this.logger, this.spotifyApi, this.interval, this.workingCredsPath, clients, this.emitter)
|
||||
.catch((e) => {
|
||||
this.logger.error('Error occurred while polling spotify, polling has been stopped', {label: 'Spotify'});
|
||||
this.logger.error(e, {label: 'Spotify'});
|
||||
})
|
||||
.finally(() => {
|
||||
this.pollerRunning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pollSpotify = function* (logger, spotifyApi, interval = 60, credsPath, clients, emitter) {
|
||||
logger.info('Starting spotify polling', {label: 'Spotify'});
|
||||
let lastTrackPlayedAt = undefined;
|
||||
let checkCount = 0;
|
||||
while (true) {
|
||||
let data = {};
|
||||
logger.debug('Refreshing recently played', {label: 'Spotify'})
|
||||
data = yield spotifyApi.getMyRecentlyPlayedTracks({
|
||||
limit: 20
|
||||
});
|
||||
if (data instanceof Error) {
|
||||
if (data.statusCode === 401) {
|
||||
if (spotifyApi.getRefreshToken() === undefined) {
|
||||
logger.error('Access token was not valid and no refresh token was present, bailing out of polling', {label: 'Spotify'});
|
||||
return Promise.resolve();
|
||||
}
|
||||
logger.debug('Access token was not valid, attempting to refresh', {label: 'Spotify'});
|
||||
|
||||
const tokenResponse = yield spotifyApi.refreshAccessToken();
|
||||
const {
|
||||
body: {
|
||||
access_token,
|
||||
// spotify may return a new refresh token
|
||||
// if it doesn't then continue to use the last refresh token we received
|
||||
refresh_token = spotifyApi.getRefreshToken(),
|
||||
} = {}
|
||||
} = tokenResponse;
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
yield writeFile(credsPath, JSON.stringify({
|
||||
token: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}));
|
||||
data = yield spotifyApi.getMyRecentlyPlayedTracks({
|
||||
limit: 20
|
||||
});
|
||||
if (data instanceof Error) {
|
||||
logger.error('Refreshing access token encountered an error', {label: 'Spotify'});
|
||||
logger.error(data, {label: 'Spotify'});
|
||||
return Promise.resolve(data);
|
||||
}
|
||||
} else {
|
||||
logger.error('Refreshing access token encountered an error', {label: 'Spotify'});
|
||||
logger.error(data, {label: 'Spotify'});
|
||||
return Promise.reject(data);
|
||||
}
|
||||
}
|
||||
checkCount++;
|
||||
let newLastPLayedAt = undefined;
|
||||
const now = dayjs();
|
||||
for (const playData of data.body.items) {
|
||||
const playObj = SpotifySource.formatPlayObj(playData);
|
||||
const {data: {playDate} = {}} = playObj;
|
||||
if (lastTrackPlayedAt === undefined) {
|
||||
lastTrackPlayedAt = playDate;
|
||||
}
|
||||
// compare play time to most recent track played_at scrobble
|
||||
if (playDate.unix() > lastTrackPlayedAt.unix()) {
|
||||
logger.info(`New Track => ${buildTrackString(playObj)}`, {label: 'Spotify'});
|
||||
emitter.emit('spotifyTrackDiscovered', playObj);
|
||||
// so we always get just the most recent played_at
|
||||
if (newLastPLayedAt === undefined) {
|
||||
newLastPLayedAt = playDate;
|
||||
}
|
||||
const closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
|
||||
if (closeToInterval) {
|
||||
// because the interval check was so close to the play date we are going to delay client calls for a few secs
|
||||
// this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups)
|
||||
// additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully
|
||||
logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first', {label: 'Spotify'});
|
||||
yield sleep(10 * 1000);
|
||||
}
|
||||
const scrobbleResult = yield clients.scrobble(playObj, {forceRefresh: closeToInterval});
|
||||
if (scrobbleResult instanceof Error) {
|
||||
return Promise.reject(scrobbleResult);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (newLastPLayedAt !== undefined) {
|
||||
lastTrackPlayedAt = newLastPLayedAt;
|
||||
}
|
||||
}
|
||||
let sleepTime = interval;
|
||||
// don't need to do back off calc if interval is 10 minutes or greater since its already pretty light on API calls
|
||||
// and don't want to back off if we just started the app
|
||||
if (checkCount > 5 && sleepTime < 600) {
|
||||
const lastPlayToNowSecs = Math.abs(now.unix() - lastTrackPlayedAt.unix());
|
||||
// back off if last play was longer than 10 minutes ago
|
||||
const backoffThreshold = Math.min((interval * 10), 600);
|
||||
if (lastPlayToNowSecs >= backoffThreshold) {
|
||||
// back off to a maximum of 5 minutes
|
||||
sleepTime = Math.min(interval * 5, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// sleep for interval
|
||||
logger.debug(`Sleeping for interval (${sleepTime}s)`, {label: 'Spotify'});
|
||||
yield sleep(sleepTime * 1000);
|
||||
}
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, readJson} from "../utils.js";
|
||||
|
||||
export default class TautulliSource {
|
||||
|
||||
name = 'Tautulli';
|
||||
|
||||
logger;
|
||||
clients;
|
||||
user;
|
||||
|
||||
discoveredTracks = 0;
|
||||
|
||||
constructor(logger, clients, { user = process.env.PLEX_USER } = {}) {
|
||||
this.logger = logger;
|
||||
this.clients = clients;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
artist_name,
|
||||
track_name,
|
||||
track_artist,
|
||||
album_name,
|
||||
media_type,
|
||||
title,
|
||||
library_name,
|
||||
duration,
|
||||
username,
|
||||
} = obj;
|
||||
let artist = artist_name;
|
||||
if (track_artist !== undefined && track_artist !== artist_name) {
|
||||
artist = `${artist},${track_artist}`;
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
artist,
|
||||
album: album_name,
|
||||
track: track_name,
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
title,
|
||||
library: library_name,
|
||||
mediaType: media_type,
|
||||
user: username,
|
||||
trackLength: duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle = async (req) => {
|
||||
const playObj = TautulliSource.formatPlayObj(req.body);
|
||||
const {meta: {mediaType, title, user}} = playObj;
|
||||
|
||||
if (this.user !== undefined && user !== undefined) {
|
||||
if (Array.isArray(this.user)) {
|
||||
if (this.user.includes(user)) {
|
||||
this.logger.debug(`Will not scrobble webhook event because specified user was not part of user array`, {
|
||||
user,
|
||||
label: this.name
|
||||
})
|
||||
return;
|
||||
}
|
||||
} else if (this.user !== user) {
|
||||
this.logger.debug(`Will not scrobble webhook event because specified user was not found`, {
|
||||
user,
|
||||
label: this.name
|
||||
})
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mediaType !== 'track') {
|
||||
this.logger.warn(`Webhook posted a non-music media type (${mediaType}), not scrobbling this. Item: ${title}`, {label: this.name});
|
||||
} else {
|
||||
this.logger.info(`New Track => ${buildTrackString(playObj)}`, {label: this.name});
|
||||
try {
|
||||
await this.clients.scrobble(playObj);
|
||||
// only gets hit if we scrobbled ok
|
||||
this.discoveredTracks++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling', {label: this.name})
|
||||
this.logger.error(e, {label: this.name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {capitalize, mergeArr} from "../utils.js";
|
||||
import {Logger} from "winston";
|
||||
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import winston from 'winston';
|
||||
|
||||
export default abstract class AbstractApiClient {
|
||||
name: string;
|
||||
type: string;
|
||||
initialized: boolean = false;
|
||||
|
||||
config: object;
|
||||
options: object;
|
||||
logger: Logger;
|
||||
|
||||
client: any;
|
||||
workingCredsPath?: string;
|
||||
redirectUri?: string;
|
||||
|
||||
constructor(type: any, name: any, config = {}, options = {}) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `API - ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = winston.loggers.get('app').child({labels: [identifier]}, mergeArr);
|
||||
this.config = config;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
|
||||
throw new Error('should be overridden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {JRiverData} from "../common/infrastructure/config/source/jriver.js";
|
||||
import request, {Request, Response} from 'superagent';
|
||||
import xml2js from 'xml2js';
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
const parser = new xml2js.Parser({'async': true});
|
||||
|
||||
export const PLAYER_STATE: Record<string, PLAYER_STATE> = {
|
||||
STOPPED: '0',
|
||||
PAUSED: '1',
|
||||
PLAYING: '2'
|
||||
}
|
||||
|
||||
export type PLAYER_STATE = '0' | '1' | '2';
|
||||
|
||||
interface JRiverResponseItem {
|
||||
_: string
|
||||
$: {
|
||||
Name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface JRiverResponse {
|
||||
Response: {
|
||||
'$': {
|
||||
Status: string
|
||||
},
|
||||
Item: JRiverResponseItem[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface JRiverTransformedResponse<T> {
|
||||
status: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface Alive {
|
||||
RuntimeGUID: string
|
||||
LibraryVersion: string
|
||||
ProgramName: string
|
||||
ProgramVersion: string
|
||||
FriendlyName: string
|
||||
AccessKey: string
|
||||
ProductVersion: string
|
||||
Platform: string
|
||||
}
|
||||
// state 0 = nothing?
|
||||
// 2 = playing
|
||||
// 1 = paused
|
||||
|
||||
export interface Authenticate {
|
||||
Token: string
|
||||
ReadOnly: number
|
||||
PreLicensed: boolean
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
ZoneID: string
|
||||
ZoneName: string
|
||||
State: PLAYER_STATE
|
||||
PositionMS: number
|
||||
DurationMS: number
|
||||
Artist: string
|
||||
Album: string
|
||||
Name: string
|
||||
Status: string
|
||||
FileKey: string
|
||||
}
|
||||
|
||||
export interface Zones {
|
||||
NumberZones: number
|
||||
CurrentZoneID: string
|
||||
CurrentZoneIndex: string
|
||||
}
|
||||
|
||||
const jriverResponseTransform = <T>(val: JRiverResponse): JRiverTransformedResponse<T> => {
|
||||
const status = val.Response.$.Status;
|
||||
const items = val.Response.Item === undefined ? undefined : val.Response.Item.map(x => {
|
||||
return [x.$.Name, x._];
|
||||
});
|
||||
return {
|
||||
status,
|
||||
data: items.reduce((acc, curr) => {
|
||||
acc[curr[0]] = curr[1];
|
||||
return acc;
|
||||
}, {}) as T
|
||||
};
|
||||
}
|
||||
|
||||
export class JRiverApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: JRiverData
|
||||
|
||||
url: string;
|
||||
|
||||
token?: string;
|
||||
|
||||
constructor(name: any, config: JRiverData, options = {}) {
|
||||
super('JRiver', name, config, options);
|
||||
const {
|
||||
url = 'http://localhost:52199/MCWS/v1/'
|
||||
} = config;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
if (this.token !== undefined) {
|
||||
req.query({token: this.token});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await req as Response;
|
||||
if (resp.text !== '') {
|
||||
const rawBody = await parser.parseStringPromise(resp.text);
|
||||
resp.body = <T>jriverResponseTransform(rawBody);
|
||||
}
|
||||
return resp;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
let req = request.get(`${this.url}Authenticate`);
|
||||
if (this.config.username !== undefined) {
|
||||
req.auth(this.config.username, this.config.password);
|
||||
}
|
||||
const resp = await this.callApi<Authenticate>(req);
|
||||
this.token = resp.body.data.Token;
|
||||
return true;
|
||||
} catch (e) {
|
||||
let msg = 'Authentication failed.';
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new ErrorWithCause(msg, {cause: e}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getInfo = async (zoneId: string = '-1') => {
|
||||
return await this.callApi<Info>(request.get(`${this.url}Playback/Info`).query({Zone: zoneId}));
|
||||
}
|
||||
|
||||
getZones = async () => {
|
||||
return await this.callApi<Zones>(request.get(`${this.url}Playback/Zones`));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {KodiData} from "../common/infrastructure/config/source/kodi.js";
|
||||
import { KodiClient } from 'kodi-api'
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {URL} from "url";
|
||||
import {RecentlyPlayedOptions} from "../sources/AbstractSource.js";
|
||||
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface KodiDuration {
|
||||
hours: number
|
||||
milliseconds: number
|
||||
minutes: number
|
||||
seconds: number
|
||||
}
|
||||
interface ActivePlayer {
|
||||
playerid: number
|
||||
playertype: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
position: -1 | 0
|
||||
time: KodiDuration
|
||||
totaltime: KodiDuration
|
||||
type: string
|
||||
}
|
||||
|
||||
interface PlayerItem {
|
||||
album: string
|
||||
albumartist: string[]
|
||||
artist: string[]
|
||||
duration: number
|
||||
id: number
|
||||
label: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export class KodiApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: KodiData
|
||||
|
||||
url: URL;
|
||||
|
||||
token?: string;
|
||||
|
||||
client: KodiClient;
|
||||
|
||||
constructor(name: any, config: KodiData, options = {}) {
|
||||
super('Kodi', name, config, options);
|
||||
const {
|
||||
url = 'http://localhost:8080/jsonrpc'
|
||||
} = config;
|
||||
this.url = KodiApiClient.parseConnectionUrl('http://localhost:8080');
|
||||
const auth = new Buffer(`${config.username}:${config.password}`).toString('base64');
|
||||
this.client = new KodiClient({
|
||||
clientType: this.url.protocol.replace(':', '') as ('http' | 'https'),
|
||||
//parameterMode: 'object',
|
||||
clientOptions: {
|
||||
host: this.url.hostname,
|
||||
port: this.url.port,
|
||||
path: this.url.pathname,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static parseConnectionUrl(val: string) {
|
||||
const normal = normalizeUrl(val, {removeTrailingSlash: true, normalizeProtocol: true})
|
||||
const url = new URL(normal);
|
||||
|
||||
if (url.port === null || url.port === '') {
|
||||
url.port = '8080';
|
||||
}
|
||||
if (url.pathname === '/') {
|
||||
url.pathname = '/jsonrpc';
|
||||
} else if (url.pathname === '/jsonrpc/') {
|
||||
url.pathname = '/jsonrpc';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {newFromSource = true} = options;
|
||||
|
||||
const {
|
||||
album: albumVal,
|
||||
albumartist,
|
||||
artist: artistVal,
|
||||
duration,
|
||||
id,
|
||||
title,
|
||||
time,
|
||||
playerid,
|
||||
} = obj;
|
||||
|
||||
let artists = artistVal === null || artistVal === undefined ? [] : artistVal;
|
||||
let album = albumVal === null || albumVal === '' ? undefined : albumVal;
|
||||
const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined;
|
||||
|
||||
return {
|
||||
data: {
|
||||
track: title,
|
||||
album: album,
|
||||
artists,
|
||||
duration,
|
||||
playDate: dayjs()
|
||||
},
|
||||
meta: {
|
||||
source: 'kodi',
|
||||
trackId: id.toString(),
|
||||
newFromSource,
|
||||
trackProgressPosition,
|
||||
deviceId: playerid !== undefined ? `Player${playerid}` : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
|
||||
await this.client.connect();
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#JSONRPC.Version
|
||||
const jsonInfo = await this.client.JSONRPC.Version();
|
||||
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties
|
||||
const applicationInfo = await this.client.Application.GetProperties(['version']);
|
||||
|
||||
this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
let msg = 'Authentication failed.';
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new ErrorWithCause(msg, {cause: e}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerInfo = async (id: number): Promise<PlayerInfo> => {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetProperties
|
||||
// @ts-ignore
|
||||
const playerInfo = await this.client.Player.GetProperties(0, ["position","type","time","totaltime"])
|
||||
return playerInfo;
|
||||
}
|
||||
|
||||
getPlayerItem = async (id: number): Promise<{item: PlayerItem}> => {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetItem
|
||||
// @ts-ignore
|
||||
const itemInfo = await this.client.Player.GetItem(0, ["title","artist","album","albumartist","starttime","endtime","duration","streamdetails","uniqueid"]);
|
||||
return itemInfo as {item: PlayerItem};
|
||||
}
|
||||
|
||||
getActivePlayers = async (): Promise<ActivePlayer[]> => {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetActivePlayers
|
||||
const players = await this.client.Player.GetActivePlayers();
|
||||
return players as ActivePlayer[];
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
const active = await this.getActivePlayers();
|
||||
if(active.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// get first active playing audio
|
||||
const audioActive = active.find(x => x.type === 'audio');
|
||||
if(audioActive === undefined) {
|
||||
return [];
|
||||
}
|
||||
const playerInfo = await this.getPlayerInfo(audioActive.playerid);
|
||||
|
||||
const itemInfo = await this.getPlayerItem(audioActive.playerid);
|
||||
|
||||
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive});
|
||||
|
||||
return [play];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "lastfm-node-client";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import dayjs from "dayjs";
|
||||
import {readJson, sleep, writeFile} from "../utils.js";
|
||||
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {LastfmData} from "../common/infrastructure/config/client/lastfm.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
'invalid session key',
|
||||
'invalid api key',
|
||||
'authentication failed'
|
||||
];
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
'temporarily unavailable',
|
||||
'rate limit'
|
||||
]
|
||||
|
||||
export default class LastfmApiClient extends AbstractApiClient {
|
||||
|
||||
user?: string;
|
||||
declare config: LastfmData;
|
||||
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config!");
|
||||
}
|
||||
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
|
||||
this.client = new LastFm(apiKey as string, secret, session);
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: TrackObject, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
const {
|
||||
artist: {
|
||||
'#text': artists,
|
||||
name: artistName,
|
||||
},
|
||||
name: title,
|
||||
album: {
|
||||
'#text': album,
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
// @ts-ignore
|
||||
uts: time,
|
||||
} = {},
|
||||
'@attr': {
|
||||
nowplaying = 'false',
|
||||
} = {},
|
||||
url,
|
||||
mbid,
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: time !== undefined ? dayjs.unix(time) : undefined,
|
||||
},
|
||||
meta: {
|
||||
nowPlaying: nowplaying === 'true',
|
||||
mbid,
|
||||
source: 'Lastfm',
|
||||
url: {
|
||||
web: url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callApi = async <T>(func: any, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await func(this.client) as T;
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if (undefined !== retryError) {
|
||||
if (retries < maxRequestRetries) {
|
||||
const delay = (retries + 1) * retryMultiplier;
|
||||
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 e;
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
}
|
||||
|
||||
authenticate = async (token: any) => {
|
||||
const sessionRes: AuthGetSessionResponse = await this.client.authGetSession({token});
|
||||
const {
|
||||
session: {
|
||||
key: sessionKey,
|
||||
name, // username
|
||||
} = {}
|
||||
} = sessionRes;
|
||||
this.client.sessionKey = sessionKey;
|
||||
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
sessionKey,
|
||||
}));
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
|
||||
try {
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
const {sessionKey} = creds || {};
|
||||
if (this.client.sessionKey === undefined && sessionKey !== undefined) {
|
||||
this.client.sessionKey = sessionKey;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const infoResp = await this.callApi<UserGetInfoResponse>((client: any) => client.userGetInfo());
|
||||
const {
|
||||
user: {
|
||||
name,
|
||||
} = {}
|
||||
} = infoResp;
|
||||
this.user = name;
|
||||
this.initialized = true;
|
||||
this.logger.info(`Client authorized for user ${name}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing auth failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import request, {Request} from 'superagent';
|
||||
import {ListenBrainzClientData} from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
artist_credit_name: string
|
||||
artist_mbid: string
|
||||
join_phrase: string
|
||||
}
|
||||
|
||||
export interface MinimumTrack {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
}
|
||||
|
||||
export interface AdditionalTrackInfo {
|
||||
artist_mbids?: string[]
|
||||
release_mbid?: string
|
||||
release_group_mbid?: string
|
||||
recording_mbid?: string
|
||||
submission_client?: string
|
||||
submission_client_version?: string
|
||||
spotify_id?: string
|
||||
media_player?: string
|
||||
media_player_version?: string
|
||||
|
||||
music_service?: string
|
||||
music_service_name?: string
|
||||
origin_url?: string
|
||||
tags?: string[]
|
||||
duration?: number
|
||||
|
||||
duration_ms?: number
|
||||
track_mbid?: string
|
||||
work_mbids?: string[]
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
artist_mbids?: string[];
|
||||
artist_msid?: string;
|
||||
recording_mbid?: string;
|
||||
release_mbid?: string;
|
||||
release_msid?: string;
|
||||
tags?: string[];
|
||||
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
|
||||
recording_msid?: string
|
||||
}
|
||||
|
||||
export interface TrackPayload extends MinimumTrack {
|
||||
additional_info?: AdditionalTrackInfo
|
||||
}
|
||||
|
||||
export interface ListenPayload {
|
||||
listened_at: Date | number;
|
||||
recording_msid?: string;
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
listen_type: 'single',
|
||||
payload: [ListenPayload]
|
||||
}
|
||||
|
||||
export interface TrackResponse extends MinimumTrack {
|
||||
duration: number
|
||||
additional_info: AdditionalTrackInfoResponse
|
||||
mbid_mapping: {
|
||||
artist_mbids?: string[]
|
||||
artists?: ArtistMBIDMapping[]
|
||||
caa_id?: number
|
||||
caa_release_mbid?: string
|
||||
recording_mbid?: string
|
||||
release_mbid?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListensResponse {
|
||||
count: number;
|
||||
listens: ListenResponse[];
|
||||
}
|
||||
|
||||
export interface ListenResponse {
|
||||
|
||||
inserted_at: number
|
||||
listened_at: number;
|
||||
recording_msid?: string;
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: string;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options = {}) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
const {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
req.set('Authorization', `Token ${this.config.token}`);
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
} = e;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(this.url))
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e.status === 410) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
|
||||
// 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
|
||||
})
|
||||
.query({
|
||||
count: maxTracks
|
||||
}));
|
||||
const {body: {payload}} = resp as any;
|
||||
return payload as ListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
submitListen = async (play: PlayObject) => {
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
|
||||
return listenPayload;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static playToListenPayload = (play: PlayObject): ListenPayload => {
|
||||
return {
|
||||
listened_at: (play.data.playDate ?? dayjs()).unix(),
|
||||
track_metadata: {
|
||||
artist_name: play.data.artists[0],
|
||||
track_name: play.data.track,
|
||||
additional_info: {
|
||||
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static listenResponseToPlay = (listen: ListenResponse): PlayObject => {
|
||||
const {
|
||||
listened_at,
|
||||
recording_msid,
|
||||
track_metadata: {
|
||||
track_name,
|
||||
artist_name,
|
||||
release_name,
|
||||
duration,
|
||||
additional_info: {
|
||||
recording_msid: aRecordingMsid,
|
||||
recording_mbid: aRecordingMbid,
|
||||
duration: aDuration,
|
||||
duration_ms: aDurationMs,
|
||||
} = {},
|
||||
mbid_mapping: {
|
||||
artists: artistMappings = [],
|
||||
recording_mbid: mRecordingMbid
|
||||
} = {}
|
||||
} = {}
|
||||
} = listen;
|
||||
|
||||
const playId = recording_msid ?? aRecordingMsid;
|
||||
const trackId = aRecordingMbid ?? mRecordingMbid;
|
||||
let dur = duration ?? aDuration;
|
||||
if (dur === undefined && aDurationMs !== undefined) {
|
||||
dur = Math.round(aDurationMs / 1000);
|
||||
}
|
||||
|
||||
let artists: string[] = [artist_name];
|
||||
if (artistMappings.length > 0) {
|
||||
const secondaryArtists = artistMappings.filter(x => x.artist_credit_name !== artist_name);
|
||||
artists = artists.concat(secondaryArtists.map(x => x.artist_credit_name));
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
playDate: dayjs.unix(listened_at),
|
||||
track: track_name,
|
||||
artists: artists,
|
||||
album: release_name,
|
||||
duration: dur
|
||||
},
|
||||
meta: {
|
||||
source: 'listenbrainz',
|
||||
trackId,
|
||||
playId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
|
||||
return ListenbrainzApiClient.listenResponseToPlay(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
buildTrackString,
|
||||
capitalize, closePlayDate,
|
||||
mergeArr,
|
||||
playObjDataMatch, setIntersection,
|
||||
truncateStringToLength
|
||||
} from "../utils.js";
|
||||
import {
|
||||
ClientType, FormatPlayObjectOptions,
|
||||
INITIALIZED,
|
||||
INITIALIZING,
|
||||
InitState,
|
||||
NOT_INITIALIZED,
|
||||
PlayObject, ScrobbledPlayObject, TrackStringOptions
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import winston, {Logger} from "winston";
|
||||
import {CommonClientConfig} from "../common/infrastructure/config/client/index.js";
|
||||
import {ClientConfig} from "../common/infrastructure/config/client/clients.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
|
||||
export default abstract class AbstractScrobbleClient {
|
||||
|
||||
name: string;
|
||||
type: ClientType;
|
||||
|
||||
#initState: InitState = NOT_INITIALIZED;
|
||||
|
||||
protected MAX_STORED_SCROBBLES = 40;
|
||||
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
recentScrobbles: PlayObject[] = [];
|
||||
scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
|
||||
newestScrobbleTime?: Dayjs
|
||||
oldestScrobbleTime?: Dayjs
|
||||
tracksScrobbled: number = 0;
|
||||
|
||||
lastScrobbleCheck: Dayjs = dayjs(0)
|
||||
refreshEnabled: boolean;
|
||||
checkExistingScrobbles: boolean;
|
||||
verboseOptions;
|
||||
|
||||
config: CommonClientConfig;
|
||||
logger: Logger;
|
||||
|
||||
notifier: Notifiers;
|
||||
|
||||
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, logger: Logger) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = logger.child({labels: [identifier]}, mergeArr);
|
||||
this.notifier = notifier;
|
||||
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES);
|
||||
|
||||
const {
|
||||
data: {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
},
|
||||
} = config;
|
||||
this.config = config;
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
this.checkExistingScrobbles = checkExistingScrobbles;
|
||||
|
||||
const {
|
||||
match: {
|
||||
onNoMatch = false,
|
||||
onMatch = false,
|
||||
confidenceBreakdown = false,
|
||||
} = {},
|
||||
...vRest
|
||||
} = verbose
|
||||
if (onMatch || onNoMatch) {
|
||||
this.logger.warn('Setting verbose matching may produce noisy logs! Use with care.');
|
||||
}
|
||||
this.verboseOptions = {
|
||||
...vRest,
|
||||
match: {
|
||||
onNoMatch,
|
||||
onMatch,
|
||||
confidenceBreakdown
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get initialized() {
|
||||
return this.#initState === INITIALIZED;
|
||||
}
|
||||
|
||||
set initialized(val) {
|
||||
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
|
||||
if(val === INITIALIZING) {
|
||||
this.#initState = INITIALIZING;
|
||||
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
|
||||
} else if(val === true || val === INITIALIZED) {
|
||||
this.#initState = INITIALIZED;
|
||||
} else {
|
||||
this.#initState = NOT_INITIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
get initializing() {
|
||||
return this.#initState === INITIALIZING;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
isReady = async () => {
|
||||
return this.initialized && (!this.requiresAuth || (this.requiresAuth && this.authed));
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
this.logger.debug('Scrobbler does not have refresh function implemented!');
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
this.logger.debug('Scrobbler does not have alreadyScrobbled check implemented!');
|
||||
return false;
|
||||
}
|
||||
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => {
|
||||
this.logger.warn('formatPlayObj should be defined by concrete class!');
|
||||
return obj;
|
||||
}
|
||||
|
||||
// time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client
|
||||
// ...this is assuming the scrobble client is returning "most recent" scrobbles
|
||||
timeFrameIsValid = (playObj: PlayObject) => {
|
||||
|
||||
if(this.oldestScrobbleTime === undefined) {
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
} = {},
|
||||
} = playObj;
|
||||
const validTime = playDate.isAfter(this.oldestScrobbleTime);
|
||||
let log = '';
|
||||
if (!validTime) {
|
||||
const dur = dayjs.duration(Math.abs(playDate.diff(this.oldestScrobbleTime))).humanize(false);
|
||||
log = `occurred ${dur} before the oldest scrobble returned by this client (${this.oldestScrobbleTime.format()})`;
|
||||
}
|
||||
return [validTime, log]
|
||||
}
|
||||
|
||||
addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => {
|
||||
this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay});
|
||||
}
|
||||
|
||||
filterScrobbledTracks = () => {
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
return track.toLocaleLowerCase().trim();
|
||||
};
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
const {
|
||||
data: {
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play));
|
||||
|
||||
if (dtInvariantMatches.length === 0) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
|
||||
const {
|
||||
play: {
|
||||
data: {
|
||||
playDate: sPlayDate
|
||||
} = {},
|
||||
meta: {
|
||||
source: playSource
|
||||
} = {},
|
||||
} = {},
|
||||
} = x;
|
||||
// need to account for inaccurate DT from subsonic
|
||||
if(source === 'Subsonic' && playSource === 'Subsonic') {
|
||||
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
|
||||
}
|
||||
return playDate.isSame(sPlayDate);
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
}
|
||||
|
||||
protected compareExistingScrobbleTime = (existing: PlayObject, candidate: PlayObject): [boolean, boolean?] => {
|
||||
let closeTime = closePlayDate(existing, candidate);
|
||||
let fuzzyTime = false;
|
||||
if(!closeTime) {
|
||||
fuzzyTime = closePlayDate(existing, candidate, {fuzzyDuration: true});
|
||||
}
|
||||
return [closeTime, fuzzyTime];
|
||||
}
|
||||
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
track: scrobbleTitle,
|
||||
} = {},
|
||||
} = existing;
|
||||
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(candidate);
|
||||
|
||||
let titleMatch;
|
||||
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
|
||||
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
|
||||
// and compare against length of scrobble title
|
||||
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== ''));
|
||||
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
|
||||
|
||||
titleMatch = commonTerms.size / sourceTitleTerms.size;
|
||||
return titleMatch;
|
||||
}
|
||||
|
||||
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
const {
|
||||
data: {
|
||||
artists: sourceArtists = [],
|
||||
} = {},
|
||||
} = candidate;
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
} = {},
|
||||
} = candidate;
|
||||
let artistMatch;
|
||||
const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
|
||||
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
|
||||
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
|
||||
|
||||
return artistMatch;
|
||||
}
|
||||
|
||||
existingScrobble = async (playObj: PlayObject) => {
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}};
|
||||
|
||||
// return early if we don't care about checking existing
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
|
||||
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
|
||||
if (existingExactSubmitted !== undefined) {
|
||||
existingScrobble = existingExactSubmitted.scrobble;
|
||||
|
||||
closestMatch = {
|
||||
score: 1,
|
||||
breakdowns: ['Exact Match found in previously successfully scrobbled']
|
||||
}
|
||||
}
|
||||
// if not though then we need to check recent scrobbles from scrobble api.
|
||||
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
|
||||
|
||||
if (existingScrobble === undefined) {
|
||||
|
||||
// if no recent scrobbles found then assume we haven't submitted it
|
||||
// (either user doesnt want to check history or there is no history to check!)
|
||||
if (this.recentScrobbles.length === 0) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// we have have found an existing submission but without an exact date
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
// clean source title so it matches title from the scrobble api response as closely as we can get it
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((x) => {
|
||||
|
||||
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
|
||||
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
|
||||
|
||||
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
|
||||
|
||||
const artistMatch = this.compareExistingScrobbleArtist(x, playObj);
|
||||
|
||||
const artistScore = .2 * artistMatch;
|
||||
const titleScore = .3 * titleMatch;
|
||||
const timeScore = .5 * (closeTime ? 1 : (fuzzyTime ? 0.5 : 0));
|
||||
const referenceScore = .5 * (referenceMatch ? 1 : 0);
|
||||
const score = artistScore + titleScore + timeScore;
|
||||
|
||||
let scoreBreakdowns = [
|
||||
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
|
||||
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
|
||||
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
|
||||
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
];
|
||||
|
||||
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
|
||||
const scoreInfo = {
|
||||
score,
|
||||
scrobble: x,
|
||||
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
|
||||
}
|
||||
|
||||
if (closestMatch.score <= score && score > 0) {
|
||||
closestMatch = scoreInfo
|
||||
}
|
||||
|
||||
return score >= .7;
|
||||
});
|
||||
}
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
|
||||
}
|
||||
return existingScrobble;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
buildTrackString, capitalize,
|
||||
playObjDataMatch, removeUndefinedKeys,
|
||||
setIntersection, sleep,
|
||||
sortByOldestPlayDate,
|
||||
truncateStringToLength,
|
||||
} from "../utils.js";
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
INITIALIZING,
|
||||
PlayObject, ScrobbledPlayObject,
|
||||
TrackStringOptions
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
|
||||
import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
import {Logger} from "winston";
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
api: LastfmApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
declare config: LastfmClientConfig;
|
||||
|
||||
constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers, logger: Logger) {
|
||||
super('lastfm', name, config, notifier, logger);
|
||||
// @ts-ignore
|
||||
this.api = new LastfmApiClient(name, config.data, options)
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
|
||||
|
||||
initialize = async () => {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
this.initialized = await this.api.initialize();
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
this.authed = await this.api.testAuth();
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Last.fm API');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit: this.MAX_STORED_SCROBBLES, extended: true}));
|
||||
const {
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
this.recentScrobbles = list.reduce((acc: any, x: any) => {
|
||||
try {
|
||||
const formatted = LastfmApiClient.formatPlayObj(x);
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
playDate,
|
||||
},
|
||||
meta: {
|
||||
mbid,
|
||||
nowPlaying,
|
||||
}
|
||||
} = formatted;
|
||||
if(nowPlaying === true) {
|
||||
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
|
||||
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
|
||||
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
|
||||
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
|
||||
return acc;
|
||||
} else if(playDate === undefined) {
|
||||
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(formatted);
|
||||
} catch (e) {
|
||||
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
|
||||
this.logger.debug('Full api response object:');
|
||||
this.logger.debug(x);
|
||||
return acc;
|
||||
}
|
||||
}, []).sort(sortByOldestPlayDate);
|
||||
this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
return track.toLocaleLowerCase().trim();
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
return await this.existingScrobble(playObj) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
data = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const rawPayload = {
|
||||
artist: artists.join(', '),
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: playDate.unix(),
|
||||
};
|
||||
// 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
|
||||
const scrobblePayload = removeUndefinedKeys(rawPayload);
|
||||
|
||||
try {
|
||||
const response = await this.api.callApi<TrackScrobbleResponse>((client: any) => client.trackScrobble(
|
||||
scrobblePayload));
|
||||
const {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
accepted = 0,
|
||||
ignored = 0,
|
||||
code = undefined,
|
||||
} = {},
|
||||
scrobble: {
|
||||
track: {
|
||||
'#text': trackName,
|
||||
} = {},
|
||||
timestamp,
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
...rest
|
||||
} = {}
|
||||
} = {},
|
||||
} = response;
|
||||
if(code === 5) {
|
||||
this.initialized = false;
|
||||
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
|
||||
}
|
||||
this.addScrobbledTrack(playObj, this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName}));
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
if(ignored > 0) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)}`, priority: 'warn'});
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/errorcodes for more information`, {payload: scrobblePayload});
|
||||
}
|
||||
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
await sleep(1000);
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobblePayload});
|
||||
throw e;
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload: ', rawPayload);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import {
|
||||
buildTrackString, capitalize,
|
||||
playObjDataMatch, removeUndefinedKeys,
|
||||
setIntersection, sleep,
|
||||
sortByOldestPlayDate,
|
||||
truncateStringToLength,
|
||||
} from "../utils.js";
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
INITIALIZING,
|
||||
PlayObject,
|
||||
TrackStringOptions
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
import {Logger} from "winston";
|
||||
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
|
||||
declare config: ListenBrainzClientConfig;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientConfig, options = {}, notifier: Notifiers, logger: Logger) {
|
||||
super('listenbrainz', name, config, notifier, logger);
|
||||
this.api = new ListenbrainzApiClient(name, config.data);
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
|
||||
|
||||
initialize = async () => {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
if(this.config.data.token === undefined) {
|
||||
this.logger.error('Must provide a User Token');
|
||||
this.initialized = false;
|
||||
} else {
|
||||
try {
|
||||
await this.api.testConnection();
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
|
||||
try {
|
||||
this.authed = await this.api.testAuth();
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Listenbrainz API');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES);
|
||||
this.logger.debug(`Found ${resp.length} recent scrobbles`);
|
||||
this.recentScrobbles = resp.sort(sortByOldestPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
return await this.existingScrobble(playObj) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
let rawPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(playObj)]};
|
||||
|
||||
try {
|
||||
const resp = await this.api.submitListen(playObj);
|
||||
rawPayload = resp;
|
||||
this.addScrobbledTrack(playObj, playObj);
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
await sleep(1000);
|
||||
} catch (e) {
|
||||
let message = e.message;
|
||||
if(e.response !== undefined) {
|
||||
if(e.response.body !== undefined) {
|
||||
message = e.response.body.messsage;
|
||||
} else if(e.response.text !== undefined) {
|
||||
message = e.response.text;
|
||||
}
|
||||
}
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'});
|
||||
this.logger.error(`Failed to scrobble => ${message}`, {payload: rawPayload});
|
||||
throw e;
|
||||
} finally {
|
||||
this.logger.debug(`Raw Payload:`, {rawPayload});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import compareVersions from 'compare-versions';
|
||||
import {
|
||||
buildTrackString,
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
truncateStringToLength,
|
||||
parseRetryAfterSecsFromObj, capitalize, closePlayDate
|
||||
} from "../utils.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
INITIALIZING,
|
||||
MalojaScrobbleData,
|
||||
MalojaScrobbleRequestData,
|
||||
MalojaScrobbleV2RequestData,
|
||||
MalojaScrobbleV3RequestData,
|
||||
MalojaV2ScrobbleData,
|
||||
MalojaV3ScrobbleData,
|
||||
PlayObject,
|
||||
TrackStringOptions
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
import {Logger} from "winston";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
requiresAuth = true;
|
||||
serverIsHealthy = false;
|
||||
serverVersion: any;
|
||||
|
||||
declare config: MalojaClientConfig
|
||||
|
||||
constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, logger: Logger) {
|
||||
super('maloja', name, config, notifier, logger);
|
||||
const {url, apiKey} = config.data;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
|
||||
}
|
||||
if (url === undefined) {
|
||||
throw new Error("Missing 'url' for Maloja config");
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
let artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time;
|
||||
|
||||
const {serverVersion} = options;
|
||||
|
||||
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
|
||||
// scrobble data structure changed for v3
|
||||
const {
|
||||
// when the track was scrobbled
|
||||
time: mTime,
|
||||
track: {
|
||||
artists: mArtists,
|
||||
title: mTitle,
|
||||
album: {
|
||||
name: mAlbum,
|
||||
artists: albumArtists
|
||||
} = {},
|
||||
// length of the track
|
||||
length: mLength,
|
||||
} = {},
|
||||
// how long the track was listened to before it was scrobbled
|
||||
duration: mDuration,
|
||||
} = obj as MalojaV3ScrobbleData;
|
||||
artists = mArtists;
|
||||
time = mTime;
|
||||
title = mTitle;
|
||||
duration = mLength;
|
||||
album = mAlbum;
|
||||
} else {
|
||||
// scrobble data structure for v2 and below
|
||||
const {
|
||||
artists: mArtists,
|
||||
title: mTitle,
|
||||
album: mAlbum,
|
||||
duration: mDuration,
|
||||
time: mTime,
|
||||
} = obj as MalojaV2ScrobbleData;
|
||||
artists = mArtists;
|
||||
title = mTitle;
|
||||
album = mAlbum;
|
||||
duration = mDuration;
|
||||
time = mTime;
|
||||
}
|
||||
let artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
} else if (typeof curr === 'object') {
|
||||
aString = curr.name;
|
||||
}
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion});
|
||||
|
||||
callApi = async (req: any, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config.data;
|
||||
|
||||
try {
|
||||
return await req;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return await this.callApi(req, retries + 1)
|
||||
}
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
status,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
body,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
text,
|
||||
} = {},
|
||||
response,
|
||||
} = e;
|
||||
let msg = response !== undefined ? `API Call failed: Server Response => ${message}` : `API Call failed: ${message}`;
|
||||
const responseMeta = body ?? text;
|
||||
this.logger.error(msg, {status, response: responseMeta});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
this.logger.info(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.info('Communication test succeeded.');
|
||||
|
||||
if (version.length === 0) {
|
||||
this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. multi-scrobbler will most likely not work with this server.');
|
||||
} else {
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
this.serverVersion = versionstring;
|
||||
if(compareVersions(versionstring, '2.7.0') < 0) {
|
||||
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Communication test failed');
|
||||
this.logger.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
testHealth = async () => {
|
||||
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0);
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
db_status: {
|
||||
healthy = false,
|
||||
rebuildinprogress = false,
|
||||
complete = false,
|
||||
}
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
return [false, `Server responded with NOT OK status: ${statusCode}`];
|
||||
}
|
||||
|
||||
if(rebuildinprogress) {
|
||||
return [false, 'Server is rebuilding database'];
|
||||
}
|
||||
|
||||
if(!healthy) {
|
||||
return [false, 'Server responded that it is not healthy'];
|
||||
}
|
||||
|
||||
return [true];
|
||||
} catch (e) {
|
||||
this.logger.error('Unexpected error encountered while testing server health');
|
||||
this.logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// just checking that we can get a connection
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
this.initialized = await this.testConnection();
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
|
||||
const {url, apiKey} = this.config.data;
|
||||
try {
|
||||
const resp = await this.callApi(request
|
||||
.get(`${url}/apis/mlj_1/test`)
|
||||
.query({key: apiKey}));
|
||||
|
||||
const {
|
||||
status,
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
status: bodyStatus,
|
||||
} = {},
|
||||
body = {},
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Auth test passed!');
|
||||
this.authed = true;
|
||||
} else {
|
||||
this.authed = false;
|
||||
this.logger.error('Testing connection failed => Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if(e.status === 403) {
|
||||
// may be an older version that doesn't support auth readiness before db upgrade
|
||||
// and if it was before api was accessible during db build then test would fail during testConnection()
|
||||
if(compareVersions(this.serverVersion, '2.12.19') < 0) {
|
||||
if(!(await this.isReady())) {
|
||||
this.logger.error(`Could not test auth because server is not ready`);
|
||||
this.authed = false;
|
||||
return this.authed;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.error('Auth test failed');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
isReady = async () => {
|
||||
if (this.serverIsHealthy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const [isHealthy, status] = await this.testHealth();
|
||||
if (!isHealthy) {
|
||||
this.logger.error(`Server is not ready: ${status}`);
|
||||
this.serverIsHealthy = false;
|
||||
} else {
|
||||
this.logger.info('Server reported database is built and status is healthy');
|
||||
this.serverIsHealthy = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Testing server health failed due to an unexpected error`);
|
||||
this.serverIsHealthy = false;
|
||||
}
|
||||
return this.serverIsHealthy
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=${this.MAX_STORED_SCROBBLES}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
this.logger.debug(`Found ${list.length} recent scrobbles`);
|
||||
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
artists: sourceArtists = [],
|
||||
} = {},
|
||||
} = playObj;
|
||||
let lowerTitle = track.toLocaleLowerCase();
|
||||
lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
|
||||
// also remove [artist] from the track if found since that gets removed as well
|
||||
const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
|
||||
lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle);
|
||||
|
||||
// remove any whitespace in parenthesis
|
||||
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
|
||||
// replace parenthesis
|
||||
.replace('()', '')
|
||||
.replace('( )', '')
|
||||
.trim();
|
||||
|
||||
return lowerTitle;
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: any, log = false) => {
|
||||
return await this.existingScrobble(playObj) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
const {url, apiKey} = this.config.data;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobbleData: MalojaScrobbleRequestData = {
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
|
||||
try {
|
||||
// 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
|
||||
(scrobbleData as MalojaScrobbleV3RequestData).artists = artists;
|
||||
} else {
|
||||
// maloja seems to detect this deliminator much better than commas
|
||||
// also less likely artist has a forward slash in their name than a comma
|
||||
(scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
|
||||
}
|
||||
|
||||
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send(scrobbleData));
|
||||
|
||||
let scrobbleResponse = {};
|
||||
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
|
||||
const {
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
track,
|
||||
} = {}
|
||||
} = response;
|
||||
scrobbleResponse = {
|
||||
time: playDate.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
},
|
||||
}
|
||||
if(album !== undefined) {
|
||||
const {
|
||||
album: malojaAlbum = {},
|
||||
} = track;
|
||||
// @ts-expect-error TS(2339): Property 'track' does not exist on type '{}'.
|
||||
scrobbleResponse.track.album = {
|
||||
...malojaAlbum,
|
||||
name: album
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
track: {
|
||||
time: mTime = playDate.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
}
|
||||
} = {}} = response;
|
||||
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
|
||||
}
|
||||
this.addScrobbledTrack(playObj, this.formatPlayObj(scrobbleResponse));
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
|
||||
throw e;
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload:', scrobbleData);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
buildTrackString,
|
||||
createAjvFactory,
|
||||
mergeArr,
|
||||
playObjDataMatch,
|
||||
readJson,
|
||||
returnDuplicateStrings, validateJson
|
||||
} from "../utils.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
import LastfmScrobbler from "./LastfmScrobbler.js";
|
||||
import {clientTypes, ConfigMeta, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {AIOConfig} from "../common/infrastructure/config/aioConfig.js";
|
||||
import * as aioSchema from '../common/schema/aio-client.json';
|
||||
import * as clientSchema from '../common/schema/client.json';
|
||||
import {ClientAIOConfig, ClientConfig} from "../common/infrastructure/config/client/clients.js";
|
||||
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
|
||||
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import {EventEmitter} from "events";
|
||||
import winston from "winston";
|
||||
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
|
||||
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
type ParsedConfig = ClientAIOConfig & ConfigMeta;
|
||||
|
||||
export default class ScrobbleClients {
|
||||
|
||||
/** @type AbstractScrobbleClient[] */
|
||||
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
|
||||
logger;
|
||||
configDir;
|
||||
|
||||
emitter: EventEmitter;
|
||||
|
||||
sourceEmitter: EventEmitter;
|
||||
|
||||
constructor(emitter: EventEmitter, sourceEmitter: EventEmitter, configDir: any) {
|
||||
this.emitter = emitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
this.configDir = configDir;
|
||||
this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
|
||||
this.sourceEmitter.on('scrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
|
||||
await this.scrobble(payload.data, payload.options);
|
||||
});
|
||||
}
|
||||
|
||||
getByName = (name: any) => {
|
||||
return this.clients.find(x => x.name === name);
|
||||
}
|
||||
|
||||
getByType = (type: any) => {
|
||||
return this.clients.filter(x => x.type === type);
|
||||
}
|
||||
|
||||
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
|
||||
let clients: AbstractScrobbleClient[];
|
||||
|
||||
const messages: string[] = [];
|
||||
let clientsReady = true;
|
||||
|
||||
if(type !== undefined) {
|
||||
clients = this.getByType(type);
|
||||
} else if(name !== undefined) {
|
||||
clients = [this.getByName(name)];
|
||||
} else {
|
||||
clients = this.clients;
|
||||
}
|
||||
|
||||
for(const client of clients) {
|
||||
if(!await client.isReady()) {
|
||||
clientsReady = false;
|
||||
messages.push(`Client ${client.type} - ${client.name} is not ready.`);
|
||||
}
|
||||
}
|
||||
|
||||
return [clientsReady, messages];
|
||||
}
|
||||
|
||||
buildClientsFromConfig = async (notifier: Notifiers) => {
|
||||
let configs: ParsedConfig[] = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
// think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
let clientDefaults = {};
|
||||
if (configFile !== undefined) {
|
||||
const aioConfig = validateJson<AIOConfig>(configFile, aioSchema, this.logger);
|
||||
const {
|
||||
clients: mainConfigClientConfigs = [],
|
||||
clientDefaults: cd = {},
|
||||
} = aioConfig;
|
||||
clientDefaults = cd;
|
||||
// const validMainConfigs = mainConfigClientConfigs.reduce((acc: any, curr: any, i: any) => {
|
||||
// if(curr === null) {
|
||||
// this.logger.error(`The client config entry at index ${i} in config.json is null but should be an object, will not parse`);
|
||||
// return acc;
|
||||
// }
|
||||
// if(typeof curr !== 'object') {
|
||||
// this.logger.error(`The client config entry at index ${i} in config.json should be an object, will not parse`);
|
||||
// return acc;
|
||||
// }
|
||||
// return acc.concat(curr);
|
||||
// }, []);
|
||||
for (const c of mainConfigClientConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c,
|
||||
name,
|
||||
source: 'config.json',
|
||||
configureAs: 'client', //override user value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientType of clientTypes) {
|
||||
let defaultConfigureAs = 'client';
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
// env builder for single user mode
|
||||
const url = process.env.MALOJA_URL;
|
||||
const apiKey = process.env.MALOJA_API_KEY;
|
||||
if (url !== undefined || apiKey !== undefined) {
|
||||
configs.push({
|
||||
type: 'maloja',
|
||||
name: 'unnamed-mlj',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
data: {
|
||||
url,
|
||||
// @ts-ignore
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
const lfm = {
|
||||
apiKey: process.env.LASTFM_API_KEY,
|
||||
secret: process.env.LASTFM_SECRET,
|
||||
redirectUri: process.env.LASTFM_REDIRECT_URI,
|
||||
session: process.env.LASTFM_SESSION,
|
||||
};
|
||||
if (!Object.values(lfm).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'lastfm',
|
||||
name: 'unnamed-lfm',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: lfm
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'listenbrainz':
|
||||
const lz = {
|
||||
url: process.env.LZ_URL,
|
||||
token: process.env.LZ_TOKEN,
|
||||
username: process.env.LZ_USER
|
||||
};
|
||||
if (!Object.values(lz).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'listenbrainz',
|
||||
name: 'unnamed-lz',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: lz
|
||||
})
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let rawClientConfigs;
|
||||
try {
|
||||
rawClientConfigs = await readJson(`${this.configDir}/${clientType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
this.logger.error(`${clientType}.json config file could not be parsed`);
|
||||
continue;
|
||||
}
|
||||
if (rawClientConfigs !== undefined) {
|
||||
let clientConfigs: ParsedConfig[] = [];
|
||||
if (Array.isArray(rawClientConfigs)) {
|
||||
clientConfigs = rawClientConfigs;
|
||||
} else if(rawClientConfigs === null) {
|
||||
this.logger.error(`${clientType}.json contained no data`);
|
||||
continue;
|
||||
} else if(typeof rawClientConfigs === 'object') {
|
||||
clientConfigs = [rawClientConfigs];
|
||||
} else {
|
||||
this.logger.error(`All top level data from ${clientType}.json must be an object or an array of objects, will not parse configs from file`);
|
||||
continue;
|
||||
}
|
||||
for(const [i,rawConf] of rawClientConfigs.entries()) {
|
||||
try {
|
||||
const validConfig = validateJson<ClientConfig>(rawConf, clientSchema, this.logger);
|
||||
// @ts-ignore
|
||||
const {configureAs = defaultConfigureAs} = validConfig;
|
||||
if (configureAs === 'client') {
|
||||
const parsedConfig: ParsedConfig = {
|
||||
...rawConf,
|
||||
source: `${clientType}.json`,
|
||||
type: clientType
|
||||
}
|
||||
configs.push(parsedConfig);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.error(`The config entry at index ${i} from ${clientType}.json was not valid`);
|
||||
}
|
||||
}
|
||||
/* for (const [i,m] of clientConfigs.entries()) {
|
||||
if(m === null) {
|
||||
this.logger.error(`The config entry at index ${i} from ${clientType}.json is null`);
|
||||
continue;
|
||||
}
|
||||
if (typeof m !== 'object') {
|
||||
this.logger.error(`The config entry at index ${i} from ${clientType}.json was not an object, skipping`, m);
|
||||
continue;
|
||||
}
|
||||
const {configureAs = defaultConfigureAs} = m;
|
||||
if(configureAs === 'client') {
|
||||
m.source = `${clientType}.json`;
|
||||
m.type = clientType;
|
||||
configs.push(m);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible client configurations so we'll check they are minimally valid
|
||||
/*const validConfigs = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
this.logger.error(`Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`);
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(c);
|
||||
}, []);*/
|
||||
|
||||
// all client configs are minimally valid
|
||||
// now check that names are unique
|
||||
const nameGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
let noConflictConfigs: ParsedConfig[] = [];
|
||||
for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
|
||||
if (configs.length > 1) {
|
||||
const sources = configs.map((c: any) => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.error(`The following clients will not be built because of config naming conflicts (they have the same name of "${name}"):
|
||||
${sources.join('\n')}`);
|
||||
if (name === 'unnamed') {
|
||||
this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
|
||||
}
|
||||
} else {
|
||||
noConflictConfigs = [...noConflictConfigs, ...configs];
|
||||
}
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// just need to re-map unnnamed to default
|
||||
const finalConfigs: ParsedConfig[] = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
|
||||
...x,
|
||||
name
|
||||
}));
|
||||
for (const c of finalConfigs) {
|
||||
try {
|
||||
await this.addClient(c, clientDefaults, notifier);
|
||||
} catch(e) {
|
||||
this.logger.error(`Client ${c.name} was not added because it had unrecoverable errors`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addClient = async (clientConfig: ParsedConfig, defaults = {}, notifier: Notifiers) => {
|
||||
/* const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}*/
|
||||
const {type, name, data: d = {}} = clientConfig;
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
let newClient;
|
||||
this.logger.debug(`Constructing ${type} (${name}) client...`);
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.logger);
|
||||
break;
|
||||
case 'lastfm':
|
||||
newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier, this.logger);
|
||||
break;
|
||||
case 'listenbrainz':
|
||||
newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as ListenBrainzClientConfig, {}, notifier, this.logger);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if(newClient === undefined) {
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Client of type ${type} was not recognized??`);
|
||||
}
|
||||
if(newClient.initialized === false) {
|
||||
this.logger.debug(`Attempting ${type} (${name}) initialization...`);
|
||||
if ((await newClient.initialize()) === false) {
|
||||
this.logger.error(`${type} (${name}) client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
|
||||
} else {
|
||||
this.logger.info(`${type} (${name}) client initialized`);
|
||||
}
|
||||
}
|
||||
if(newClient.requiresAuth && !newClient.authed) {
|
||||
this.logger.debug(`Checking ${type} (${name}) client auth...`);
|
||||
let success;
|
||||
try {
|
||||
success = await newClient.testAuth();
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`${type} (${name}) client auth failed.`);
|
||||
} else {
|
||||
this.logger.info(`${type} (${name}) client auth OK`);
|
||||
}
|
||||
}
|
||||
this.clients.push(newClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} data
|
||||
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
|
||||
* @returns {Array}
|
||||
*/
|
||||
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
forceRefresh = false,
|
||||
checkTime = dayjs(),
|
||||
scrobbleTo = [],
|
||||
scrobbleFrom = 'source',
|
||||
} = options;
|
||||
|
||||
const tracksScrobbled: any = [];
|
||||
|
||||
if (this.clients.length === 0) {
|
||||
this.logger.warn('Cannot scrobble! No clients are configured.');
|
||||
}
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
|
||||
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`);
|
||||
continue;
|
||||
}
|
||||
if(!client.initialized) {
|
||||
if(client.initializing) {
|
||||
client.logger.warn(`Cannot scrobble because it is still initializing`);
|
||||
continue;
|
||||
}
|
||||
if(!(await client.initialize())) {
|
||||
client.logger.warn(`Cannot scrobble because it could not be initialized`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(client.requiresAuth && !client.authed) {
|
||||
if (client.requiresAuthInteraction) {
|
||||
client.logger.warn(`Cannot scrobble because user interaction is required for authentication`);
|
||||
continue;
|
||||
} else if (!(await client.testAuth())) {
|
||||
client.logger.warn(`Cannot scrobble because auth test failed`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(!(await client.isReady())) {
|
||||
client.logger.warn(`Cannot scrobble because it is not ready`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
try {
|
||||
await client.refreshScrobbles();
|
||||
} catch(e) {
|
||||
client.logger.error(`Encountered error while refreshing scrobbles`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
try {
|
||||
const {
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj);
|
||||
if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) {
|
||||
await client.scrobble(playObj)
|
||||
client.tracksScrobbled++;
|
||||
// since this is what we return to the source only add to tracksScrobbled if not already in array
|
||||
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
|
||||
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
|
||||
tracksScrobbled.push(playObj);
|
||||
}
|
||||
} else {
|
||||
if(!timeFrameValid) {
|
||||
client.logger.debug(`Will not scrobble ${buildTrackString(playObj)} from Source '${scrobbleFrom}' because it ${timeFrameValidLog}`);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
client.logger.error(new ErrorWithCause(`Encountered error while in scrobble loop`, {cause: e}));
|
||||
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
|
||||
if(e.continueScrobbling !== true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tracksScrobbled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as path from 'path';
|
||||
//import {fileURLToPath} from "url";
|
||||
|
||||
//const __filename = fileURLToPath(import.meta.url);
|
||||
//const __dirname = path.dirname(__filename);
|
||||
|
||||
export const projectDir = path.resolve(__dirname, '../../');
|
||||
export const configDir: string = path.resolve(projectDir, './config');
|
||||
@@ -0,0 +1,237 @@
|
||||
import {Dayjs} from "dayjs";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import {MESSAGE} from 'triple-beam';
|
||||
import {Logger} from "winston";
|
||||
|
||||
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 const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
|
||||
|
||||
export type ClientType = 'maloja' | 'lastfm' | 'listenbrainz';
|
||||
export const clientTypes: ClientType[] = ['maloja', 'lastfm', 'listenbrainz'];
|
||||
|
||||
export type InitState = 0 | 1 | 2;
|
||||
export const NOT_INITIALIZED: InitState = 0;
|
||||
export const INITIALIZING: InitState = 1;
|
||||
export const INITIALIZED: InitState = 2;
|
||||
export const initStates: InitState[] = [NOT_INITIALIZED, INITIALIZING, INITIALIZED];
|
||||
|
||||
export type ReadyState = 0 | 1 | 2;
|
||||
export const NOT_READY: ReadyState = 0;
|
||||
export const GETTING_READY: ReadyState = 1;
|
||||
export const READY: ReadyState = 2;
|
||||
export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY];
|
||||
|
||||
export interface InternalConfig {
|
||||
localUrl: string
|
||||
configDir: string
|
||||
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export interface ConfigMeta {
|
||||
source: string
|
||||
mode?: string
|
||||
configureAs: string
|
||||
}
|
||||
|
||||
export interface PlayData {
|
||||
artists?: string[]
|
||||
album?: string
|
||||
track?: string
|
||||
/**
|
||||
* The length of the track, in seconds
|
||||
* */
|
||||
duration?: number
|
||||
/**
|
||||
* The date the track was played at
|
||||
* */
|
||||
playDate?: Dayjs
|
||||
}
|
||||
|
||||
export interface PlayMeta {
|
||||
source?: string
|
||||
|
||||
/**
|
||||
* Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc...
|
||||
* */
|
||||
parsedFrom?: string
|
||||
/**
|
||||
* Unique ID for this track, given by the Source
|
||||
* */
|
||||
trackId?: string
|
||||
|
||||
/**
|
||||
* Atomic ID for this instance of played tracked IE a unique ID for "this track played at this time"
|
||||
* */
|
||||
playId?: string
|
||||
newFromSource?: boolean
|
||||
url?: {
|
||||
web: string
|
||||
[key: string]: string
|
||||
}
|
||||
user?: string
|
||||
mediaType?: string
|
||||
server?: string
|
||||
library?: string
|
||||
/**
|
||||
* The position the "player" is at in the track at the time the play was reported, in seconds
|
||||
* */
|
||||
trackProgressPosition?: number
|
||||
/**
|
||||
* A unique identifier for the device playing this track
|
||||
* */
|
||||
deviceId?: string
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PlayObject {
|
||||
data: PlayData,
|
||||
meta: PlayMeta
|
||||
}
|
||||
|
||||
export interface FormatPlayObjectOptions {
|
||||
newFromSource?: boolean
|
||||
parsedFrom?: string
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ProgressAwarePlayObject extends PlayObject {
|
||||
meta: PlayMeta & {
|
||||
initialTrackProgressPosition?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type GroupedPlays = Map<string, ProgressAwarePlayObject[]>;
|
||||
|
||||
export type GroupedFixedPlays = Map<string, FixedSizeList<ProgressAwarePlayObject>>;
|
||||
|
||||
export interface TrackStringOptions {
|
||||
include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'trackId')[]
|
||||
transformers?: {
|
||||
artists?: (a: string[]) => string
|
||||
track?: (t: string) => string
|
||||
time?: (t: Dayjs) => string
|
||||
timeFromNow?: (t: Dayjs) => string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScrobbledPlayObject {
|
||||
play: PlayObject
|
||||
scrobble: PlayObject
|
||||
}
|
||||
|
||||
export interface MalojaV2ScrobbleData {
|
||||
artists: string[]
|
||||
title: string
|
||||
album: string
|
||||
/**
|
||||
* Length of the track
|
||||
* */
|
||||
duration: number
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface MalojaV3ScrobbleData {
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
track: {
|
||||
artists: string[]
|
||||
title: string
|
||||
album?: {
|
||||
name: string
|
||||
artists: string[]
|
||||
}
|
||||
/**
|
||||
* length of the track
|
||||
* */
|
||||
length: number
|
||||
}
|
||||
/**
|
||||
* how long the track was listened to before it was scrobbled
|
||||
* */
|
||||
duration: number
|
||||
}
|
||||
|
||||
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
|
||||
|
||||
export interface MalojaScrobbleRequestData {
|
||||
key: string
|
||||
title: string
|
||||
album: string
|
||||
time: number
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
|
||||
artist: string
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
|
||||
artists: string[]
|
||||
}
|
||||
|
||||
export interface RemoteIdentityParts {
|
||||
host: string,
|
||||
proxy: string | undefined,
|
||||
agent: string | undefined
|
||||
}
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
|
||||
export const logLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
|
||||
|
||||
export interface LogConfig {
|
||||
level?: string
|
||||
file?: string | false
|
||||
stream?: string
|
||||
console?: string
|
||||
}
|
||||
|
||||
export interface LogOptions {
|
||||
/**
|
||||
* Specify the minimum log level for all log outputs without their own level specified.
|
||||
*
|
||||
* Defaults to env `LOG_LEVEL` or `info` if not specified.
|
||||
*
|
||||
* @default 'info'
|
||||
* */
|
||||
level?: LogLevel
|
||||
/**
|
||||
* Specify the minimum log level to output to rotating files. If `false` no log files will be created.
|
||||
* */
|
||||
file?: LogLevel | false
|
||||
/**
|
||||
* Specify the minimum log level streamed to the UI
|
||||
* */
|
||||
stream?: LogLevel
|
||||
/**
|
||||
* Specify the minimum log level streamed to the console (or docker container)
|
||||
* */
|
||||
console?: LogLevel
|
||||
}
|
||||
|
||||
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
|
||||
return Object.entries(obj).every(([key, val]) => {
|
||||
if(key !== 'file') {
|
||||
return val === undefined || logLevels.includes(val.toLocaleLowerCase());
|
||||
}
|
||||
return val === undefined || val === false || logLevels.includes(val.toLocaleLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
export interface LogInfo {
|
||||
message: string
|
||||
[MESSAGE]: string,
|
||||
level: string
|
||||
timestamp: string
|
||||
labels?: string[]
|
||||
transport?: string[]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {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 AIOConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
clientDefaults?: RequestRetryOptions
|
||||
sources?: SourceAIOConfig[]
|
||||
clients?: ClientAIOConfig[]
|
||||
|
||||
webhooks?: WebhookConfig[]
|
||||
|
||||
logging?: LogOptions
|
||||
}
|
||||
|
||||
export interface AIOClientConfig {
|
||||
clientDefaults?: RequestRetryOptions
|
||||
clients?: ClientAIOConfig[]
|
||||
}
|
||||
|
||||
export interface AIOSourceConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
sources?: SourceAIOConfig[]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.js";
|
||||
import {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.js";
|
||||
import {ListenBrainzClientAIOConfig, ListenBrainzClientConfig} from "./listenbrainz.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
|
||||
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig;
|
||||
@@ -0,0 +1,80 @@
|
||||
import {CommonConfig, CommonData} from "../common.js";
|
||||
|
||||
/**
|
||||
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
|
||||
* */
|
||||
export interface MatchLoggingOptions {
|
||||
/**
|
||||
* Log to DEBUG when a new track does NOT match an existing scrobble
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
onNoMatch?: boolean
|
||||
/**
|
||||
* Log to DEBUG when a new track DOES match an existing scrobble
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
onMatch?: boolean
|
||||
/**
|
||||
* Include confidence breakdowns in track match logging, if applicable
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
confidenceBreakdown?: boolean
|
||||
}
|
||||
|
||||
export interface CommonClientData extends CommonData {
|
||||
/**
|
||||
* default # of http request retries a client can make before error is thrown.
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxRequestRetries?: number
|
||||
/**
|
||||
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).
|
||||
*
|
||||
* @default 1.5
|
||||
* @examples [1.5]
|
||||
* */
|
||||
retryMultiplier?: number
|
||||
|
||||
options?: {
|
||||
/**
|
||||
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
refreshEnabled?: boolean
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
checkExistingScrobbles?: boolean
|
||||
/**
|
||||
* Options used for increasing verbosity of logging in MS (used for debugging)
|
||||
* */
|
||||
verbose?: {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonClientConfig extends CommonConfig {
|
||||
/**
|
||||
* Unique identifier for this client. Used with sources to restrict where scrobbles are sent.
|
||||
*
|
||||
* @examples ["MyConfig"]
|
||||
* */
|
||||
name: string
|
||||
/**
|
||||
* Specific data required to configure this client
|
||||
* */
|
||||
data?: CommonClientData
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {CommonClientConfig, CommonClientData} from "./index.js";
|
||||
import {RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface LastfmData extends RequestRetryOptions {
|
||||
/**
|
||||
* API Key generated from Last.fm account
|
||||
*
|
||||
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
|
||||
* */
|
||||
apiKey: string
|
||||
/**
|
||||
* Secret generated from Last.fm account
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
secret: string
|
||||
/**
|
||||
* Optional session id returned from a completed auth flow
|
||||
* */
|
||||
session?: string
|
||||
/**
|
||||
* Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "lastfm/callback" in the URL somewhere.
|
||||
*
|
||||
* @default "http://localhost:9078/lastfm/callback"
|
||||
* @examples ["http://localhost:9078/lastfm/callback"]
|
||||
* */
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface LastfmClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using LastFM as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: CommonClientData & LastfmData
|
||||
}
|
||||
|
||||
export interface LastfmClientAIOConfig extends LastfmClientConfig {
|
||||
type: 'lastfm'
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {CommonClientConfig, CommonClientData} from "./index.js";
|
||||
import {RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface ListenBrainzData extends RequestRetryOptions{
|
||||
/**
|
||||
* URL for the ListenBrainz server, if not using the default
|
||||
*
|
||||
* @examples ["https://api.listenbrainz.org/"]
|
||||
* @default "https://api.listenbrainz.org/"
|
||||
* */
|
||||
url?: string
|
||||
/**
|
||||
* User token for the user to scrobble for
|
||||
*
|
||||
* @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"]
|
||||
* */
|
||||
token: string
|
||||
|
||||
/**
|
||||
* Username of the user to scrobble for
|
||||
* */
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ListenBrainzClientData extends ListenBrainzData, CommonClientData {}
|
||||
|
||||
export interface ListenBrainzClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using Listenbrainz as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: ListenBrainzClientData
|
||||
}
|
||||
|
||||
export interface ListenBrainzClientAIOConfig extends ListenBrainzClientConfig {
|
||||
type: 'listenbrainz'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CommonClientConfig, CommonClientData} from "./index.js";
|
||||
import {RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
|
||||
/**
|
||||
* URL for maloja server
|
||||
*
|
||||
* @examples ["http://localhost:42010"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* API Key for Maloja server
|
||||
*
|
||||
* @examples ["myApiKey"]
|
||||
* */
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface MalojaClientConfig extends CommonClientConfig {
|
||||
data: MalojaClientData
|
||||
}
|
||||
|
||||
export interface MalojaClientAIOConfig extends MalojaClientConfig {
|
||||
type: 'maloja'
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
data?: CommonData
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
[key: string]: any
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface RequestRetryOptions {
|
||||
/**
|
||||
* default # of http request retries a source can make before error is thrown
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxRequestRetries?: number
|
||||
/**
|
||||
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)
|
||||
*
|
||||
* @default 1.5
|
||||
* @examples [1.5]
|
||||
* */
|
||||
retryMultiplier?: number
|
||||
}
|
||||
|
||||
export interface PollingOptions {
|
||||
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* */
|
||||
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]
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface WebhookPayload {
|
||||
title?: string
|
||||
message: string
|
||||
priority: 'info' | 'warn' | 'error'
|
||||
}
|
||||
|
||||
export interface PrioritiesConfig {
|
||||
/**
|
||||
* @examples [5]
|
||||
* */
|
||||
info: number
|
||||
/**
|
||||
* @examples [7]
|
||||
* */
|
||||
warn: number
|
||||
/**
|
||||
* @examples [10]
|
||||
* */
|
||||
error: number
|
||||
}
|
||||
|
||||
export interface CommonWebhookConfig {
|
||||
/**
|
||||
* Webhook type. Valid values are:
|
||||
*
|
||||
* * gotify
|
||||
* * ntfy
|
||||
*
|
||||
* @examples ["gotify"]
|
||||
* */
|
||||
type: 'gotify' | 'ntfy'
|
||||
/**
|
||||
* A friendly name used to identify webhook config in logs
|
||||
* */
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface GotifyConfig extends CommonWebhookConfig {
|
||||
/**
|
||||
* The URL of the Gotify server. Same URL that would be used to reach the Gotify UI
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* The token created for this Application in Gotify
|
||||
*
|
||||
* @examples ["AQZI58fA.rfSZbm"]
|
||||
* */
|
||||
token: string
|
||||
/**
|
||||
* Priority of messages
|
||||
*
|
||||
* * Info -> 5
|
||||
* * Warn -> 7
|
||||
* * Error -> 10
|
||||
* */
|
||||
priorities?: PrioritiesConfig
|
||||
}
|
||||
|
||||
export interface NtfyConfig extends CommonWebhookConfig {
|
||||
/**
|
||||
* The URL of the Ntfy server
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The topic mutli-scrobbler should POST to
|
||||
* */
|
||||
topic: string
|
||||
|
||||
/**
|
||||
* Required if topic is protected
|
||||
* */
|
||||
username?: string
|
||||
/**
|
||||
* Required if topic is protected
|
||||
* */
|
||||
password?: string
|
||||
|
||||
/**
|
||||
* Priority of messages
|
||||
*
|
||||
* * Info -> 3
|
||||
* * Warn -> 4
|
||||
* * Error -> 5
|
||||
* */
|
||||
priorities?: PrioritiesConfig
|
||||
}
|
||||
|
||||
export type WebhookConfig = GotifyConfig | NtfyConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface DeezerData extends CommonSourceData {
|
||||
/**
|
||||
* deezer client id
|
||||
*
|
||||
* @examples ["a89cba1569901a0671d5a9875fed4be1"]
|
||||
* */
|
||||
clientId: string
|
||||
/**
|
||||
* deezer client secret
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
clientSecret: string
|
||||
/**
|
||||
* deezer redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
*
|
||||
* @default "http://localhost:9078/deezer/callback"
|
||||
* @examples ["http://localhost:9078/deezer/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
/**
|
||||
* optional, how long to wait before calling spotify for new tracks (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
export interface DeezerSourceConfig extends CommonSourceConfig {
|
||||
data: DeezerData
|
||||
}
|
||||
|
||||
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
|
||||
type: 'deezer'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {CommonConfig, CommonData, RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
/**
|
||||
* default # of automatic polling restarts on error
|
||||
*
|
||||
* @default 5
|
||||
* @examples [5]
|
||||
* */
|
||||
maxPollRetries?: number
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData, SourceRetryOptions {
|
||||
}
|
||||
|
||||
export interface CommonSourceConfig extends CommonConfig {
|
||||
/**
|
||||
* Unique identifier for this source.
|
||||
* */
|
||||
name?: string
|
||||
/**
|
||||
* 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"]]
|
||||
* */
|
||||
clients?: string[]
|
||||
data?: CommonSourceData
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface JellyData extends CommonSourceData {
|
||||
/**
|
||||
* optional list of users to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all users will be scrobbled
|
||||
*
|
||||
* @examples [["MyUser1","MyUser2"]]
|
||||
* */
|
||||
users?: string | string[]
|
||||
/**
|
||||
* optional list of servers to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all servers will be scrobbled
|
||||
*
|
||||
* @examples [["MyServerName1"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for jellyfin logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
/**
|
||||
* Log raw Jellyfin webhook payload to debug
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
logPayload?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface JellySourceConfig extends CommonSourceConfig {
|
||||
data: JellyData
|
||||
}
|
||||
|
||||
export interface JellySourceAIOConfig extends JellySourceConfig {
|
||||
type: 'jellyfin'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
export interface JRiverData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* URL of the JRiver HTTP server to connect to
|
||||
*
|
||||
* multi-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`
|
||||
*
|
||||
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
|
||||
*
|
||||
* Parts => [default value]
|
||||
*
|
||||
* * Protocol => `http://`
|
||||
* * Hostname => `localhost`
|
||||
* * Port => `52199`
|
||||
* * Path => `/MCWS/v1/`
|
||||
*
|
||||
*
|
||||
* @examples ["http://localhost:52199/MCWS/v1/"]
|
||||
* @default "http://localhost:52199/MCWS/v1/"
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* If you have enabled authentication, the username you set
|
||||
* */
|
||||
username?: string
|
||||
|
||||
/**
|
||||
* If you have enabled authentication, the password you set
|
||||
* */
|
||||
password?: string
|
||||
}
|
||||
export interface JRiverSourceConfig extends CommonSourceConfig {
|
||||
data: JRiverData
|
||||
}
|
||||
|
||||
export interface JRiverSourceAIOConfig extends JRiverSourceConfig {
|
||||
type: 'jriver'
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
|
||||
export interface KodiData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* URL of the Kodi HTTP server to connect to
|
||||
*
|
||||
* multi-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`
|
||||
*
|
||||
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
|
||||
*
|
||||
* Parts => [default value]
|
||||
*
|
||||
* * Protocol => `http://`
|
||||
* * Hostname => `localhost`
|
||||
* * Port => `8080`
|
||||
* * Path => `/jsonrpc`
|
||||
*
|
||||
*
|
||||
* @examples ["http://localhost:8080/jsonrpc"]
|
||||
* @default "http://localhost:8080/jsonrpc"
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The username set for Remote Control via Web Sever
|
||||
* */
|
||||
username: string
|
||||
|
||||
/**
|
||||
* The password set for Remote Control via Web Sever
|
||||
* */
|
||||
password: string
|
||||
}
|
||||
export interface KodiSourceConfig extends CommonSourceConfig {
|
||||
data: KodiData
|
||||
}
|
||||
|
||||
export interface KodiSourceAIOConfig extends KodiSourceConfig {
|
||||
type: 'kodi'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {LastfmData} from "../client/lastfm.js";
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
|
||||
|
||||
export interface LastfmSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
* When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client.
|
||||
*
|
||||
* @default source
|
||||
* @examples ["source"]
|
||||
* */
|
||||
configureAs?: 'source'
|
||||
data: LastFmSourceData
|
||||
}
|
||||
|
||||
export interface LastFmSouceAIOConfig extends LastfmSourceConfig {
|
||||
type: 'lastfm'
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {ListenBrainzData} from "../client/listenbrainz.js";
|
||||
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
|
||||
}
|
||||
|
||||
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
* When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.
|
||||
*
|
||||
* @default source
|
||||
* @examples ["source"]
|
||||
* */
|
||||
configureAs?: 'source'
|
||||
data: ListenBrainzSourceData
|
||||
}
|
||||
|
||||
export interface ListenBrainzSourceAIOConfig extends ListenBrainzSourceConfig {
|
||||
type: 'listenbrainz'
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
export interface MopidyData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* URL of the Mopidy HTTP server to connect to
|
||||
*
|
||||
* You MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http
|
||||
*
|
||||
* multi-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/`
|
||||
*
|
||||
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
|
||||
*
|
||||
* Parts => [default value]
|
||||
*
|
||||
* * Protocol => `ws://`
|
||||
* * Hostname => `localhost`
|
||||
* * Port => `6680`
|
||||
* * Path => `/mopidy/ws/`
|
||||
*
|
||||
*
|
||||
* @examples ["ws://localhost:6680/mopidy/ws/"]
|
||||
* @default "ws://localhost:6680/mopidy/ws/"
|
||||
* */
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive
|
||||
*
|
||||
* EX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.
|
||||
*
|
||||
* List is ignored if uriWhitelist is used.
|
||||
* */
|
||||
uriBlacklist?: string[]
|
||||
|
||||
/**
|
||||
* Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive
|
||||
*
|
||||
* EX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list.
|
||||
*
|
||||
* */
|
||||
uriWhitelist?: string[]
|
||||
|
||||
/**
|
||||
* Remove album data that matches any case-insensitive string from this list when scrobbling,
|
||||
*
|
||||
* For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data.
|
||||
*
|
||||
* @examples [["Soundcloud", "Mixcloud"]]
|
||||
* @default ["Soundcloud"]
|
||||
* */
|
||||
albumBlacklist?: string[]
|
||||
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* @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 30
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
export interface MopidySourceConfig extends CommonSourceConfig {
|
||||
data: MopidyData
|
||||
}
|
||||
|
||||
export interface MopidySourceAIOConfig extends MopidySourceConfig {
|
||||
type: 'mopidy'
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export const PLAYBACK_STATUS_PLAYING = 'Playing';
|
||||
export const PLAYBACK_STATUS_PAUSED = 'Paused';
|
||||
export const PLAYBACK_STATUS_STOPPED = 'Stopped';
|
||||
|
||||
export type PlaybackStatus = 'Playing' | 'Paused' | 'Stopped';
|
||||
|
||||
export const MPRIS_IFACE = 'org.mpris.MediaPlayer2.Player';
|
||||
export const MPRIS_PATH = '/org/mpris/MediaPlayer2';
|
||||
export const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties';
|
||||
|
||||
export interface MPRISMetadata {
|
||||
trackid?: string
|
||||
length?: number
|
||||
artUrl?: string
|
||||
album?: string
|
||||
albumArtist?: string[]
|
||||
artist?: string[]
|
||||
title?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
name: string
|
||||
status: PlaybackStatus
|
||||
position?: number
|
||||
metadata: MPRISMetadata
|
||||
}
|
||||
|
||||
export interface MPRISData extends CommonSourceData {
|
||||
/**
|
||||
* DO NOT scrobble from any players that START WITH these values, case-insensitive
|
||||
*
|
||||
* @examples [["spotify","vlc"]]
|
||||
* */
|
||||
blacklist?: string | string[]
|
||||
|
||||
/**
|
||||
* ONLY from any players that START WITH these values, case-insensitive
|
||||
*
|
||||
* If whitelist is present then blacklist is ignored
|
||||
*
|
||||
* @examples [["spotify","vlc"]]
|
||||
* */
|
||||
whitelist?: string | string[]
|
||||
}
|
||||
|
||||
export interface MPRISSourceConfig extends CommonSourceConfig {
|
||||
data: MPRISData
|
||||
}
|
||||
|
||||
export interface MPRISSourceAIOConfig extends MPRISSourceConfig {
|
||||
type: 'mpris'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface PlexSourceData extends CommonSourceData {
|
||||
/**
|
||||
* optional list of users to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all users will be scrobbled
|
||||
*
|
||||
* @examples [["MyUser1", "MyUser2"]]
|
||||
* */
|
||||
user?: string | string[]
|
||||
/**
|
||||
* optional list of libraries to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all libraries will be scrobbled
|
||||
*
|
||||
* @examples [["Audio","Music"]]
|
||||
* */
|
||||
libraries?: string | string[]
|
||||
/**
|
||||
* optional list of servers to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all servers will be scrobbled
|
||||
*
|
||||
* @examples [["MyServerName"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
}
|
||||
|
||||
export interface PlexSourceConfig extends CommonSourceConfig {
|
||||
data: PlexSourceData
|
||||
}
|
||||
|
||||
export interface PlexSourceAIOConfig extends PlexSourceConfig {
|
||||
type: 'plex'
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,46 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* spotify client id
|
||||
*
|
||||
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
|
||||
* */
|
||||
clientId: string
|
||||
/**
|
||||
* spotify client secret
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
clientSecret: string
|
||||
/**
|
||||
* spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
*
|
||||
* @default "http://localhost:9078/callback"
|
||||
* @examples ["http://localhost:9078/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* It is unlikely you should need to change this unless you scrobble many very short tracks often
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
export interface SpotifySourceConfig extends CommonSourceConfig {
|
||||
data: SpotifySourceData
|
||||
}
|
||||
|
||||
export interface SpotifySourceAIOConfig extends SpotifySourceConfig {
|
||||
type: 'spotify'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
export interface SubsonicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* URL of the subsonic media server to query
|
||||
*
|
||||
* @examples ["http://airsonic.local"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* Username to login to the server with
|
||||
*
|
||||
* @example ["MyUser"]
|
||||
* */
|
||||
user: string
|
||||
|
||||
/**
|
||||
* Password for the user to login to the server with
|
||||
*
|
||||
* @examples ["MyPassword"]
|
||||
* */
|
||||
password: string
|
||||
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* @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 30
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
export interface SubSonicSourceConfig extends CommonSourceConfig {
|
||||
data: SubsonicData
|
||||
}
|
||||
|
||||
export interface SubsonicSourceAIOConfig extends SubSonicSourceConfig {
|
||||
type: 'subsonic'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {PlexSourceConfig} from "./plex.js";
|
||||
|
||||
export interface TautulliSourceConfig extends PlexSourceConfig {
|
||||
}
|
||||
|
||||
export interface TautulliSourceAIOConfig extends TautulliSourceConfig {
|
||||
type: 'tautulli'
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
import {PollingOptions} from "../common.js";
|
||||
|
||||
export interface YTMusicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
|
||||
*
|
||||
* See https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.
|
||||
*
|
||||
* @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."]
|
||||
* */
|
||||
cookie: string
|
||||
/**
|
||||
* If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included
|
||||
*
|
||||
* @example [0]
|
||||
* */
|
||||
authUser?: number
|
||||
}
|
||||
export interface YTMusicSourceConfig extends CommonSourceConfig {
|
||||
data: YTMusicData
|
||||
}
|
||||
|
||||
export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig {
|
||||
type: 'ytmusic'
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
declare module 'lastfm-node-client' {
|
||||
/**
|
||||
* Must REMOVE (unset) any undefined/null properties from payload before sending or LastFM will return an error
|
||||
* */
|
||||
export interface TrackScrobblePayload {
|
||||
/**
|
||||
* join multiple artists with ', '
|
||||
* */
|
||||
artist: string
|
||||
|
||||
/**
|
||||
* track title
|
||||
* */
|
||||
track: string
|
||||
|
||||
/**
|
||||
* Unix timestamp of time track should be scrobbled at
|
||||
* */
|
||||
timestamp: number
|
||||
/**
|
||||
* length of track in seconds
|
||||
* */
|
||||
duration?: number
|
||||
|
||||
album?: string
|
||||
|
||||
albumArtist?: string
|
||||
}
|
||||
|
||||
export interface TrackScrobbleResponse {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
accepted: number,
|
||||
ignored: number
|
||||
code: number
|
||||
},
|
||||
scrobble?: {
|
||||
track: {
|
||||
'#text': string
|
||||
},
|
||||
timestamp: number,
|
||||
ignoredMessage: {
|
||||
code: number
|
||||
'#text': string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthGetSessionPayload {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AuthGetSessionResponse {
|
||||
session: {
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserGetInfoResponse {
|
||||
user: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserGetRecentTracksPayload {
|
||||
user: string
|
||||
limit?: number
|
||||
extended?: boolean
|
||||
}
|
||||
|
||||
export interface UserGetRecentTracksResponse {
|
||||
recenttracks: {
|
||||
track: TrackObject[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrackObject {
|
||||
artist: {
|
||||
'#text': string,
|
||||
name: string,
|
||||
},
|
||||
name: string,
|
||||
album: {
|
||||
'#text': string,
|
||||
},
|
||||
duration: number,
|
||||
date?: {
|
||||
// @ts-ignore
|
||||
uts: number,
|
||||
},
|
||||
'@attr'?: {
|
||||
nowplaying: 'true' | 'false'
|
||||
}
|
||||
url: string,
|
||||
mbid: string,
|
||||
}
|
||||
|
||||
export default class LastFM {
|
||||
constructor(apiKey: string, secret?: string, session?: string);
|
||||
|
||||
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
|
||||
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
|
||||
userGetInfo(): Promise<UserGetInfoResponse>
|
||||
userGetRecentTracks(params: UserGetRecentTracksPayload): Promise<UserGetRecentTracksResponse>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
declare module 'passport-deezer' {
|
||||
//import {Strategy as Oauth2Strategy} from 'passport-oauth2';
|
||||
import {Strategy as PassportStrategy} from "passport";
|
||||
export class Strategy extends PassportStrategy {
|
||||
constructor(options: any, verify: any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import path from "path";
|
||||
import {projectDir} from "./index.js";
|
||||
import winston, {format, Logger} from "winston";
|
||||
import {DuplexTransport} from "winston-duplex";
|
||||
import {asLogOptions, LogConfig, LogInfo, LogLevel, LogOptions} from "./infrastructure/Atomic.js";
|
||||
import process from "process";
|
||||
import {fileOrDirectoryIsWriteable, truncateStringToLength} from "../utils.js";
|
||||
import {ErrorWithCause, stackWithCauses} from "pony-cause";
|
||||
import {NullTransport} from 'winston-null';
|
||||
import 'winston-daily-rotate-file';
|
||||
import dayjs from "dayjs";
|
||||
import stringify from 'safe-stable-stringify';
|
||||
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
|
||||
import {Symbol} from "typescript-json-schema";
|
||||
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
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()]});
|
||||
|
||||
export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
|
||||
|
||||
if (!winston.loggers.has(name)) {
|
||||
const errors: (Error | string)[] = [];
|
||||
|
||||
let options: LogOptions = {};
|
||||
if (asLogOptions(config)) {
|
||||
options = config;
|
||||
} else {
|
||||
errors.push(`Logging levels were not valid. Must be one of: 'error', 'warn', 'info', 'verbose', 'debug' -- 'file' may be false.`);
|
||||
}
|
||||
|
||||
const {level: configLevel} = options;
|
||||
const defaultLevel = process.env.LOG_LEVEL || 'info';
|
||||
const {
|
||||
level = configLevel || defaultLevel,
|
||||
file = configLevel || defaultLevel,
|
||||
stream = configLevel || 'debug',
|
||||
console = configLevel || 'debug'
|
||||
} = options;
|
||||
|
||||
const consoleTransport = new transports.Console({level: console});
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
new DuplexTransport({
|
||||
stream: {
|
||||
transform(chunk, e, cb) {
|
||||
cb(null, chunk);
|
||||
},
|
||||
objectMode: true,
|
||||
},
|
||||
name: 'duplex',
|
||||
handleExceptions: true,
|
||||
handleRejections: true,
|
||||
level: stream,
|
||||
dump: false,
|
||||
}),
|
||||
];
|
||||
|
||||
if (file !== false) {
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m',
|
||||
level: file
|
||||
});
|
||||
|
||||
try {
|
||||
fileOrDirectoryIsWriteable(logPath);
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
} catch (e: any) {
|
||||
let msg = 'WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory';
|
||||
errors.push(new ErrorWithCause<Error>(msg, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
const loggerOptions: winston.LoggerOptions = {
|
||||
level: level,
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add(name, loggerOptions);
|
||||
|
||||
const logger = winston.loggers.get(name);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
return winston.loggers.get(name);
|
||||
}
|
||||
|
||||
const breakSymbol = '<br />';
|
||||
export const formatLogToHtml = (chunk: any) => {
|
||||
const line = chunk.toString().replace('\n', breakSymbol)
|
||||
.replace(/(debug)\s/gi, '<span class="debug blue">$1 </span>')
|
||||
.replace(/(warn)\s/gi, '<span class="warn yellow">$1 </span>')
|
||||
.replace(/(info)\s/gi, '<span class="info green">$1 </span>')
|
||||
.replace(/(verbose)\s/gi, '<span class="verbose purple">$1 </span>')
|
||||
.replace(/(error)\s/gi, '<span class="error red">$1 </span>')
|
||||
.trim();
|
||||
if(line.slice(-6) !== breakSymbol) {
|
||||
return `${line}${breakSymbol}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
const levelSymbol = Symbol.for('level');
|
||||
const s = splat();
|
||||
//const errorsFormat = errors({stack: true});
|
||||
const CWD = process.cwd();
|
||||
|
||||
const causeKeys = ['name', 'cause']
|
||||
|
||||
export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
label,
|
||||
[levelSymbol]: levelSym,
|
||||
level,
|
||||
message,
|
||||
labels = [defaultLabel],
|
||||
leaf,
|
||||
timestamp,
|
||||
durationMs,
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
...rest
|
||||
}) => {
|
||||
const keys = Object.keys(rest);
|
||||
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify(rest) : '';
|
||||
let msg = message;
|
||||
let stackMsg = '';
|
||||
if (stack !== undefined) {
|
||||
const stackArr = stack.split('\n');
|
||||
const stackTop = stackArr[0];
|
||||
const cleanedStack = stackArr
|
||||
.slice(1) // don't need actual error message since we are showing it as msg
|
||||
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
|
||||
.join('\n'); // rejoin with newline to preserve formatting
|
||||
stackMsg = `\n${cleanedStack}`;
|
||||
if (msg === undefined || msg === null || typeof message === 'object') {
|
||||
msg = stackTop;
|
||||
} else {
|
||||
stackMsg = `\n${stackTop}${stackMsg}`
|
||||
}
|
||||
}
|
||||
|
||||
let nodes = Array.isArray(labels) ? labels : [labels];
|
||||
if (leaf !== null && leaf !== undefined && !nodes.includes(leaf)) {
|
||||
nodes.push(leaf);
|
||||
}
|
||||
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
|
||||
|
||||
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
export const labelledFormat = (labelName = 'App') => {
|
||||
const l = label({label: labelName, message: false});
|
||||
return combine(
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
l,
|
||||
s,
|
||||
errorAwareFormat,
|
||||
defaultFormat(labelName),
|
||||
);
|
||||
}
|
||||
|
||||
export const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
verbose: 4,
|
||||
debug: 5,
|
||||
trace: 5,
|
||||
silly: 6
|
||||
};
|
||||
|
||||
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
|
||||
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel): boolean => {
|
||||
// @ts-ignore
|
||||
const minLevel = logLevels[minLevelText];
|
||||
let level: number;
|
||||
|
||||
if(typeof log === 'string') {
|
||||
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
|
||||
if (lineLevelMatch === null) {
|
||||
return false;
|
||||
}
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch[1]];
|
||||
} else {
|
||||
const lineLevelMatch = log.level;
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch];
|
||||
}
|
||||
return level <= minLevel;
|
||||
}
|
||||
|
||||
const isProbablyError = (val: any, explicitErrorName?: string) => {
|
||||
if(typeof val !== 'object' || val === null) {
|
||||
return false;
|
||||
}
|
||||
const {name, stack} = val;
|
||||
if(explicitErrorName !== undefined) {
|
||||
if(name !== undefined && name.toLowerCase().includes(explicitErrorName)) {
|
||||
return true;
|
||||
}
|
||||
if(stack !== undefined && stack.trim().toLowerCase().indexOf(explicitErrorName.toLowerCase()) === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if(stack !== undefined) {
|
||||
return true;
|
||||
} else if(name !== undefined && name.toLowerCase().includes('error')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorAwareFormat = {
|
||||
transform: (einfo: any, {stack = true}: any = {}) => {
|
||||
|
||||
// because winston logger.child() re-assigns its input to an object ALWAYS the object we recieve here will never actually be of type Error
|
||||
const includeStack = stack && (!isProbablyError(einfo, 'simpleerror') && !isProbablyError(einfo.message, 'simpleerror'));
|
||||
|
||||
if (!isProbablyError(einfo.message) && !isProbablyError(einfo)) {
|
||||
return einfo;
|
||||
}
|
||||
|
||||
let info: any = {};
|
||||
|
||||
if (isProbablyError(einfo)) {
|
||||
const tinfo = transformError(einfo);
|
||||
info = Object.assign({}, tinfo, {
|
||||
// @ts-ignore
|
||||
level: einfo.level,
|
||||
// @ts-ignore
|
||||
[LEVEL]: einfo[LEVEL] || einfo.level,
|
||||
message: tinfo.message,
|
||||
// @ts-ignore
|
||||
[MESSAGE]: tinfo[MESSAGE] || tinfo.message
|
||||
});
|
||||
if(includeStack) {
|
||||
// so we have to create a dummy error and re-assign all error properties from our info object to it so we can get a proper stack trace
|
||||
const dummyErr = new ErrorWithCause('');
|
||||
const names = Object.getOwnPropertyNames(tinfo);
|
||||
for(const k of names) {
|
||||
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
|
||||
// @ts-ignore
|
||||
dummyErr[k] = tinfo[k];
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
info.stack = stackWithCauses(dummyErr);
|
||||
}
|
||||
} else {
|
||||
const err = transformError(einfo.message);
|
||||
info = Object.assign({}, einfo, err);
|
||||
// @ts-ignore
|
||||
info.message = err.message;
|
||||
// @ts-ignore
|
||||
info[MESSAGE] = err.message;
|
||||
|
||||
if(includeStack) {
|
||||
const dummyErr = new ErrorWithCause('');
|
||||
// Error properties are not enumerable
|
||||
// https://stackoverflow.com/a/18278145/1469797
|
||||
const names = Object.getOwnPropertyNames(err);
|
||||
for(const k of names) {
|
||||
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
|
||||
// @ts-ignore
|
||||
dummyErr[k] = err[k];
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
info.stack = stackWithCauses(dummyErr);
|
||||
}
|
||||
}
|
||||
|
||||
// remove redundant message from stack and make stack causes easier to read
|
||||
if(info.stack !== undefined) {
|
||||
let cleanedStack = info.stack.replace(info.message, '');
|
||||
cleanedStack = `${cleanedStack}`;
|
||||
cleanedStack = cleanedStack.replaceAll('caused by:', '\ncaused by:');
|
||||
info.stack = cleanedStack;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export const transformError = (err: Error): any => _transformError(err, new Set());
|
||||
|
||||
const _transformError = (err: Error, seen: Set<Error>) => {
|
||||
if (!err || !isProbablyError(err)) {
|
||||
return '';
|
||||
}
|
||||
if (seen.has(err)) {
|
||||
return err;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// @ts-ignore
|
||||
let mOpts = err.matchOptions ?? matchOptions;
|
||||
|
||||
// @ts-ignore
|
||||
const cause = err.cause as unknown;
|
||||
|
||||
if (cause !== undefined && cause instanceof Error) {
|
||||
// @ts-ignore
|
||||
err.cause = _transformError(cause, seen, mOpts);
|
||||
}
|
||||
|
||||
return err;
|
||||
} catch (e: any) {
|
||||
// oops :(
|
||||
// we're gonna swallow silently instead of reporting to avoid any infinite nesting and hopefully the original error looks funny enough to provide clues as to what to fix here
|
||||
return err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ClientAIOConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LastfmClientAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzClientAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MalojaClientAIOConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommonClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientAIOConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using LastFM as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonClientData"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmData"
|
||||
}
|
||||
],
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"lastfm"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientAIOConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using Listenbrainz as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ListenBrainzClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"listenbrainz"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"token": {
|
||||
"description": "User token for the user to scrobble for",
|
||||
"examples": [
|
||||
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "https://api.listenbrainz.org/",
|
||||
"description": "URL for the ListenBrainz server, if not using the default",
|
||||
"examples": [
|
||||
"https://api.listenbrainz.org/"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"description": "Username of the user to scrobble for",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token",
|
||||
"username"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientAIOConfig": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/MalojaClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"maloja"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key for Maloja server",
|
||||
"examples": [
|
||||
"myApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for maloja server",
|
||||
"examples": [
|
||||
"http://localhost:42010"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MatchLoggingOptions": {
|
||||
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
|
||||
"properties": {
|
||||
"confidenceBreakdown": {
|
||||
"default": false,
|
||||
"description": "Include confidence breakdowns in track match logging, if applicable",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onNoMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RequestRetryOptions": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"clientDefaults": {
|
||||
"$ref": "#/definitions/RequestRetryOptions"
|
||||
},
|
||||
"clients": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ClientAIOConfig"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LastfmClientConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzClientConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MalojaClientConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"CommonClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using LastFM as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonClientData"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmData"
|
||||
}
|
||||
],
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using Listenbrainz as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ListenBrainzClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"token": {
|
||||
"description": "User token for the user to scrobble for",
|
||||
"examples": [
|
||||
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "https://api.listenbrainz.org/",
|
||||
"description": "URL for the ListenBrainz server, if not using the default",
|
||||
"examples": [
|
||||
"https://api.listenbrainz.org/"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"description": "Username of the user to scrobble for",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token",
|
||||
"username"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientConfig": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/MalojaClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key for Maloja server",
|
||||
"examples": [
|
||||
"myApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"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",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for maloja server",
|
||||
"examples": [
|
||||
"http://localhost:42010"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MatchLoggingOptions": {
|
||||
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
|
||||
"properties": {
|
||||
"confidenceBreakdown": {
|
||||
"default": false,
|
||||
"description": "Include confidence breakdowns in track match logging, if applicable",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onNoMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+578
@@ -0,0 +1,578 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import {Logger} from 'winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import passport from 'passport';
|
||||
import session from 'express-session';
|
||||
|
||||
import {
|
||||
buildTrackString,
|
||||
capitalize,
|
||||
longestString, mergeArr,
|
||||
readJson,
|
||||
remoteHostIdentifiers,
|
||||
sleep,
|
||||
truncateStringToLength
|
||||
} from "./utils.js";
|
||||
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js";
|
||||
import TautulliSource from "./sources/TautulliSource.js";
|
||||
import PlexSource, {plexRequestMiddle} from "./sources/PlexSource.js";
|
||||
import JellyfinSource from "./sources/JellyfinSource.js";
|
||||
import {Server} from "socket.io";
|
||||
import * as path from "path";
|
||||
import {projectDir} from "./common/index.js";
|
||||
import LastfmSource from "./sources/LastfmSource.js";
|
||||
import LastfmScrobbler from "./clients/LastfmScrobbler.js";
|
||||
import DeezerSource from "./sources/DeezerSource.js";
|
||||
import AbstractSource from "./sources/AbstractSource.js";
|
||||
import {LogInfo, LogLevel, PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js";
|
||||
import SpotifySource from "./sources/SpotifySource.js";
|
||||
import {JellyfinNotifier} from "./sources/ingressNotifiers/JellyfinNotifier.js";
|
||||
import {PlexNotifier} from "./sources/ingressNotifiers/PlexNotifier.js";
|
||||
import {TautulliNotifier} from "./sources/ingressNotifiers/TautulliNotifier.js";
|
||||
import {AIOConfig} from "./common/infrastructure/config/aioConfig.js";
|
||||
import createRoot from "./ioc.js";
|
||||
import {formatLogToHtml, getLogger, isLogLineMinLevel} from "./common/logging.js";
|
||||
import {MESSAGE} from "triple-beam";
|
||||
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
|
||||
const port = process.env.PORT ?? 9078;
|
||||
|
||||
(async function () {
|
||||
|
||||
const server = await app.listen(port)
|
||||
const io = new Server(server);
|
||||
|
||||
app.use(router);
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false}));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
let output: LogInfo[] = []
|
||||
|
||||
const initLogger = getLogger({}, 'init');
|
||||
initLogger.stream().on('log', (log: LogInfo) => {
|
||||
output.unshift(log);
|
||||
output = output.slice(0, 301);
|
||||
io.emit('log', formatLogToHtml(log[MESSAGE]));
|
||||
});
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
|
||||
|
||||
try {
|
||||
// try to read a configuration file
|
||||
let appConfigFail = false;
|
||||
let config = {};
|
||||
try {
|
||||
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
appConfigFail = true;
|
||||
}
|
||||
|
||||
const {
|
||||
webhooks = [],
|
||||
logging = {},
|
||||
} = (config || {}) as AIOConfig;
|
||||
|
||||
const logConfig: {level: LogLevel, sort: string, limit: number} = {
|
||||
level: logging.level || (process.env.LOG_LEVEL || 'info') as LogLevel,
|
||||
sort: 'descending',
|
||||
limit: 50,
|
||||
}
|
||||
|
||||
logger = getLogger(logging, 'app');
|
||||
logger.stream().on('log', (log: LogInfo) => {
|
||||
output.unshift(log);
|
||||
output = output.slice(0, 301);
|
||||
if(isLogLineMinLevel(log, logConfig.level)) {
|
||||
io.emit('log', formatLogToHtml(log[MESSAGE]));
|
||||
}
|
||||
});
|
||||
|
||||
if(appConfigFail) {
|
||||
logger.warn('App config file exists but could not be parsed!');
|
||||
}
|
||||
const root = createRoot();
|
||||
const localUrl = root.get('localUrl');
|
||||
|
||||
const notifiers = root.get('notifiers');
|
||||
await notifiers.buildWebhooks(webhooks);
|
||||
|
||||
const availableLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
|
||||
|
||||
|
||||
/*
|
||||
* setup clients
|
||||
* */
|
||||
const scrobbleClients = root.get('clients');
|
||||
await scrobbleClients.buildClientsFromConfig(notifiers);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
}
|
||||
|
||||
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
|
||||
await scrobbleSources.buildSourcesFromConfig([]);
|
||||
|
||||
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
// check ambiguous client/source types like this for now
|
||||
const lastfmSources = scrobbleSources.getByType('lastfm');
|
||||
const lastfmScrobbles = scrobbleClients.getByType('lastfm');
|
||||
|
||||
const scrobblerNames = lastfmScrobbles.map(x => x.name);
|
||||
const nameColl = lastfmSources.filter(x => scrobblerNames.includes(x.name));
|
||||
if(nameColl.length > 0) {
|
||||
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
|
||||
}
|
||||
|
||||
// initialize deezer strategies
|
||||
const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
|
||||
for(const d of deezerSources) {
|
||||
passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
|
||||
}
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
// TODO links for re-trying auth and variables for signalling it (and API recently played)
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksDiscovered = 0,
|
||||
name,
|
||||
canPoll = false,
|
||||
polling = false,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else if(canPoll) {
|
||||
base.status = polling ? 'Running' : 'Idle';
|
||||
} else {
|
||||
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
}
|
||||
return base;
|
||||
});
|
||||
const clientData = scrobbleClients.clients.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: requiresAuth,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
return base;
|
||||
})
|
||||
res.render('status', {
|
||||
sources: sourceData,
|
||||
clients: clientData,
|
||||
logs: {
|
||||
output: slicedLog,
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" data-limit="${x}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" data-sort="${x}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize log-level log-${x} ${logConfig.level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const tauIngress = new TautulliNotifier();
|
||||
app.postAsync('/tautulli', async function(this: any, req, res) {
|
||||
tauIngress.trackIngress(req, false);
|
||||
|
||||
const payload = TautulliSource.formatPlayObj(req.body, {newFromSource: true});
|
||||
// try to get config name from payload
|
||||
if (req.body.scrobblerConfig !== undefined) {
|
||||
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
|
||||
if (source !== undefined) {
|
||||
if (source.type !== 'tautulli') {
|
||||
this.logger.warn(`Tautulli event specified a config name but the configured source was not a Tautulli type: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
} else {
|
||||
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
|
||||
await source.handle(payload);
|
||||
return res.send('OK');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Tautulli event specified a config name but no configured source found: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
}
|
||||
}
|
||||
// if none specified we'll iterate through all tautulli sources and hopefully the user has configured them with filters
|
||||
const tSources = scrobbleSources.getByType('tautulli');
|
||||
for (const source of tSources) {
|
||||
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
|
||||
await source.handle(payload);
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
const plexMiddle = plexRequestMiddle();
|
||||
const plexLog = logger.child({labels: ['Plex Request']}, mergeArr);
|
||||
const plexIngress = new PlexNotifier();
|
||||
app.postAsync('/plex',
|
||||
async function (req, res, next) {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
plexIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
plexMiddle, async function (req, res) {
|
||||
plexIngress.trackIngress(req, false);
|
||||
|
||||
const { payload } = req as any;
|
||||
if(payload !== undefined) {
|
||||
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
|
||||
|
||||
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
|
||||
if(pSources.length === 0) {
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
|
||||
}
|
||||
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj);
|
||||
}
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
|
||||
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
|
||||
const jellyIngress = new JellyfinNotifier();
|
||||
app.postAsync('/jellyfin',
|
||||
async function (req, res, next) {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
jellyIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
jellyfinJsonParser, async function (req, res) {
|
||||
jellyIngress.trackIngress(req, false);
|
||||
|
||||
const parts = remoteHostIdentifiers(req);
|
||||
const connectionId = `${parts.host}-${parts.proxy ?? ''}`;
|
||||
|
||||
const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId}, {newFromSource: true});
|
||||
const pSources = scrobbleSources.getByType('jellyfin') as JellyfinSource[];
|
||||
if(pSources.length === 0) {
|
||||
logger.warn('Received Jellyfin connection but no Jellyfin sources are configured');
|
||||
}
|
||||
const logPayload = pSources.some(x => {
|
||||
const {
|
||||
data: {
|
||||
options: {
|
||||
logPayload = false
|
||||
} = {}
|
||||
} = {},
|
||||
} = x.config;
|
||||
return logPayload;
|
||||
});
|
||||
if(logPayload) {
|
||||
logger.debug(`[Jellyfin] Logging payload due to at least one Jellyfin source having 'logPayload: true`, req.body);
|
||||
}
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj);
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/client/auth', clientCheckMiddle);
|
||||
app.getAsync('/client/auth', async function (req, res) {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
|
||||
switch (scrobbleClient.type) {
|
||||
case 'lastfm':
|
||||
res.redirect(scrobbleClient.api.getAuthUrl());
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/source/auth', sourceCheckMiddle);
|
||||
app.getAsync('/source/auth', async function (req, res, next) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
// @ts-expect-error TS(2339): Property 'sourceName' does not exist on type 'Requ... Remove this comment to see the full error message
|
||||
sourceName: name,
|
||||
} = req;
|
||||
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if (source.spotifyApi === undefined) {
|
||||
res.status(400).send('Spotify configuration is not valid');
|
||||
} else {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
res.redirect(source.createAuthUrl());
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
res.redirect(source.api.getAuthUrl());
|
||||
break;
|
||||
case 'deezer':
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
req.session.deezerSource = name;
|
||||
return passport.authenticate(`deezer-${source.name}`)(req,res,next);
|
||||
default:
|
||||
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/poll', sourceCheckMiddle);
|
||||
app.getAsync('/poll', async function (req, res) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
|
||||
if (!source.canPoll) {
|
||||
return res.status(400).send(`Specified source cannot poll (${source.type})`);
|
||||
}
|
||||
|
||||
source.poll();
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/recent', sourceCheckMiddle);
|
||||
app.getAsync('/recent', async function (req, res) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
if (!source.canPoll) {
|
||||
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
|
||||
}
|
||||
|
||||
const result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat())));
|
||||
const trackLength = longestString(result.map((x: any) => x.data.track))
|
||||
const plays = result.map((x: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
url: {
|
||||
web
|
||||
} = {}
|
||||
} = {}
|
||||
} = x;
|
||||
const buildOpts: TrackStringOptions = {
|
||||
include: ['time', 'timeFromNow', 'track', 'artist'],
|
||||
transformers: {
|
||||
artists: (a: any) => artistTruncFunc(a.join(' / ')).padEnd(33),
|
||||
track: (t: any) => t.padEnd(trackLength)
|
||||
}
|
||||
}
|
||||
if (web !== undefined) {
|
||||
buildOpts.transformers.track = t => `<a href="${web}">${t}</a>${''.padEnd(Math.max(trackLength - t.length, 0))}`;
|
||||
}
|
||||
return buildTrackString(x, buildOpts);
|
||||
});
|
||||
res.render('recent', {plays, name: source.name, sourceType: source.type});
|
||||
});
|
||||
|
||||
app.getAsync('/logs/settings/update', async function (req, res) {
|
||||
const e = req.query;
|
||||
for (const [setting, val] of Object.entries(req.query)) {
|
||||
switch (setting) {
|
||||
case 'limit':
|
||||
logConfig.limit = Number.parseInt(val as string);
|
||||
break;
|
||||
case 'sort':
|
||||
logConfig.sort = val as string;
|
||||
break;
|
||||
case 'level':
|
||||
logConfig.level = val as LogLevel;
|
||||
// for (const [key, logger] of winston.loggers.loggers) {
|
||||
// logger.level = val as string;
|
||||
// }
|
||||
break;
|
||||
}
|
||||
}
|
||||
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
res.send('OK');
|
||||
io.emit('logClear', slicedLog);
|
||||
});
|
||||
|
||||
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
|
||||
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
|
||||
app.getAsync(/.*deezer\/callback*$/, function (req, res, next) {
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
const entity = scrobbleSources.getByName(req.session.deezerSource as string);
|
||||
const passportFunc = passport.authenticate(`deezer-${entity.name}`, {session: false});
|
||||
return passportFunc(req, res, next);
|
||||
}, async function (req, res) {
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
let entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
|
||||
for(let i = 0; i < 3; i++) {
|
||||
if(entity.error !== undefined) {
|
||||
return res.send('Error with deezer credentials storage');
|
||||
} else if(entity.config.data.accessToken !== undefined) {
|
||||
// start polling
|
||||
entity.poll()
|
||||
return res.redirect('/');
|
||||
} else {
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
res.send('Waited too long for credentials to store. Try restarting polling.');
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async function (req, res, next) {
|
||||
const {
|
||||
query: {
|
||||
state
|
||||
} = {}
|
||||
} = req;
|
||||
if (req.url.includes('lastfm')) {
|
||||
const {
|
||||
query: {
|
||||
token
|
||||
} = {}
|
||||
} = req;
|
||||
let entity: LastfmScrobbler | LastfmSource | undefined = scrobbleClients.getByName(state) as (LastfmScrobbler | undefined);
|
||||
if(entity === undefined) {
|
||||
entity = scrobbleSources.getByName(state) as LastfmSource;
|
||||
}
|
||||
try {
|
||||
await entity.api.authenticate(token);
|
||||
await entity.initialize();
|
||||
return res.send('OK');
|
||||
} catch (e) {
|
||||
return res.send(e.message);
|
||||
}
|
||||
} else {
|
||||
// TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here)
|
||||
// but eventually should update all source callbacks to url specific URLS to avoid ambiguity...
|
||||
// wish we could use state param to identify name/source but not all auth strategies and auth provides may provide access to that
|
||||
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
|
||||
const source = scrobbleSources.getByNameAndType(state as string, 'spotify') as SpotifySource;
|
||||
const tokenResult = await source.handleAuthCodeCallback(req.query);
|
||||
let responseContent = 'OK';
|
||||
if (tokenResult === true) {
|
||||
source.poll();
|
||||
} else {
|
||||
responseContent = tokenResult;
|
||||
}
|
||||
return res.send(responseContent);
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/health', async function (req, res) {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
} = req.query;
|
||||
|
||||
const [sourcesReady, sourceMessages] = await scrobbleSources.getStatusSummary(type as string|undefined, name as string|undefined);
|
||||
const [clientsReady, clientMessages] = await scrobbleClients.getStatusSummary(type as string|undefined, name as string|undefined);
|
||||
|
||||
|
||||
return res.status((clientsReady && sourcesReady) ? 200 : 500).json({messages: sourceMessages.concat(clientMessages)});
|
||||
});
|
||||
|
||||
app.useAsync(async function (req, res) {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = req.headers["x-forwarded-for"];
|
||||
const ua = req.headers["user-agent"];
|
||||
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.url}`);
|
||||
return res.sendStatus(404);
|
||||
});
|
||||
|
||||
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 (anyNotReady) {
|
||||
logger.info(`Some sources are not ready, open ${localUrl} to continue`);
|
||||
}
|
||||
|
||||
app.set('views', path.resolve(projectDir, 'src/views'));
|
||||
app.set('view engine', 'ejs');
|
||||
logger.info(`Server started at ${localUrl}`);
|
||||
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
}
|
||||
}());
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import {createContainer} from "iti";
|
||||
import path from "path";
|
||||
import {projectDir} from "./common/index.js";
|
||||
import ScrobbleClients from "./clients/ScrobbleClients.js";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import {Notifiers} from "./notifier/Notifiers.js";
|
||||
import {EventEmitter} from "events";
|
||||
import {logPath} from "./common/logging.js";
|
||||
import {Container} from "winston";
|
||||
//import ScrobbleClients from "./clients/ScrobbleClients.js";
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
const port = process.env.PORT ?? 9078;
|
||||
|
||||
/*let logPath = path.resolve(projectDir, `./logs`);
|
||||
if(typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}*/
|
||||
|
||||
//let root: Container;
|
||||
|
||||
export const createRoot = () => {
|
||||
return createContainer().add({
|
||||
configDir: configDir,
|
||||
logDir: logPath,
|
||||
localUrl: `http://localhost:${port}`,
|
||||
clientEmitter: () => new EventEmitter(),
|
||||
sourceEmitter: () => new EventEmitter(),
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default createRoot;
|
||||
@@ -0,0 +1,41 @@
|
||||
import {GotifyConfig, NtfyConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {Logger} from "winston";
|
||||
import {mergeArr} from "../utils.js";
|
||||
|
||||
export abstract class AbstractWebhookNotifier {
|
||||
|
||||
config: GotifyConfig | NtfyConfig
|
||||
logger: Logger;
|
||||
|
||||
initialized: boolean = false;
|
||||
requiresAuth: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) {
|
||||
this.config = config;
|
||||
const label = `${type} - ${config.name ?? defaultName}`
|
||||
this.logger = logger.child({labels: [label]}, mergeArr);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
this.logger.verbose('Initialized');
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
notify = async (payload: WebhookPayload) => {
|
||||
if(!this.initialized) {
|
||||
this.logger.debug('Will not use notifier because it is not initialized.');
|
||||
return;
|
||||
}
|
||||
if(this.requiresAuth && !this.authed) {
|
||||
this.logger.debug('Will not use notifier because it is not correctly authenticated.');
|
||||
return;
|
||||
}
|
||||
return await this.doNotify(payload);
|
||||
}
|
||||
abstract doNotify: (payload: WebhookPayload) => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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";
|
||||
import {Logger} from "winston";
|
||||
|
||||
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
declare config: GotifyConfig;
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
constructor(defaultName: string, config: GotifyConfig, logger: Logger) {
|
||||
super('Gotify', defaultName, config, logger);
|
||||
this.requiresAuth = true;
|
||||
const {
|
||||
info = 5,
|
||||
warn = 7,
|
||||
error = 10
|
||||
} = this.config.priorities || {};
|
||||
|
||||
this.priorities = {
|
||||
info,
|
||||
warn,
|
||||
error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
try {
|
||||
const url = this.config.url;
|
||||
const resp = await request.get(`${url}/version`);
|
||||
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to contact server | Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
this.authed = true;
|
||||
// TODO no easy way to test token is working without also pushing a message -- instead will de-auth if we get the right error message when trying to push for the first time
|
||||
}
|
||||
|
||||
doNotify = async (payload: WebhookPayload) => {
|
||||
try {
|
||||
await gotify({
|
||||
server: this.config.url,
|
||||
app: this.config.token,
|
||||
message: payload.message,
|
||||
title: payload.title,
|
||||
priority: this.priorities[payload.priority]
|
||||
});
|
||||
this.logger.debug(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
if(e instanceof HTTPError && e.response.statusCode === 401) {
|
||||
this.logger.error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
|
||||
this.authed = false;
|
||||
} else {
|
||||
this.logger.error(`Failed to push notification | Error => ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import winston, {config, format, Logger} from "winston";
|
||||
import {mergeArr} from "../utils.js";
|
||||
import {
|
||||
GotifyConfig,
|
||||
NtfyConfig,
|
||||
WebhookConfig,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
|
||||
import {GotifyWebhookNotifier} from "./GotifyWebhookNotifier.js";
|
||||
import {NtfyWebhookNotifier} from "./NtfyWebhookNotifier.js";
|
||||
import {EventEmitter} from "events";
|
||||
|
||||
export class Notifiers {
|
||||
|
||||
logger: Logger;
|
||||
|
||||
webhooks: AbstractWebhookNotifier[] = [];
|
||||
|
||||
emitter: EventEmitter;
|
||||
|
||||
clientEmitter: EventEmitter;
|
||||
sourceEmitter: EventEmitter;
|
||||
|
||||
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter) {
|
||||
this.emitter = emitter;
|
||||
this.clientEmitter = clientEmitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
|
||||
this.logger = winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr);
|
||||
|
||||
this.sourceEmitter.on('notify', async (payload: WebhookPayload) => {
|
||||
await this.notify(payload);
|
||||
})
|
||||
}
|
||||
|
||||
buildWebhooks = async (webhookConfigs: WebhookConfig[]) => {
|
||||
for (const [i, config] of Object.entries(webhookConfigs)) {
|
||||
let webhook: AbstractWebhookNotifier;
|
||||
const defaultName = `Config ${i}`
|
||||
switch (config.type) {
|
||||
case 'gotify':
|
||||
webhook = new GotifyWebhookNotifier(defaultName, config as GotifyConfig, this.logger);
|
||||
break;
|
||||
case 'ntfy':
|
||||
webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger);
|
||||
break;
|
||||
default:
|
||||
this.logger.error(`'${config.type}' is not a valid webhook type`);
|
||||
continue;
|
||||
}
|
||||
await webhook.initialize();
|
||||
if(webhook.initialized) {
|
||||
await webhook.testAuth();
|
||||
}
|
||||
this.webhooks.push(webhook);
|
||||
}
|
||||
}
|
||||
|
||||
notify = async (payload: WebhookPayload) => {
|
||||
for (const webhook of this.webhooks) {
|
||||
await webhook.notify(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user