Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f856f79e51 | ||
|
|
fbea08dded | ||
|
|
dc52376395 | ||
|
|
1e2bc0ca85 | ||
|
|
7849d6098a | ||
|
|
0b101b758e | ||
|
|
756ff0e0c1 | ||
|
|
2ddeca7d29 | ||
|
|
1de934e30a | ||
|
|
0409c526ee | ||
|
|
a7c340188f | ||
|
|
5dc54849cc | ||
|
|
058fbee0c8 | ||
|
|
daea76eef3 | ||
|
|
b81b1d94a3 | ||
|
|
226b7c741c | ||
|
|
e452bd761c | ||
|
|
19c3d97a46 | ||
|
|
64164b28de | ||
|
|
b03cbe7a0a | ||
|
|
91bd702deb | ||
|
|
ede22e10e9 | ||
|
|
c648ad4ae3 | ||
|
|
a0fbd1129a | ||
|
|
b90ea03924 | ||
|
|
88efe2ad87 | ||
|
|
39f933418e | ||
|
|
cc8c7d84e2 | ||
|
|
6d9783c5f5 | ||
|
|
c10d46b774 | ||
|
|
a5417277a8 | ||
|
|
5a1c931513 | ||
|
|
09f4503593 | ||
|
|
045cae66f5 | ||
|
|
e67ef5fb0f | ||
|
|
1677c0021f | ||
|
|
3400e747a8 | ||
|
|
c08ff4843c | ||
|
|
5e905eae17 | ||
|
|
7ebe73ccb3 | ||
|
|
574911cfa0 | ||
|
|
91a84dc4f4 | ||
|
|
9a578e54a8 | ||
|
|
1a8d5eded3 | ||
|
|
94765057db | ||
|
|
86c639ad2c | ||
|
|
f3045debb3 | ||
|
|
ffc826f04d | ||
|
|
e70a4bbf1c | ||
|
|
a9eca51bfa | ||
|
|
de4a0aecd5 | ||
|
|
f097fac8ea | ||
|
|
a20b35f7cd | ||
|
|
2d51a0e159 | ||
|
|
e524077242 | ||
|
|
a5b5fe8d2a | ||
|
|
ddb3e0e02d | ||
|
|
ec97ec428a | ||
|
|
49b834af5d | ||
|
|
6329aaf6a9 | ||
|
|
fd6e5ab084 | ||
|
|
1dbb1b9ba3 | ||
|
|
fc65a63d43 | ||
|
|
4d1ddde57b | ||
|
|
7d14fb5341 | ||
|
|
e310b3aec8 | ||
|
|
056457d028 | ||
|
|
73183e760e | ||
|
|
3fff729d32 | ||
|
|
19e199bd58 | ||
|
|
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 |
@@ -4,10 +4,14 @@ 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
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
flatpak/.flatpak-builder
|
||||
|
||||
@@ -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/docsite/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/docsite/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,42 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Review gh actions docs if you want to further define triggers, paths, etc
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./docsite
|
||||
- name: Build website
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
publish_dir: ./docsite/build
|
||||
# The following lines assign commit authorship to the official
|
||||
# GH-Actions bot for deploys to `gh-pages` branch:
|
||||
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
# The GH actions bot is used by default if you didn't specify the two fields.
|
||||
# You can swap them out with your own user credentials.
|
||||
user_name: github-actions[bot]
|
||||
user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/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@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -117,6 +117,13 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.json
|
||||
config/*.json
|
||||
*.txt
|
||||
.idea/
|
||||
|
||||
src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
|
||||
@@ -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,95 @@
|
||||
|
||||
[](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)
|
||||
<img src="/assets/icon.png" align="right"
|
||||
alt="multi-scrobbler logo" width="180" height="180">
|
||||
|
||||
* 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)
|
||||
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)
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
|
||||
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
|
||||
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz--source-)
|
||||
* [Deezer](/docsite/docs/configuration/configuration.md#deezer)
|
||||
* [MPRIS (Linux Desktop)](/docsite/docs/configuration/configuration.md#mpris)
|
||||
* [Mopidy](/docsite/docs/configuration/configuration.md#mopidy)
|
||||
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
|
||||
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Install using [Docker images for x86/ARM](/docsite/docs/installation/installation.md#docker), [flatpak](/docsite/docs/installation/installation.md#flatpak), or [locally with NodeJS](/docsite/docs/installation/installation.md#nodejs)
|
||||
|
||||
**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**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src="/assets/status-ui.jpg" width="800">
|
||||
|
||||
## 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](/docsite/docs/installation/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](/docsite/docs/configuration/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](/docsite/docs/FAQ.md) before creating an issue!
|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #231f20;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z"/>
|
||||
<path class="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
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,67 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits
|
||||
| filter(attribute="scope")
|
||||
| sort(attribute="scope") %}
|
||||
- *({{commit.scope}})* {{ commit.message | upper_first }}
|
||||
{%- if commit.breaking %}
|
||||
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- for commit in commits %}
|
||||
{%- if commit.scope -%}
|
||||
{% else -%}
|
||||
- *(No Category)* {{ commit.message | upper_first }}
|
||||
{% if commit.breaking -%}
|
||||
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
{% endfor %}
|
||||
"""
|
||||
# remove the leading and trailing whitespaces from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
|
||||
[git]
|
||||
# allow only conventional commits
|
||||
# https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
@@ -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](../docsite/docs/configuration/configuration.md)
|
||||
* External Link: https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite/docs/configuration/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,14 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logPayload": false,
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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,14 @@
|
||||
{
|
||||
"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"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,14 @@
|
||||
[
|
||||
{
|
||||
"name": "MyTautuilli",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
* [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)
|
||||
* [Scrobbling Issues](#scrobbling-issues)
|
||||
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
|
||||
# Connection Issues
|
||||
|
||||
## Plex/Tautulli/Jellyfin don't connect
|
||||
|
||||
These three [sources](/#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](configuration/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](configuration/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.
|
||||
|
||||
# Scrobbling Issues
|
||||
|
||||
## Last.fm does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
|
||||
|
||||
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
|
||||
|
||||
## Jellyfin does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configuring Multi-Scrobbler and examples"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Overview
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
* [Overview](#overview)
|
||||
* [ENV-Based Configuration](#env-based-configuration)
|
||||
* [File-Based Configuration](#file-based-configuration)
|
||||
* [All-in-One File Configuration](#all-in-one-file-configuration)
|
||||
* [Specific File Configuration](#specific-file-configuration)
|
||||
* [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)
|
||||
|
||||
# Overview
|
||||
|
||||
[**Sources** and **Clients**](/#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**](../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](/#client) and one [Source](/#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](../installation/installation.md#docker) for how to configure this correctly
|
||||
|
||||
Use File-based configuration if:
|
||||
|
||||
* You have many [Sources](/#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](https://github.com/FoxxMD/multi-scrobbler/tree/master/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**](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 title="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](https://github.com/FoxxMD/multi-scrobbler/blob/master/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](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
|
||||
|
||||
# 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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
**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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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**](../installation/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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
# 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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [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`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
# 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:
|
||||
|
||||
```json5
|
||||
{
|
||||
"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**
|
||||
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,293 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Kitchen Sink
|
||||
---
|
||||
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -1,3 +1,8 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Plex/Tautulli
|
||||
---
|
||||
|
||||
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
|
||||
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
|
||||
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
|
||||
@@ -40,8 +45,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}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 1,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to install Multi-Scrobbler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
# Local
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
|
||||
## Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
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
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
* The web UI is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```shell
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```shell
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
|
||||
## Flatpak
|
||||
|
||||
You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
|
||||
```shell
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
|
||||
|
||||
```shell
|
||||
flatpak run io.github.multiscrobbler
|
||||
```
|
||||
|
||||
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
|
||||
|
||||
```shell
|
||||
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
[Dockerhub](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
```
|
||||
docker.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
[Github Packages](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
|
||||
```
|
||||
ghcr.io/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](../configuration/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](../configuration/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,73 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 'As a Service'
|
||||
---
|
||||
|
||||
If you have multi-scrobbler installed [locally](installation.md#local) you can enable it to run as a background service when you login.
|
||||
|
||||
Before running as a service you should run it at least once in the foreground to ensure it can start up correctly!
|
||||
|
||||
# [Systemd](https://systemd.io/)
|
||||
|
||||
Systemd is system and service manager that is used by [most popular linux distros](https://en.wikipedia.org/wiki/Systemd#Adoption) including [Ubuntu](https://wiki.ubuntu.com/SystemdForUpstartUsers), [Fedora](https://docs.fedoraproject.org/en-US/quick-docs/understanding-and-administering-systemd/), Pop!_OS, [Debian](https://wiki.debian.org/systemd), and [Arch](https://wiki.archlinux.org/title/systemd).
|
||||
|
||||
This setup will create a [user service](https://wiki.archlinux.org/title/systemd/User) that runs on login.
|
||||
|
||||
## Create A Unit File
|
||||
|
||||
Create a new service file for multi-scrobbler under your HOME config:
|
||||
|
||||
```console
|
||||
mkdir -p ~/.config/systemd/user
|
||||
touch ~/.config/systemd/user/multi-scrobbler.service
|
||||
```
|
||||
|
||||
In a text editor add contents below to the file you created, `multi-scrobbler.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=multi-scrobbler
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=flatpak run io.github.multiscrobbler
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
The above assumes you [installed multi-scrobbler using flatpak](installation.md#flatpak)
|
||||
|
||||
### Node.js Installs
|
||||
|
||||
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.md#nodejs) you should modify the `[Service]`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/path/to/multi-scrobbler/directory
|
||||
ExecStart=node src/index.js
|
||||
Restart=no
|
||||
```
|
||||
|
||||
## Start the Service
|
||||
|
||||
Save the file then run:
|
||||
|
||||
```console
|
||||
systemctl daemon-reload
|
||||
systemctl --user enable multi-scrobbler.service
|
||||
systemctl --user start multi-scrobbler.service
|
||||
```
|
||||
|
||||
This will
|
||||
|
||||
* scan for new services and pickup our multi-scrobbler user service
|
||||
* enable the service to run at login automatically
|
||||
* start the service now
|
||||
|
||||
# Other Service Methods
|
||||
|
||||
Open a PR if you would like to document setting up multi-scrobbler for other service managers!
|
||||
@@ -0,0 +1,155 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require('prism-react-renderer/themes/github');
|
||||
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'My Site',
|
||||
tagline: 'Dinosaurs are cool',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
// Set the production url of your site here
|
||||
url: 'https://foxxmd.github.io',
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: '/multi-scrobbler',
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: 'foxxmd', // Usually your GitHub org/user name.
|
||||
projectName: 'multi-scrobbler', // Usually your repo name.
|
||||
|
||||
trailingSlash: false,
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
// // Please change this to your repo.
|
||||
// // Remove this to remove the "edit this page" links.
|
||||
// editUrl:
|
||||
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
// },
|
||||
blog: false,
|
||||
|
||||
theme: {
|
||||
customCss: require.resolve('./src/css/custom.css'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
themes: ["docusaurus-json-schema-plugin"],
|
||||
plugins: [
|
||||
[
|
||||
require.resolve("@cmfcmf/docusaurus-search-local"),
|
||||
{
|
||||
indexBlog: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
navbar: {
|
||||
title: 'Multi-Scrobbler',
|
||||
logo: {
|
||||
alt: 'Logo',
|
||||
src: 'img/icon.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'tutorialSidebar',
|
||||
position: 'left',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
to: 'playground',
|
||||
position: 'left',
|
||||
label: 'Config Playground',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
items: [
|
||||
{
|
||||
label: 'Installation',
|
||||
to: '/docs/installation',
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
to: '/docs/configuration',
|
||||
},
|
||||
],
|
||||
},
|
||||
/* {
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Stack Overflow',
|
||||
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discordapp.com/invite/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
href: 'https://twitter.com/docusaurus',
|
||||
},
|
||||
],
|
||||
},*/
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Multi-Scrobbler. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "my-website",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cmfcmf/docusaurus-search-local": "^1.1.0",
|
||||
"@docusaurus/core": "2.4.1",
|
||||
"@docusaurus/preset-classic": "2.4.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-json-schema-plugin": "^1.6.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.1",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [
|
||||
{type: 'autogenerated', dirName: '.'},
|
||||
{
|
||||
type: "link",
|
||||
label: "Config Playground",
|
||||
href: "/playground"
|
||||
}
|
||||
],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
import { useColorMode } from "@docusaurus/theme-common"
|
||||
|
||||
import JSONSchemaEditor from "@theme/JSONSchemaEditor"
|
||||
// Context
|
||||
import { usePlaygroundContext } from "@site/src/contexts/PlaygroundContext"
|
||||
|
||||
// Common stringify of the JSON
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
|
||||
|
||||
function JSONSchemaDataInner(): JSX.Element {
|
||||
const {
|
||||
state: { userSchema, editorRef, data: value },
|
||||
updateState,
|
||||
} = usePlaygroundContext()
|
||||
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<div style={{ boxSizing: "border-box", width: "50%" }}>
|
||||
<JSONSchemaEditor
|
||||
value={value}
|
||||
schema={userSchema}
|
||||
theme={colorMode === "dark" ? "vs-dark" : "vs"}
|
||||
editorDidMount={(editor) => {
|
||||
updateState({ editorRef: editor })
|
||||
}}
|
||||
height={"70vh"}
|
||||
key={STRINGIFY_JSON(userSchema)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JSONSchemaDataComponent(): JSX.Element {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
return <JSONSchemaDataInner />
|
||||
}}
|
||||
</BrowserOnly>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
|
||||
import Schema from "@site/static/aio.json";
|
||||
import ConfigExample from "@site/static/configExample.json";
|
||||
import JSONSchemaViewer from "@theme/JSONSchemaViewer"
|
||||
import JSONSchemaEditor from "@theme/JSONSchemaEditor";
|
||||
import {useColorMode} from "@docusaurus/theme-common";
|
||||
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
|
||||
|
||||
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
|
||||
function PlaygroundInner(): JSX.Element {
|
||||
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflowY: "hidden",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{width: '50%'}}>
|
||||
<JSONSchemaViewer
|
||||
schema={Schema}
|
||||
showExamples={true}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ boxSizing: "border-box", width: "50%" }}>
|
||||
<JSONSchemaEditor
|
||||
value={STRINGIFY_JSON(ConfigExample)}
|
||||
theme={colorMode === "dark" ? "vs-dark" : "vs"}
|
||||
schema={Schema}
|
||||
height={"70vh"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlaygroundComponent(): JSX.Element {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
return <PlaygroundInner />
|
||||
}}
|
||||
</BrowserOnly>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useContext, createContext } from "react"
|
||||
|
||||
// Type I need for useRef
|
||||
import type { MonacoEditorTypes } from "@theme/MonacoEditor"
|
||||
|
||||
export type State = {
|
||||
// The full schema
|
||||
// We might need to scope it with a JSON Pointer
|
||||
fullSchema: unknown
|
||||
// The current schema displayed (after the json pointer)
|
||||
userSchema: unknown
|
||||
// The current json pointer
|
||||
jsonPointer: string
|
||||
// Schema editor ref
|
||||
schemaRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
|
||||
// Data editor ref
|
||||
editorRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
|
||||
// Data on the data editor
|
||||
// When using "Infer schema from data", it would be annoying to lose our written
|
||||
data: string
|
||||
// Errors in schema
|
||||
schemaErrors: MonacoEditorTypes.IMarkerData[]
|
||||
// Errors in data
|
||||
dataErrors: MonacoEditorTypes.IMarkerData[]
|
||||
}
|
||||
|
||||
export type Playground = {
|
||||
// state
|
||||
state: State
|
||||
// update function
|
||||
updateState: (_: Partial<State>) => void
|
||||
}
|
||||
|
||||
export const PlaygroundContext = createContext<Playground>({
|
||||
state: {
|
||||
fullSchema: {},
|
||||
userSchema: {},
|
||||
jsonPointer: "",
|
||||
data: "{}",
|
||||
schemaRef: undefined,
|
||||
editorRef: undefined,
|
||||
schemaErrors: [],
|
||||
dataErrors: [],
|
||||
},
|
||||
updateState: () => {},
|
||||
})
|
||||
|
||||
export const usePlaygroundContext = () => useContext(PlaygroundContext)
|
||||
|
||||
export const PlaygroundContextProvider = PlaygroundContext.Provider
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Multi-Scrobbler
|
||||
---
|
||||
|
||||
# multi-scrobbler
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
<img src={require('/img/icon.png').default} align="right" alt="multi-scrobbler logo" width="180" height="180"/>
|
||||
|
||||
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)
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz--source-)
|
||||
* [Deezer](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
**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#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src={require('/img/status-ui.jpg').default} width="800"/>
|
||||
|
||||
## 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)
|
||||
|
||||
## Configuration
|
||||
|
||||
[See the **Configuration** documentation](docs/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
A status page with statistics, recent logs, and some runtime configuration options can be found at
|
||||
|
||||
```
|
||||
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 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.
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](docs/FAQ) before creating an issue!
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react"
|
||||
import Layout from "@theme/Layout"
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly"
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/pages/playground.tsx
|
||||
function PlaygroundComponent(): JSX.Element {
|
||||
// No SSR for the live preview
|
||||
// See https://github.com/facebook/docusaurus/issues/5747
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={({ error, tryAgain }) => (
|
||||
<div>
|
||||
<p>Playground component crashed because of error: {error.message}.</p>
|
||||
<button onClick={tryAgain}>Try Again!</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
const PlaygroundInnerComponent =
|
||||
require("@site/src/components/PlaygroundInner").default
|
||||
return <PlaygroundInnerComponent />
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Playground(): JSX.Element {
|
||||
return (
|
||||
<Layout
|
||||
title={`Playground`}
|
||||
description="Playground of docusaurus-json-schema-plugin"
|
||||
>
|
||||
<PlaygroundComponent />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #231f20;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z"/>
|
||||
<path class="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
// Extending "@tsconfig/docusaurus/tsconfig.json".types with "docusaurus-json-schema-plugin"
|
||||
"types": ["node", "@docusaurus/module-type-aliases", "@docusaurus/theme-classic", "docusaurus-json-schema-plugin"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
NOTE: This steps are for building the flatpak entirely locally, from source. If you want to install the application normally then [get it through flathub](/docs/installation.md#flatpak)
|
||||
|
||||
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
|
||||
|
||||
# 1. Install Requirements
|
||||
|
||||
## Flatpak and flatpak-builder
|
||||
|
||||
Install [Flatpak](https://flatpak.org/setup/)
|
||||
|
||||
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
|
||||
|
||||
## [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
|
||||
|
||||
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
|
||||
|
||||
# 2. Update Project source
|
||||
|
||||
Set the `branch` `tag` or `commit` to use for MS in the `git` source in [`io.github.foxxmd.multiscrobbler.yml`](/flatpak/io.github.foxxmd.multiscrobbler.yml)
|
||||
|
||||
# 3. Use `flatpak-node-generator` to generate sources
|
||||
|
||||
First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442)
|
||||
|
||||
Then, from MS project root:
|
||||
|
||||
```
|
||||
flatpak-node-generator npm package-lock.json
|
||||
```
|
||||
|
||||
Move `generated-sources.json` into [`/flatpak`](/flatpak)
|
||||
|
||||
# 4. Build flatpak
|
||||
|
||||
From MS project root:
|
||||
|
||||
```console
|
||||
cd flatpak
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
|
||||
```
|
||||
Add `--install --user` to have the app installed immediately.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"logging": {
|
||||
"file": false
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #231f20;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z"/>
|
||||
<path class="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=multi-scrobbler
|
||||
Comment=Scrobbles music from multiple sources to multiple clients
|
||||
Icon=io.github.foxxmd.multiscrobbler
|
||||
TryExec=multiscrobbler
|
||||
Exec=multiscrobbler
|
||||
Terminal=true
|
||||
Categories=Utility;Music;ConsoleOnly;
|
||||
X-Flatpak=io.github.foxxmd.multiscrobbler
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="console-application">
|
||||
<id>io.github.foxxmd.multiscrobbler</id>
|
||||
<provides>
|
||||
<id>io.github.foxxmd.multiscrobbler.desktop</id>
|
||||
</provides>
|
||||
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
|
||||
<name>multi-scrobbler</name>
|
||||
<summary>Scrobbles music from many sources to many clients</summary>
|
||||
<url type="homepage">https://github.com/FoxxMD/multi-scrobbler</url>
|
||||
<url type="help">https://github.com/FoxxMD/multi-scrobbler/blob/flatpak-clean/docs/configuration.md</url>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<description>
|
||||
<p>
|
||||
Track your music listening history from many different sources:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Spotify</li>
|
||||
<li>Plex (Tautulli)</li>
|
||||
<li>Any subsonic-based music player (Airsonic)</li>
|
||||
<li>Jellyfin</li>
|
||||
<li>Mopidy</li>
|
||||
<li>Deezer</li>
|
||||
<li>JRiver</li>
|
||||
<li>Kodi</li>
|
||||
<li>MPRIS (linux desktop)</li>
|
||||
</ul>
|
||||
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
|
||||
<ul>
|
||||
<li>Maloja</li>
|
||||
<li>Last.fm</li>
|
||||
<li>Listenbrainz</li>
|
||||
</ul>
|
||||
<p>multi-scrobbler requires some configuration before use. Please visit the website or help links below to get started.</p>
|
||||
</description>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Browser-based UI</caption>
|
||||
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/7b0f5801a1aa728986f440f4c67fe158cecd763d/assets/status-ui.jpg</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.4.6" date="2023-06-07"/>
|
||||
<release version="0.4.4" date="2023-03-20"/>
|
||||
</releases>
|
||||
<categories>
|
||||
<category>Utility</category>
|
||||
<category>Music</category>
|
||||
<category>ConsoleOnly</category>
|
||||
</categories>
|
||||
<content_rating type="oars-1.0">
|
||||
<content_attribute id="language-profanity">mild</content_attribute>
|
||||
</content_rating>
|
||||
</component>
|
||||
@@ -0,0 +1,78 @@
|
||||
app-id: io.github.foxxmd.multiscrobbler
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '22.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: multiscrobbler
|
||||
finish-args:
|
||||
- --share=network
|
||||
# used for access to MPRIS in order to scrobble
|
||||
- --own-name=org.mpris.MediaPlayer2.*
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.node18
|
||||
modules:
|
||||
- name: multiscrobbler
|
||||
buildsystem: simple
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/node18/bin
|
||||
env:
|
||||
XDG_CACHE_HOME: /run/build/multiscrobbler/flatpak-node/cache
|
||||
npm_config_cache: /run/build/multiscrobbler/flatpak-node/npm-cache
|
||||
npm_config_nodedir: /usr/lib/sdk/node18
|
||||
npm_config_offline: 'true'
|
||||
build-commands:
|
||||
# install npm dependencies
|
||||
- npm install --offline
|
||||
|
||||
# build app
|
||||
- npm run build
|
||||
|
||||
# makes node and npm available
|
||||
- mkdir -p /app/bin /app/lib /app/lib/src
|
||||
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
|
||||
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
|
||||
- rm -r /app/lib/node_modules/npm/{docs,man}
|
||||
|
||||
# remove dev dependencies
|
||||
- npm prune --production
|
||||
|
||||
# copy node_modules needed to run app
|
||||
- cp -r node_modules/* /app/lib/node_modules
|
||||
|
||||
# delete typescript files (not needed for running app)
|
||||
- find src/ -name "*.ts" -type f -delete
|
||||
|
||||
# copy app files to runtime dir
|
||||
- cp -r src/* /app/lib/src
|
||||
- cp -r config /app/lib/config
|
||||
- cp flatpak/defaultConfig.json /app/lib/config/config.json
|
||||
|
||||
- install -Dm644 flatpak/${FLATPAK_ID}.desktop /app/share/applications/${FLATPAK_ID}.desktop
|
||||
- install -Dm644 flatpak/icon.png /app/share/icons/hicolor/512x512/apps/${FLATPAK_ID}.png
|
||||
- install -Dm644 assets/icon.svg /app/share/icons/hicolor/scalable/apps/${FLATPAK_ID}.svg
|
||||
- install -Dm644 flatpak/${FLATPAK_ID}.metainfo.xml /app/share/metainfo/${FLATPAK_ID}.metainfo.xml
|
||||
|
||||
- install -Dm755 ../run.sh /app/bin/multiscrobbler
|
||||
subdir: main
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/FoxxMD/multi-scrobbler
|
||||
tag: 0.4.6
|
||||
commit: b90ea039245491767baeefc53966b0350016aa4d
|
||||
dest: main
|
||||
# Wrapper to launch the app
|
||||
- type: script
|
||||
dest-filename: run.sh
|
||||
commands:
|
||||
# Run config check and copy examples + default config (disable file logging)
|
||||
- |
|
||||
echo -e "Checking CONFIG_DIR for existing configs: ${XDG_CONFIG_HOME}"
|
||||
if [ "$(ls -A ${XDG_CONFIG_HOME})" ]; then
|
||||
echo "Directory is not empty! Will not add default config/examples"
|
||||
else
|
||||
echo "Directory is empty! Adding default config/examples"
|
||||
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
|
||||
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
|
||||
fi
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true node src/index.js
|
||||
|
||||
- generated-sources.json
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,36 +1,102 @@
|
||||
{
|
||||
"name": "maloja-spotify-scrobbler",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.4.8",
|
||||
"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 --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-source": "typescript-json-schema tsconfig.json SourceConfig --out src/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aio": "typescript-json-schema tsconfig.json AIOConfig --out src/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema tsconfig.json AIOSourceConfig --out src/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema tsconfig.json AIOClientConfig --out src/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"typedoc": "typedoc"
|
||||
},
|
||||
"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",
|
||||
"@foxxmd/winston": "3.3.31",
|
||||
"@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-daily-rotate-file": "^4.5.0",
|
||||
"winston-duplex": "0.1.3",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"xml2js": "^0.4.23",
|
||||
"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",
|
||||
"typedoc": "^0.23.28"
|
||||
},
|
||||
"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 '@foxxmd/winston';
|
||||
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import winston from '@foxxmd/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(url);
|
||||
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,271 @@
|
||||
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 => {
|
||||
const {
|
||||
data: {
|
||||
meta: {
|
||||
brainz = {}
|
||||
} = {}
|
||||
}
|
||||
} = play;
|
||||
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,
|
||||
track_mbid: brainz.track,
|
||||
artist_mbids: brainz.artist !== undefined ? [brainz.artist] : undefined,
|
||||
release_mbid: brainz.album,
|
||||
release_group_mbid: brainz.releaseGroup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,82 @@
|
||||
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 MalojaAlbumData {
|
||||
name?: string
|
||||
albumtitle?: string
|
||||
artists: string[]
|
||||
}
|
||||
|
||||
export interface MalojaV3ScrobbleData {
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
track: {
|
||||
artists: string[]
|
||||
title: string
|
||||
album?: MalojaAlbumData | null
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
|
||||
interface MalojaScrobbleWarning {
|
||||
type: string
|
||||
value: string[] | string
|
||||
desc: string
|
||||
}
|
||||
|
||||
export interface MalojaResponseV3CommonData {
|
||||
status: 'failure' | 'error' | 'success' | 'ok'
|
||||
error?: {
|
||||
type: string
|
||||
value?: string | object
|
||||
desc: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3ResponseData extends MalojaResponseV3CommonData {
|
||||
track?: {
|
||||
title: string
|
||||
artists: string[]
|
||||
album?: MalojaAlbumData | null
|
||||
}
|
||||
desc: string
|
||||
warnings?: MalojaScrobbleWarning[]
|
||||
}
|
||||
@@ -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 '@foxxmd/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,215 @@
|
||||
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 '@foxxmd/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';
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const rawPayload = {
|
||||
artist: artist,
|
||||
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 '@foxxmd/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,512 @@
|
||||
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,
|
||||
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 '@foxxmd/winston';
|
||||
import {
|
||||
MalojaScrobbleData,
|
||||
MalojaScrobbleRequestData,
|
||||
MalojaScrobbleV2RequestData,
|
||||
MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaV2ScrobbleData, MalojaV3ScrobbleData
|
||||
} from "../apis/maloja/interfaces";
|
||||
|
||||
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: mAlbum,
|
||||
// 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;
|
||||
if(mAlbum !== null) {
|
||||
const {
|
||||
albumtitle,
|
||||
name: mAlbumName,
|
||||
artists: albumArtists
|
||||
} = mAlbum || {};
|
||||
album = albumtitle ?? mAlbumName;
|
||||
}
|
||||
} 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,
|
||||
};
|
||||
|
||||
let responseBody: MalojaScrobbleV3ResponseData;
|
||||
|
||||
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: any | undefined = undefined,
|
||||
scrobbledPlay: PlayObject;
|
||||
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
|
||||
responseBody = response.body;
|
||||
const {
|
||||
track,
|
||||
status,
|
||||
warnings = [],
|
||||
} = responseBody;
|
||||
if(status === 'success') {
|
||||
if(track !== undefined) {
|
||||
scrobbleResponse = {
|
||||
time: playDate.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
},
|
||||
}
|
||||
if (album !== undefined) {
|
||||
const {
|
||||
album: malojaAlbum = {},
|
||||
} = track;
|
||||
scrobbleResponse.track.album = {
|
||||
...malojaAlbum,
|
||||
name: album
|
||||
}
|
||||
}
|
||||
}
|
||||
if(warnings.length > 0) {
|
||||
for(const w of warnings) {
|
||||
this.logger.warn(`Maloja Warning: ${w.desc} => ${JSON.stringify(w.value)}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(buildErrorString(response));
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
body: {
|
||||
track: {
|
||||
time: mTime = playDate.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
} = {}
|
||||
} = {}
|
||||
} = response;
|
||||
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
|
||||
}
|
||||
let warning = '';
|
||||
if(scrobbleResponse === undefined) {
|
||||
warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`;
|
||||
scrobbledPlay = playObj;
|
||||
} else {
|
||||
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
|
||||
}
|
||||
this.addScrobbledTrack(playObj, scrobbledPlay);
|
||||
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
|
||||
if(warning !== '') {
|
||||
this.logger.warn(`${scrobbleInfo} | ${warning}`);
|
||||
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`);
|
||||
} else {
|
||||
this.logger.info(scrobbleInfo);
|
||||
}
|
||||
} 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});
|
||||
if(responseBody !== undefined) {
|
||||
this.logger.error('Raw Response:', responseBody);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload:', scrobbleData);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const buildErrorString = (body: MalojaScrobbleV3ResponseData) => {
|
||||
let valString: string | undefined = undefined;
|
||||
const {
|
||||
status,
|
||||
error: {
|
||||
type,
|
||||
value,
|
||||
desc
|
||||
} = {}
|
||||
} = body;
|
||||
if(value !== undefined && value !== null) {
|
||||
if(typeof value === 'string') {
|
||||
valString = value;
|
||||
} else if(Array.isArray(value)) {
|
||||
valString = value.map(x => {
|
||||
if(typeof x === 'string') {
|
||||
return x;
|
||||
}
|
||||
return JSON.stringify(x);
|
||||
}).join(', ');
|
||||
} else {
|
||||
valString = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
|
||||
}
|
||||
@@ -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 '@foxxmd/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');
|
||||