mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+5
-4
@@ -4,10 +4,11 @@ npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.github
|
||||
.git
|
||||
config/currentCreds.json
|
||||
*.log
|
||||
config/maloja.json
|
||||
config/spotify.json
|
||||
config/config.json
|
||||
.idea
|
||||
config/*.json
|
||||
config/*.bak
|
||||
/docs
|
||||
/logs
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
[Please check the FAQ](https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/FAQ.md) before submitting a bug report.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If possible reproduce the issue with [debug logging ON](https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/FAQ.md#turn-on-debug-logging)
|
||||
|
||||
```
|
||||
Copy and paste as much log data as possible related to this issue here.
|
||||
```
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
Provide version information for any related sources/clients.
|
||||
|
||||
- multi-scrobbler: [e.g. 0.4.0 on docker]
|
||||
- maloja [e.g. 3.1.4]
|
||||
- jellyfin [e.g. 10.8.9]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'develop'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@f2a13332ac1ce8c0a71aeac48a150dbb1838ab67
|
||||
with:
|
||||
images: foxxmd/multi-scrobbler
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
+6
-1
@@ -117,6 +117,11 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.json
|
||||
config/*.json
|
||||
*.txt
|
||||
.idea/
|
||||
|
||||
src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
|
||||
+37
-18
@@ -1,35 +1,54 @@
|
||||
FROM node:fermium-alpine3.10
|
||||
FROM lsiobase/alpine:3.17 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
alpine-base \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
openssh && \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
WORKDIR /home/node/app
|
||||
COPY docker/root/ /
|
||||
|
||||
COPY package*.json ./
|
||||
WORKDIR /app
|
||||
|
||||
USER node
|
||||
FROM base as build
|
||||
|
||||
RUN npm install --production
|
||||
# copy NPM dependencies and install
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc tsconfig.json .
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npm install
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL debug
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
ARG config_dir=/home/node/config
|
||||
RUN mkdir -p $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_DIR=$config_dir
|
||||
RUN npm run build && rm -rf node_modules
|
||||
|
||||
ARG log_dir=/home/node/logs
|
||||
RUN mkdir -p $log_dir
|
||||
VOLUME $log_dir
|
||||
ENV LOG_DIR=$log_dir
|
||||
FROM base as app
|
||||
|
||||
COPY --from=build --chown=abc:abc /app /app
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
RUN npm install --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown abc:abc node_modules \
|
||||
&& rm -rf node_modules/ts-node \
|
||||
&& rm -rf node_modules/typescript
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
|
||||
CMD [ "node", "index.js" ]
|
||||
|
||||
@@ -4,9 +4,20 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja) (and other clients, eventually!)
|
||||
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients (eventually!)
|
||||
|
||||
* Supports scrobbling from [Spotify](/docs/configuration.md#spotify), [Plex](/docs/configuration.md#plex), and [Tautulli](/docs/configuration.md#tautulli)
|
||||
* Supports scrobbling for many sources
|
||||
* [Spotify](/docs/configuration.md#spotify)
|
||||
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Jellyfin](/docs/configuration.md#jellyfin)
|
||||
* [Youtube Music](/docs/configuration.md#youtube-music)
|
||||
* [Last.fm](/docs/configuration.md#lastfm-source)
|
||||
* [Deezer](/docs/configuration.md#deezer)
|
||||
* Supports scrobbling to many clients
|
||||
* [Maloja](/docs/configuration.md#maloja)
|
||||
* [Last.fm](/docs/configuration.md#lastfm)
|
||||
* Monitor status of sources and clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/configuration.md#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
@@ -17,63 +28,60 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git
|
||||
|
||||
* **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.
|
||||
* **Consolidate play sources** -- 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, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/docs/configuration.md#lastfm--source-) to mirror scrobbles from your Last.fm profile to Maloja. 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, Airsonic**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Source.
|
||||
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
|
||||
|
||||
### Client
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](/docs/installation.md)
|
||||
|
||||
### Locally
|
||||
## Configuration
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
npm install
|
||||
```
|
||||
|
||||
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/spotify-scrobbler:latest
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Some setup is required! See the [configuration](docs/configuration.md) docs for a full reference.
|
||||
|
||||
### TLDR, Minimal Example
|
||||
|
||||
You want to use multi-scrobbler to scrobble your plays from Spotify to Maloja:
|
||||
|
||||
#### Local
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://domain.tld MALOJA_API_KEY=1234 node index.js
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
|
||||
```
|
||||
|
||||
**But I want to use json for configuration?**
|
||||
|
||||
Then use [config.json.example](/config/config.json.example) and drop it in your `CONFIG_DIR` directory
|
||||
|
||||
**Is there an example configuration using everything?**
|
||||
|
||||
Yes, check out the [kitchen sink example](/docs/kitchensink.md)
|
||||
[See the **Configuration** documentation](/docs/configuration.md)
|
||||
|
||||
## Usage
|
||||
|
||||
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). Visit the status page above to find the applicable link to trigger this.
|
||||
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.md) before creating an issue!
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -1,125 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js";
|
||||
|
||||
export default class AbstractScrobbleClient {
|
||||
|
||||
name;
|
||||
type;
|
||||
|
||||
recentScrobbles = [];
|
||||
scrobbledPlayObjs = [];
|
||||
newestScrobbleTime;
|
||||
oldestScrobbleTime = dayjs();
|
||||
|
||||
lastScrobbleCheck = dayjs();
|
||||
refreshEnabled;
|
||||
checkExistingScrobbles;
|
||||
verboseOptions;
|
||||
|
||||
config;
|
||||
logger;
|
||||
|
||||
constructor(type, name, config = {}) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(identifier, identifier);
|
||||
|
||||
const {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
...rest
|
||||
} = config;
|
||||
this.config = rest;
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
|
||||
formatPlayObj = obj => {
|
||||
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, log = false) => {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
} = {},
|
||||
} = playObj;
|
||||
const validTime = playDate.isAfter(this.oldestScrobbleTime);
|
||||
if (log && !validTime) {
|
||||
this.logger.debug(`${buildTrackString(playObj)} was in an invalid time frame (played before the oldest scrobble found)`);
|
||||
}
|
||||
return validTime;
|
||||
}
|
||||
|
||||
addScrobbledTrack = (playObj, scrobbleResp) => {
|
||||
this.scrobbledPlayObjs.push({play: playObj, scrobble: this.formatPlayObj(scrobbleResp)});
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
return track;
|
||||
};
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
playDate
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.filter(x => playObjDataMatch(playObj, x.play));
|
||||
|
||||
if (dtInvariantMatches.length === 0) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x) => {
|
||||
const {
|
||||
play: {
|
||||
data: {
|
||||
playDate: sPlayDate
|
||||
} = {}
|
||||
} = {},
|
||||
} = x;
|
||||
return playDate.isSame(sPlayDate);
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {createLabelledLogger, isValidConfigStructure, readJson, returnDuplicateStrings} from "../utils.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
|
||||
export default class ScrobbleClients {
|
||||
|
||||
clients = [];
|
||||
logger;
|
||||
|
||||
clientTypes = ['maloja'];
|
||||
|
||||
constructor() {
|
||||
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
|
||||
}
|
||||
|
||||
getByName = (name) => {
|
||||
return this.clients.find(x => x.name === name);
|
||||
}
|
||||
|
||||
buildClientsFromConfig = async (configDir = undefined) => {
|
||||
let configs = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
configFile = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
if (configFile !== undefined) {
|
||||
const {clients: mainConfigClientConfigs = []} = configFile;
|
||||
if (!mainConfigClientConfigs.every(x => x !== null && typeof x === 'object')) {
|
||||
throw new Error('All clients from config.json must be objects');
|
||||
}
|
||||
for (const c of mainConfigClientConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c, name, source: 'config.json'});
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientType of this.clientTypes) {
|
||||
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',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: {
|
||||
url,
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let rawClientConfigs;
|
||||
try {
|
||||
rawClientConfigs = await readJson(`${configDir}/${clientType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error(`${clientType}.json config file could not be parsed`);
|
||||
}
|
||||
if (rawClientConfigs !== undefined) {
|
||||
let clientConfigs = [];
|
||||
if (Array.isArray(rawClientConfigs)) {
|
||||
clientConfigs = rawClientConfigs;
|
||||
} else if (rawClientConfigs === null || typeof rawClientConfigs === 'object') {
|
||||
// backwards compatibility, assuming its single-user mode
|
||||
if (rawClientConfigs.data === undefined) {
|
||||
clientConfigs = [{data: rawClientConfigs, mode: 'single', name: 'unnamed'}];
|
||||
} else {
|
||||
clientConfigs = [rawClientConfigs];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`All top level data from ${clientType}.json must be an object or array of objects`);
|
||||
}
|
||||
for (const m of clientConfigs) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
throw new Error(`All top-level data from ${clientType}.json must be an object or array of objects`);
|
||||
}
|
||||
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 configErrors = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
const msg = `Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] has errors: ${isValid.join(' | ')}`;
|
||||
return acc.concat(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
if (configErrors.length > 0) {
|
||||
for (const m of configErrors) {
|
||||
this.logger.error(m);
|
||||
}
|
||||
throw new Error('Could not build clients due to above errors');
|
||||
}
|
||||
|
||||
// all client configs are minimally valid
|
||||
// now check that names are unique
|
||||
const nameGroupedConfigs = configs.reduce((acc, curr) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
let nameErrors = false;
|
||||
for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
|
||||
if (configs.length > 1) {
|
||||
const sources = configs.map(c => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.error(`Client config naming conflicts -- the following configs have the same name "${name}":
|
||||
${sources.join('\n')}`);
|
||||
nameErrors = true;
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameErrors) {
|
||||
throw new Error('Could not build clients due to naming conflicts');
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// just need to re-map unnnamed to default
|
||||
const finalConfigs = configs.map(({name = 'unnamed', ...x}) => ({
|
||||
...x,
|
||||
name
|
||||
}));
|
||||
for (const c of finalConfigs) {
|
||||
await this.addClient(c);
|
||||
}
|
||||
}
|
||||
|
||||
addClient = async (clientConfig) => {
|
||||
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 = {}} = clientConfig;
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
this.logger.debug(`(${name}) Attempting Maloja initialization...`);
|
||||
const mj = new MalojaScrobbler(name, data);
|
||||
const testSuccess = await mj.testConnection();
|
||||
if (testSuccess === false) {
|
||||
throw new Error(`(${name}) Maloja client not initialized due to failure during connection testing`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) Maloja client initialized`);
|
||||
this.clients.push(mj)
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
scrobble = async (data, options = {}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
forceRefresh = false,
|
||||
checkTime = dayjs(),
|
||||
scrobbleTo = [],
|
||||
scrobbleFrom = 'source',
|
||||
} = options;
|
||||
|
||||
const tracksScrobbled = [];
|
||||
|
||||
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)) {
|
||||
this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
await client.refreshScrobbles();
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
const {
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
|
||||
tracksScrobbled.push(playObj);
|
||||
await client.scrobble(playObj);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
return tracksScrobbled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
These are **example configurations** for all Source/Client types and AIO config.
|
||||
|
||||
These can be used as-is by renaming them to `.json` and filling or replacing sample data.
|
||||
|
||||
For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
|
||||
These are **NOT** exhaustive examples. You should consult the **configuration** documentation and the **schema explorer links** for each source/config type to see a complete list of options and descriptions for all properties.
|
||||
|
||||
Documentation at
|
||||
|
||||
* [internal docs](/docs/configuration.md)
|
||||
* External Link: https://github.com/FoxxMD/multi-scrobbler/blob/master/docs/configuration.md
|
||||
+47
-11
@@ -1,23 +1,59 @@
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify", // required, source type
|
||||
"clients": ["myConfig"], // optional, a list of Client config names this Source should scrobble to. Using an empty list or not including this property will make this Source scrobble to all Clients.
|
||||
"name": "mySpotifySource", // optional, friendly name for the log
|
||||
"data": { // required, the data for your config
|
||||
"clientId": "example",
|
||||
//...
|
||||
"type": "spotify",
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/callback"
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja", // required, Client type
|
||||
"name": "myConfig", // required, a name to identifier your Client
|
||||
"data": { // required, the data for your config
|
||||
"url": "http://example.com",
|
||||
//...
|
||||
"type": "maloja",
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "FirstGotifyServer",
|
||||
"type": "gotify",
|
||||
"url": "http://localhost:8070",
|
||||
"token": "MyGotifyToken",
|
||||
"priorities": {
|
||||
"info": 5,
|
||||
"warn": 7,
|
||||
"error": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ntfy",
|
||||
"name": "MyNtfyFriendlyNameForLogs",
|
||||
"url": "http://localhost:9991",
|
||||
"topic": "MyMultiScrobblerTopic",
|
||||
"username": "Optional",
|
||||
"password": "Optional",
|
||||
"priorities": {
|
||||
"info": 3,
|
||||
"warn": 4,
|
||||
"error": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logPayload": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myMaloja", // required, a name to identify your Client
|
||||
"name": "myMaloja",
|
||||
"data": {
|
||||
"url": "https://domain.tld", // required, the base url of your maloja installation
|
||||
"apiKey": "string" // required, your maloja api key
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MyPlex",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
|
||||
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
|
||||
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MySpotify",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "string", // spotify client id -- required if not providing access token
|
||||
"clientSecret": "string", // spotify client secret -- required if not providing access token
|
||||
"accessToken": "string", // spotify access token -- required if not providing client id/secret
|
||||
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
|
||||
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
"interval": 60 // optional, how long to wait before calling spotify for new tracks
|
||||
"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",
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,11 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MyTautuilli",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
|
||||
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
|
||||
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyYTMusic",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
|
||||
"authUser": "0",
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#- SPOTIFY_CLIENT_ID=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
volumes:
|
||||
- /path_on_host/multi-scrobbler-config:/config
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI on
|
||||
restart: unless-stopped
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-plex as a template
|
||||
|
||||
POPULATE_EXAMPLES=false
|
||||
|
||||
echo "-------------------------------------"
|
||||
echo -e "Setting up multi-scrobbler config directory based on CONFIG_DIR env: ${CONFIG_DIR}\n"
|
||||
|
||||
# make config folder if it does not exist
|
||||
if [ ! -d "${CONFIG_DIR}" ]; then
|
||||
echo "Directory does not exist! Creating..."
|
||||
POPULATE_EXAMPLES=true
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
else
|
||||
if [ "$(ls -A ${CONFIG_DIR})" ]; then
|
||||
echo "Directory is not empty, not creating examples."
|
||||
else
|
||||
POPULATE_EXAMPLES=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# add example configs
|
||||
if [ "$POPULATE_EXAMPLES" = true ]; then
|
||||
echo "Directory is empty, adding examples..."
|
||||
cp -r /app/config/. "${CONFIG_DIR}"/
|
||||
fi
|
||||
|
||||
# permissions
|
||||
echo "chown'ing directory to ensure correct permissions."
|
||||
chown -R abc:abc "${CONFIG_DIR}"
|
||||
echo "Done!"
|
||||
echo -e "-------------------------------------\n"
|
||||
@@ -0,0 +1 @@
|
||||
oneshot
|
||||
@@ -0,0 +1 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-ms-config/run
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-wikijs/blob/master/root/etc/s6-overlay/s6-rc.d/svc-wikijs/run as a template
|
||||
|
||||
# NODE_ARGS can be passed by ENV in docker command like "docker run foxxmd/multi-scrobbler -e NODE_ARGS=--optimize_for_size"
|
||||
echo -e "\nmulti-scrobbler is starting!"
|
||||
exec \
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/src/index.js run
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
* [Connection Issues](#connection-issues)
|
||||
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
|
||||
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
|
||||
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
|
||||
* [Configuration Issues](#configuration-issues)
|
||||
* [Config could not be parsed](#config-could-not-be-parsed)
|
||||
|
||||
# Connection Issues
|
||||
|
||||
## Plex/Tautulli/Jellyfin don't connect
|
||||
|
||||
These three [sources](/README.md#source) are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server to contact multi-scrobbler, as opposed to multi-scrobbler contacting the server.
|
||||
|
||||
multi-scrobbler will log information about any server that connects to it for these three services. In the logs it looks something like this:
|
||||
|
||||
```
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) Received valid data from server examplePlex for the first time.
|
||||
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
|
||||
```
|
||||
It also logs if a server tries to connect to a URL that it does not recognize:
|
||||
|
||||
```
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
```
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin is not able to connect to your multi-scrobbler instance at all.**
|
||||
|
||||
This is not something multi-scrobbler can fix and means you have an issue in your network.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Check or try all these steps before submitting an issue:
|
||||
|
||||
#### Turn on Debug Logging
|
||||
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
|
||||
|
||||
* using node `LOG_LEVEL=debug ... node src/index.js`
|
||||
* using docker `docker run -e LOG_LEVEL=debug ... foxxmd/multi-scrobbler`
|
||||
|
||||
Check the output for any additional information.
|
||||
|
||||
#### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](/docs/configuration.md) documentation assume you are running Plex/Tautulli/Jellyfin on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
|
||||
|
||||
#### Check Firewall and Port Forwarding
|
||||
|
||||
If the machine multi-scrobbler is running on has a firewall ensure that port **9078** is open. Or if it is in another network entirely make sure your router is forwarding this port and it is open to the correct machine.
|
||||
|
||||
#### Check Source Service Logs
|
||||
|
||||
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
|
||||
|
||||
##### Plex
|
||||
|
||||
Settings -> Manage -> Console
|
||||
|
||||
##### Tautulli
|
||||
|
||||
Check the command-line output of the application or docker logs.
|
||||
|
||||
##### Jellyfin
|
||||
|
||||
Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
## Jellyfin has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](/docs/configuration.md#jellyfin)
|
||||
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
|
||||
You can verify the payload sent from the webhook by modifying your jellyfin configuration to include `logPayload: true` which will output the raw payload to DEBUG level logging:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
|
||||
## Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
Ensure any **client id** or **secrets** are correct in your configuration.
|
||||
|
||||
The callback/redirect URL for these services must be:
|
||||
|
||||
* the same address you would use to access the multi-scrobbler web interface
|
||||
* the web-interface must be accessible from the browser you are completing authentication from.
|
||||
|
||||
If multi-scrobbler is not running on the same machine your browser is on then the default/example addresses (`http://localhost...`) **will not work.** You must determine the address you can reach the web interface at (such as `http://192.168.0.140:9078`) then use that in place of `localhost` in the callback URLs.
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
# Configuration Issues
|
||||
|
||||
## Config could not be parsed
|
||||
|
||||
If you see something like this in your logs:
|
||||
|
||||
```
|
||||
2023-02-19T10:05:42-06:00 warn : [App] App config file exists but could not be parsed!
|
||||
2023-02-19T10:05:42-06:00 error : [App] Exited with uncaught error
|
||||
2023-02-19T10:05:42-06:00 error : [App] Error: config.json could not be parsed
|
||||
```
|
||||
|
||||
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
|
||||
+378
-50
@@ -1,28 +1,83 @@
|
||||
# General
|
||||
* [Configuration Overview](#configuration-overview)
|
||||
* [ENV-Based Configuration](#env-based-configuration)
|
||||
* [File-Based Configuration](#file-based-configuration)
|
||||
* [All-in-One File Configuration](#all-in-one-file-configuration)
|
||||
* [Specific File Configuration](#specific-file-configuration)
|
||||
* [Source Configurations](#source-configurations)
|
||||
* [Spotify](#spotify)
|
||||
* [Plex](#plex)
|
||||
* [Tautulli](#tautulli)
|
||||
* [Subsonic](#subsonic)
|
||||
* [Jellyfin](#jellyfin)
|
||||
* [Last.fm (Source)](#lastfm--source-)
|
||||
* [Deezer](#deezer)
|
||||
* [Youtube Music](#youtube-music)
|
||||
* [Client Configurations](#client-configurations)
|
||||
* [Maloja](#maloja)
|
||||
* [Last.fm](#lastfm)
|
||||
* [Monitoring](#monitoring)
|
||||
* [Webhooks](#webhook-configurations)
|
||||
* [Health Endpoint](#health-endpoint)
|
||||
|
||||
General configuration options. These must be set through environmental variables because they affect initial startup of
|
||||
the app. **These variables are also available to Docker containers.**
|
||||
# Configuration Overview
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|--------------|-------------------------------------------------------------------------------------------|
|
||||
| `CONFIG_DIR` | No | `CWD/config` | Directory to look for all other configuration files |
|
||||
| `LOG_PATH` | No | `CWD/logs` | If `false` no logs will be written. If `string` will be the directory logs are written to |
|
||||
| `PORT` | No | 9078 | Port to run web server on |
|
||||
[**Sources** and **Clients**](/README.md#how-does-multi-scrobbler-ms-work) are configured using environmental (ENV) variables and/or json files.
|
||||
|
||||
**The app must have permission to write to `CONFIG_DIR` in order to store the current spotify access token.**
|
||||
**MS will parse configuration from both configuration types.** You can mix and match configurations but it is generally better to stick to one or the other.
|
||||
|
||||
TIP: Check the [**FAQ**](/docs/FAQ.md) if you have any issues after configuration!
|
||||
|
||||
# Sources and (Scrobble) Clients
|
||||
## ENV-Based Configuration
|
||||
|
||||
The app has two types of configurations:
|
||||
This is done by passing environmental variables and so does not require any files to run MS.
|
||||
|
||||
* **Sources** -- Where plays are parsed from
|
||||
* **Clients** -- Scrobble clients that plays are scrobbled to
|
||||
* 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`
|
||||
|
||||
All configurations can be configured through:
|
||||
* environmental variables
|
||||
* individual **json** files for each source/client type found in the `CONFIG_DIR` directory IE `config/spotify.json`
|
||||
* or through the main `config.json` (also found in `CONFIG_DIR` directory) using the `clients` or `sources` property under `data`:
|
||||
Use ENV-based configuration if:
|
||||
|
||||
* You are the only person for whom MS is scrobbling for
|
||||
* You have a very simple setup for MS such as one scrobble [Client](/README.md#client) and one [Source](/README.md#source) IE Plex -> Maloja
|
||||
|
||||
## File-Based Configuration
|
||||
|
||||
MS will parse configuration files located in the directory specified by the `CONFIG_DIR` environmental variable. This variable defaults to:
|
||||
|
||||
* Local installation -> `PROJECT_DIR/config`
|
||||
* Docker -> `/config` (in the container) -- see the [install docs](/docs/installation.md#docker) for how to configure this correctly
|
||||
|
||||
Use File-based configuration if:
|
||||
|
||||
* You have many [Sources](/README.md#source)
|
||||
* You have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
|
||||
Funkwhale...
|
||||
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers
|
||||
* You want only some **Sources** to scrobble to some **Clients** IE Fred's Spotify account scrobbles to Fred's Maloja
|
||||
server, but not Mary's Maloja server
|
||||
|
||||
File-based configurations located in the `CONFIG_DIR` directory can be parsed from
|
||||
|
||||
* an **all-in-one** config file named `config.json` that contains information for all Sources and Clients and/or
|
||||
* many **specific** files named based on the client/source to configure IE `plex.json` `spotify.json`
|
||||
|
||||
There are **example configurations** for all Source/Client types and AIO config located in the [/config](/config) directory of this project. These can be used as-is by renaming them to `.json`.
|
||||
For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
|
||||
There is also a [**kitchensink example**](/docs/kitchensink.md) that provides examples of using all sources/clients in a complex configuration.
|
||||
|
||||
### All-in-One File Configuration
|
||||
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
Example directory structure:
|
||||
|
||||
```
|
||||
/CONFIG_DIR
|
||||
config.json
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Config Example</summary>
|
||||
|
||||
```json5
|
||||
// in config.json
|
||||
@@ -40,38 +95,68 @@ All configurations can be configured through:
|
||||
//...
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"name": "myFirstMalojaClient",
|
||||
"type": "maloja",
|
||||
"data": {
|
||||
"url": "http://myMalojaServer.example",
|
||||
// ...
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [config.json.example](../config/config.json.example) for a short example of this or check out [the kitchen sink example](kitchensink.md).
|
||||
</details>
|
||||
|
||||
### ENV-Based or JSON-Based?
|
||||
`config.json` can also be used to set default behavior for all sources/clients using `sourceDefaults` and `clientDefaults` properties.
|
||||
|
||||
multi-scrobbler can be configured differently depending on how you will use it. See which use-case fits you the best and then use that approach when setting up each configuration:
|
||||
See [config.json.example](/config/config.json.example) for an annotated example or check out [the kitchen sink example](kitchensink.md).
|
||||
|
||||
#### ENV-Based (Single User)
|
||||
### Specific File Configuration
|
||||
|
||||
* You are the only person for whom the application is scrobbling
|
||||
* You may have many sources (Plex, Spotify, Tautulli...) but you only have one of each type of source
|
||||
* You have only one scrobble client
|
||||
* **Easier for small setups. Difficult for larger, multi-sourced setups (may want to switch to json)**
|
||||
* **Will not work for multi-user setups**
|
||||
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.
|
||||
|
||||
#### JSON-Based (Multi User)
|
||||
Example directory structure:
|
||||
|
||||
* You are a single user but want to set up many sources
|
||||
* You want to use multi-scrobbler to scrobble for yourself and others IE family, friends, etc.
|
||||
* You may 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, one for each person
|
||||
* 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
|
||||
```
|
||||
/CONFIG_DIR
|
||||
plex.json
|
||||
spotify.json
|
||||
maloja.json
|
||||
```
|
||||
|
||||
Note: While you may mix and match configuration approaches it is recommended to **only use ENV-based configs if you are
|
||||
doing everything in ENV-based configurations.**
|
||||
<details>
|
||||
<summary>Config Example</summary>
|
||||
|
||||
# Sources
|
||||
```json5
|
||||
// in maloja.json
|
||||
[
|
||||
{
|
||||
"name": "myFirstMalojaClient",
|
||||
"data": {
|
||||
"url": "http://myMalojaServer.example",
|
||||
"apiKey": "myKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mySecondMalojaClient",
|
||||
"data": {
|
||||
"url": "http://my2ndMalojaServer.example",
|
||||
"apiKey": "myKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See the [/config](/config) directory of this project for examples of each type of config file or reference specific files below.
|
||||
|
||||
# Source Configurations
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
|
||||
@@ -80,17 +165,15 @@ Client ID/Secret. Make sure to also whitelist your redirect URI in the applicati
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_ACCESS_TOKEN` | No | | Must include either this token or client id/secret |
|
||||
| `SPOTIFY_REFRESH_TOKEN` | No | | If using access token this is also recommended |
|
||||
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:{PORT}/callback` | URI must end in `callback` |
|
||||
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:9078/callback` | URI must end in `callback` |
|
||||
|
||||
### JSON-Based
|
||||
### File-Based
|
||||
|
||||
See [`spotify.json.example`](../config/spotify.json.example)
|
||||
See [`spotify.json.example`](/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Plex](https://plex.tv)
|
||||
|
||||
@@ -102,9 +185,9 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `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. |
|
||||
|
||||
### JSON-Based
|
||||
### File-Based
|
||||
|
||||
See [`plex.json.example`](../config/plex.json.example)
|
||||
See [`plex.json.example`](/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Tautulli](https://tautulli.com)
|
||||
|
||||
@@ -116,11 +199,131 @@ Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `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. |
|
||||
|
||||
### JSON-Based
|
||||
### File-Based
|
||||
|
||||
See [`tautulli.json.example`](../config/tautulli.json.example)
|
||||
See [`tautulli.json.example`](/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
# Clients
|
||||
## [Subsonic](http://www.subsonic.org/)
|
||||
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
|
||||
|
||||
**Known Issues:**
|
||||
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
|
||||
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
|
||||
* "played X minutes ago" sometimes is also not reported correctly
|
||||
* Multiple artists are reported as one value and cannot be separated
|
||||
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SUBSONIC_USER` | Yes | | |
|
||||
| `SUBSONIC_PASSWORD` | Yes | | |
|
||||
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`subsonic.json.example`](/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
Must be using Jellyfin 10.7 or greater
|
||||
|
||||
* In the Jellyfin desktop web UI Navigate to -> Administration -> Dashboard -> Plugins -> Catalog
|
||||
* Under Notifications -> **Webhook** -> Install, then restart your server
|
||||
* Navigate back to -> Administration -> Dashboard -> Plugins -> My Plugins -> Webhook
|
||||
* Click "..." -> Settings
|
||||
* In Webhook settings:
|
||||
* `Add Generic Destination`
|
||||
* In the new `Generic` dropdown:
|
||||
* Webhook Url: `http://localhost:9078/jellyfin`
|
||||
* Notification Type: `Playback Progress`
|
||||
* Item Type: `Songs`
|
||||
* Check `Send All Properties`
|
||||
* Save
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-------------------------------------------------------------------|
|
||||
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
|
||||
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jellyfin.json.example`](/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
See the [Last.fm (Client)](#lastfm) setup for registration instructions. You may need to disable "Hide recent listening information" on your [privacy page](https://www.last.fm/settings/privacy) for this to work.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
No support for ENV based for Last.fm as a client (only source)
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Deezer](https://deezer.com/)
|
||||
|
||||
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
|
||||
|
||||
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
|
||||
* Redirect URL must end in `deezer/callback`
|
||||
* Default would be `http://localhost:9078/deezer/callback`
|
||||
|
||||
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
|
||||
|
||||
* **Application ID**
|
||||
* **Secret Key**
|
||||
* **Redirect URL** (if not the default)
|
||||
|
||||
**If no access token is provided...**
|
||||
|
||||
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|-----------------------------------------|----------------------------------------------------|
|
||||
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
|
||||
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
|
||||
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`deezer.json.example`](/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Youtube Music](https://music.youtube.com)
|
||||
|
||||
Credentials for YT Music are obtained from a browser request to https://music.youtube.com **once you are logged in.** [Specific requirements are here and summarized below:](https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate)
|
||||
|
||||
* Open a new tab
|
||||
* Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
|
||||
* Go to https://music.youtube.com and ensure you are logged in
|
||||
|
||||
Then...
|
||||
|
||||
1. Find and select an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you don’t see the request, try scrolling down a bit or clicking on the library button in the top bar.
|
||||
2. **Make sure **Headers** pane is selected and open
|
||||
3. In the **Request Headers** section find and copy the **entire value** found after `Cookie:` and use this as the `cookie` value in your multi-scrobbler config
|
||||
4. If present, in the **Request Headers** section find and copy the number found in `X-google-AuthUser` and use this as the value for `authUser` in your multi-scrobbler config
|
||||
|
||||

|
||||
|
||||
NOTES:
|
||||
|
||||
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps above to get new credentials.
|
||||
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`ytmusic.json.example`](/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
|
||||
# Client Configurations
|
||||
|
||||
## [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
@@ -131,6 +334,131 @@ See [`tautulli.json.example`](../config/tautulli.json.example)
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
|
||||
### JSON-Based
|
||||
### File-Based
|
||||
|
||||
See [`maloja.json.example`](../config/maloja.json.example)
|
||||
See [`maloja.json.example`](/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [Last.fm](https://www.last.fm)
|
||||
|
||||
[Register for an API account here.](https://www.last.fm/api/account/create)
|
||||
|
||||
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
|
||||
```
|
||||
http://localhost:9078/lastfm/callback
|
||||
```
|
||||
or replace `localhost:9078` with your own base URL
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|-----------------------------------------|-------------------------------|
|
||||
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
|
||||
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
|
||||
| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
|
||||
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
# Monitoring
|
||||
|
||||
multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
|
||||
|
||||
## Webhook Configurations
|
||||
|
||||
Webhooks will **push** a notification to your configured servers on these events:
|
||||
|
||||
* Source polling started
|
||||
* Source polling retry
|
||||
* Source polling stopped on error
|
||||
* Scrobble client scrobble failure
|
||||
|
||||
Webhooks are configured in the main [config.json](#all-in-one-file-configuration) file under the `webhook` top-level property. Multiple webhooks may be configured for each webhook type. EX:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
...
|
||||
],
|
||||
"clients": [
|
||||
...
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "FirstGotifyServer",
|
||||
"type": "gotify",
|
||||
"url": "http://192.168.0.100:8070",
|
||||
"token": "abcd"
|
||||
},
|
||||
{
|
||||
"name": "SecondGotifyServer",
|
||||
"type": "gotify",
|
||||
...
|
||||
},
|
||||
{
|
||||
"name": "NtfyServerOne",
|
||||
"type": "ntfy",
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### [Gotify](https://gotify.net/)
|
||||
|
||||
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
EX
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "gotify",
|
||||
"name": "MyGotifyFriendlyNameForLogs",
|
||||
"url": "http://192.168.0.100:8070",
|
||||
"token": "AQZI58fA.rfSZbm",
|
||||
"priorities": {
|
||||
"info": 5,
|
||||
"warn": 7,
|
||||
"error": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Ntfy](https://ntfy.sh/)
|
||||
|
||||
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
EX
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ntfy",
|
||||
"name": "MyNtfyFriendlyNameForLogs",
|
||||
"url": "http://192.168.0.100:9991",
|
||||
"topic": "RvOwKJ1XtIVMXGLR",
|
||||
"username": "Optional",
|
||||
"password": "Optional",
|
||||
"priorities": {
|
||||
"info": 3,
|
||||
"warn": 4,
|
||||
"error": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Endpoint
|
||||
|
||||
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
|
||||
|
||||
* Returns `200 OK` when **everything** is working or `500 Internal Server Error` if **anything** is not
|
||||
* The plain url (`/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
|
||||
* Use query params `type` or `name` to restrict client/sources aggregated IE `/health?type=spotify` or `/health?name=MyMaloja`
|
||||
* On 500 the response returns a JSON payload with `messages` array that describes any issues
|
||||
* For any clients/sources that require authentication `/health` will return 500 if they are **not authenticated**
|
||||
* For sources that poll (spotify, yt music, subsonic) `/health` will 500 if they are **not polling**
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,80 @@
|
||||
# Installation
|
||||
|
||||
## Local
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Local Usage Examples
|
||||
|
||||
* The web UI is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
|
||||
## [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
Or use the provided [docker-compose.yml](/docker-compose.yml) after modifying it to fit your configuration.
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
* If you must **bind a host directory into the container for storing configurations and credentials:**
|
||||
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
|
||||
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
|
||||
* (Optionally) map the web UI port in the container **9078** to the host
|
||||
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
|
||||
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
|
||||
### Linux Host
|
||||
|
||||
If you are
|
||||
|
||||
* using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_)
|
||||
* running docker on MacOS or Windows
|
||||
|
||||
this **DOES NOT** apply to you.
|
||||
|
||||
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
### Docker Usage Examples
|
||||
|
||||
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
See the [docker-compose.yml](/docker-compose.yml) file for how to use with docker-compose.
|
||||
+100
-5
@@ -5,10 +5,14 @@ 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
|
||||
|
||||
@@ -16,6 +20,15 @@ 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",
|
||||
@@ -24,6 +37,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -69,7 +83,34 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"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": [
|
||||
{
|
||||
@@ -95,6 +136,15 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"url": "https://maloja.mary.example",
|
||||
"apiKey": "maryApiKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "lastfm",
|
||||
"name": "maryLFM",
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -113,7 +163,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -121,7 +171,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -130,7 +180,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"data": {
|
||||
"accessToken": "fredsToken",
|
||||
"refreshToken": "fredsRefreshToken",
|
||||
"interval": 120,
|
||||
"interval": 120
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -158,7 +208,36 @@ In `CONFIG_DIR/plex.json`
|
||||
{
|
||||
"name": "partyPlex",
|
||||
"data": {
|
||||
"libraries": ["party"],
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -191,3 +270,19 @@ In `CONFIG_DIR/maloja.json`:
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/lastfm.json`:
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
"name": "maryLFM",
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
+5
-2
@@ -40,8 +40,11 @@ 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}"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
import {addAsync, Router} 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 isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import {Writable} from 'stream';
|
||||
import 'winston-daily-rotate-file';
|
||||
import {
|
||||
buildTrackString,
|
||||
capitalize,
|
||||
labelledFormat,
|
||||
longestString,
|
||||
readJson,
|
||||
truncateStringToLength
|
||||
} from "./utils.js";
|
||||
import Clients from './clients/ScrobbleClients.js';
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.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)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
let output = []
|
||||
const stream = new Writable()
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
output.unshift(chunk.toString().replace('\n', ''));
|
||||
output = output.slice(0, 101);
|
||||
next()
|
||||
}
|
||||
const streamTransport = new winston.transports.Stream({
|
||||
stream,
|
||||
})
|
||||
|
||||
const logConfig = {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
sort: 'descending',
|
||||
limit: 50,
|
||||
}
|
||||
|
||||
const availableLevels = ['info', 'debug'];
|
||||
const logPath = process.env.LOG_DIR || `${process.cwd()}/logs`;
|
||||
const port = process.env.PORT ?? 9078;
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
});
|
||||
|
||||
const consoleTransport = new transports.Console();
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
streamTransport,
|
||||
];
|
||||
|
||||
if (typeof logPath === 'string') {
|
||||
myTransports.push(rotateTransport);
|
||||
}
|
||||
|
||||
const loggerOptions = {
|
||||
level: logConfig.level,
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add('default', loggerOptions);
|
||||
|
||||
const logger = winston.loggers.get('default');
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
|
||||
app.use(router);
|
||||
app.use(bodyParser.json());
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
// try to read a configuration file
|
||||
let config = {};
|
||||
try {
|
||||
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
logger.warn('App config file exists but could not be parsed!');
|
||||
}
|
||||
|
||||
// setup defaults for other configs and general config
|
||||
const {
|
||||
spotify,
|
||||
plex,
|
||||
} = config || {};
|
||||
|
||||
/*
|
||||
* setup clients
|
||||
* */
|
||||
const scrobbleClients = new Clients();
|
||||
await scrobbleClients.buildClientsFromConfig(configDir);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
}
|
||||
|
||||
const scrobbleSources = new ScrobbleSources(localUrl, configDir);
|
||||
let deprecatedConfigs = [];
|
||||
if(spotify !== undefined) {
|
||||
logger.warn(`Using 'spotify' top-level property in config.json is deprecated and will be removed in next major version. Please use 'sources' instead.`)
|
||||
deprecatedConfigs.push({
|
||||
type: 'spotify',
|
||||
name: 'unnamed',
|
||||
source: 'config.json (top level)',
|
||||
mode: 'single',
|
||||
data: spotify
|
||||
});
|
||||
}
|
||||
if(plex !== undefined) {
|
||||
logger.warn(`Using 'plex' top-level property in config.json is deprecated and will be removed in next major version. Please use 'sources' instead.`)
|
||||
deprecatedConfigs.push({
|
||||
type: 'plex',
|
||||
name: 'unnamed',
|
||||
source: 'config.json (top level)',
|
||||
mode: 'single',
|
||||
data: plex
|
||||
});
|
||||
}
|
||||
await scrobbleSources.buildSourcesFromConfig(deprecatedConfigs);
|
||||
|
||||
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
let slicedLog = output.slice(0, logConfig.limit + 1);
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {type, discoveredTracks = 0, name} = x;
|
||||
const base = {type, display: capitalize(type), discoveredTracks, name};
|
||||
switch (x.type) {
|
||||
case 'spotify':
|
||||
const authed = x.spotifyApi === undefined || x.spotifyApi.getAccessToken() !== undefined;
|
||||
let status = authed ? 'Yes' : 'Auth Interaction Required';
|
||||
if(authed) {
|
||||
status = x.pollerRunning ? 'Running' : 'Idle';
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
authed,
|
||||
status,
|
||||
}
|
||||
case 'plex':
|
||||
case 'tautulli':
|
||||
return {
|
||||
...base,
|
||||
status: discoveredTracks > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
}
|
||||
}
|
||||
})
|
||||
res.render('status', {
|
||||
sources: sourceData,
|
||||
logs: {
|
||||
output: slicedLog,
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'bold' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'bold' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'bold' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
app.postAsync('/tautulli', async function (req, res) {
|
||||
const payload = TautulliSource.formatPlayObj(req.body, true);
|
||||
// try to get config name from payload
|
||||
if (req.body.scrobblerConfig !== undefined) {
|
||||
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
|
||||
if (source !== undefined) {
|
||||
if (source.type !== 'tautulli') {
|
||||
this.logger.warn(`Tautulli event specified a config name but the configured source was not a Tautulli type: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
} else {
|
||||
await source.handle(payload, scrobbleClients);
|
||||
return res.send('OK');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Tautulli event specified a config name but no configured source found: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
}
|
||||
}
|
||||
// if none specified we'll iterate through all tautulli sources and hopefully the user has configured them with filters
|
||||
const tSources = scrobbleSources.getByType('tautulli');
|
||||
for (const source of tSources) {
|
||||
await source.handle(payload, scrobbleClients);
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.postAsync('/plex', upload.any(), async function (req, res) {
|
||||
const {
|
||||
body: {
|
||||
payload
|
||||
} = {}
|
||||
} = req;
|
||||
if (payload !== undefined) {
|
||||
const playObj = PlexSource.formatPlayObj(JSON.parse(payload), true);
|
||||
|
||||
const pSources = scrobbleSources.getByType('plex');
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj, scrobbleClients);
|
||||
}
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/authSpotify', sourceCheckMiddle);
|
||||
app.getAsync('/authSpotify', async function (req, res) {
|
||||
const {
|
||||
scrobbleSource: source,
|
||||
sourceName: name,
|
||||
} = req;
|
||||
|
||||
if (source.type !== 'spotify') {
|
||||
return res.status(400).send(`Specified source is not spotify (${source.type})`);
|
||||
}
|
||||
|
||||
if (source.spotifyApi === undefined) {
|
||||
res.status(400).send('Spotify configuration is not valid');
|
||||
} else {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
res.redirect(source.createAuthUrl());
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/pollSpotify', sourceCheckMiddle);
|
||||
app.getAsync('/pollSpotify', async function (req, res) {
|
||||
const {
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
|
||||
if (source.type !== 'spotify') {
|
||||
return res.status(400).send(`Specified source is not spotify (${source.type})`);
|
||||
}
|
||||
|
||||
source.pollSpotify(scrobbleClients);
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/spotify/recent', sourceCheckMiddle);
|
||||
app.getAsync('/spotify/recent', async function (req, res) {
|
||||
const {
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
if (source.type !== 'spotify') {
|
||||
return res.status(400).send(`Specified source is not spotify (${source.type})`);
|
||||
}
|
||||
|
||||
const result = await source.getRecentlyPlayed({formatted: true});
|
||||
const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map(x => x.data.artists.join(' / ')).flat())));
|
||||
const trackLength = longestString(result.map(x => x.data.track))
|
||||
const plays = result.map((x) => {
|
||||
const {
|
||||
meta: {
|
||||
url: {
|
||||
web
|
||||
} = {}
|
||||
} = {}
|
||||
} = x;
|
||||
const buildOpts = {
|
||||
include: ['time', 'timeFromNow'],
|
||||
transformers: {
|
||||
artists: a => artistTruncFunc(a.join(' / ')).padEnd(33),
|
||||
track: t => t.padEnd(trackLength)
|
||||
}
|
||||
}
|
||||
if (web !== undefined) {
|
||||
buildOpts.transformers.track = t => `<a href="${web}">${t}</a>${''.padEnd(Math.max(trackLength - t.length, 0))}`;
|
||||
}
|
||||
return buildTrackString(x, buildOpts);
|
||||
});
|
||||
res.render('spotify/recent', {plays, name: source.name});
|
||||
});
|
||||
|
||||
app.getAsync('/logs/settings/update', async function (req, res) {
|
||||
const e = req.query;
|
||||
for (const [setting, val] of Object.entries(req.query)) {
|
||||
switch (setting) {
|
||||
case 'limit':
|
||||
logConfig.limit = Number.parseInt(val);
|
||||
break;
|
||||
case 'sort':
|
||||
logConfig.sort = val;
|
||||
break;
|
||||
case 'level':
|
||||
logConfig.level = val;
|
||||
for (const [key, logger] of winston.loggers.loggers) {
|
||||
logger.level = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async function (req, res) {
|
||||
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
|
||||
const {
|
||||
query: {
|
||||
state
|
||||
} = {}
|
||||
} = req;
|
||||
const source = scrobbleSources.getByName(state);
|
||||
const tokenResult = await source.handleAuthCodeCallback(req.query);
|
||||
let responseContent = 'OK';
|
||||
if (tokenResult === true) {
|
||||
source.pollSpotify(scrobbleClients);
|
||||
} else {
|
||||
responseContent = tokenResult;
|
||||
}
|
||||
return res.send(responseContent);
|
||||
});
|
||||
|
||||
let anyNotReady = false;
|
||||
for (const spotifySource of scrobbleSources.sources.filter(x => x.type === 'spotify')) {
|
||||
if (spotifySource.spotifyApi !== undefined) {
|
||||
if (spotifySource.spotifyApi.getAccessToken() === undefined) {
|
||||
anyNotReady = true;
|
||||
} else {
|
||||
spotifySource.pollSpotify(scrobbleClients);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
logger.info(`Some spotify sources are not ready, open ${localUrl} to continue`);
|
||||
}
|
||||
|
||||
app.set('views', './views');
|
||||
app.set('view engine', 'ejs');
|
||||
logger.info(`Server started at ${localUrl}`);
|
||||
const server = await app.listen(port)
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
}
|
||||
}());
|
||||
|
||||
Generated
+2667
-865
File diff suppressed because it is too large
Load Diff
+70
-17
@@ -1,37 +1,90 @@
|
||||
{
|
||||
"name": "maloja-spotify-scrobbler",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.4.0",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"start": "node src/index.js",
|
||||
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
|
||||
"schema-client": "typescript-json-schema tsconfig.json ClientConfig --out src/common/schema/client.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-source": "typescript-json-schema tsconfig.json SourceConfig --out src/common/schema/source.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aio": "typescript-json-schema tsconfig.json AIOConfig --out src/common/schema/aio.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema tsconfig.json AIOSourceConfig --out src/common/schema/aio-source.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema tsconfig.json AIOClientConfig --out src/common/schema/aio-client.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/common/infrastructure/typings/lastfm-node-client.d.ts",
|
||||
"import": "./src/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0",
|
||||
"npm": ">=6.0.0"
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.1.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/FoxxMD/maloja-spotify-scrobbler.git"
|
||||
"url": "git+https://github.com/FoxxMD/multi-scrobbler.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/FoxxMD/maloja-spotify-scrobbler/issues"
|
||||
"url": "https://github.com/FoxxMD/multi-scrobbler/issues"
|
||||
},
|
||||
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
|
||||
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"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",
|
||||
"ejs": "^3.1.6",
|
||||
"es6-error": "^4.1.1",
|
||||
"express": "^4.17.1",
|
||||
"multer": "^1.4.2",
|
||||
"express-session": "^1.17.2",
|
||||
"formidable": "^2.1",
|
||||
"gotify": "^1.1.0",
|
||||
"json5": "^2.2.3",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"ntfy": "^1.0.5",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
"pony-cause": "^1.1.1",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"spotify-web-api-node": "^5.0.0",
|
||||
"superagent": "^6.1.0",
|
||||
"socket.io": "4.1.3",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0"
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-duplex": "^0.1.1",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"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",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths": "^3.13.0",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript-json-schema": "~0.55"
|
||||
},
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export const makeSourceCheckMiddle = sources => (req, res, next) => {
|
||||
const {
|
||||
query: {
|
||||
name
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
return res.status(404).send('Source name must be defined');
|
||||
}
|
||||
|
||||
const source = sources.getByName(name);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
next();
|
||||
}
|
||||
|
||||
export const makeClientCheckMiddle = clients => (req, res, next) => {
|
||||
const {
|
||||
query: {
|
||||
name
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
return res.status(404).send('Client name must be defined');
|
||||
}
|
||||
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
next();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {capitalize, createLabelledLogger} from "../utils.js";
|
||||
|
||||
export default class AbstractSource {
|
||||
|
||||
name;
|
||||
type;
|
||||
identifier;
|
||||
|
||||
config;
|
||||
clients;
|
||||
logger;
|
||||
|
||||
constructor(type, name, config = {}, clients = []) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(this.identifier, this.identifier);
|
||||
this.config = config;
|
||||
this.clients = clients;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString} from "../utils.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
|
||||
export default class PlexSource extends AbstractSource {
|
||||
users;
|
||||
libraries;
|
||||
servers;
|
||||
|
||||
discoveredTracks = 0;
|
||||
|
||||
constructor(name, config, clients, type = 'plex') {
|
||||
super(type, name, config, clients);
|
||||
const {user, libraries, servers} = config
|
||||
|
||||
if (user === undefined || user === null) {
|
||||
this.users = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(user)) {
|
||||
this.users = [user];
|
||||
} else {
|
||||
this.users = user;
|
||||
}
|
||||
this.users = this.users.map(x => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (libraries === undefined || libraries === null) {
|
||||
this.libraries = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(libraries)) {
|
||||
this.libraries = [libraries];
|
||||
} else {
|
||||
this.libraries = libraries;
|
||||
}
|
||||
this.libraries = this.libraries.map(x => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (servers === undefined || servers === null) {
|
||||
this.servers = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(servers)) {
|
||||
this.servers = [servers];
|
||||
} else {
|
||||
this.servers = servers;
|
||||
}
|
||||
this.servers = this.servers.map(x => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (user === undefined && libraries === undefined && servers === undefined) {
|
||||
this.logger.warn('Initialized, but with no filters! All tracks from all users on all servers and libraries will be scrobbled.');
|
||||
} else {
|
||||
this.logger.info(`Initialized with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries === undefined ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj(obj, newFromSource = false) {
|
||||
const {
|
||||
event,
|
||||
Account: {
|
||||
title: user,
|
||||
} = {},
|
||||
Metadata: {
|
||||
type,
|
||||
title: track,
|
||||
parentTitle: album,
|
||||
grandparentTitle: artist,
|
||||
librarySectionTitle: library
|
||||
} = {},
|
||||
Server: {
|
||||
title: server
|
||||
} = {},
|
||||
} = obj;
|
||||
return {
|
||||
data: {
|
||||
artists: [artist],
|
||||
album,
|
||||
track,
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
event,
|
||||
mediaType: type,
|
||||
user,
|
||||
library,
|
||||
server,
|
||||
source: 'Plex',
|
||||
newFromSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValidEvent = (playObj) => {
|
||||
const {
|
||||
meta: {
|
||||
mediaType, event, user, library, server
|
||||
},
|
||||
data: {
|
||||
artists,
|
||||
track,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const hint = this.type === 'tautulli' ? ' (Check notification agent json data configuration)' : '';
|
||||
|
||||
if (this.users !== undefined) {
|
||||
if (user === undefined) {
|
||||
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
|
||||
} else if (!this.users.includes(user.toLocaleLowerCase())) {
|
||||
this.logger.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event !== undefined && event !== 'media.scrobble') {
|
||||
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaType !== 'track') {
|
||||
this.logger.debug(`Will not scrobble event because media type was not a track (${mediaType})`, {
|
||||
artists,
|
||||
track
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.libraries !== undefined) {
|
||||
if (library === undefined) {
|
||||
this.logger.warn(`Config defined libraries but payload contained no library info${hint}`);
|
||||
} else if (!this.libraries.includes(library.toLocaleLowerCase())) {
|
||||
this.logger.debug(`Will not scrobble event because library was not on allowed list: ${library}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.servers !== undefined) {
|
||||
if (server === undefined) {
|
||||
this.logger.warn(`Config defined server but payload contained no server info${hint}`);
|
||||
} else if (!this.servers.includes(server.toLocaleLowerCase())) {
|
||||
this.logger.debug(`Will not scrobble event because server was not on allowed list: ${server}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handle = async (playObj, allClients) => {
|
||||
if (!this.isValidEvent(playObj)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
|
||||
try {
|
||||
await allClients.scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier});
|
||||
// only gets hit if we scrobbled ok
|
||||
this.discoveredTracks++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import {createLabelledLogger, isValidConfigStructure, readJson} from "../utils.js";
|
||||
import SpotifySource from "./SpotifySource.js";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
import TautulliSource from "./TautulliSource.js";
|
||||
|
||||
export default class ScrobbleSources {
|
||||
|
||||
sources = [];
|
||||
logger;
|
||||
configDir;
|
||||
localUrl;
|
||||
|
||||
sourceTypes = ['spotify', 'plex', 'tautulli'];
|
||||
|
||||
constructor(localUrl, configDir = process.cwd()) {
|
||||
this.configDir = configDir;
|
||||
this.localUrl = localUrl;
|
||||
this.logger = createLabelledLogger('sources', 'Sources');
|
||||
}
|
||||
|
||||
getByName = (name) => {
|
||||
return this.sources.find(x => x.name === name);
|
||||
}
|
||||
|
||||
getByType = (type) => {
|
||||
return this.sources.filter(x => x.type === type);
|
||||
}
|
||||
|
||||
buildSourcesFromConfig = async (additionalConfigs = []) => {
|
||||
let configs = additionalConfigs;
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
if (configFile !== undefined) {
|
||||
const {sources: mainConfigSourcesConfigs = []} = configFile;
|
||||
if (!mainConfigSourcesConfigs.every(x => x !== null && typeof x === 'object')) {
|
||||
throw new Error('All sources from config.json must be objects');
|
||||
}
|
||||
for (const c of mainConfigSourcesConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c, name, source: 'config.json'});
|
||||
}
|
||||
}
|
||||
|
||||
for (let sourceType of this.sourceTypes) {
|
||||
// env builder for single user mode
|
||||
switch (sourceType) {
|
||||
case 'spotify':
|
||||
const s = {
|
||||
accessToken: process.env.SPOTIFY_ACCESS_TOKEN,
|
||||
clientId: process.env.SPOTIFY_CLIENT_ID,
|
||||
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
|
||||
redirectUri: process.env.SPOTIFY_REDIRECT_URI,
|
||||
refreshToken: process.env.SPOTIFY_REFRESH_TOKEN,
|
||||
};
|
||||
if (!Object.values(s).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'spotify',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: s
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'tautulli':
|
||||
const t = {
|
||||
// support this for now
|
||||
user: process.env.TAUTULLI_USER || process.env.PLEX_USER
|
||||
};
|
||||
if (!Object.values(t).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'tautulli',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: t
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'plex':
|
||||
const p = {
|
||||
user: process.env.PLEX_USER
|
||||
};
|
||||
if (!Object.values(p).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'plex',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: p
|
||||
})
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let rawSourceConfigs;
|
||||
try {
|
||||
rawSourceConfigs = await readJson(`${this.configDir}/${sourceType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error(`${sourceType}.json config file could not be parsed`);
|
||||
}
|
||||
if (rawSourceConfigs !== undefined) {
|
||||
let sourceConfigs = [];
|
||||
if (Array.isArray(rawSourceConfigs)) {
|
||||
sourceConfigs = rawSourceConfigs;
|
||||
} else if (rawSourceConfigs === null || typeof rawSourceConfigs === 'object') {
|
||||
// backwards compatibility, assuming its single-user mode
|
||||
if (rawSourceConfigs.data === undefined) {
|
||||
sourceConfigs = [{data: rawSourceConfigs, mode: 'single', name: 'unnamed'}];
|
||||
} else {
|
||||
sourceConfigs = [rawSourceConfigs];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`All top level data from ${sourceType}.json must be an object or array of objects`);
|
||||
}
|
||||
for (const m of sourceConfigs) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
throw new Error(`All top-level data from ${sourceType}.json must be an object or array of objects`);
|
||||
}
|
||||
m.source = `${sourceType}.json`;
|
||||
m.type = sourceType;
|
||||
configs.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible configurations so we'll check they are minimally valid
|
||||
const configErrors = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
const msg = `Source config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] has errors: ${isValid.join(' | ')}`;
|
||||
return acc.concat(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
if (configErrors.length > 0) {
|
||||
for (const m of configErrors) {
|
||||
this.logger.error(m);
|
||||
}
|
||||
throw new Error('Could not build sources due to above errors');
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// do a last check that names (within each type) are unique and warn if not, but add anyways
|
||||
const typeGroupedConfigs = configs.reduce((acc, curr) => {
|
||||
const {type} = curr;
|
||||
const {[type]: t = []} = acc;
|
||||
return {...acc, [type]: [...t, curr]};
|
||||
}, {});
|
||||
// only need to warn if dup names PER TYPE
|
||||
for (const [type, typedConfigs] of Object.entries(typeGroupedConfigs)) {
|
||||
const nameGroupedConfigs = typedConfigs.reduce((acc, curr) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
for (const [name, namedConfigs] of Object.entries(nameGroupedConfigs)) {
|
||||
let tempNamedConfigs = namedConfigs;
|
||||
const hasDups = namedConfigs.length > 1;
|
||||
if (hasDups) {
|
||||
const sources = namedConfigs.map(c => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.warn(`Source configs have naming conflicts -- the following configs have the same name "${name}":\n\n${sources.join('\n')}\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');
|
||||
}
|
||||
}
|
||||
tempNamedConfigs = tempNamedConfigs.map(({name = 'unnamed', ...x},i) => ({...x, name: hasDups ? `${name}${i+1}` : name}));
|
||||
for(const c of tempNamedConfigs) {
|
||||
await this.addSource(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSource = async (clientConfig) => {
|
||||
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, clients = [], data = {}} = clientConfig;
|
||||
switch (type) {
|
||||
case 'spotify':
|
||||
const spotifySource = new SpotifySource(name, {
|
||||
...data,
|
||||
localUrl: this.localUrl,
|
||||
configDir: this.configDir
|
||||
}, clients);
|
||||
await spotifySource.buildSpotifyApi();
|
||||
this.sources.push(spotifySource);
|
||||
break;
|
||||
case 'plex':
|
||||
const plexSource = await new PlexSource(name, data, clients);
|
||||
this.sources.push(plexSource);
|
||||
break;
|
||||
case 'tautulli':
|
||||
const tautulliSource = await new TautulliSource(name, data, clients);
|
||||
this.sources.push(tautulliSource);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {EventEmitter} from "events";
|
||||
import {
|
||||
buildTrackString,
|
||||
readJson,
|
||||
sleep,
|
||||
writeFile,
|
||||
makeSingle,
|
||||
sortByPlayDate,
|
||||
createLabelledLogger
|
||||
} from "../utils.js";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
|
||||
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
|
||||
const state = 'random';
|
||||
|
||||
export default class SpotifySource extends AbstractSource {
|
||||
|
||||
spotifyApi;
|
||||
interval;
|
||||
localUrl;
|
||||
workingCredsPath;
|
||||
configDir;
|
||||
|
||||
spotifyPoller;
|
||||
pollerRunning = false;
|
||||
|
||||
emitter;
|
||||
discoveredTracks = 0;
|
||||
|
||||
constructor(name, config = {}, clients = []) {
|
||||
super('spotify', name, config, clients);
|
||||
const {
|
||||
localUrl,
|
||||
configDir,
|
||||
} = config;
|
||||
|
||||
this.configDir = configDir;
|
||||
this.workingCredsPath = `${configDir}/currentCreds-${name}.json`;
|
||||
this.localUrl = localUrl;
|
||||
this.spotifyPoller = makeSingle(pollSpotify);
|
||||
this.emitter = new EventEmitter();
|
||||
this.emitter.addListener('spotifyTrackDiscovered', this.handleDiscoveredTrack);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj, newFromSource = false) {
|
||||
const {
|
||||
track: {
|
||||
artists = [],
|
||||
name,
|
||||
id,
|
||||
duration_ms,
|
||||
album: {
|
||||
name: albumName,
|
||||
} = {},
|
||||
external_urls: {
|
||||
spotify,
|
||||
} = {}
|
||||
} = {},
|
||||
played_at
|
||||
} = obj;
|
||||
//let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
|
||||
return {
|
||||
data: {
|
||||
artists: artists.map(x => x.name),
|
||||
album: albumName,
|
||||
track: name,
|
||||
duration: duration_ms / 1000,
|
||||
playDate: dayjs(played_at),
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration_ms / 1000,
|
||||
source: 'Spotify',
|
||||
sourceId: id,
|
||||
newFromSource,
|
||||
url: {
|
||||
web: spotify
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDiscoveredTrack = (e) => {
|
||||
this.discoveredTracks++;
|
||||
}
|
||||
|
||||
buildSpotifyApi = async () => {
|
||||
|
||||
this.logger.debug('Initializing');
|
||||
|
||||
let spotifyCreds = {};
|
||||
try {
|
||||
spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
this.logger.warn('Current spotify credentials file exists but could not be parsed', { path: this.workingCredsPath });
|
||||
}
|
||||
|
||||
const {
|
||||
accessToken,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
refreshToken,
|
||||
interval = 60,
|
||||
} = this.config || {};
|
||||
|
||||
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,
|
||||
refreshToken: rt,
|
||||
}
|
||||
|
||||
if (Object.values(apiConfig).every(x => x === undefined)) {
|
||||
this.logger.info('No values found for Spotify configuration, skipping initialization');
|
||||
return;
|
||||
}
|
||||
apiConfig.redirectUri = rdUri;
|
||||
|
||||
const validationErrors = [];
|
||||
|
||||
if (token === undefined) {
|
||||
if (clientId === undefined) {
|
||||
validationErrors.push('clientId must be defined when access token is not present');
|
||||
}
|
||||
if (clientSecret === undefined) {
|
||||
validationErrors.push('clientSecret must be defined when access token is not present');
|
||||
}
|
||||
if (rdUri === undefined) {
|
||||
validationErrors.push('redirectUri must be defined when access token is not present');
|
||||
}
|
||||
if (validationErrors.length !== 0) {
|
||||
validationErrors.unshift('no access token is defined');
|
||||
}
|
||||
} else if (rt === undefined && (
|
||||
clientId === undefined ||
|
||||
clientSecret === undefined ||
|
||||
rdUri === undefined
|
||||
)) {
|
||||
this.logger.warn('Access token is present but no refresh token is defined and remaining configuration is not sufficient to re-authorize. Without a refresh token API calls will fail after current token is expired.');
|
||||
}
|
||||
|
||||
if (validationErrors.length !== 0) {
|
||||
this.logger.warn(`Configuration was not valid:\*${validationErrors.join('\n')}`);
|
||||
throw new Error('Failed to initialize a Spotify source');
|
||||
}
|
||||
|
||||
this.logger.info('Initialized');
|
||||
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
||||
}
|
||||
|
||||
createAuthUrl = () => {
|
||||
return this.spotifyApi.createAuthorizeURL(scopes, this.name);
|
||||
}
|
||||
|
||||
handleAuthCodeCallback = async ({error, code}) => {
|
||||
if (error === undefined) {
|
||||
const tokenResponse = await this.spotifyApi.authorizationCodeGrant(code);
|
||||
this.spotifyApi.setAccessToken(tokenResponse.body['access_token']);
|
||||
this.spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
token: tokenResponse.body['access_token'],
|
||||
refreshToken: tokenResponse.body['refresh_token']
|
||||
}));
|
||||
this.logger.info('Got token from code grant authorization!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.warn('Callback contained an error! User may have denied access?')
|
||||
this.logger.error(error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
const {limit = 20, formatted = false} = options;
|
||||
const func = api => api.getMyRecentlyPlayedTracks({
|
||||
limit
|
||||
});
|
||||
const result = await this.trySpotifyCall(func);
|
||||
if (formatted === true) {
|
||||
return result.body.items.map(x => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
trySpotifyCall = async (func) => {
|
||||
try {
|
||||
return await func(this.spotifyApi);
|
||||
} catch (e) {
|
||||
if (e.statusCode === 401) {
|
||||
if (this.spotifyApi.getRefreshToken() === undefined) {
|
||||
this.logger.error('Access token was not valid and no refresh token was present, bailing out of polling');
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.logger.debug('Access token was not valid, attempting to refresh');
|
||||
|
||||
const tokenResponse = await this.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 = this.spotifyApi.getRefreshToken(),
|
||||
} = {}
|
||||
} = tokenResponse;
|
||||
this.spotifyApi.setAccessToken(access_token);
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
token: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}));
|
||||
try {
|
||||
return await func(this.spotifyApi);
|
||||
} catch (ee) {
|
||||
this.logger.error('Refreshing access token encountered an error');
|
||||
this.logger.error(ee, {label: 'Spotify'});
|
||||
throw ee;
|
||||
}
|
||||
} else {
|
||||
this.logger.error('Refreshing access token encountered an error');
|
||||
this.logger.error(e, {label: 'Spotify'});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pollSpotify = (allClients) => {
|
||||
if (this.spotifyApi === undefined) {
|
||||
this.logger.warn('Cannot poll spotify without valid credentials configuration')
|
||||
return;
|
||||
}
|
||||
this.pollerRunning = true;
|
||||
return this.spotifyPoller(this.logger, this, this.interval, this.workingCredsPath, allClients, this.clients, this.identifier, this.emitter)
|
||||
.catch((e) => {
|
||||
this.logger.error('Error occurred while polling spotify, polling has been stopped');
|
||||
this.logger.error(e);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pollerRunning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pollSpotify = function* (logger, source, interval = 60, credsPath, clients, clientsToScrobbleTo = [], scrobbleFrom, emitter) {
|
||||
logger.info('Polling started');
|
||||
let lastTrackPlayedAt = dayjs();
|
||||
let checkCount = 0;
|
||||
while (true) {
|
||||
let playObjs = [];
|
||||
logger.debug('Refreshing recently played')
|
||||
playObjs = yield source.getRecentlyPlayed({formatted: true});
|
||||
if (playObjs instanceof Error) {
|
||||
return Promise.reject(playObjs);
|
||||
}
|
||||
checkCount++;
|
||||
let newTracksFound = false;
|
||||
let closeToInterval = false;
|
||||
const now = dayjs();
|
||||
|
||||
const playInfo = playObjs.reduce((acc, playObj) => {
|
||||
const {data: {playDate} = {}} = playObj;
|
||||
if (playDate.unix() > lastTrackPlayedAt.unix()) {
|
||||
newTracksFound = true;
|
||||
logger.info(`New Track => ${buildTrackString(playObj)}`);
|
||||
|
||||
if (closeToInterval === false) {
|
||||
closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
|
||||
}
|
||||
|
||||
return {
|
||||
plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}],
|
||||
lastTrackPlayedAt: playDate
|
||||
}
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
plays: [...acc.plays, playObj]
|
||||
}
|
||||
}, {plays: [], lastTrackPlayedAt});
|
||||
playObjs = playInfo.plays;
|
||||
lastTrackPlayedAt = playInfo.lastTrackPlayedAt;
|
||||
|
||||
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');
|
||||
yield sleep(10 * 1000);
|
||||
}
|
||||
|
||||
if (newTracksFound === false) {
|
||||
if (playObjs.length === 0) {
|
||||
logger.debug(`No new tracks found and no tracks returned from API`);
|
||||
} else {
|
||||
logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`);
|
||||
}
|
||||
} else {
|
||||
checkCount = 0;
|
||||
}
|
||||
|
||||
const scrobbleResult = yield clients.scrobble(playObjs, {forceRefresh: closeToInterval, scrobbleFrom, scrobbleTo: clientsToScrobbleTo});
|
||||
if (scrobbleResult instanceof Error) {
|
||||
return Promise.reject(scrobbleResult);
|
||||
} else if (scrobbleResult.length > 0) {
|
||||
checkCount = 0;
|
||||
for (const t of scrobbleResult) {
|
||||
emitter.emit('spotifyTrackDiscovered', t);
|
||||
}
|
||||
}
|
||||
|
||||
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 ${sleepTime}s`);
|
||||
yield sleep(sleepTime * 1000);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import {capitalize, createLabelledLogger} from "../utils.js";
|
||||
import {Logger} from "winston";
|
||||
import {PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
|
||||
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 = createLabelledLogger(identifier, identifier);
|
||||
this.config = config;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any): PlayObject => {
|
||||
throw new Error('should be overridden');
|
||||
}
|
||||
}
|
||||
@@ -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 {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): 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,203 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js";
|
||||
import {
|
||||
ClientType,
|
||||
INITIALIZED,
|
||||
INITIALIZING,
|
||||
InitState,
|
||||
NOT_INITIALIZED,
|
||||
PlayObject, ScrobbledPlayObject
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {Logger} from "winston";
|
||||
import {CommonClientConfig} from "../common/infrastructure/config/client/index.js";
|
||||
import {ClientConfig} from "../common/infrastructure/config/client/clients.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default abstract class AbstractScrobbleClient {
|
||||
|
||||
name: string;
|
||||
type: ClientType;
|
||||
|
||||
#initState: InitState = NOT_INITIALIZED;
|
||||
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
recentScrobbles: PlayObject[] = [];
|
||||
scrobbledPlayObjs: ScrobbledPlayObject[] = [];
|
||||
newestScrobbleTime?: Dayjs
|
||||
oldestScrobbleTime: Dayjs = dayjs();
|
||||
tracksScrobbled: number = 0;
|
||||
|
||||
lastScrobbleCheck: Dayjs = dayjs();
|
||||
refreshEnabled: boolean;
|
||||
checkExistingScrobbles: boolean;
|
||||
verboseOptions;
|
||||
|
||||
config: CommonClientConfig;
|
||||
logger: Logger;
|
||||
|
||||
notifier: Notifiers;
|
||||
|
||||
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `Client ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(identifier, identifier);
|
||||
this.notifier = notifier;
|
||||
|
||||
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) => {
|
||||
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: any, log = false) => {
|
||||
const {
|
||||
data: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
playDate,
|
||||
} = {},
|
||||
} = playObj;
|
||||
const validTime = playDate.isAfter(this.oldestScrobbleTime);
|
||||
if (log && !validTime) {
|
||||
this.logger.debug(`${buildTrackString(playObj)} was in an invalid time frame (played before the oldest scrobble found)`);
|
||||
}
|
||||
return validTime;
|
||||
}
|
||||
|
||||
addScrobbledTrack = (playObj: any, scrobbleResp: any) => {
|
||||
this.scrobbledPlayObjs.push({play: playObj, scrobble: this.formatPlayObj(scrobbleResp)});
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
return track;
|
||||
};
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
const {
|
||||
data: {
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.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];
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,93 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import {buildTrackString, playObjDataMatch, setIntersection, sortByPlayDate, truncateStringToLength} from "../utils.js";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
import {
|
||||
buildTrackString, capitalize,
|
||||
playObjDataMatch, removeUndefinedKeys,
|
||||
setIntersection, sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength,
|
||||
} from "../utils.js";
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
import {INITIALIZING, PlayObject, 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";
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('maloja', name, config, options);
|
||||
const {url, apiKey} = config;
|
||||
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");
|
||||
}
|
||||
api: LastfmApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
declare config: LastfmClientConfig;
|
||||
|
||||
constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers) {
|
||||
super('lastfm', name, config, notifier);
|
||||
// @ts-ignore
|
||||
this.api = new LastfmApiClient(name, config.data, options)
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time,
|
||||
} = obj;
|
||||
let artistStrings = artists.reduce((acc, curr) => {
|
||||
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)],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
}
|
||||
}
|
||||
formatPlayObj = (obj: any) => LastfmApiClient.formatPlayObj(obj);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj);
|
||||
|
||||
callApi = async (req) => {
|
||||
testAuth = async () => {
|
||||
try {
|
||||
return await req;
|
||||
this.authed = await this.api.testAuth();
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
status,
|
||||
body,
|
||||
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, apiKey} = this.config;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
if (version.length === 0) {
|
||||
this.logger.error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old :(');
|
||||
return false;
|
||||
}
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
if (version[0] < 2 || version[1] < 7) {
|
||||
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
|
||||
}
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${url}/apis/mlj_1/test`)
|
||||
.query({key: apiKey}));
|
||||
|
||||
const {
|
||||
status,
|
||||
body: {
|
||||
status: bodyStatus,
|
||||
} = {},
|
||||
body = {},
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Test connection succeeded!');
|
||||
return true;
|
||||
}
|
||||
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)
|
||||
})
|
||||
return false;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
return false;
|
||||
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 {url} = this.config;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=20`));
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit: 20, extended: true}));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
this.recentScrobbles = list.map(x => MalojaScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
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(sortByPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
@@ -143,36 +100,22 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj) => {
|
||||
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 => x.toLocaleLowerCase());
|
||||
lowerTitle = lowerArtists.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
|
||||
|
||||
// remove any whitespace in parenthesis
|
||||
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
|
||||
// replace parenthesis
|
||||
.replace('()', '')
|
||||
.replace('( )', '')
|
||||
.trim();
|
||||
|
||||
return lowerTitle;
|
||||
return track.toLocaleLowerCase().trim();
|
||||
}
|
||||
|
||||
alreadyScrobbled = (playObj, log = false) => {
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
|
||||
}
|
||||
|
||||
existingScrobble = (playObj, logMatch = false) => {
|
||||
existingScrobble = (playObj: PlayObject, logMatch = false) => {
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
|
||||
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) {
|
||||
@@ -183,7 +126,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch = {score: 0, breakdowns: ['None']};
|
||||
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);
|
||||
@@ -222,6 +165,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} = {},
|
||||
meta: {
|
||||
trackLength,
|
||||
source,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
@@ -234,18 +178,19 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
|
||||
|
||||
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
|
||||
let closeTime = false;
|
||||
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
|
||||
let scrobblePlayStartDiff;
|
||||
if (scrobblePlayDiff < 10) {
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
|
||||
if (closeTime === false && trackLength !== undefined) {
|
||||
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
|
||||
if (scrobblePlayStartDiff < 10) {
|
||||
if (scrobblePlayStartDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
@@ -255,13 +200,13 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
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 => x !== ''));
|
||||
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== ''));
|
||||
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
|
||||
|
||||
titleMatch = commonTerms.size / sourceTitleTerms.size;
|
||||
|
||||
let artistMatch;
|
||||
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
|
||||
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;
|
||||
|
||||
@@ -302,9 +247,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
return existingScrobble;
|
||||
}
|
||||
|
||||
scrobble = async (playObj) => {
|
||||
const {url, apiKey} = this.config;
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
@@ -313,6 +256,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
data = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
@@ -321,28 +265,67 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const rawPayload = {
|
||||
artist: artists.join(', '),
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: playDate.unix(),
|
||||
};
|
||||
// i don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
const scrobblePayload = removeUndefinedKeys(rawPayload);
|
||||
|
||||
try {
|
||||
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send({
|
||||
// maloja seems to detect this deliminator much better than commas
|
||||
// also less likely artist has a forward slash in their name than a comma
|
||||
artist: artists.join(' / '),
|
||||
seconds: duration,
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
}));
|
||||
this.addScrobbledTrack(playObj, response.body.track);
|
||||
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, {...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) {
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
|
||||
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,583 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import compareVersions from 'compare-versions';
|
||||
import {
|
||||
buildTrackString,
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength,
|
||||
parseRetryAfterSecsFromObj, capitalize
|
||||
} from "../utils.js";
|
||||
import {
|
||||
INITIALIZING,
|
||||
MalojaScrobbleData,
|
||||
MalojaScrobbleRequestData,
|
||||
MalojaScrobbleV2RequestData,
|
||||
MalojaScrobbleV3RequestData,
|
||||
MalojaV2ScrobbleData,
|
||||
MalojaV3ScrobbleData,
|
||||
PlayObject,
|
||||
TrackStringOptions
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
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) {
|
||||
super('maloja', name, config, notifier);
|
||||
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, serverVersion = undefined): PlayObject {
|
||||
let artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time;
|
||||
|
||||
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
|
||||
// scrobble data structure changed for v3
|
||||
const {
|
||||
// when the track was scrobbled
|
||||
time: mTime,
|
||||
track: {
|
||||
artists: mArtists,
|
||||
title: mTitle,
|
||||
album: {
|
||||
name: mAlbum,
|
||||
artists: albumArtists
|
||||
} = {},
|
||||
// length of the track
|
||||
length: mLength,
|
||||
} = {},
|
||||
// how long the track was listened to before it was scrobbled
|
||||
duration: mDuration,
|
||||
} = obj as MalojaV3ScrobbleData;
|
||||
artists = mArtists;
|
||||
time = mTime;
|
||||
title = mTitle;
|
||||
duration = mDuration;
|
||||
album = mAlbum;
|
||||
} else {
|
||||
// scrobble data structure for v2 and below
|
||||
const {
|
||||
artists: mArtists,
|
||||
title: mTitle,
|
||||
album: mAlbum,
|
||||
duration: mDuration,
|
||||
time: mTime,
|
||||
} = obj as MalojaV2ScrobbleData;
|
||||
artists = mArtists;
|
||||
title = mTitle;
|
||||
album = mAlbum;
|
||||
duration = mDuration;
|
||||
time = mTime;
|
||||
}
|
||||
let artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
} else if (typeof curr === 'object') {
|
||||
aString = curr.name;
|
||||
}
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any) => MalojaScrobbler.formatPlayObj(obj, 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=20`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
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.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
|
||||
}
|
||||
}
|
||||
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 this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
|
||||
}
|
||||
|
||||
existingScrobble = (playObj: PlayObject, logMatch = false) => {
|
||||
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 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;
|
||||
}
|
||||
|
||||
if (existingScrobble === 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;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists: sourceArtists = [],
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
trackLength,
|
||||
source,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
// 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 {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
|
||||
|
||||
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
|
||||
let closeTime = false;
|
||||
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
|
||||
let scrobblePlayStartDiff;
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
|
||||
if (closeTime === false && trackLength !== undefined) {
|
||||
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
|
||||
if (scrobblePlayStartDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
const artistScore = .2 * artistMatch;
|
||||
const titleScore = .3 * titleMatch;
|
||||
const timeScore = .5 * (closeTime ? 1 : 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;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
const {url, apiKey} = this.config.data;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobbleData: MalojaScrobbleRequestData = {
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
|
||||
try {
|
||||
// 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
|
||||
(scrobbleData as MalojaScrobbleV3RequestData).artists = artists;
|
||||
} else {
|
||||
// maloja seems to detect this deliminator much better than commas
|
||||
// also less likely artist has a forward slash in their name than a comma
|
||||
(scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
|
||||
}
|
||||
|
||||
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send(scrobbleData));
|
||||
|
||||
let scrobbleResponse = {};
|
||||
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
|
||||
const {
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
track,
|
||||
} = {}
|
||||
} = response;
|
||||
scrobbleResponse = {
|
||||
time: playDate.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
},
|
||||
}
|
||||
if(album !== undefined) {
|
||||
const {
|
||||
album: malojaAlbum = {},
|
||||
} = track;
|
||||
// @ts-expect-error TS(2339): Property 'track' does not exist on type '{}'.
|
||||
scrobbleResponse.track.album = {
|
||||
...malojaAlbum,
|
||||
name: album
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
track: {
|
||||
time: mTime = playDate.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
}
|
||||
} = {}} = response;
|
||||
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
|
||||
}
|
||||
this.addScrobbledTrack(playObj, scrobbleResponse);
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
this.logger.debug('Payload:', scrobbleData);
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
|
||||
throw e;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
createAjvFactory,
|
||||
createLabelledLogger,
|
||||
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";
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
type ParsedConfig = ClientAIOConfig & ConfigMeta;
|
||||
|
||||
export default class ScrobbleClients {
|
||||
|
||||
/** @type AbstractScrobbleClient[] */
|
||||
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
|
||||
logger;
|
||||
configDir;
|
||||
|
||||
constructor(configDir: any) {
|
||||
this.configDir = configDir;
|
||||
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
|
||||
}
|
||||
|
||||
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',
|
||||
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',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: lfm
|
||||
})
|
||||
}
|
||||
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(`(${name}) Constructing ${type} client...`);
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier);
|
||||
break;
|
||||
case 'lastfm':
|
||||
newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier);
|
||||
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(`(${name}) Attempting ${type} initialization...`);
|
||||
if ((await newClient.initialize()) === false) {
|
||||
this.logger.error(`(${name}) ${type} client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} client initialized`);
|
||||
}
|
||||
}
|
||||
if(newClient.requiresAuth && !newClient.authed) {
|
||||
this.logger.debug(`(${name}) Checking ${type} client auth...`);
|
||||
let success;
|
||||
try {
|
||||
success = await newClient.testAuth();
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`(${name}) ${type} client auth failed.`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} 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)) {
|
||||
this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`);
|
||||
continue;
|
||||
}
|
||||
if(!client.initialized) {
|
||||
if(client.initializing) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is still initializing`);
|
||||
continue;
|
||||
}
|
||||
if(!(await client.initialize())) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it could not be initialized`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(client.requiresAuth && !client.authed) {
|
||||
if (client.requiresAuthInteraction) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because user interaction is required for authentication`);
|
||||
continue;
|
||||
} else if (!(await client.testAuth())) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because auth test failed`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(!(await client.isReady())) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not ready`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
try {
|
||||
await client.refreshScrobbles();
|
||||
} catch(e) {
|
||||
this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
try {
|
||||
const {
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
if (client.timeFrameIsValid(playObj, newFromSource) && !(await client.alreadyScrobbled(playObj, newFromSource))) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
|
||||
this.logger.error(e);
|
||||
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
|
||||
if(e.continueScrobbling !== true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tracksScrobbled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as path from 'path';
|
||||
import {fileURLToPath} from "url";
|
||||
|
||||
//const __filename = fileURLToPath(import.meta.url);
|
||||
//const __dirname = path.dirname(__filename);
|
||||
|
||||
export const projectDir = path.resolve(__dirname, '../../');
|
||||
export const configDir: string = path.resolve(projectDir, './config');
|
||||
@@ -0,0 +1,138 @@
|
||||
import {Dayjs} from "dayjs";
|
||||
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic'];
|
||||
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
|
||||
|
||||
export type ClientType = 'maloja' | 'lastfm';
|
||||
export const clientTypes: ClientType[] = ['maloja', 'lastfm'];
|
||||
|
||||
export type InitState = 0 | 1 | 2;
|
||||
export const NOT_INITIALIZED: InitState = 0;
|
||||
export const INITIALIZING: InitState = 1;
|
||||
export const INITIALIZED: InitState = 2;
|
||||
export const initStates: InitState[] = [NOT_INITIALIZED, INITIALIZING, INITIALIZED];
|
||||
|
||||
export type ReadyState = 0 | 1 | 2;
|
||||
export const NOT_READY: ReadyState = 0;
|
||||
export const GETTING_READY: ReadyState = 1;
|
||||
export const READY: ReadyState = 2;
|
||||
export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY];
|
||||
|
||||
export interface InternalConfig {
|
||||
localUrl: string
|
||||
configDir: string
|
||||
}
|
||||
|
||||
export interface ConfigMeta {
|
||||
source: string
|
||||
mode?: string
|
||||
configureAs: string
|
||||
}
|
||||
|
||||
export interface PlayData {
|
||||
artists?: string[]
|
||||
album?: string
|
||||
track?: string
|
||||
duration?: number
|
||||
playDate?: Dayjs
|
||||
}
|
||||
|
||||
export interface PlayMeta {
|
||||
source?: string
|
||||
sourceId?: string
|
||||
newFromSource?: boolean
|
||||
url?: {
|
||||
web: string
|
||||
[key: string]: string
|
||||
}
|
||||
trackLength?: number
|
||||
user?: string
|
||||
mediaType?: string
|
||||
server?: string
|
||||
library?: string
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface PlayObject {
|
||||
data: PlayData,
|
||||
meta: PlayMeta
|
||||
}
|
||||
|
||||
export interface TrackStringOptions {
|
||||
include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'sourceId')[]
|
||||
transformers?: {
|
||||
artists?: (a: string[]) => string
|
||||
track?: (t: string) => string
|
||||
time?: (t: Dayjs) => string
|
||||
timeFromNow?: (t: Dayjs) => string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScrobbledPlayObject {
|
||||
play: PlayObject
|
||||
scrobble: PlayObject
|
||||
}
|
||||
|
||||
export interface MalojaV2ScrobbleData {
|
||||
artists: string[]
|
||||
title: string
|
||||
album: string
|
||||
/**
|
||||
* Length of the track
|
||||
* */
|
||||
duration: number
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface MalojaV3ScrobbleData {
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
track: {
|
||||
artists: string[]
|
||||
title: string
|
||||
album?: {
|
||||
name: string
|
||||
artists: string[]
|
||||
}
|
||||
/**
|
||||
* length of the track
|
||||
* */
|
||||
length: number
|
||||
}
|
||||
/**
|
||||
* how long the track was listened to before it was scrobbled
|
||||
* */
|
||||
duration: number
|
||||
}
|
||||
|
||||
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
|
||||
|
||||
export interface MalojaScrobbleRequestData {
|
||||
key: string
|
||||
title: string
|
||||
album: string
|
||||
time: number
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
|
||||
artist: string
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
|
||||
artists: string[]
|
||||
}
|
||||
|
||||
export interface RemoteIdentityParts {
|
||||
host: string,
|
||||
proxy: string | undefined,
|
||||
agent: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {SourceRetryOptions} from "./source/index.js";
|
||||
import {RequestRetryOptions} from "./common.js";
|
||||
import {SourceAIOConfig} from "./source/sources.js";
|
||||
import {ClientAIOConfig} from "./client/clients.js";
|
||||
import {WebhookConfig} from "./health/webhooks.js";
|
||||
|
||||
export interface AIOConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
clientDefaults?: RequestRetryOptions
|
||||
sources?: SourceAIOConfig[]
|
||||
clients?: ClientAIOConfig[]
|
||||
|
||||
webhooks?: WebhookConfig[]
|
||||
}
|
||||
|
||||
export interface AIOClientConfig {
|
||||
clientDefaults?: RequestRetryOptions
|
||||
clients?: ClientAIOConfig[]
|
||||
}
|
||||
|
||||
export interface AIOSourceConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
sources?: SourceAIOConfig[]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.js";
|
||||
import {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig;
|
||||
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig;
|
||||
@@ -0,0 +1,80 @@
|
||||
import {CommonConfig, CommonData} from "../common.js";
|
||||
|
||||
/**
|
||||
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
|
||||
* */
|
||||
export interface MatchLoggingOptions {
|
||||
/**
|
||||
* Log to DEBUG when a new track does NOT match an existing scrobble
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
onNoMatch?: boolean
|
||||
/**
|
||||
* Log to DEBUG when a new track DOES match an existing scrobble
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
onMatch?: boolean
|
||||
/**
|
||||
* Include confidence breakdowns in track match logging, if applicable
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
confidenceBreakdown?: boolean
|
||||
}
|
||||
|
||||
export interface CommonClientData extends CommonData {
|
||||
/**
|
||||
* default # of http request retries a client can make before error is thrown.
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxRequestRetries?: number
|
||||
/**
|
||||
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).
|
||||
*
|
||||
* @default 1.5
|
||||
* @examples [1.5]
|
||||
* */
|
||||
retryMultiplier?: number
|
||||
|
||||
options?: {
|
||||
/**
|
||||
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
refreshEnabled?: boolean
|
||||
/**
|
||||
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
checkExistingScrobbles?: boolean
|
||||
/**
|
||||
* Options used for increasing verbosity of logging in MS (used for debugging)
|
||||
* */
|
||||
verbose?: {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonClientConfig extends CommonConfig {
|
||||
/**
|
||||
* Unique identifier for this client. Used with sources to restrict where scrobbles are sent.
|
||||
*
|
||||
* @examples ["MyConfig"]
|
||||
* */
|
||||
name: string
|
||||
/**
|
||||
* Specific data required to configure this client
|
||||
* */
|
||||
data?: CommonClientData
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {CommonClientConfig, CommonClientData} from "./index.js";
|
||||
import {RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface LastfmData extends RequestRetryOptions {
|
||||
/**
|
||||
* API Key generated from Last.fm account
|
||||
*
|
||||
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
|
||||
* */
|
||||
apiKey: string
|
||||
/**
|
||||
* Secret generated from Last.fm account
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
secret: string
|
||||
/**
|
||||
* Optional session id returned from a completed auth flow
|
||||
* */
|
||||
session?: string
|
||||
/**
|
||||
* Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "lastfm/callback" in the URL somewhere.
|
||||
*
|
||||
* @default "http://localhost:9078/lastfm/callback"
|
||||
* @examples ["http://localhost:9078/lastfm/callback"]
|
||||
* */
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface LastfmClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using LastFM as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: CommonClientData & LastfmData
|
||||
}
|
||||
|
||||
export interface LastfmClientAIOConfig extends LastfmClientConfig {
|
||||
type: 'lastfm'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CommonClientConfig, CommonClientData} from "./index.js";
|
||||
import {RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
|
||||
/**
|
||||
* URL for maloja server
|
||||
*
|
||||
* @examples ["http://localhost:42010"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* API Key for Maloja server
|
||||
*
|
||||
* @examples ["myApiKey"]
|
||||
* */
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface MalojaClientConfig extends CommonClientConfig {
|
||||
data: MalojaClientData
|
||||
}
|
||||
|
||||
export interface MalojaClientAIOConfig extends MalojaClientConfig {
|
||||
type: 'maloja'
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
data?: CommonData
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
[key: string]: any
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface RequestRetryOptions {
|
||||
/**
|
||||
* default # of http request retries a source can make before error is thrown
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxRequestRetries?: number
|
||||
/**
|
||||
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)
|
||||
*
|
||||
* @default 1.5
|
||||
* @examples [1.5]
|
||||
* */
|
||||
retryMultiplier?: number
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface WebhookPayload {
|
||||
title?: string
|
||||
message: string
|
||||
priority: 'info' | 'warn' | 'error'
|
||||
}
|
||||
|
||||
export interface PrioritiesConfig {
|
||||
/**
|
||||
* @examples [5]
|
||||
* */
|
||||
info: number
|
||||
/**
|
||||
* @examples [7]
|
||||
* */
|
||||
warn: number
|
||||
/**
|
||||
* @examples [10]
|
||||
* */
|
||||
error: number
|
||||
}
|
||||
|
||||
export interface CommonWebhookConfig {
|
||||
/**
|
||||
* Webhook type. Valid values are:
|
||||
*
|
||||
* * gotify
|
||||
* * ntfy
|
||||
*
|
||||
* @examples ["gotify"]
|
||||
* */
|
||||
type: 'gotify' | 'ntfy'
|
||||
/**
|
||||
* A friendly name used to identify webhook config in logs
|
||||
* */
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface GotifyConfig extends CommonWebhookConfig {
|
||||
/**
|
||||
* The URL of the Gotify server. Same URL that would be used to reach the Gotify UI
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* The token created for this Application in Gotify
|
||||
*
|
||||
* @examples ["AQZI58fA.rfSZbm"]
|
||||
* */
|
||||
token: string
|
||||
/**
|
||||
* Priority of messages
|
||||
*
|
||||
* * Info -> 5
|
||||
* * Warn -> 7
|
||||
* * Error -> 10
|
||||
* */
|
||||
priorities?: PrioritiesConfig
|
||||
}
|
||||
|
||||
export interface NtfyConfig extends CommonWebhookConfig {
|
||||
/**
|
||||
* The URL of the Ntfy server
|
||||
*
|
||||
* @examples ["http://192.168.0.100:8078"]
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The topic mutli-scrobbler should POST to
|
||||
* */
|
||||
topic: string
|
||||
|
||||
/**
|
||||
* Required if topic is protected
|
||||
* */
|
||||
username?: string
|
||||
/**
|
||||
* Required if topic is protected
|
||||
* */
|
||||
password?: string
|
||||
|
||||
/**
|
||||
* Priority of messages
|
||||
*
|
||||
* * Info -> 3
|
||||
* * Warn -> 4
|
||||
* * Error -> 5
|
||||
* */
|
||||
priorities?: PrioritiesConfig
|
||||
}
|
||||
|
||||
export type WebhookConfig = GotifyConfig | NtfyConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface DeezerData extends CommonSourceData {
|
||||
/**
|
||||
* deezer client id
|
||||
*
|
||||
* @examples ["a89cba1569901a0671d5a9875fed4be1"]
|
||||
* */
|
||||
clientId: string
|
||||
/**
|
||||
* deezer client secret
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
clientSecret: string
|
||||
/**
|
||||
* deezer redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
*
|
||||
* @default "http://localhost:9078/deezer/callback"
|
||||
* @examples ["http://localhost:9078/deezer/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
/**
|
||||
* optional, how long to wait before calling spotify for new tracks (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
export interface DeezerSourceConfig extends CommonSourceConfig {
|
||||
data: DeezerData
|
||||
}
|
||||
|
||||
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
|
||||
type: 'deezer'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {CommonConfig, CommonData, RequestRetryOptions} from "../common.js";
|
||||
|
||||
export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
/**
|
||||
* default # of automatic polling restarts on error
|
||||
*
|
||||
* @default 0
|
||||
* @examples [1]
|
||||
* */
|
||||
maxPollRetries?: number
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData, SourceRetryOptions {
|
||||
}
|
||||
|
||||
export interface CommonSourceConfig extends CommonConfig {
|
||||
/**
|
||||
* Unique identifier for this source.
|
||||
* */
|
||||
name?: string
|
||||
/**
|
||||
* Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.
|
||||
*
|
||||
* @examples [["MyMalojaConfigName","MyLastFMConfigName"]]
|
||||
* */
|
||||
clients?: string[]
|
||||
data?: CommonSourceData
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface JellyData extends CommonSourceData {
|
||||
/**
|
||||
* optional list of users to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all users will be scrobbled
|
||||
*
|
||||
* @examples [["MyUser1","MyUser2"]]
|
||||
* */
|
||||
users?: string | string[]
|
||||
/**
|
||||
* optional list of servers to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all servers will be scrobbled
|
||||
*
|
||||
* @examples [["MyServerName1"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional options for jellyfin logging and tuning
|
||||
* */
|
||||
options?: {
|
||||
/**
|
||||
* Log raw Jellyfin webhook payload to debug
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
logPayload?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface JellySourceConfig extends CommonSourceConfig {
|
||||
data: JellyData
|
||||
}
|
||||
|
||||
export interface JellySourceAIOConfig extends JellySourceConfig {
|
||||
type: 'jellyfin'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {LastfmData} from "../client/lastfm.js";
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
|
||||
|
||||
export interface LastfmSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
* When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client.
|
||||
*
|
||||
* @default source
|
||||
* @examples ["source"]
|
||||
* */
|
||||
configureAs?: 'source'
|
||||
data: LastFmSourceData
|
||||
}
|
||||
|
||||
export interface LastFmSouceAIOConfig extends LastfmSourceConfig {
|
||||
type: 'lastfm'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface PlexSourceData extends CommonSourceData {
|
||||
/**
|
||||
* optional list of users to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all users will be scrobbled
|
||||
*
|
||||
* @examples [["MyUser1", "MyUser2"]]
|
||||
* */
|
||||
user?: string | string[]
|
||||
/**
|
||||
* optional list of libraries to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all libraries will be scrobbled
|
||||
*
|
||||
* @examples [["Audio","Music"]]
|
||||
* */
|
||||
libraries?: string | string[]
|
||||
/**
|
||||
* optional list of servers to scrobble tracks from
|
||||
*
|
||||
* If none are provided tracks from all servers will be scrobbled
|
||||
*
|
||||
* @examples [["MyServerName"]]
|
||||
* */
|
||||
servers?: string | string[]
|
||||
}
|
||||
|
||||
export interface PlexSourceConfig extends CommonSourceConfig {
|
||||
data: PlexSourceData
|
||||
}
|
||||
|
||||
export interface PlexSourceAIOConfig extends PlexSourceConfig {
|
||||
type: 'plex'
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {SpotifySourceAIOConfig, SpotifySourceConfig} from "./spotify.js";
|
||||
import {PlexSourceAIOConfig, PlexSourceConfig} from "./plex.js";
|
||||
import {TautulliSourceAIOConfig, TautulliSourceConfig} from "./tautulli.js";
|
||||
import {DeezerSourceAIOConfig, DeezerSourceConfig} from "./deezer.js";
|
||||
import {SubsonicSourceAIOConfig, SubSonicSourceConfig} from "./subsonic.js";
|
||||
import {JellySourceAIOConfig, JellySourceConfig} from "./jellyfin.js";
|
||||
import {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.js";
|
||||
import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js";
|
||||
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig;
|
||||
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig;
|
||||
@@ -0,0 +1,38 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface SpotifySourceData extends CommonSourceData {
|
||||
/**
|
||||
* spotify client id
|
||||
*
|
||||
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
|
||||
* */
|
||||
clientId: string
|
||||
/**
|
||||
* spotify client secret
|
||||
*
|
||||
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
|
||||
* */
|
||||
clientSecret: string
|
||||
/**
|
||||
* spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
*
|
||||
* @default "http://localhost:9078/callback"
|
||||
* @examples ["http://localhost:9078/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
/**
|
||||
* optional, how long to wait before calling spotify for new tracks (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
export interface SpotifySourceConfig extends CommonSourceConfig {
|
||||
data: SpotifySourceData
|
||||
}
|
||||
|
||||
export interface SpotifySourceAIOConfig extends SpotifySourceConfig {
|
||||
type: 'spotify'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface SubsonicData extends CommonSourceData {
|
||||
/**
|
||||
* URL of the subsonic media server to query
|
||||
*
|
||||
* @examples ["http://airsonic.local"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* Username to login to the server with
|
||||
*
|
||||
* @example ["MyUser"]
|
||||
* */
|
||||
user: string
|
||||
|
||||
/**
|
||||
* Password for the user to login to the server with
|
||||
*
|
||||
* @examples ["MyPassword"]
|
||||
* */
|
||||
password: string
|
||||
}
|
||||
export interface SubSonicSourceConfig extends CommonSourceConfig {
|
||||
data: SubsonicData
|
||||
}
|
||||
|
||||
export interface SubsonicSourceAIOConfig extends SubSonicSourceConfig {
|
||||
type: 'subsonic'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {PlexSourceConfig} from "./plex.js";
|
||||
|
||||
export interface TautulliSourceConfig extends PlexSourceConfig {
|
||||
}
|
||||
|
||||
export interface TautulliSourceAIOConfig extends TautulliSourceConfig {
|
||||
type: 'tautulli'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CommonSourceConfig, CommonSourceData} from "./index.js";
|
||||
|
||||
export interface YTMusicData extends CommonSourceData {
|
||||
/**
|
||||
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
|
||||
*
|
||||
* See https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.
|
||||
*
|
||||
* @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."]
|
||||
* */
|
||||
cookie: string
|
||||
/**
|
||||
* If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included
|
||||
*
|
||||
* @example [0]
|
||||
* */
|
||||
authUser?: number
|
||||
}
|
||||
export interface YTMusicSourceConfig extends CommonSourceConfig {
|
||||
data: YTMusicData
|
||||
}
|
||||
|
||||
export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig {
|
||||
type: 'ytmusic'
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
declare module 'lastfm-node-client' {
|
||||
/**
|
||||
* Must REMOVE (unset) any undefined/null properties from payload before sending or LastFM will return an error
|
||||
* */
|
||||
export interface TrackScrobblePayload {
|
||||
/**
|
||||
* join multiple artists with ', '
|
||||
* */
|
||||
artist: string
|
||||
|
||||
/**
|
||||
* track title
|
||||
* */
|
||||
track: string
|
||||
|
||||
/**
|
||||
* Unix timestamp of time track should be scrobbled at
|
||||
* */
|
||||
timestamp: number
|
||||
/**
|
||||
* length of track in seconds
|
||||
* */
|
||||
duration?: number
|
||||
|
||||
album?: string
|
||||
|
||||
albumArtist?: string
|
||||
}
|
||||
|
||||
export interface TrackScrobbleResponse {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
accepted: number,
|
||||
ignored: number
|
||||
code: number
|
||||
},
|
||||
scrobble?: {
|
||||
track: {
|
||||
'#text': string
|
||||
},
|
||||
timestamp: number,
|
||||
ignoredMessage: {
|
||||
code: number
|
||||
'#text': string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthGetSessionPayload {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AuthGetSessionResponse {
|
||||
session: {
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserGetInfoResponse {
|
||||
user: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserGetRecentTracksPayload {
|
||||
user: string
|
||||
limit?: number
|
||||
extended?: boolean
|
||||
}
|
||||
|
||||
export interface UserGetRecentTracksResponse {
|
||||
recenttracks: {
|
||||
track: TrackObject[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrackObject {
|
||||
artist: {
|
||||
'#text': string,
|
||||
name: string,
|
||||
},
|
||||
name: string,
|
||||
album: {
|
||||
'#text': string,
|
||||
},
|
||||
duration: number,
|
||||
date?: {
|
||||
// @ts-ignore
|
||||
uts: number,
|
||||
},
|
||||
'@attr'?: {
|
||||
nowplaying: 'true' | 'false'
|
||||
}
|
||||
url: string,
|
||||
mbid: string,
|
||||
}
|
||||
|
||||
export default class LastFM {
|
||||
constructor(apiKey: string, secret?: string, session?: string);
|
||||
|
||||
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
|
||||
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
|
||||
userGetInfo(): Promise<UserGetInfoResponse>
|
||||
userGetRecentTracks(params: UserGetRecentTracksPayload): Promise<UserGetRecentTracksResponse>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
declare module 'passport-deezer' {
|
||||
//import {Strategy as Oauth2Strategy} from 'passport-oauth2';
|
||||
import {Strategy as PassportStrategy} from "passport";
|
||||
export class Strategy extends PassportStrategy {
|
||||
constructor(options: any, verify: any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ClientAIOConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LastfmClientAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MalojaClientAIOConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommonClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientAIOConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using LastFM as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonClientData"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmData"
|
||||
}
|
||||
],
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"lastfm"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientAIOConfig": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/MalojaClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"maloja"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key for Maloja server",
|
||||
"examples": [
|
||||
"myApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for maloja server",
|
||||
"examples": [
|
||||
"http://localhost:42010"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MatchLoggingOptions": {
|
||||
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
|
||||
"properties": {
|
||||
"confidenceBreakdown": {
|
||||
"default": false,
|
||||
"description": "Include confidence breakdowns in track match logging, if applicable",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onNoMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RequestRetryOptions": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"clientDefaults": {
|
||||
"$ref": "#/definitions/RequestRetryOptions"
|
||||
},
|
||||
"clients": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ClientAIOConfig"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"DeezerData": {
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"description": "deezer client id",
|
||||
"examples": [
|
||||
"a89cba1569901a0671d5a9875fed4be1"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "deezer client secret",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 60,
|
||||
"description": "optional, how long to wait before calling spotify for new tracks (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/deezer/callback",
|
||||
"description": "deezer redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
"examples": [
|
||||
"http://localhost:9078/deezer/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"redirectUri"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DeezerSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/DeezerData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"deezer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JellyData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for jellyfin logging and tuning",
|
||||
"properties": {
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw Jellyfin webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of servers to scrobble tracks from\n\nIf none are provided tracks from all servers will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyServerName1"
|
||||
]
|
||||
]
|
||||
},
|
||||
"users": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of users to scrobble tracks from\n\nIf none are provided tracks from all users will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyUser1",
|
||||
"MyUser2"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"JellySourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/JellyData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"jellyfin"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastFmSouceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"configureAs": {
|
||||
"default": "source",
|
||||
"description": "When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client.",
|
||||
"enum": [
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"source"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/LastFmSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"lastfm"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastFmSourceData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/PlexSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"plex"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceData": {
|
||||
"properties": {
|
||||
"libraries": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of libraries to scrobble tracks from\n\nIf none are provided tracks from all libraries will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"Audio",
|
||||
"Music"
|
||||
]
|
||||
]
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of servers to scrobble tracks from\n\nIf none are provided tracks from all servers will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyServerName"
|
||||
]
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of users to scrobble tracks from\n\nIf none are provided tracks from all users will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyUser1",
|
||||
"MyUser2"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Record<string,any>": {
|
||||
"type": "object"
|
||||
},
|
||||
"SourceAIOConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/DeezerSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubsonicSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JellySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastFmSouceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/YTMusicSourceAIOConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SourceRetryOptions": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SpotifySourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/SpotifySourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"spotify"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SpotifySourceData": {
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"description": "spotify client id",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "spotify client secret",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 60,
|
||||
"description": "optional, how long to wait before calling spotify for new tracks (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/callback",
|
||||
"description": "spotify redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
"examples": [
|
||||
"http://localhost:9078/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"redirectUri"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubsonicData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password for the user to login to the server with",
|
||||
"examples": [
|
||||
"MyPassword"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL of the subsonic media server to query",
|
||||
"examples": [
|
||||
"http://airsonic.local"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "Username to login to the server with",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password",
|
||||
"url",
|
||||
"user"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubsonicSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/SubsonicData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"subsonic"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TautulliSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/PlexSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"tautulli"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicData": {
|
||||
"properties": {
|
||||
"authUser": {
|
||||
"description": "If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included",
|
||||
"type": "number"
|
||||
},
|
||||
"cookie": {
|
||||
"description": "The cookie retrieved from the Request Headers of music.youtube.com after logging in.\n\nSee https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.",
|
||||
"examples": [
|
||||
"VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cookie"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/YTMusicData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ytmusic"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"sourceDefaults": {
|
||||
"$ref": "#/definitions/SourceRetryOptions"
|
||||
},
|
||||
"sources": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/SourceAIOConfig"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LastfmClientConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MalojaClientConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"CommonClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a client can make before error is thrown.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using LastFM as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonClientData"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmData"
|
||||
}
|
||||
],
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientConfig": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/MalojaClientData",
|
||||
"description": "Specific data required to configure this client"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key for Maloja server",
|
||||
"examples": [
|
||||
"myApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for maloja server",
|
||||
"examples": [
|
||||
"http://localhost:42010"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MatchLoggingOptions": {
|
||||
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
|
||||
"properties": {
|
||||
"confidenceBreakdown": {
|
||||
"default": false,
|
||||
"description": "Include confidence breakdowns in track match logging, if applicable",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"onNoMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpotifySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/PlexSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/TautulliSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/DeezerSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubSonicSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JellySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/YTMusicSourceConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"DeezerData": {
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"description": "deezer client id",
|
||||
"examples": [
|
||||
"a89cba1569901a0671d5a9875fed4be1"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "deezer client secret",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 60,
|
||||
"description": "optional, how long to wait before calling spotify for new tracks (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/deezer/callback",
|
||||
"description": "deezer redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
"examples": [
|
||||
"http://localhost:9078/deezer/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"redirectUri"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DeezerSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/DeezerData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JellyData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"description": "Additional options for jellyfin logging and tuning",
|
||||
"properties": {
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "Log raw Jellyfin webhook payload to debug",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of servers to scrobble tracks from\n\nIf none are provided tracks from all servers will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyServerName1"
|
||||
]
|
||||
]
|
||||
},
|
||||
"users": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of users to scrobble tracks from\n\nIf none are provided tracks from all users will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyUser1",
|
||||
"MyUser2"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"JellySourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/JellyData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastFmSourceData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"configureAs": {
|
||||
"default": "source",
|
||||
"description": "When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client.",
|
||||
"enum": [
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"source"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/LastFmSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/PlexSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PlexSourceData": {
|
||||
"properties": {
|
||||
"libraries": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of libraries to scrobble tracks from\n\nIf none are provided tracks from all libraries will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"Audio",
|
||||
"Music"
|
||||
]
|
||||
]
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"servers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of servers to scrobble tracks from\n\nIf none are provided tracks from all servers will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyServerName"
|
||||
]
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "optional list of users to scrobble tracks from\n\nIf none are provided tracks from all users will be scrobbled",
|
||||
"examples": [
|
||||
[
|
||||
"MyUser1",
|
||||
"MyUser2"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Record<string,any>": {
|
||||
"type": "object"
|
||||
},
|
||||
"SpotifySourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/SpotifySourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SpotifySourceData": {
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"description": "spotify client id",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "spotify client secret",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 60,
|
||||
"description": "optional, how long to wait before calling spotify for new tracks (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/callback",
|
||||
"description": "spotify redirect URI -- required only if not the default shown here. URI must end in \"callback\"",
|
||||
"examples": [
|
||||
"http://localhost:9078/callback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"redirectUri"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubSonicSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/SubsonicData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubsonicData": {
|
||||
"properties": {
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password for the user to login to the server with",
|
||||
"examples": [
|
||||
"MyPassword"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL of the subsonic media server to query",
|
||||
"examples": [
|
||||
"http://airsonic.local"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "Username to login to the server with",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password",
|
||||
"url",
|
||||
"user"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TautulliSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/PlexSourceData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicData": {
|
||||
"properties": {
|
||||
"authUser": {
|
||||
"description": "If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included",
|
||||
"type": "number"
|
||||
},
|
||||
"cookie": {
|
||||
"description": "The cookie retrieved from the Request Headers of music.youtube.com after logging in.\n\nSee https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.",
|
||||
"examples": [
|
||||
"VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 0,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/Record<string,any>"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cookie"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"YTMusicSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/YTMusicData"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import winston from 'winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import passport from 'passport';
|
||||
import session from 'express-session';
|
||||
import {Writable} from 'stream';
|
||||
import 'winston-daily-rotate-file';
|
||||
import {
|
||||
buildTrackString,
|
||||
capitalize, createLabelledLogger,
|
||||
labelledFormat,
|
||||
longestString,
|
||||
readJson, remoteHostIdentifiers, sleep,
|
||||
truncateStringToLength
|
||||
} from "./utils.js";
|
||||
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js";
|
||||
import TautulliSource from "./sources/TautulliSource.js";
|
||||
import PlexSource, {plexRequestMiddle} from "./sources/PlexSource.js";
|
||||
import JellyfinSource from "./sources/JellyfinSource.js";
|
||||
import { Server } from "socket.io";
|
||||
import * as path from "path";
|
||||
import {projectDir} from "./common/index.js";
|
||||
import LastfmApiClient from "./apis/LastfmApiClient.js";
|
||||
import LastfmSource from "./sources/LastfmSource.js";
|
||||
import LastfmScrobbler from "./clients/LastfmScrobbler.js";
|
||||
import ScrobbleClients from "./clients/ScrobbleClients.js";
|
||||
import DeezerSource from "./sources/DeezerSource.js";
|
||||
import AbstractSource from "./sources/AbstractSource.js";
|
||||
import {PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js";
|
||||
import SpotifySource from "./sources/SpotifySource.js";
|
||||
import {JellyfinNotifier} from "./sources/ingressNotifiers/JellyfinNotifier.js";
|
||||
import {PlexNotifier} from "./sources/ingressNotifiers/PlexNotifier.js";
|
||||
import {TautulliNotifier} from "./sources/ingressNotifiers/TautulliNotifier.js";
|
||||
import {Notifiers} from "./notifier/Notifiers.js";
|
||||
import {AIOConfig} from "./common/infrastructure/config/aioConfig.js";
|
||||
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
|
||||
const port = process.env.PORT ?? 9078;
|
||||
|
||||
(async function () {
|
||||
|
||||
const server = await app.listen(port)
|
||||
const io = new Server(server);
|
||||
|
||||
app.use(router);
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false}));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
let output: any = []
|
||||
const stream = new Writable()
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
let formatString = chunk.toString().replace('\n', '<br />')
|
||||
.replace(/(debug)\s/gi, '<span class="debug text-pink-400">$1 </span>')
|
||||
.replace(/(warn)\s/gi, '<span class="warn text-blue-400">$1 </span>')
|
||||
.replace(/(info)\s/gi, '<span class="info text-yellow-500">$1 </span>')
|
||||
.replace(/(error)\s/gi, '<span class="error text-red-400">$1 </span>')
|
||||
output.unshift(formatString);
|
||||
output = output.slice(0, 101);
|
||||
io.emit('log', formatString);
|
||||
next();
|
||||
}
|
||||
const streamTransport = new winston.transports.Stream({
|
||||
stream,
|
||||
})
|
||||
|
||||
const logConfig = {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
sort: 'descending',
|
||||
limit: 50,
|
||||
}
|
||||
|
||||
const availableLevels = ['info', 'debug'];
|
||||
let logPath = path.resolve(projectDir, `./logs`);
|
||||
if(typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
});
|
||||
|
||||
const consoleTransport = new transports.Console();
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
streamTransport,
|
||||
];
|
||||
|
||||
if (typeof logPath === 'string') {
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
}
|
||||
|
||||
const loggerOptions: winston.LoggerOptions = {
|
||||
level: logConfig.level,
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add('default', loggerOptions);
|
||||
|
||||
const logger = winston.loggers.get('default');
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
|
||||
|
||||
try {
|
||||
// try to read a configuration file
|
||||
let config = {};
|
||||
try {
|
||||
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
logger.warn('App config file exists but could not be parsed!');
|
||||
}
|
||||
|
||||
const notifiers = new Notifiers();
|
||||
const {
|
||||
webhooks = []
|
||||
} = (config || {}) as AIOConfig;
|
||||
|
||||
await notifiers.buildWebhooks(webhooks)
|
||||
|
||||
/*
|
||||
* setup clients
|
||||
* */
|
||||
const scrobbleClients = new ScrobbleClients(configDir);
|
||||
await scrobbleClients.buildClientsFromConfig(notifiers);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
}
|
||||
|
||||
const scrobbleSources = new ScrobbleSources(localUrl, configDir);
|
||||
await scrobbleSources.buildSourcesFromConfig([], notifiers);
|
||||
|
||||
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
// check ambiguous client/source types like this for now
|
||||
const lastfmSources = scrobbleSources.getByType('lastfm');
|
||||
const lastfmScrobbles = scrobbleClients.getByType('lastfm');
|
||||
|
||||
const scrobblerNames = lastfmScrobbles.map(x => x.name);
|
||||
const nameColl = lastfmSources.filter(x => scrobblerNames.includes(x.name));
|
||||
if(nameColl.length > 0) {
|
||||
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
|
||||
}
|
||||
|
||||
// initialize deezer strategies
|
||||
const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
|
||||
for(const d of deezerSources) {
|
||||
passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
|
||||
}
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
let slicedLog = output.slice(0, logConfig.limit + 1);
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
// TODO links for re-trying auth and variables for signalling it (and API recently played)
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksDiscovered = 0,
|
||||
name,
|
||||
canPoll = false,
|
||||
polling = false,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else if(canPoll) {
|
||||
base.status = polling ? 'Running' : 'Idle';
|
||||
} else {
|
||||
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
}
|
||||
return base;
|
||||
});
|
||||
const clientData = scrobbleClients.clients.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: requiresAuth,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
return base;
|
||||
})
|
||||
res.render('status', {
|
||||
sources: sourceData,
|
||||
clients: clientData,
|
||||
logs: {
|
||||
output: slicedLog,
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" data-limit="${x}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" data-sort="${x}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize log-${x} ${logConfig.level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const tauIngress = new TautulliNotifier();
|
||||
app.postAsync('/tautulli', async function(this: any, req, res) {
|
||||
tauIngress.trackIngress(req, false);
|
||||
|
||||
const payload = TautulliSource.formatPlayObj(req.body, true);
|
||||
// try to get config name from payload
|
||||
if (req.body.scrobblerConfig !== undefined) {
|
||||
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
|
||||
if (source !== undefined) {
|
||||
if (source.type !== 'tautulli') {
|
||||
this.logger.warn(`Tautulli event specified a config name but the configured source was not a Tautulli type: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
} else {
|
||||
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
|
||||
await source.handle(payload, scrobbleClients);
|
||||
return res.send('OK');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Tautulli event specified a config name but no configured source found: ${req.body.scrobblerConfig}`);
|
||||
return res.send('OK');
|
||||
}
|
||||
}
|
||||
// if none specified we'll iterate through all tautulli sources and hopefully the user has configured them with filters
|
||||
const tSources = scrobbleSources.getByType('tautulli');
|
||||
for (const source of tSources) {
|
||||
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
|
||||
await source.handle(payload, scrobbleClients);
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
const plexMiddle = plexRequestMiddle();
|
||||
const plexLog = createLabelledLogger('plexReq', 'Plex Request');
|
||||
const plexIngress = new PlexNotifier();
|
||||
app.postAsync('/plex',
|
||||
async function (req, res, next) {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
plexIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
plexMiddle, async function (req, res) {
|
||||
plexIngress.trackIngress(req, false);
|
||||
|
||||
const { payload } = req as any;
|
||||
if(payload !== undefined) {
|
||||
const playObj = PlexSource.formatPlayObj(payload, true);
|
||||
|
||||
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
|
||||
if(pSources.length === 0) {
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
|
||||
}
|
||||
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj, scrobbleClients);
|
||||
}
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
|
||||
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
|
||||
const jellyIngress = new JellyfinNotifier();
|
||||
app.postAsync('/jellyfin',
|
||||
async function (req, res, next) {
|
||||
// track request before parsing body to ensure we at least log that something is happening
|
||||
// (in the event body parsing does not work or request is not POST/PATCH)
|
||||
jellyIngress.trackIngress(req, true);
|
||||
next();
|
||||
},
|
||||
jellyfinJsonParser, async function (req, res) {
|
||||
jellyIngress.trackIngress(req, false);
|
||||
|
||||
const parts = remoteHostIdentifiers(req);
|
||||
const connectionId = `${parts.host}-${parts.proxy ?? ''}`;
|
||||
|
||||
const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId}, true);
|
||||
const pSources = scrobbleSources.getByType('jellyfin') as JellyfinSource[];
|
||||
if(pSources.length === 0) {
|
||||
logger.warn('Received Jellyfin connection but no Jellyfin sources are configured');
|
||||
}
|
||||
const logPayload = pSources.some(x => {
|
||||
const {
|
||||
data: {
|
||||
options: {
|
||||
logPayload = false
|
||||
} = {}
|
||||
} = {},
|
||||
} = x.config;
|
||||
return logPayload;
|
||||
});
|
||||
if(logPayload) {
|
||||
logger.debug(`[Jellyfin] Logging payload due to at least one Jellyfin source having 'logPayload: true`, req.body);
|
||||
}
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj, scrobbleClients);
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/client/auth', clientCheckMiddle);
|
||||
app.getAsync('/client/auth', async function (req, res) {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
|
||||
switch (scrobbleClient.type) {
|
||||
case 'lastfm':
|
||||
res.redirect(scrobbleClient.api.getAuthUrl());
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/source/auth', sourceCheckMiddle);
|
||||
app.getAsync('/source/auth', async function (req, res, next) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
// @ts-expect-error TS(2339): Property 'sourceName' does not exist on type 'Requ... Remove this comment to see the full error message
|
||||
sourceName: name,
|
||||
} = req;
|
||||
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if (source.spotifyApi === undefined) {
|
||||
res.status(400).send('Spotify configuration is not valid');
|
||||
} else {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
res.redirect(source.createAuthUrl());
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
res.redirect(source.api.getAuthUrl());
|
||||
break;
|
||||
case 'deezer':
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
req.session.deezerSource = name;
|
||||
return passport.authenticate(`deezer-${source.name}`)(req,res,next);
|
||||
default:
|
||||
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/poll', sourceCheckMiddle);
|
||||
app.getAsync('/poll', async function (req, res) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
|
||||
if (!source.canPoll) {
|
||||
return res.status(400).send(`Specified source cannot poll (${source.type})`);
|
||||
}
|
||||
|
||||
source.poll(scrobbleClients);
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/recent', sourceCheckMiddle);
|
||||
app.getAsync('/recent', async function (req, res) {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
if (!source.canPoll) {
|
||||
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
|
||||
}
|
||||
|
||||
const result = await (source as AbstractSource).getRecentlyPlayed({formatted: true, display: true});
|
||||
const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat())));
|
||||
const trackLength = longestString(result.map((x: any) => x.data.track))
|
||||
const plays = result.map((x: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
url: {
|
||||
web
|
||||
} = {}
|
||||
} = {}
|
||||
} = x;
|
||||
const buildOpts: TrackStringOptions = {
|
||||
include: ['time', 'timeFromNow', 'track', 'artist'],
|
||||
transformers: {
|
||||
artists: (a: any) => artistTruncFunc(a.join(' / ')).padEnd(33),
|
||||
track: (t: any) => t.padEnd(trackLength)
|
||||
}
|
||||
}
|
||||
if (web !== undefined) {
|
||||
buildOpts.transformers.track = t => `<a href="${web}">${t}</a>${''.padEnd(Math.max(trackLength - t.length, 0))}`;
|
||||
}
|
||||
return buildTrackString(x, buildOpts);
|
||||
});
|
||||
res.render('recent', {plays, name: source.name, sourceType: source.type});
|
||||
});
|
||||
|
||||
app.getAsync('/logs/settings/update', async function (req, res) {
|
||||
const e = req.query;
|
||||
for (const [setting, val] of Object.entries(req.query)) {
|
||||
switch (setting) {
|
||||
case 'limit':
|
||||
logConfig.limit = Number.parseInt(val as string);
|
||||
break;
|
||||
case 'sort':
|
||||
logConfig.sort = val as string;
|
||||
break;
|
||||
case 'level':
|
||||
logConfig.level = val as string;
|
||||
for (const [key, logger] of winston.loggers.loggers) {
|
||||
logger.level = val as string;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let slicedLog = output.slice(0, logConfig.limit + 1);
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
res.send('OK');
|
||||
io.emit('logClear', slicedLog);
|
||||
});
|
||||
|
||||
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
|
||||
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
|
||||
app.getAsync(/.*deezer\/callback*$/, function (req, res, next) {
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
const entity = scrobbleSources.getByName(req.session.deezerSource as string);
|
||||
const passportFunc = passport.authenticate(`deezer-${entity.name}`, {session: false});
|
||||
return passportFunc(req, res, next);
|
||||
}, async function (req, res) {
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
let entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
|
||||
for(let i = 0; i < 3; i++) {
|
||||
if(entity.error !== undefined) {
|
||||
return res.send('Error with deezer credentials storage');
|
||||
} else if(entity.config.data.accessToken !== undefined) {
|
||||
// start polling
|
||||
entity.poll(entity.clients)
|
||||
return res.redirect('/');
|
||||
} else {
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
res.send('Waited too long for credentials to store. Try restarting polling.');
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async function (req, res, next) {
|
||||
const {
|
||||
query: {
|
||||
state
|
||||
} = {}
|
||||
} = req;
|
||||
if (req.url.includes('lastfm')) {
|
||||
const {
|
||||
query: {
|
||||
token
|
||||
} = {}
|
||||
} = req;
|
||||
let entity: LastfmScrobbler | LastfmSource | undefined = scrobbleClients.getByName(state) as (LastfmScrobbler | undefined);
|
||||
if(entity === undefined) {
|
||||
entity = scrobbleSources.getByName(state) as LastfmSource;
|
||||
}
|
||||
try {
|
||||
await entity.api.authenticate(token);
|
||||
await entity.initialize();
|
||||
return res.send('OK');
|
||||
} catch (e) {
|
||||
return res.send(e.message);
|
||||
}
|
||||
} else {
|
||||
// TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here)
|
||||
// but eventually should update all source callbacks to url specific URLS to avoid ambiguity...
|
||||
// wish we could use state param to identify name/source but not all auth strategies and auth provides may provide access to that
|
||||
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
|
||||
const source = scrobbleSources.getByNameAndType(state as string, 'spotify') as SpotifySource;
|
||||
const tokenResult = await source.handleAuthCodeCallback(req.query);
|
||||
let responseContent = 'OK';
|
||||
if (tokenResult === true) {
|
||||
source.poll(scrobbleClients);
|
||||
} else {
|
||||
responseContent = tokenResult;
|
||||
}
|
||||
return res.send(responseContent);
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/health', async function (req, res) {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
} = req.query;
|
||||
|
||||
const [sourcesReady, sourceMessages] = await scrobbleSources.getStatusSummary(type as string|undefined, name as string|undefined);
|
||||
const [clientsReady, clientMessages] = await scrobbleClients.getStatusSummary(type as string|undefined, name as string|undefined);
|
||||
|
||||
|
||||
return res.status((clientsReady && sourcesReady) ? 200 : 500).json({messages: sourceMessages.concat(clientMessages)});
|
||||
});
|
||||
|
||||
app.useAsync(async function (req, res) {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = req.headers["x-forwarded-for"];
|
||||
const ua = req.headers["user-agent"];
|
||||
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.url}`);
|
||||
return res.sendStatus(404);
|
||||
});
|
||||
|
||||
let anyNotReady = false;
|
||||
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
|
||||
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if ((source as SpotifySource).spotifyApi !== undefined) {
|
||||
if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) {
|
||||
anyNotReady = true;
|
||||
} else {
|
||||
(source as SpotifySource).poll(scrobbleClients);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
if(source.initialized === true) {
|
||||
source.poll(scrobbleClients);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (source.poll !== undefined) {
|
||||
source.poll(scrobbleClients);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
logger.info(`Some sources are not ready, open ${localUrl} to continue`);
|
||||
}
|
||||
|
||||
app.set('views', path.resolve(projectDir, 'src/views'));
|
||||
app.set('view engine', 'ejs');
|
||||
logger.info(`Server started at ${localUrl}`);
|
||||
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {GotifyConfig, NtfyConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {Logger} from "winston";
|
||||
import {createLabelledLogger} from "../utils.js";
|
||||
|
||||
export abstract class AbstractWebhookNotifier {
|
||||
|
||||
config: GotifyConfig | NtfyConfig
|
||||
logger: Logger;
|
||||
|
||||
initialized: boolean = false;
|
||||
requiresAuth: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig) {
|
||||
this.config = config;
|
||||
const label = `${type} - ${config.name ?? defaultName}`
|
||||
this.logger = createLabelledLogger(label, label);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
this.logger.verbose('Initialized');
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
notify = async (payload: WebhookPayload) => {
|
||||
if(!this.initialized) {
|
||||
this.logger.debug('Will not use notifier because it is not initialized.');
|
||||
return;
|
||||
}
|
||||
if(this.requiresAuth && !this.authed) {
|
||||
this.logger.debug('Will not use notifier because it is not correctly authenticated.');
|
||||
return;
|
||||
}
|
||||
return await this.doNotify(payload);
|
||||
}
|
||||
abstract doNotify: (payload: WebhookPayload) => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
|
||||
import {GotifyConfig, PrioritiesConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {gotify} from 'gotify';
|
||||
import request from 'superagent';
|
||||
import {HTTPError} from "got";
|
||||
|
||||
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
declare config: GotifyConfig;
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
constructor(defaultName: string, config: GotifyConfig) {
|
||||
super('Gotify', defaultName, config);
|
||||
this.requiresAuth = true;
|
||||
const {
|
||||
info = 5,
|
||||
warn = 7,
|
||||
error = 10
|
||||
} = this.config.priorities || {};
|
||||
|
||||
this.priorities = {
|
||||
info,
|
||||
warn,
|
||||
error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
try {
|
||||
const url = this.config.url;
|
||||
const resp = await request.get(`${url}/version`);
|
||||
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to contact server | Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
this.authed = true;
|
||||
// TODO no easy way to test token is working without also pushing a message -- instead will de-auth if we get the right error message when trying to push for the first time
|
||||
}
|
||||
|
||||
doNotify = async (payload: WebhookPayload) => {
|
||||
try {
|
||||
await gotify({
|
||||
server: this.config.url,
|
||||
app: this.config.token,
|
||||
message: payload.message,
|
||||
title: payload.title,
|
||||
priority: this.priorities[payload.priority]
|
||||
});
|
||||
this.logger.debug(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
if(e instanceof HTTPError && e.response.statusCode === 401) {
|
||||
this.logger.error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
|
||||
this.authed = false;
|
||||
} else {
|
||||
this.logger.error(`Failed to push notification | Error => ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {config, Logger} from "winston";
|
||||
import {createLabelledLogger} from "../utils.js";
|
||||
import {
|
||||
GotifyConfig,
|
||||
NtfyConfig,
|
||||
WebhookConfig,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
|
||||
import {GotifyWebhookNotifier} from "./GotifyWebhookNotifier.js";
|
||||
import {NtfyWebhookNotifier} from "./NtfyWebhookNotifier.js";
|
||||
|
||||
export class Notifiers {
|
||||
|
||||
logger: Logger;
|
||||
|
||||
webhooks: AbstractWebhookNotifier[] = [];
|
||||
|
||||
constructor() {
|
||||
this.logger = createLabelledLogger('Notifiers', 'Notifiers');
|
||||
}
|
||||
|
||||
buildWebhooks = async (webhookConfigs: WebhookConfig[]) => {
|
||||
for (const [i, config] of Object.entries(webhookConfigs)) {
|
||||
let webhook: AbstractWebhookNotifier;
|
||||
const defaultName = `Config ${i}`
|
||||
switch (config.type) {
|
||||
case 'gotify':
|
||||
webhook = new GotifyWebhookNotifier(defaultName, config as GotifyConfig);
|
||||
break;
|
||||
case 'ntfy':
|
||||
webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig);
|
||||
break;
|
||||
default:
|
||||
this.logger.error(`'${config.type}' is not a valid webhook type`);
|
||||
continue;
|
||||
}
|
||||
await webhook.initialize();
|
||||
if(webhook.initialized) {
|
||||
await webhook.testAuth();
|
||||
}
|
||||
this.webhooks.push(webhook);
|
||||
}
|
||||
}
|
||||
|
||||
notify = async (payload: WebhookPayload) => {
|
||||
for (const webhook of this.webhooks) {
|
||||
await webhook.notify(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
|
||||
import {
|
||||
NtfyConfig,
|
||||
PrioritiesConfig,
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import {publish} from 'ntfy';
|
||||
import request from "superagent";
|
||||
|
||||
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
declare config: NtfyConfig;
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
constructor(defaultName: string, config: NtfyConfig) {
|
||||
super('Ntfy', defaultName, config);
|
||||
const {
|
||||
info = 3,
|
||||
warn = 4,
|
||||
error = 5
|
||||
} = this.config.priorities || {};
|
||||
|
||||
this.priorities = {
|
||||
info,
|
||||
warn,
|
||||
error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
try {
|
||||
const url = this.config.url;
|
||||
const resp = await request.get(`${url}/v1/health`);
|
||||
if(resp.body !== undefined && typeof resp.body === 'object') {
|
||||
const {health} = resp.body;
|
||||
if(health === false) {
|
||||
this.logger.error('Found Ntfy server but it responded that it was not ready.')
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Found Ntfy server but expected a response with 'health' in payload. Found => ${resp.body}`);
|
||||
return;
|
||||
}
|
||||
this.logger.info('Initialized. Found Ntfy server');
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to contact Ntfy server | Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
doNotify = async (payload: WebhookPayload) => {
|
||||
try {
|
||||
let authorization = {};
|
||||
if (this.config.username !== undefined) {
|
||||
authorization = {
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
}
|
||||
}
|
||||
await publish({
|
||||
message: payload.message,
|
||||
topic: this.config.topic,
|
||||
title: payload.title,
|
||||
server: this.config.url,
|
||||
priority: this.priorities[payload.priority],
|
||||
...authorization,
|
||||
});
|
||||
this.logger.debug(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to push notification: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const makeSourceCheckMiddle = (sources: any) => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
name,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
type
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
return res.status(404).send('Source name must be defined');
|
||||
}
|
||||
|
||||
const source = sources.getByNameAndType(name, type);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
next();
|
||||
}
|
||||
|
||||
export const makeClientCheckMiddle = (clients: any) => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
name
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
return res.status(404).send('Client name must be defined');
|
||||
}
|
||||
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../utils.js";
|
||||
import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js";
|
||||
import {Logger} from "winston";
|
||||
import {SourceConfig} from "../common/infrastructure/config/source/sources.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export interface RecentlyPlayedOptions {
|
||||
limit?: number
|
||||
formatted?: boolean
|
||||
|
||||
display?: boolean
|
||||
}
|
||||
|
||||
export default abstract class AbstractSource {
|
||||
|
||||
name: string;
|
||||
type: SourceType;
|
||||
identifier: string;
|
||||
|
||||
config: SourceConfig;
|
||||
clients: string[];
|
||||
logger: Logger;
|
||||
notifier: Notifiers;
|
||||
instantiatedAt: Dayjs;
|
||||
initialized: boolean = false;
|
||||
requiresAuth: boolean = false;
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
localUrl: string;
|
||||
|
||||
configDir: string;
|
||||
|
||||
canPoll: boolean = false;
|
||||
polling: boolean = false;
|
||||
pollRetries: number = 0;
|
||||
tracksDiscovered: number = 0;
|
||||
|
||||
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, notifiers: Notifiers) {
|
||||
const {clients = [] } = config;
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.identifier = `Source - ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(this.identifier, this.identifier);
|
||||
this.notifier = notifiers;
|
||||
this.config = config;
|
||||
this.clients = clients;
|
||||
this.instantiatedAt = dayjs();
|
||||
this.localUrl = internal.localUrl;
|
||||
this.configDir = internal.configDir;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
return [];
|
||||
}
|
||||
|
||||
// by default if the track was recently played it is valid
|
||||
// this is useful for sources where the track doesn't have complete information like Subsonic
|
||||
// TODO make this more descriptive? or move it elsewhere
|
||||
recentlyPlayedTrackIsValid = (playObj: PlayObject) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
poll = async (allClients: any) => {
|
||||
await this.startPolling(allClients);
|
||||
}
|
||||
|
||||
startPolling = async (allClients: any) => {
|
||||
if(this.requiresAuth && !this.authed) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'});
|
||||
this.logger.error('Cannot start polling because user interaction is required for authentication');
|
||||
} else {
|
||||
await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because source does not have authentication.', priority: 'error'});
|
||||
this.logger.error('Cannot start polling because source is not authenticated correctly.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// reset poll attempts if already previously run
|
||||
this.pollRetries = 0;
|
||||
|
||||
const {
|
||||
data: {
|
||||
maxPollRetries = 0,
|
||||
retryMultiplier = 1.5,
|
||||
} = {},
|
||||
} = this.config;
|
||||
|
||||
// can't have negative retries!
|
||||
const maxRetries = Math.max(0, maxPollRetries);
|
||||
|
||||
while (this.pollRetries <= maxRetries) {
|
||||
try {
|
||||
await this.doPolling(allClients);
|
||||
} catch (e) {
|
||||
if (this.pollRetries < maxRetries) {
|
||||
const delayFor = (this.pollRetries + 1) * retryMultiplier;
|
||||
this.logger.info(`Poll retries (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
|
||||
await this.notifier.notify({title: `${this.identifier} - Polling Retry`, message: `Encountered error while polling but retries (${this.pollRetries}) are less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'});
|
||||
await sleep((delayFor) * 1000);
|
||||
} else {
|
||||
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
|
||||
await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: `Encountered error while polling and retries (${this.pollRetries}) are equal to max poll retries (${maxRetries}), stopping polling!. | Error: ${e.message}`, priority: 'error'});
|
||||
}
|
||||
this.pollRetries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScrobbleClients} allClients
|
||||
*/
|
||||
doPolling = async (allClients: any) => {
|
||||
if (this.polling === true) {
|
||||
return;
|
||||
}
|
||||
this.logger.info('Polling started');
|
||||
this.notifier.notify({title: `${this.identifier} - Polling Started`, message: 'Polling Started', priority: 'info'});
|
||||
let lastTrackPlayedAt = this.instantiatedAt;
|
||||
let checkCount = 0;
|
||||
let checksOverThreshold = 0;
|
||||
try {
|
||||
this.polling = true;
|
||||
while (true) {
|
||||
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
|
||||
if(this.polling === false) {
|
||||
this.logger.info('Stopped polling due to user input');
|
||||
break;
|
||||
}
|
||||
let playObjs: PlayObject[] = [];
|
||||
this.logger.debug('Refreshing recently played')
|
||||
playObjs = await this.getRecentlyPlayed({formatted: true});
|
||||
checkCount++;
|
||||
let newTracksFound = false;
|
||||
let closeToInterval = false;
|
||||
const now = dayjs();
|
||||
|
||||
const playInfo = playObjs.reduce((acc, playObj) => {
|
||||
if(this.recentlyPlayedTrackIsValid(playObj)) {
|
||||
const {data: {
|
||||
playDate
|
||||
} = {}
|
||||
} = playObj;
|
||||
if (playDate.unix() > lastTrackPlayedAt.unix()) {
|
||||
newTracksFound = true;
|
||||
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
|
||||
|
||||
if (closeToInterval === false) {
|
||||
closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
|
||||
}
|
||||
|
||||
return {
|
||||
plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}],
|
||||
lastTrackPlayedAt: playDate
|
||||
}
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
plays: [...acc.plays, playObj]
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {plays: [], lastTrackPlayedAt});
|
||||
playObjs = playInfo.plays;
|
||||
lastTrackPlayedAt = playInfo.lastTrackPlayedAt;
|
||||
|
||||
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
|
||||
this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
|
||||
await sleep(10 * 1000);
|
||||
}
|
||||
|
||||
if (newTracksFound === false) {
|
||||
if (playObjs.length === 0) {
|
||||
this.logger.debug(`No new tracks found and no tracks returned from API`);
|
||||
} else {
|
||||
this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`);
|
||||
}
|
||||
} else {
|
||||
checkCount = 0;
|
||||
checksOverThreshold = 0;
|
||||
}
|
||||
|
||||
let scrobbleResult = [];
|
||||
|
||||
if(playObjs.length > 0) {
|
||||
// use the source instantiation time or the last track play time to determine if we should refresh clients...
|
||||
// we only need to refresh clients when the source has "newer" information otherwise we're just refreshing clients for no reason
|
||||
scrobbleResult = await allClients.scrobble(playObjs, {
|
||||
checkTime: lastTrackPlayedAt.add(2, 's'),
|
||||
forceRefresh: closeToInterval,
|
||||
scrobbleFrom: this.identifier,
|
||||
scrobbleTo: this.clients
|
||||
});
|
||||
}
|
||||
|
||||
if (scrobbleResult.length > 0) {
|
||||
checkCount = 0;
|
||||
this.tracksDiscovered += scrobbleResult.length;
|
||||
}
|
||||
|
||||
const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config.data;
|
||||
|
||||
let sleepTime = interval;
|
||||
// don't need to do back off calc if interval is 5 minutes or greater since its already pretty light on API calls
|
||||
// and don't want to back off if we just started the app
|
||||
const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's');
|
||||
if (activeThreshold.isBefore(dayjs()) && sleepTime < 300) {
|
||||
checksOverThreshold++;
|
||||
const backoffMultiplier = Math.min(checksOverThreshold, 1000) * 1.5;
|
||||
sleepTime = Math.min(interval * backoffMultiplier, maxSleep);
|
||||
}
|
||||
|
||||
// sleep for interval
|
||||
this.logger.debug(`Sleeping for ${sleepTime}s`);
|
||||
await sleep(sleepTime * 1000);
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Error occurred while polling');
|
||||
this.logger.error(e);
|
||||
this.polling = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import request from 'superagent';
|
||||
import {parseRetryAfterSecsFromObj, readJson, sleep, sortByPlayDate, writeFile} from "../utils.js";
|
||||
import {Strategy as DeezerStrategy} from 'passport-deezer';
|
||||
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
|
||||
import dayjs from "dayjs";
|
||||
import {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default class DeezerSource extends AbstractSource {
|
||||
workingCredsPath;
|
||||
error: any;
|
||||
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
baseUrl = 'https://api.deezer.com';
|
||||
|
||||
declare config: DeezerSourceConfig;
|
||||
|
||||
constructor(name: any, config: DeezerSourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super('deezer', name, config, internal, notifier);
|
||||
const {
|
||||
data: {
|
||||
interval = 60,
|
||||
} = {},
|
||||
} = config;
|
||||
|
||||
if (interval < 15) {
|
||||
this.logger.warn('Interval should be above 30 seconds...😬');
|
||||
}
|
||||
|
||||
this.config.data.interval = interval;
|
||||
|
||||
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, newFromSource = false): PlayObject {
|
||||
const {
|
||||
title: name,
|
||||
artist: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
name: artistName,
|
||||
} = {},
|
||||
duration,
|
||||
timestamp,
|
||||
id,
|
||||
link,
|
||||
album: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
title: albumName,
|
||||
} = {},
|
||||
} = obj;
|
||||
return {
|
||||
data: {
|
||||
artists: [artistName],
|
||||
album: albumName,
|
||||
track: name,
|
||||
duration,
|
||||
playDate: dayjs(timestamp * 1000),
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration,
|
||||
source: 'Deezer',
|
||||
sourceId: id,
|
||||
newFromSource,
|
||||
url: {
|
||||
web: link
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
try {
|
||||
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
this.config.data.accessToken = credFile.accessToken;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current deezer credentials file exists but could not be parsed', { path: this.workingCredsPath });
|
||||
}
|
||||
if(this.config.data.accessToken === undefined) {
|
||||
if(this.config.data.clientId === undefined) {
|
||||
throw new Error('clientId must be defined when accessToken is not present');
|
||||
} else if(this.config.data.clientSecret === undefined) {
|
||||
throw new Error('clientSecret must be defined when accessToken is not present');
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
await this.callApi(request.get(`${this.baseUrl}/user/me`));
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Deezer API');
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const {formatted = false} = options;
|
||||
const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history`));
|
||||
if(formatted) {
|
||||
return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
}
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
callApi = async (req: any, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config.data;
|
||||
|
||||
req.query({
|
||||
access_token: this.config.data.accessToken,
|
||||
output: 'json'
|
||||
});
|
||||
try {
|
||||
const resp = await req;
|
||||
const {
|
||||
body = {},
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
error,
|
||||
} = {}
|
||||
} = resp;
|
||||
if (error !== undefined) {
|
||||
const err = new Error(error.message);
|
||||
// @ts-expect-error TS(2339): Property 'type' does not exist on type 'Error'.
|
||||
err.type = error.type;
|
||||
// @ts-expect-error TS(2339): Property 'code' does not exist on type 'Error'.
|
||||
err.code = error.code;
|
||||
// @ts-expect-error TS(2339): Property 'response' does not exist on type 'Error'... Remove this comment to see the full error message
|
||||
err.response = resp;
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
} 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,
|
||||
body: {
|
||||
"subsonic-response": {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
status: ssStatus,
|
||||
error: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
code,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
message: ssMessage,
|
||||
} = {},
|
||||
} = {},
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
"subsonic-response": ssResp
|
||||
} = {},
|
||||
// @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 => ${ssMessage}` : `API Call failed: ${message}`;
|
||||
const responseMeta = ssResp ?? text;
|
||||
this.logger.error(msg, {status, response: responseMeta});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
generatePassportStrategy = () => {
|
||||
return new DeezerStrategy({
|
||||
clientID: this.config.data.clientId,
|
||||
clientSecret: this.config.data.clientSecret,
|
||||
callbackURL: this.config.data.redirectUri || `${this.localUrl}/deezer/callback`,
|
||||
scope: ['listening_history','offline_access'],
|
||||
}, (accessToken: any, refreshToken: any, profile: any, done: any) => {
|
||||
// return done(null, {
|
||||
// accessToken,
|
||||
// refreshToken,
|
||||
// ...profile,
|
||||
// });
|
||||
this.handleAuthCodeCallback({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
...profile,
|
||||
}).then((r) => {
|
||||
if(r === true) {
|
||||
return done(null, {});
|
||||
}
|
||||
return done(r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleAuthCodeCallback = async (res: any) => {
|
||||
const {error, accessToken, id, displayName} = res;
|
||||
if (error === undefined) {
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
accessToken,
|
||||
id,
|
||||
displayName,
|
||||
}));
|
||||
this.config.data.accessToken = accessToken;
|
||||
this.logger.info('Got token Deezer SDK callback!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.warn('Callback contained an error! User may have denied access?')
|
||||
this.error = error;
|
||||
this.logger.error(error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, parseDurationFromTimestamp} from "../utils.js";
|
||||
import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
|
||||
export default class JellyfinSource extends MemorySource {
|
||||
users;
|
||||
servers;
|
||||
|
||||
declare config: JellySourceConfig;
|
||||
|
||||
constructor(name: any, config: JellySourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super('jellyfin', name, config, internal, notifier);
|
||||
const {data: {users, servers} = {}} = config;
|
||||
|
||||
if (users === undefined || users === null) {
|
||||
this.users = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(users)) {
|
||||
this.users = users.split(',')
|
||||
} else {
|
||||
this.users = users;
|
||||
}
|
||||
this.users = this.users.map((x: any) => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (servers === undefined || servers === null) {
|
||||
this.servers = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(servers)) {
|
||||
this.servers = servers.split(',')
|
||||
} else {
|
||||
this.servers = servers;
|
||||
}
|
||||
this.servers = this.servers.map((x: any) => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (users === undefined && servers === undefined) {
|
||||
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers will be scrobbled.');
|
||||
} else {
|
||||
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, newFromSource = false): PlayObject {
|
||||
const {
|
||||
ServerId,
|
||||
ServerName,
|
||||
ServerVersion,
|
||||
NotificationUsername,
|
||||
UserId,
|
||||
NotificationType,
|
||||
UtcTimestamp,
|
||||
Album,
|
||||
Artist,
|
||||
Name,
|
||||
RunTime,
|
||||
ItemId,
|
||||
ItemType,
|
||||
PlaybackPosition,
|
||||
connectionId,
|
||||
} = obj;
|
||||
|
||||
const dur = parseDurationFromTimestamp(RunTime);
|
||||
|
||||
let server = ServerName;
|
||||
if(server === undefined || server === '') {
|
||||
server = ServerId;
|
||||
}
|
||||
if(server === undefined || server === '') {
|
||||
server = connectionId;
|
||||
}
|
||||
|
||||
let artists = [];
|
||||
if(Artist !== undefined) {
|
||||
artists = [Artist];
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists,
|
||||
album: Album,
|
||||
track: Name,
|
||||
duration: dur !== undefined ? dur.as('seconds') : undefined,
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
event: NotificationType,
|
||||
mediaType: ItemType,
|
||||
sourceId: ItemId,
|
||||
user: NotificationUsername ?? UserId,
|
||||
server,
|
||||
source: 'Jellyfin',
|
||||
newFromSource,
|
||||
playbackPosition: parseDurationFromTimestamp(PlaybackPosition),
|
||||
sourceVersion: ServerVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValidEvent = (playObj: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
mediaType, event, user, server
|
||||
},
|
||||
data: {
|
||||
artists,
|
||||
track,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
if (event !== undefined && !['PlaybackProgress','PlaybackStarted'].includes(event)) {
|
||||
this.logger.debug(`Will not scrobble event because event type is not PlaybackProgress or PlaybackStarted, found event: ${event}`)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaType !== 'Audio') {
|
||||
this.logger.debug(`Will not scrobble event because media type was not 'Audio', found type: ${mediaType}`, {
|
||||
track
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.servers !== undefined && !this.servers.includes(server.toLocaleLowerCase())) {
|
||||
this.logger.warn(`Will not scrobble event because server was not on allowed list, found server: ${server}`, {
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.users !== undefined) {
|
||||
if (user === undefined) {
|
||||
this.logger.warn(`Will not scrobble event because config defined users but payload contained no user info`);
|
||||
return false;
|
||||
} else if (!this.users.includes(user.toLocaleLowerCase())) {
|
||||
this.logger.warn(`Will not scrobble event because author was not an allowed user: ${user}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
return this.statefulRecentlyPlayed;
|
||||
}
|
||||
|
||||
handle = async (playObj: any, allClients: any) => {
|
||||
if (!this.isValidEvent(playObj)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPlays = this.processRecentPlays([playObj]);
|
||||
|
||||
for(const p of newPlays) {
|
||||
this.logger.info(`New Track => ${buildTrackString(p)}`);
|
||||
}
|
||||
|
||||
if(newPlays.length > 0) {
|
||||
const recent = await this.getRecentlyPlayed();
|
||||
const newestPlay = recent[recent.length - 1];
|
||||
try {
|
||||
await allClients.scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate});
|
||||
// only gets hit if we scrobbled ok
|
||||
this.tracksDiscovered++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
import {sortByPlayDate} from "../utils.js";
|
||||
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {UserGetRecentTracksResponse} from "lastfm-node-client";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default class LastfmSource extends AbstractSource {
|
||||
|
||||
api: LastfmApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
declare config: LastfmClientConfig;
|
||||
|
||||
constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super('lastfm', name, config, internal, notifier);
|
||||
this.canPoll = true;
|
||||
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir});
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any): PlayObject {
|
||||
return LastfmApiClient.formatPlayObj(obj);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
|
||||
const {
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
|
||||
return 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(sortByPlayDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default class MemorySource extends AbstractSource {
|
||||
/*
|
||||
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid.
|
||||
* This is necessary for any source that
|
||||
* * doesn't have its own source of truth for "recently played" or
|
||||
* * that does not return "started at" and "duration" timestamps for recent plays or
|
||||
* * where these timestamps don't have enough granularity (IE second accuracy)
|
||||
* such as subsonic and jellyfin */
|
||||
|
||||
statefulRecentlyPlayed = [];
|
||||
candidateRecentlyPlayed = [];
|
||||
|
||||
processRecentPlays = (plays: any) => {
|
||||
|
||||
let newStatefulPlays: any = [];
|
||||
// first format new plays with locked play date
|
||||
const lockedPlays = plays.map((p: any) => {
|
||||
const {data: {playDate, ...restData}, ...rest} = p;
|
||||
return {data: {...restData, playDate: dayjs()}, ...rest};
|
||||
})
|
||||
// if no candidates exist new plays are new candidates
|
||||
if(this.candidateRecentlyPlayed.length === 0) {
|
||||
for(const p of lockedPlays) {
|
||||
this.logger.debug(`No prior candidate recent plays! Adding new locked plays: ${buildTrackString(p, {include: ['sourceId', 'artist', 'track']})}`);
|
||||
}
|
||||
this.candidateRecentlyPlayed = lockedPlays;
|
||||
} else {
|
||||
// otherwise determine new tracks (not found in prior candidates)
|
||||
const newTracks = lockedPlays.filter((x: any) => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
|
||||
if(newTracks.length > 0) {
|
||||
for(const p of newTracks) {
|
||||
this.logger.debug(`New play found that does not match existing candidates will be added: ${buildTrackString(p, {include: ['sourceId', 'artist', 'track']})}`);
|
||||
}
|
||||
}
|
||||
// filter prior candidates based on new recently played
|
||||
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => {
|
||||
const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y));
|
||||
if(!candidateMatchedLocked) {
|
||||
this.logger.debug(`Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['sourceId', 'artist', 'track']})}`);
|
||||
}
|
||||
return candidateMatchedLocked;
|
||||
});
|
||||
// and then combine still playing with new tracks
|
||||
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.concat(newTracks);
|
||||
this.candidateRecentlyPlayed.sort(sortByPlayDate);
|
||||
|
||||
for(const candidate of this.candidateRecentlyPlayed) {
|
||||
const {data: {playDate, track}} = candidate;
|
||||
if(playDate.isBefore(dayjs().subtract(30, 's'))) {
|
||||
// a prior candidate has been playing for more than 30 seconds, time to check statefuls
|
||||
|
||||
const matchingRecent = this.statefulRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
|
||||
let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['sourceId', 'artist', 'track']})}`;
|
||||
if(matchingRecent === undefined) {
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and not matching any prior plays`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
} else {
|
||||
const {data: { playDate, duration }} = candidate;
|
||||
const {data: { playDate: rplayDate }} = matchingRecent;
|
||||
if(!playDate.isSame(rplayDate)) {
|
||||
if(duration !== undefined) {
|
||||
if(playDate.isAfter(rplayDate.add(duration, 's'))) {
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and having a different timestamp than a prior play`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
}
|
||||
} else if(!playObjDataMatch(this.statefulRecentlyPlayed[0], candidate)) {
|
||||
// if most recent stateful play is not this track we'll add it
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.statefulRecentlyPlayed.sort(sortByPlayDate);
|
||||
}
|
||||
return newStatefulPlays;
|
||||
}
|
||||
|
||||
recentlyPlayedTrackIsValid = (playObj: any) => {
|
||||
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, createLabelledLogger} from "../utils.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import formidable from 'formidable';
|
||||
import concatStream from 'concat-stream';
|
||||
import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js";
|
||||
import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default class PlexSource extends AbstractSource {
|
||||
users: string[];
|
||||
libraries: string[];
|
||||
servers: string[];
|
||||
|
||||
declare config: PlexSourceConfig;
|
||||
|
||||
constructor(name: any, config: PlexSourceConfig, internal: InternalConfig, type: SourceType = 'plex', notifier: Notifiers) {
|
||||
super(type, name, config, internal, notifier);
|
||||
const {data: {user = [], libraries = [], servers = []} = {}} = config
|
||||
|
||||
if (!Array.isArray(user)) {
|
||||
this.users = [user];
|
||||
} else {
|
||||
this.users = user;
|
||||
}
|
||||
this.users = this.users.map((x: any) => x.toLocaleLowerCase())
|
||||
|
||||
if (!Array.isArray(libraries)) {
|
||||
this.libraries = [libraries];
|
||||
} else {
|
||||
this.libraries = libraries;
|
||||
}
|
||||
this.libraries = this.libraries.map((x: any) => x.toLocaleLowerCase())
|
||||
|
||||
if (!Array.isArray(servers)) {
|
||||
this.servers = [servers];
|
||||
} else {
|
||||
this.servers = servers;
|
||||
}
|
||||
this.servers = this.servers.map((x: any) => x.toLocaleLowerCase())
|
||||
|
||||
if (this.users.length === 0 && this.libraries.length === 0 && this.servers.length === 0) {
|
||||
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers and libraries will be scrobbled.');
|
||||
} else {
|
||||
this.logger.info(`Initializing with the following filters => Users: ${this.users.length === 0 ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries.length === 0 ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers.length === 0 ? 'N/A' : this.servers.join(', ')}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, newFromSource = false): PlayObject {
|
||||
const {
|
||||
event,
|
||||
Account: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
title: user,
|
||||
} = {},
|
||||
Metadata: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
type,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
title: track,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
parentTitle: album,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
grandparentTitle: artist,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
librarySectionTitle: library
|
||||
} = {},
|
||||
Server: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
title: server
|
||||
} = {},
|
||||
} = obj;
|
||||
return {
|
||||
data: {
|
||||
artists: [artist],
|
||||
album,
|
||||
track,
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
event,
|
||||
mediaType: type,
|
||||
user,
|
||||
library,
|
||||
server,
|
||||
source: 'Plex',
|
||||
newFromSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValidEvent = (playObj: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
mediaType, event, user, library, server
|
||||
},
|
||||
data: {
|
||||
artists,
|
||||
track,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const hint = this.type === 'tautulli' ? ' (Check notification agent json data configuration)' : '';
|
||||
|
||||
if (this.users.length !== 0) {
|
||||
if (user === undefined) {
|
||||
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
|
||||
} else if (!this.users.includes(user.toLocaleLowerCase())) {
|
||||
this.logger.verbose(`Will not scrobble event because author was not an allowed user: ${user}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event !== undefined && event !== 'media.scrobble') {
|
||||
this.logger.verbose(`Will not scrobble event because it is not media.scrobble (${event})`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaType !== 'track') {
|
||||
this.logger.verbose(`Will not scrobble event because media type was not a track (${mediaType})`, {
|
||||
artists,
|
||||
track
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.libraries.length !== 0) {
|
||||
if (library === undefined) {
|
||||
this.logger.warn(`Config defined libraries but payload contained no library info${hint}`);
|
||||
} else if (!this.libraries.includes(library.toLocaleLowerCase())) {
|
||||
this.logger.verbose(`Will not scrobble event because library was not on allowed list: ${library}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.servers.length !== 0) {
|
||||
if (server === undefined) {
|
||||
this.logger.warn(`Config defined server but payload contained no server info${hint}`);
|
||||
} else if (!this.servers.includes(server.toLocaleLowerCase())) {
|
||||
this.logger.verbose(`Will not scrobble event because server was not on allowed list: ${server}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handle = async (playObj: any, allClients: any) => {
|
||||
if (!this.isValidEvent(playObj)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
|
||||
try {
|
||||
await allClients.scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier});
|
||||
// only gets hit if we scrobbled ok
|
||||
this.tracksDiscovered++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const plexRequestMiddle = () => {
|
||||
|
||||
const plexLog = createLabelledLogger('plexReq', 'Plex Request');
|
||||
|
||||
return async (req: any, res: any, next: any) => {
|
||||
|
||||
const form = formidable({
|
||||
allowEmptyFiles: true,
|
||||
multiples: true,
|
||||
// issue with typings https://github.com/node-formidable/formidable/issues/821
|
||||
// @ts-ignore
|
||||
fileWriteStreamHandler: (file: any) => {
|
||||
return concatStream((data: any) => {
|
||||
file.buffer = data;
|
||||
});
|
||||
}
|
||||
});
|
||||
form.on('progress', (received: any, expected: any) => {
|
||||
plexLog.debug(`Received ${received} bytes of expected ${expected}`);
|
||||
});
|
||||
form.on('error', (err: any) => {
|
||||
plexLog.error(err);
|
||||
})
|
||||
form.on('aborted', () => {
|
||||
plexLog.warn('Request aborted')
|
||||
})
|
||||
form.on('end', () => {
|
||||
plexLog.debug('Received end of form data from Plex');
|
||||
});
|
||||
form.on('fileBegin', (formname: any, file: any) => {
|
||||
plexLog.debug(`File Begin: ${formname}`);
|
||||
});
|
||||
form.on('file', (formname: any) => {
|
||||
plexLog.debug(`File Recieved: ${formname}`);
|
||||
});
|
||||
|
||||
|
||||
plexLog.debug('Receiving request from Plex...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
form.parse(req, (err: any, fields: any, files: any) => {
|
||||
if (err) {
|
||||
plexLog.error('Error occurred while parsing formdata');
|
||||
plexLog.error(err);
|
||||
next(err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let validFile = null;
|
||||
for (const namedFile of Object.values(files)) {
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
if (namedFile.mimetype.includes('json')) {
|
||||
validFile = namedFile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validFile === null) {
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
const err = new Error(`No files parsed from formdata had a mimetype that included 'json'. Found files:\n ${Object.entries(files).map(([k, v]) => `${k}: ${v.mimetype}`).join('\n')}`);
|
||||
plexLog.error(err);
|
||||
next(err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadRaw = validFile.buffer.toString();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(payloadRaw);
|
||||
req.payload = payload;
|
||||
next();
|
||||
// @ts-expect-error TS(2794): Expected 1 arguments, but got 0. Did you forget to... Remove this comment to see the full error message
|
||||
resolve();
|
||||
} catch (e) {
|
||||
plexLog.error(`Error occurred while trying to parse Plex file payload to json. Raw text:\n${payloadRaw}`);
|
||||
plexLog.error(e);
|
||||
next(e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {createLabelledLogger, readJson, validateJson} from "../utils.js";
|
||||
import SpotifySource from "./SpotifySource.js";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
import TautulliSource from "./TautulliSource.js";
|
||||
import {SubsonicSource} from "./SubsonicSource.js";
|
||||
import JellyfinSource from "./JellyfinSource.js";
|
||||
import LastfmSource from "./LastfmSource.js";
|
||||
import DeezerSource from "./DeezerSource.js";
|
||||
import {ConfigMeta, InternalConfig, SourceType, sourceTypes} from "../common/infrastructure/Atomic.js";
|
||||
import {configDir as defaultConfigDir} from "../common/index.js";
|
||||
import {Logger} from "winston";
|
||||
import {SourceAIOConfig, SourceConfig} from "../common/infrastructure/config/source/sources.js";
|
||||
import {DeezerData, DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js";
|
||||
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
|
||||
import {JellyData, JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js";
|
||||
import {SubsonicData, SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.js";
|
||||
import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautulli.js";
|
||||
import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js";
|
||||
import {SpotifySourceConfig, SpotifySourceData} from "../common/infrastructure/config/source/spotify.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import {AIOConfig} from "../common/infrastructure/config/aioConfig.js";
|
||||
import * as aioSchema from "../common/schema/aio-source.json";
|
||||
import * as sourceSchema from "../common/schema/source.json";
|
||||
import {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm.js";
|
||||
import YTMusicSource from "./YTMusicSource.js";
|
||||
import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
type ParsedConfig = SourceAIOConfig & ConfigMeta;
|
||||
|
||||
export default class ScrobbleSources {
|
||||
|
||||
sources: AbstractSource[] = [];
|
||||
logger: Logger;
|
||||
configDir: string;
|
||||
localUrl: string;
|
||||
|
||||
constructor(localUrl: string, configDir: string = defaultConfigDir) {
|
||||
this.configDir = configDir;
|
||||
this.localUrl = localUrl;
|
||||
this.logger = createLabelledLogger('sources', 'Sources');
|
||||
}
|
||||
|
||||
getByName = (name: any) => {
|
||||
return this.sources.find(x => x.name === name);
|
||||
}
|
||||
|
||||
getByType = (type: any) => {
|
||||
return this.sources.filter(x => x.type === type);
|
||||
}
|
||||
|
||||
getByNameAndType = (name: string, type: SourceType) => {
|
||||
return this.sources.find(x => x.name === name && x.type === type);
|
||||
}
|
||||
|
||||
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
|
||||
let sources: AbstractSource[]
|
||||
let sourcesReady = true;
|
||||
let messages: string[] = [];
|
||||
|
||||
if(type !== undefined) {
|
||||
sources = this.getByType(type);
|
||||
} else if(name !== undefined) {
|
||||
sources = [this.getByName(name)];
|
||||
} else {
|
||||
sources = this.sources;
|
||||
}
|
||||
|
||||
for(const source of sources) {
|
||||
if(source.requiresAuth && !source.authed) {
|
||||
sourcesReady = false;
|
||||
messages.push(`Source ${source.type} - ${source.name} requires authentication.`);
|
||||
}
|
||||
if(source.canPoll && !source.polling) {
|
||||
sourcesReady = false;
|
||||
messages.push(`Source ${source.type} - ${source.name} is not polling.`);
|
||||
}
|
||||
}
|
||||
|
||||
return [sourcesReady, messages];
|
||||
}
|
||||
|
||||
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = [], notifier: Notifiers) => {
|
||||
let configs: ParsedConfig[] = additionalConfigs;
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
let sourceDefaults = {};
|
||||
if (configFile !== undefined) {
|
||||
const aioConfig = validateJson<AIOConfig>(configFile, aioSchema, this.logger);
|
||||
const {
|
||||
sources: mainConfigSourcesConfigs = [],
|
||||
sourceDefaults: sd = {},
|
||||
} = aioConfig;
|
||||
sourceDefaults = sd;
|
||||
/* const validMainConfigs = mainConfigSourcesConfigs.reduce((acc: any, curr: any, i: any) => {
|
||||
if(curr === null) {
|
||||
this.logger.error(`The source 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 source 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 mainConfigSourcesConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c,
|
||||
name,
|
||||
source: 'config.json',
|
||||
configureAs: 'source' // override user value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let sourceType of sourceTypes) {
|
||||
let defaultConfigureAs = 'source';
|
||||
// env builder for single user mode
|
||||
switch (sourceType) {
|
||||
case 'spotify':
|
||||
const s = {
|
||||
accessToken: process.env.SPOTIFY_ACCESS_TOKEN,
|
||||
clientId: process.env.SPOTIFY_CLIENT_ID,
|
||||
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
|
||||
redirectUri: process.env.SPOTIFY_REDIRECT_URI,
|
||||
refreshToken: process.env.SPOTIFY_REFRESH_TOKEN,
|
||||
};
|
||||
if (!Object.values(s).every(x => x === undefined && x !== null)) {
|
||||
configs.push({
|
||||
type: 'spotify',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: s as SpotifySourceData
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'tautulli':
|
||||
const t = {
|
||||
// support this for now
|
||||
user: process.env.TAUTULLI_USER || process.env.PLEX_USER
|
||||
};
|
||||
if (!Object.values(t).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'tautulli',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: t
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'plex':
|
||||
const p = {
|
||||
user: process.env.PLEX_USER
|
||||
};
|
||||
if (!Object.values(p).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'plex',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: p
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'subsonic':
|
||||
const sub = {
|
||||
user: process.env.SUBSONIC_USER,
|
||||
password: process.env.SUBSONIC_PASSWORD,
|
||||
url: process.env.SUBSONIC_URL,
|
||||
};
|
||||
if (!Object.values(sub).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'subsonic',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: sub as SubsonicData
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'jellyfin':
|
||||
const j: JellyData = {
|
||||
users: process.env.JELLYFIN_USER,
|
||||
servers: process.env.JELLYFIN_SERVER,
|
||||
};
|
||||
if (!Object.values(j).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'jellyfin',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: j
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
|
||||
defaultConfigureAs = 'client';
|
||||
break;
|
||||
case 'deezer':
|
||||
const d = {
|
||||
clientId: process.env.DEEZER_APP_ID,
|
||||
clientSecret: process.env.DEEZER_SECRET_KEY,
|
||||
redirectUri: process.env.DEEZER_REDIRECT_URI,
|
||||
accessToken: process.env.DEEZER_ACCESS_TOKEN,
|
||||
};
|
||||
if (!Object.values(d).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'deezer',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: d as DeezerData
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let rawSourceConfigs;
|
||||
try {
|
||||
rawSourceConfigs = await readJson(`${this.configDir}/${sourceType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
this.logger.error(`${sourceType}.json config file could not be parsed`);
|
||||
continue;
|
||||
}
|
||||
if (rawSourceConfigs !== undefined) {
|
||||
let sourceConfigs: ParsedConfig[] = [];
|
||||
if (Array.isArray(rawSourceConfigs)) {
|
||||
sourceConfigs = rawSourceConfigs;
|
||||
} else if (rawSourceConfigs === null) {
|
||||
this.logger.error(`${sourceType}.json contained no data`);
|
||||
continue;
|
||||
} else if (typeof rawSourceConfigs === 'object') {
|
||||
sourceConfigs = [rawSourceConfigs];
|
||||
} else {
|
||||
this.logger.error(`All top level data from ${sourceType}.json must be an object or an array of objects, will not parse configs from file`);
|
||||
continue;
|
||||
}
|
||||
for (const [i,rawConf] of sourceConfigs.entries()) {
|
||||
try {
|
||||
const validConfig = validateJson<SourceConfig>(rawConf, sourceSchema, this.logger);
|
||||
|
||||
if(sourceType !== 'lastfm' || ((validConfig as LastfmSourceConfig).configureAs === 'source')) {
|
||||
// @ts-ignore
|
||||
const parsedConfig: ParsedConfig = {
|
||||
...rawConf,
|
||||
source: `${sourceType}.json`,
|
||||
type: sourceType
|
||||
}
|
||||
configs.push(parsedConfig);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not valid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible 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) {
|
||||
// @ts-expect-error TS(2339): Property 'source' does not exist on type 'never'.
|
||||
this.logger.error(`Source 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);
|
||||
}, []);*/
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// do a last check that names (within each type) are unique and warn if not, but add anyways
|
||||
const typeGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => {
|
||||
const {type} = curr;
|
||||
const {[type]: t = []} = acc;
|
||||
return {...acc, [type]: [...t, curr]};
|
||||
}, {});
|
||||
// only need to warn if dup names PER TYPE
|
||||
for (const [type, typedConfigs] of Object.entries(typeGroupedConfigs)) {
|
||||
const nameGroupedConfigs = typedConfigs.reduce((acc: any, curr: any) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
for (const [name, namedConfigs] of Object.entries(nameGroupedConfigs)) {
|
||||
let tempNamedConfigs = namedConfigs;
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
const hasDups = namedConfigs.length > 1;
|
||||
if (hasDups) {
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
const sources = namedConfigs.map((c: any) => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.warn(`Source configs have naming conflicts -- the following configs have the same name "${name}":\n\n${sources.join('\n')}\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');
|
||||
}
|
||||
}
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
tempNamedConfigs = tempNamedConfigs.map(({name = 'unnamed', ...x}, i) => ({
|
||||
...x,
|
||||
name: hasDups ? `${name}${i + 1}` : name
|
||||
}));
|
||||
// @ts-expect-error TS(2571): Object is of type 'unknown'.
|
||||
for (const c of tempNamedConfigs) {
|
||||
try {
|
||||
await this.addSource(c, sourceDefaults, notifier);
|
||||
} catch(e) {
|
||||
this.logger.error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSource = async (clientConfig: any, 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 internal: InternalConfig = {
|
||||
localUrl: this.localUrl,
|
||||
configDir: this.configDir
|
||||
};
|
||||
|
||||
const {type, name, data: d = {}} = clientConfig;
|
||||
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
const compositeConfig: SourceConfig = {...clientConfig, data};
|
||||
|
||||
this.logger.debug(`(${name}) Constructing ${type} source`);
|
||||
let newSource;
|
||||
switch (type) {
|
||||
case 'spotify':
|
||||
newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, internal, notifier);
|
||||
break;
|
||||
case 'plex':
|
||||
newSource = await new PlexSource(name, compositeConfig as PlexSourceConfig, internal, 'plex', notifier);
|
||||
break;
|
||||
case 'tautulli':
|
||||
newSource = await new TautulliSource(name, compositeConfig as TautulliSourceConfig, internal, notifier);
|
||||
break;
|
||||
case 'subsonic':
|
||||
newSource = new SubsonicSource(name, compositeConfig as SubSonicSourceConfig, internal, notifier);
|
||||
break;
|
||||
case 'jellyfin':
|
||||
newSource = await new JellyfinSource(name, compositeConfig as JellySourceConfig, internal, notifier);
|
||||
break;
|
||||
case 'lastfm':
|
||||
newSource = await new LastfmSource(name, compositeConfig as LastfmClientConfig, internal, notifier);
|
||||
break;
|
||||
case 'deezer':
|
||||
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, internal, notifier);
|
||||
break;
|
||||
case 'ytmusic':
|
||||
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, internal, notifier);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if(newSource === undefined) {
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Source of type ${type} was not recognized??`);
|
||||
}
|
||||
if(newSource.initialized === false) {
|
||||
this.logger.debug(`(${name}) Attempting ${type} initialization...`);
|
||||
if ((await newSource.initialize()) === false) {
|
||||
this.logger.error(`(${name}) ${type} source failed to initialize. Source needs to be successfully initialized before activity capture can begin.`);
|
||||
return;
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source initialized`);
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source initialized`);
|
||||
}
|
||||
|
||||
if(newSource.requiresAuth && !newSource.authed) {
|
||||
this.logger.debug(`(${name}) Checking ${type} source auth...`);
|
||||
let success;
|
||||
try {
|
||||
success = await newSource.testAuth();
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`(${name}) ${type} source auth failed.`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source auth OK`);
|
||||
}
|
||||
}
|
||||
|
||||
this.sources.push(newSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
readJson,
|
||||
writeFile,
|
||||
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
|
||||
} from "../utils.js";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
|
||||
import {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import PlayHistoryObject = SpotifyApi.PlayHistoryObject;
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
|
||||
const state = 'random';
|
||||
|
||||
export default class SpotifySource extends AbstractSource {
|
||||
|
||||
spotifyApi: SpotifyWebApi;
|
||||
workingCredsPath: string;
|
||||
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
declare config: SpotifySourceConfig;
|
||||
|
||||
constructor(name: any, config: SpotifySourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super('spotify', name, config, internal, notifier);
|
||||
const {
|
||||
data: {
|
||||
interval = 60,
|
||||
} = {}
|
||||
} = config;
|
||||
|
||||
if (interval < 15) {
|
||||
this.logger.warn('Interval should be above 30 seconds...😬');
|
||||
}
|
||||
|
||||
this.config.data.interval = interval;
|
||||
|
||||
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: PlayHistoryObject, newFromSource = false): PlayObject {
|
||||
const {
|
||||
track: {
|
||||
artists = [],
|
||||
name,
|
||||
id,
|
||||
duration_ms,
|
||||
album: {
|
||||
name: albumName,
|
||||
} = {},
|
||||
external_urls: {
|
||||
spotify,
|
||||
} = {}
|
||||
} = {},
|
||||
played_at
|
||||
} = obj;
|
||||
//let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
|
||||
return {
|
||||
data: {
|
||||
artists: artists.map((x: any) => x.name),
|
||||
album: albumName,
|
||||
track: name,
|
||||
duration: duration_ms / 1000,
|
||||
playDate: dayjs(played_at),
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration_ms / 1000,
|
||||
source: 'Spotify',
|
||||
sourceId: id,
|
||||
newFromSource,
|
||||
url: {
|
||||
web: spotify
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildSpotifyApi = async () => {
|
||||
|
||||
let spotifyCreds = {};
|
||||
try {
|
||||
spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false}) as any;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current spotify credentials file exists but could not be parsed', { path: this.workingCredsPath });
|
||||
}
|
||||
|
||||
const {token: accessToken = undefined, refreshToken = undefined} = (spotifyCreds || {}) as any;
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
} = this.config.data || {};
|
||||
|
||||
const rdUri = redirectUri || `${this.localUrl}/callback`;
|
||||
|
||||
const apiConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
|
||||
if (Object.values(apiConfig).every(x => x === undefined)) {
|
||||
this.logger.info('No values found for Spotify configuration, skipping initialization');
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error TS(2339): Property 'redirectUri' does not exist on type '{ c... Remove this comment to see the full error message
|
||||
apiConfig.redirectUri = rdUri;
|
||||
|
||||
const validationErrors = [];
|
||||
|
||||
if (clientId === undefined) {
|
||||
validationErrors.push('clientId must be defined');
|
||||
}
|
||||
if (clientSecret === undefined) {
|
||||
validationErrors.push('clientSecret must be defined');
|
||||
}
|
||||
if (rdUri === undefined) {
|
||||
validationErrors.push('redirectUri must be defined');
|
||||
}
|
||||
|
||||
if (validationErrors.length !== 0) {
|
||||
this.logger.warn(`Configuration was not valid:\*${validationErrors.join('\n')}`);
|
||||
throw new Error('Failed to initialize a Spotify source');
|
||||
}
|
||||
|
||||
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
if(this.spotifyApi === undefined) {
|
||||
await this.buildSpotifyApi();
|
||||
}
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
await this.callApi<ReturnType<typeof this.spotifyApi.getMe>>(((api: any) => api.getMe()));
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Spotify API');
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
createAuthUrl = () => {
|
||||
return this.spotifyApi.createAuthorizeURL(scopes, this.name);
|
||||
}
|
||||
|
||||
handleAuthCodeCallback = async ({
|
||||
error,
|
||||
code
|
||||
}: any) => {
|
||||
if (error === undefined) {
|
||||
const tokenResponse = await this.spotifyApi.authorizationCodeGrant(code);
|
||||
this.spotifyApi.setAccessToken(tokenResponse.body['access_token']);
|
||||
this.spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
token: tokenResponse.body['access_token'],
|
||||
refreshToken: tokenResponse.body['refresh_token']
|
||||
}));
|
||||
this.logger.info('Got token from code grant authorization!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.warn('Callback contained an error! User may have denied access?')
|
||||
this.logger.error(error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
const func = (api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks({
|
||||
limit
|
||||
});
|
||||
const result = await this.callApi<ReturnType<typeof this.spotifyApi.getMyRecentlyPlayedTracks>>(func);
|
||||
return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
}
|
||||
|
||||
callApi = async <T>(func: (api: SpotifyWebApi) => Promise<any>, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 2,
|
||||
} = this.config.data;
|
||||
try {
|
||||
return await func(this.spotifyApi);
|
||||
} catch (e) {
|
||||
if (e.statusCode === 401) {
|
||||
if (this.spotifyApi.getRefreshToken() === undefined) {
|
||||
throw new Error('Access token was not valid and no refresh token was present')
|
||||
}
|
||||
this.logger.debug('Access token was not valid, attempting to refresh');
|
||||
|
||||
const tokenResponse = await this.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 = this.spotifyApi.getRefreshToken(),
|
||||
} = {}
|
||||
} = tokenResponse;
|
||||
this.spotifyApi.setAccessToken(access_token);
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
token: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}));
|
||||
try {
|
||||
return await func(this.spotifyApi);
|
||||
} catch (ee) {
|
||||
this.logger.error('Refreshing access token encountered an error');
|
||||
this.logger.error(ee, {label: 'Spotify'});
|
||||
throw ee;
|
||||
}
|
||||
} else if(maxRequestRetries > retries) {
|
||||
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 this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.error(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`);
|
||||
this.logger.error(e, {label: 'Spotify'});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll = async (allClients: any) => {
|
||||
if (this.spotifyApi === undefined) {
|
||||
this.logger.warn('Cannot poll spotify without valid credentials configuration')
|
||||
return;
|
||||
}
|
||||
await this.startPolling(allClients);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import request from 'superagent';
|
||||
import * as crypto from 'crypto';
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
|
||||
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {RecentlyPlayedOptions} from "./AbstractSource.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
export class SubsonicSource extends MemorySource {
|
||||
|
||||
requiresAuth = true;
|
||||
|
||||
declare config: SubSonicSourceConfig;
|
||||
|
||||
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
// default to quick interval so we can get a decently accurate nowPlaying
|
||||
const {
|
||||
data: {
|
||||
// @ts-ignore
|
||||
interval = 10,
|
||||
// @ts-ignore
|
||||
maxSleep = 30,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
const subsonicConfig = {...config, data: {...restData, internal, maxSleep}};
|
||||
super('subsonic', name, subsonicConfig, internal, notifier);
|
||||
|
||||
const {data: {user, password, url} = {}} = this.config;
|
||||
|
||||
if (user === undefined) {
|
||||
throw new Error(`Cannot setup Subsonic source, 'user' is not defined`);
|
||||
}
|
||||
if (password === undefined) {
|
||||
throw new Error(`Cannot setup Subsonic source, 'password' is not defined`);
|
||||
}
|
||||
if (url === undefined) {
|
||||
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
|
||||
}
|
||||
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, newFromSource = false): PlayObject {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration, // seconds
|
||||
minutesAgo,
|
||||
} = obj;
|
||||
return {
|
||||
data: {
|
||||
artists: [artist],
|
||||
album,
|
||||
track: title,
|
||||
duration,
|
||||
// subsonic doesn't return an exact datetime, only how many whole minutes ago it was played
|
||||
// so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off
|
||||
playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'),
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration,
|
||||
source: 'Subsonic',
|
||||
sourceId: id,
|
||||
newFromSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callApi = async (req: any, retries = 0) => {
|
||||
const {
|
||||
user,
|
||||
password,
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config.data;
|
||||
|
||||
|
||||
const salt = await crypto.randomBytes(10).toString('hex');
|
||||
const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex')
|
||||
req.query({
|
||||
u: user,
|
||||
t: hash,
|
||||
s: salt,
|
||||
v: '1.15.0',
|
||||
c: `multi-scrobbler - ${this.name}`,
|
||||
f: 'json'
|
||||
});
|
||||
try {
|
||||
const resp = await req;
|
||||
const {
|
||||
body: {
|
||||
"subsonic-response": {
|
||||
status,
|
||||
},
|
||||
"subsonic-response": ssResp = {}
|
||||
} = {}
|
||||
} = resp;
|
||||
if (status === 'failed') {
|
||||
const err = new Error('Subsonic API returned an error');
|
||||
// @ts-expect-error TS(2339): Property 'response' does not exist on type 'Error'... Remove this comment to see the full error message
|
||||
err.response = resp;
|
||||
throw err;
|
||||
}
|
||||
return ssResp;
|
||||
} 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,
|
||||
body: {
|
||||
"subsonic-response": {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
status: ssStatus,
|
||||
error: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
code,
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
message: ssMessage,
|
||||
} = {},
|
||||
} = {},
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
"subsonic-response": ssResp
|
||||
} = {},
|
||||
// @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 => ${ssMessage}` : `API Call failed: ${message}`;
|
||||
const responseMeta = ssResp ?? text;
|
||||
this.logger.error(msg, {status, response: responseMeta});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
await request.get(`${url}/`);
|
||||
this.logger.info('Subsonic Connection: ok');
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
if(e.status !== undefined && e.status !== 404) {
|
||||
this.logger.info('Subsonic Connection: ok');
|
||||
// we at least got a response!
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth= async () => {
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
await this.callApi(request.get(`${url}/rest/ping`));
|
||||
this.authed = true;
|
||||
this.logger.info('Subsonic API Status: ok');
|
||||
} catch (e) {
|
||||
this.authed = false;
|
||||
}
|
||||
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const {formatted = false} = options;
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`));
|
||||
const {
|
||||
nowPlaying: {
|
||||
entry = []
|
||||
} = {}
|
||||
} = resp;
|
||||
this.processRecentPlays(entry.map((x: any) => formatted ? SubsonicSource.formatPlayObj(x) : x));
|
||||
return this.statefulRecentlyPlayed;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import dayjs from "dayjs";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautulli.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default class TautulliSource extends PlexSource {
|
||||
|
||||
constructor(name, config, clients) {
|
||||
super(name, config, clients, 'tautulli');
|
||||
declare config: TautulliSourceConfig;
|
||||
constructor(name: any, config: TautulliSourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super(name, config, internal, 'tautulli', notifier);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj, newFromSource = false) {
|
||||
static formatPlayObj(obj: any, newFromSource = false): PlayObject {
|
||||
const {
|
||||
artist_name,
|
||||
track_name,
|
||||
@@ -17,8 +21,10 @@ export default class TautulliSource extends PlexSource {
|
||||
title,
|
||||
library_name,
|
||||
server,
|
||||
version,
|
||||
duration,
|
||||
username,
|
||||
library
|
||||
} = obj;
|
||||
let artists = [artist_name];
|
||||
if (track_artist !== undefined && track_artist !== artist_name) {
|
||||
@@ -34,8 +40,9 @@ export default class TautulliSource extends PlexSource {
|
||||
},
|
||||
meta: {
|
||||
title,
|
||||
library: library_name,
|
||||
library: library_name ?? library,
|
||||
server,
|
||||
sourceVersion: version,
|
||||
mediaType: media_type,
|
||||
user: username,
|
||||
trackLength: duration,
|
||||
@@ -0,0 +1,192 @@
|
||||
import YouTubeMusic from "youtube-music-ts-api";
|
||||
|
||||
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
|
||||
import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
|
||||
import {IYouTubeMusicAuthenticated} from "youtube-music-ts-api/interfaces-primary";
|
||||
import dayjs from "dayjs";
|
||||
import {parseDurationFromTimestamp, playObjDataMatch} from "../utils.js";
|
||||
import {IPlaylistDetail, ITrackDetail} from "youtube-music-ts-api/interfaces-supplementary";
|
||||
import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.js";
|
||||
import {Notifiers} from "../notifier/Notifiers.js";
|
||||
|
||||
export default class YTMusicSource extends AbstractSource {
|
||||
apiInstance?: IYouTubeMusicAuthenticated
|
||||
|
||||
requiresAuth = true;
|
||||
|
||||
declare config: YTMusicSourceConfig
|
||||
|
||||
recentlyPlayed: PlayObject[] = [];
|
||||
|
||||
constructor(name: string, config: YTMusicSourceConfig, internal: InternalConfig, notifier: Notifiers) {
|
||||
super('ytmusic', name, config, internal, notifier);
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: ITrackDetail, newFromSource = false): PlayObject {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
album: albumData,
|
||||
artists: artistsData,
|
||||
duration: durTimestamp, // string timestamp
|
||||
} = obj;
|
||||
|
||||
let artists = undefined,
|
||||
album = undefined,
|
||||
duration = undefined;
|
||||
if(artistsData !== undefined) {
|
||||
artists = artistsData.map(x => x.name) as string[];
|
||||
}
|
||||
if(albumData !== undefined) {
|
||||
album = albumData.name;
|
||||
}
|
||||
if(durTimestamp !== undefined) {
|
||||
const durObj = parseDurationFromTimestamp(durTimestamp);
|
||||
duration = durObj.asSeconds();
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track: title,
|
||||
duration,
|
||||
// if object is new from source then we know we've picked it up AFTER we started polling so it can't be older than 1 minute (default polling interval)
|
||||
playDate: newFromSource ? dayjs().startOf('minute') : undefined,
|
||||
},
|
||||
meta: {
|
||||
trackLength: duration,
|
||||
source: 'YTMusic',
|
||||
sourceId: id,
|
||||
newFromSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentlyPlayedTrackIsValid = (playObj: PlayObject) => {
|
||||
return playObj.meta.newFromSource;
|
||||
}
|
||||
|
||||
api = async (): Promise<IYouTubeMusicAuthenticated> => {
|
||||
if(this.apiInstance !== undefined) {
|
||||
return this.apiInstance;
|
||||
}
|
||||
const ytm = new YouTubeMusic();
|
||||
try {
|
||||
this.apiInstance = await ytm.authenticate(this.config.data.cookie, this.config.data.authUser);
|
||||
} catch (e: any) {
|
||||
this.logger.error('Failed to authenticate', e);
|
||||
throw e;
|
||||
}
|
||||
return this.apiInstance;
|
||||
}
|
||||
|
||||
protected getLibraryHistory = async (): Promise<IPlaylistDetail> => {
|
||||
// internally for this call YT returns a *list* of playlists with decreasing granularity from most recent to least recent like this:
|
||||
// * Today
|
||||
// * Yesterday
|
||||
// * ....
|
||||
// * January 2023
|
||||
//
|
||||
// the playlist returned can therefore change abruptly IE MS started yesterday and new music listened to today -> "today" playlist is cleared
|
||||
try {
|
||||
const playlist = await (await this.api()).getLibraryHistory();
|
||||
return {tracks: [], ...playlist};
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last 20 recently played tracks
|
||||
* */
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const { display = false } = options;
|
||||
|
||||
let playlistDetail: IPlaylistDetail;
|
||||
try {
|
||||
playlistDetail = await this.getLibraryHistory();
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
const plays = playlistDetail.tracks.map((x) => YTMusicSource.formatPlayObj(x, false)).slice(0, 20);
|
||||
if(this.polling === false) {
|
||||
this.recentlyPlayed = plays;
|
||||
} else {
|
||||
let newPlays: PlayObject[] = [];
|
||||
|
||||
// iterate through each play until we find one that matched the "newest" from the recently played
|
||||
for (const [i, value] of plays.entries()) {
|
||||
if (this.recentlyPlayed.length === 0) {
|
||||
// playlist was empty when we started, nothing to compare to so all tracks are new
|
||||
newPlays.push(value);
|
||||
} else if (this.recentlyPlayed.length !== plays.length) { // if there is a difference in list length we need to check for consecutive repeat tracks as well
|
||||
const match = playObjDataMatch(value, this.recentlyPlayed[0]);
|
||||
if (!match) {
|
||||
newPlays.push(value)
|
||||
} else if (match && playObjDataMatch(plays[i + 1], this.recentlyPlayed[0])) { // if it matches but next ALSO matches the current it's a repeat "new"
|
||||
// check if repeated track
|
||||
newPlays.push(value)
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (!playObjDataMatch(value, this.recentlyPlayed[0])) {
|
||||
// if the newest doesn't match a play then the play is new
|
||||
newPlays.push(value);
|
||||
} else {
|
||||
// otherwise we're back known plays
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(newPlays.length > 0) {
|
||||
newPlays = newPlays.map((x) => {
|
||||
return {
|
||||
data: {
|
||||
...x.data,
|
||||
playDate: dayjs().startOf('minute')
|
||||
},
|
||||
meta: {
|
||||
...x.meta,
|
||||
newFromSource: true
|
||||
}
|
||||
}
|
||||
});
|
||||
this.recentlyPlayed = newPlays.concat(this.recentlyPlayed).slice(0, 20);
|
||||
}
|
||||
}
|
||||
|
||||
if(!display) {
|
||||
// used by MS for newely monitored tracks where we know the play date
|
||||
return this.recentlyPlayed.filter(x => x.meta.newFromSource);
|
||||
}
|
||||
// used by UI for returning all results
|
||||
return this.recentlyPlayed;
|
||||
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
await this.getRecentlyPlayed();
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
if(e.message.includes('Status code: 401')) {
|
||||
let hint = 'Verify your cookie and authUser are correct.';
|
||||
if(this.config.data.authUser === undefined) {
|
||||
hint = `${hint} TIP: 'authUser' is not defined your credentials. If you are using Chrome to retrieve credentials from music.youtube.com make sure the value from the 'X-Goog-AuthUser' is used as 'authUser'.`;
|
||||
}
|
||||
this.logger.error(`Authentication failed with the given credentials. ${hint} | Error => ${e.message}`);
|
||||
}
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
poll = async (allClients: any) => {
|
||||
if(this.authed) {
|
||||
this.logger.verbose('Hydrating initial recently played tracks for reference.');
|
||||
await this.getRecentlyPlayed();
|
||||
}
|
||||
await this.startPolling(allClients);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {Logger} from "winston";
|
||||
import {createLabelledLogger, remoteHostIdentifiers, remoteHostStr} from "../../utils.js";
|
||||
import {Request} from "express";
|
||||
import {RemoteIdentityParts} from "../../common/infrastructure/Atomic.js";
|
||||
|
||||
|
||||
export abstract class IngressNotifier {
|
||||
|
||||
identifier: string;
|
||||
logger: Logger;
|
||||
|
||||
remotes: Record<string, RemoteIdentityParts> = {};
|
||||
|
||||
protected constructor(name: string) {
|
||||
this.identifier = name;
|
||||
const label = `Ingress - ${name}`;
|
||||
this.logger = createLabelledLogger(label, label);
|
||||
}
|
||||
|
||||
public trackIngress(req: Request, isRaw: boolean) {
|
||||
const parts = remoteHostIdentifiers(req);
|
||||
const id = `${parts.host}-${parts.proxy ?? ''}`;
|
||||
|
||||
const notifs: string[] = [];
|
||||
|
||||
const newRemote = this.remotes[id] === undefined;
|
||||
if (newRemote) {
|
||||
this.remotes[id] = parts;
|
||||
notifs.push(`Received request from a new remote address: ${remoteHostStr(req)}`);
|
||||
}
|
||||
const requestNotif = this.notifyByRequest(req, isRaw);
|
||||
if (requestNotif !== undefined) {
|
||||
notifs.push(`${!newRemote ? remoteHostStr(req) : ''} ${requestNotif}`);
|
||||
}
|
||||
const [sourceValid, sourceNotif] = this.notifyBySource(req, isRaw);
|
||||
if (sourceNotif !== undefined) {
|
||||
notifs.push(`${!newRemote && requestNotif === undefined ? `${remoteHostStr(req)} ` : ''}${sourceNotif}`);
|
||||
}
|
||||
|
||||
if(notifs.length > 0) {
|
||||
if (requestNotif !== undefined || sourceValid === false) {
|
||||
this.logger.warn(notifs.join(' | '));
|
||||
} else {
|
||||
this.logger.info(notifs.join(' | '))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
return;
|
||||
}
|
||||
|
||||
abstract notifyBySource(req: Request, isRaw: boolean): [boolean, string | undefined];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {IngressNotifier} from "./IngressNotifier.js";
|
||||
import {Request} from "express";
|
||||
import JellyfinSource from "../JellyfinSource.js";
|
||||
import {remoteHostIdentifiers, remoteHostStr} from "../../utils.js";
|
||||
|
||||
export class JellyfinNotifier extends IngressNotifier {
|
||||
|
||||
constructor() {
|
||||
super('Jellyfin');
|
||||
}
|
||||
|
||||
seenServers: Record<string, string> = {};
|
||||
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
|
||||
if(!isRaw) {
|
||||
const parts = remoteHostIdentifiers(req);
|
||||
let serverIdentifier = `${parts.host}-${parts.proxy ?? ''}`;
|
||||
|
||||
const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId: serverIdentifier});
|
||||
|
||||
const warnings = [];
|
||||
|
||||
if(req.body.ServerName === undefined || req.body.ServerName === '') {
|
||||
if(req.body.ServerId !== undefined && req.body.ServerId !== '') {
|
||||
warnings.push(`Webhook payload did not contain ServerName, will use ServerId (${req.body.ServerId}) instead`);
|
||||
serverIdentifier = req.body.ServerId;
|
||||
} else {
|
||||
warnings.push(`Webhook payload did not contain ServerName OR ServerId will use connection ID (${serverIdentifier}) instead`);
|
||||
}
|
||||
} else {
|
||||
serverIdentifier = playObj.meta.server;
|
||||
}
|
||||
|
||||
if(this.seenServers[serverIdentifier] === undefined) {
|
||||
let version = req.body.ServerVersion;
|
||||
if(version === undefined || version === '') {
|
||||
warnings.push('Webhook payload did not contain ServerVersion');
|
||||
version = 'Unknown';
|
||||
}
|
||||
|
||||
if(warnings.length > 0) {
|
||||
this.logger.warn(`${remoteHostStr(req)} There is information missing from the Jellyfin webhook payload. Make sure 'Send all Properties' is checked in webhook: ${warnings.join(' | ')}`);
|
||||
}
|
||||
|
||||
this.seenServers[serverIdentifier] = version;
|
||||
return [true, `Received valid data from server ${serverIdentifier} (Version ${version}) for the first time.`];
|
||||
}
|
||||
}
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
if(req.method !== 'POST') {
|
||||
return `Expected POST request (webhook payload) but received ${req.method}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {IngressNotifier} from "./IngressNotifier.js";
|
||||
import {Request} from "express";
|
||||
import PlexSource from "../PlexSource.js";
|
||||
|
||||
export class PlexNotifier extends IngressNotifier {
|
||||
|
||||
constructor() {
|
||||
super('Plex');
|
||||
}
|
||||
|
||||
seenServers: string[] = [];
|
||||
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
|
||||
if(!isRaw) {
|
||||
const { payload } = req as any;
|
||||
if(payload === undefined) {
|
||||
|
||||
return [false, 'Received a request without any data'];
|
||||
|
||||
}
|
||||
|
||||
const playObj = PlexSource.formatPlayObj(payload, true);
|
||||
|
||||
if(playObj.meta.server === undefined) {
|
||||
return [false, `Payload from Plex did not contain server info! Check Plex logs for any errors.`];
|
||||
}
|
||||
|
||||
if(!this.seenServers.includes(playObj.meta.server)) {
|
||||
this.seenServers.push(playObj.meta.server);
|
||||
return [true, `Received valid data from server ${playObj.meta.server} for the first time.`];
|
||||
}
|
||||
}
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
if(req.method !== 'POST') {
|
||||
return `Expected POST request (webhook payload) but received ${req.method}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {IngressNotifier} from "./IngressNotifier.js";
|
||||
import {Request} from "express";
|
||||
import PlexSource from "../PlexSource.js";
|
||||
import TautulliSource from "../TautulliSource.js";
|
||||
|
||||
export class TautulliNotifier extends IngressNotifier {
|
||||
|
||||
constructor() {
|
||||
super('Tautulli');
|
||||
}
|
||||
|
||||
seenServers: string[] = [];
|
||||
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
|
||||
if(!isRaw) {
|
||||
const playObj = TautulliSource.formatPlayObj(req, true);
|
||||
|
||||
if(!this.seenServers.includes(playObj.meta.server)) {
|
||||
this.seenServers.push(playObj.meta.server);
|
||||
let msg = [`Received valid data from server ${playObj.meta.server} for the first time.`];
|
||||
if(playObj.meta.library === undefined) {
|
||||
msg.push('WARNING: library was not defined in payload. If you want to filter plays by library this must be present in webhook payload.');
|
||||
}
|
||||
return [true, msg.join(' ')];
|
||||
}
|
||||
}
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
|
||||
if(req.method !== 'POST') {
|
||||
return `Expected POST request (webhook payload) but received ${req.method}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
import {promises, constants} from "fs";
|
||||
import dayjs, {Dayjs} from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import winston, {Logger} from "winston";
|
||||
import stringify from 'safe-stable-stringify';
|
||||
import JSON5 from 'json5';
|
||||
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
|
||||
import { Response } from 'superagent';
|
||||
import Ajv, {Schema} from 'ajv';
|
||||
import {
|
||||
lowGranularitySources,
|
||||
PlayObject,
|
||||
RemoteIdentityParts,
|
||||
TrackStringOptions
|
||||
} from "./common/infrastructure/Atomic.js";
|
||||
import {Request} from "express";
|
||||
|
||||
const {format} = winston;
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export async function readJson(this: any, path: any, {logErrors = true, throwOnNotFound = true} = {}) {
|
||||
try {
|
||||
await promises.access(path, constants.R_OK);
|
||||
const data = await promises.readFile(path);
|
||||
return JSON5.parse(data as unknown as string);
|
||||
} catch (e) {
|
||||
const {code} = e;
|
||||
if (code === 'ENOENT') {
|
||||
if (throwOnNotFound) {
|
||||
if (logErrors) {
|
||||
this.logger.warn('No file found at given path', {filePath: path});
|
||||
}
|
||||
throw e;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (logErrors) {
|
||||
this.logger.warn(`Encountered error while parsing file`, {filePath: path});
|
||||
this.logger.error(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readText(path: any) {
|
||||
await promises.access(path, constants.R_OK);
|
||||
const data = await promises.readFile(path);
|
||||
return data.toString();
|
||||
|
||||
// return new Promise((resolve, reject) => {
|
||||
// fs.readFile(path, 'utf8', function (err, data) {
|
||||
// if (err) {
|
||||
// reject(err);
|
||||
// }
|
||||
// resolve(JSON.parse(data));
|
||||
// });
|
||||
// });
|
||||
}
|
||||
|
||||
export async function writeFile(path: any, text: any) {
|
||||
// await promises.access(path, constants.W_OK | constants.O_CREAT);
|
||||
await promises.writeFile(path, text, 'utf8');
|
||||
|
||||
// return new Promise((resolve, reject) => {
|
||||
// fs.readFile(path, 'utf8', function (err, data) {
|
||||
// if (err) {
|
||||
// reject(err);
|
||||
// }
|
||||
// resolve(JSON.parse(data));
|
||||
// });
|
||||
// });
|
||||
}
|
||||
|
||||
|
||||
export function sleep(ms: any) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const longestString = (strings: any) => strings.reduce((acc: any, curr: any) => curr.length > acc ? curr.length : acc, 0);
|
||||
export const truncateStringArrToLength = (length: any, truncStr = '...') => {
|
||||
const truncater = truncateStringToLength(length, truncStr);
|
||||
return (strings: any) => strings.map(truncater);
|
||||
}
|
||||
export const truncateStringToLength = (length: any, truncStr = '...') => (str: any) => str.length > length ? `${str.slice(0, length)}${truncStr}` : str;
|
||||
|
||||
const defaultTransformer = (input: any) => input;
|
||||
|
||||
export const buildTrackString = (playObj: PlayObject, options: TrackStringOptions = {}) => {
|
||||
const {
|
||||
include = ['time', 'artist', 'track'],
|
||||
transformers: {
|
||||
artists: artistsFunc = (a: string[]) => a.join(' / '),
|
||||
track: trackFunc = defaultTransformer,
|
||||
time: timeFunc = (t: Dayjs | undefined) => t === undefined ? 'N/A' : t.local().format(),
|
||||
timeFromNow = (t: Dayjs | undefined) => t === undefined ? undefined : t.local().fromNow(),
|
||||
} = {}
|
||||
} = options;
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
sourceId
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
const strParts = [];
|
||||
if(include.includes('sourceId') && sourceId !== undefined) {
|
||||
strParts.push(`(${sourceId})`);
|
||||
}
|
||||
if(include.includes('artist')) {
|
||||
strParts.push(`${artistsFunc(artists)}`)
|
||||
}
|
||||
if(include.includes('track')) {
|
||||
if(strParts.length > 0) {
|
||||
strParts.push(`- ${trackFunc(track)}`);
|
||||
} else {
|
||||
strParts.push(`${trackFunc(track)}`);
|
||||
}
|
||||
}
|
||||
if (include.includes('time')) {
|
||||
strParts.push(`@ ${timeFunc(playDate)}`);
|
||||
}
|
||||
if (include.includes('timeFromNow')) {
|
||||
const tfn = timeFromNow(playDate);
|
||||
if(tfn !== undefined) {
|
||||
strParts.push(`(${tfn})`)
|
||||
}
|
||||
|
||||
}
|
||||
return strParts.join(' ');
|
||||
}
|
||||
|
||||
// sorts playObj formatted objects by playDate in ascending (oldest first) order
|
||||
export const sortByPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1;
|
||||
|
||||
const s = splat();
|
||||
const SPLAT = Symbol.for('splat')
|
||||
const errorsFormat = errors({stack: true});
|
||||
const CWD = process.cwd();
|
||||
|
||||
let longestLabel = 3;
|
||||
export const defaultFormat = printf(({level, message, label = 'App', timestamp, [SPLAT]: splatObj, stack, ...rest}) => {
|
||||
let stringifyValue = splatObj !== undefined ? stringify(splatObj) : '';
|
||||
if (label.length > longestLabel) {
|
||||
longestLabel = label.length;
|
||||
}
|
||||
let msg = message;
|
||||
let stackMsg = '';
|
||||
if (stack !== undefined) {
|
||||
const stackArr = stack.split('\n');
|
||||
msg = stackArr[0];
|
||||
const cleanedStack = stackArr
|
||||
.slice(1) // don't need actual error message since we are showing it as msg
|
||||
.map((x: any) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
|
||||
.join('\n'); // rejoin with newline to preserve formatting
|
||||
stackMsg = `\n${cleanedStack}`;
|
||||
}
|
||||
|
||||
return `${timestamp} ${level.padEnd(7)}: [${label.padEnd(longestLabel)}] ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
export const labelledFormat = (labelName = 'App') => {
|
||||
const l = label({label: labelName, message: false});
|
||||
return combine(
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
l,
|
||||
s,
|
||||
errorsFormat,
|
||||
defaultFormat,
|
||||
);
|
||||
}
|
||||
|
||||
export const createLabelledLogger = (name = 'default', label = 'App'): Logger => {
|
||||
if (winston.loggers.has(name)) {
|
||||
return winston.loggers.get(name);
|
||||
}
|
||||
const def = winston.loggers.get('default');
|
||||
winston.loggers.add(name, {
|
||||
transports: def.transports,
|
||||
level: def.level,
|
||||
format: labelledFormat(label)
|
||||
});
|
||||
return winston.loggers.get(name);
|
||||
}
|
||||
|
||||
export const setIntersection = (setA: any, setB: any) => {
|
||||
let _intersection = new Set()
|
||||
for (let elem of setB) {
|
||||
if (setA.has(elem)) {
|
||||
_intersection.add(elem)
|
||||
}
|
||||
}
|
||||
return _intersection
|
||||
}
|
||||
|
||||
export const returnDuplicateStrings = (arr: any) => {
|
||||
const alreadySeen: any = [];
|
||||
const dupes: any = [];
|
||||
|
||||
arr.forEach((str: any) => alreadySeen[str] ? dupes.push(str) : alreadySeen[str] = true);
|
||||
return dupes;
|
||||
}
|
||||
|
||||
export const capitalize = (str: any) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
/**
|
||||
* Check if two play objects are the same by comparing non time-related data using most-to-least specific/confidence
|
||||
*
|
||||
* Checks sources and source ID's (unique identifiers) first then
|
||||
* Checks track, album, and artists in that order
|
||||
* */
|
||||
export const playObjDataMatch = (a: PlayObject, b: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
artists: aArtists = [],
|
||||
album: aAlbum,
|
||||
track: aTrack,
|
||||
} = {},
|
||||
meta: {
|
||||
source: aSource,
|
||||
sourceId: aSourceId,
|
||||
} = {},
|
||||
} = a;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists: bArtists = [],
|
||||
album: bAlbum,
|
||||
track: bTrack,
|
||||
} = {},
|
||||
meta: {
|
||||
source: bSource,
|
||||
sourceId: bSourceId,
|
||||
} = {},
|
||||
} = b;
|
||||
|
||||
// if sources are the same and both plays have source ids then we can just compare by id
|
||||
if(aSource === bSource && aSourceId !== undefined && bSourceId !== undefined) {
|
||||
if(aSourceId !== bSourceId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (aTrack !== bTrack) {
|
||||
return false;
|
||||
}
|
||||
if (aAlbum !== bAlbum) {
|
||||
return false;
|
||||
}
|
||||
if (aArtists.length !== bArtists.length) {
|
||||
return false;
|
||||
}
|
||||
// check if every artist from either playObj matches (one way or another) with the artists from the other play obj
|
||||
if (!aArtists.every((x: any) => bArtists.includes(x)) && bArtists.every((x: any) => aArtists.includes(x))) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const parseRetryAfterSecsFromObj = (err: any) => {
|
||||
|
||||
let raVal;
|
||||
|
||||
if (err instanceof TimeoutError) {
|
||||
return undefined;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (err instanceof WebapiError || err instanceof Response) {
|
||||
const {headers = {}} = err;
|
||||
raVal = headers['retry-after']
|
||||
}
|
||||
// if (err instanceof Response) {
|
||||
// const {headers = {}} = err;
|
||||
// raVal = headers['retry-after']
|
||||
// }
|
||||
const {
|
||||
response: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
headers, // returned in superagent error
|
||||
} = {},
|
||||
retryAfter: ra // possible custom property we have set
|
||||
} = err;
|
||||
|
||||
if (ra !== undefined) {
|
||||
raVal = ra;
|
||||
} else if (headers !== null && typeof headers === 'object') {
|
||||
raVal = headers['retry-after'];
|
||||
}
|
||||
|
||||
if (raVal === undefined || raVal === null) {
|
||||
return raVal;
|
||||
}
|
||||
|
||||
// first try to parse as float
|
||||
let retryAfter = Number.parseFloat(raVal);
|
||||
if (!isNaN(retryAfter)) {
|
||||
return retryAfter; // got a number!
|
||||
}
|
||||
// try to parse as date
|
||||
// @ts-ignore
|
||||
retryAfter = dayjs(retryAfter);
|
||||
if (!dayjs.isDayjs(retryAfter)) {
|
||||
return undefined; // could not parse string if not in ISO 8601 format
|
||||
}
|
||||
// otherwise we got a date! now get the difference the specified retry-after date and now in seconds
|
||||
const diff = retryAfter.diff(dayjs(), 'second');
|
||||
|
||||
if (diff <= 0) {
|
||||
// if diff is in the past returned undefined as its irrelevant now
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
export const spreadDelay = (retries: any, multiplier: any) => {
|
||||
if(retries === 0) {
|
||||
return [];
|
||||
}
|
||||
let r;
|
||||
let s = [];
|
||||
for(r = 0; r < retries; r++) {
|
||||
s.push(((r+1) * multiplier) * 1000);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export const removeUndefinedKeys = <T extends Record<string, any>>(obj: T): T | undefined => {
|
||||
let newObj: any = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if(Array.isArray(obj[key])) {
|
||||
newObj[key] = obj[key];
|
||||
} else if (obj[key] === Object(obj[key])) {
|
||||
newObj[key] = removeUndefinedKeys(obj[key]);
|
||||
} else if (obj[key] !== undefined) {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
});
|
||||
if(Object.keys(newObj).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
Object.keys(newObj).forEach(key => {
|
||||
if(newObj[key] === undefined || (null !== newObj[key] && typeof newObj[key] === 'object' && Object.keys(newObj[key]).length === 0)) {
|
||||
delete newObj[key]
|
||||
}
|
||||
});
|
||||
//Object.keys(newObj).forEach(key => newObj[key] === undefined || newObj[key] && delete newObj[key])
|
||||
return newObj;
|
||||
}
|
||||
|
||||
export const parseDurationFromTimestamp = (timestamp: any) => {
|
||||
if (timestamp === null || timestamp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!(typeof timestamp === 'string')) {
|
||||
throw new Error('Timestamp must be a string');
|
||||
}
|
||||
if (timestamp.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsedRuntime = timestamp.split(':');
|
||||
let hours = '0',
|
||||
minutes = '0',
|
||||
seconds = '0',
|
||||
milli = '0';
|
||||
|
||||
switch (parsedRuntime.length) {
|
||||
case 3:
|
||||
hours = parsedRuntime[0];
|
||||
minutes = parsedRuntime[1];
|
||||
seconds = parsedRuntime[2];
|
||||
break;
|
||||
case 2:
|
||||
minutes = parsedRuntime[0];
|
||||
seconds = parsedRuntime[1];
|
||||
break;
|
||||
case 1:
|
||||
seconds = parsedRuntime[0];
|
||||
}
|
||||
const splitSec = seconds.split('.');
|
||||
if (splitSec.length > 1) {
|
||||
seconds = splitSec[0];
|
||||
milli = splitSec[1];
|
||||
}
|
||||
return dayjs.duration({
|
||||
hours: Number.parseInt(hours),
|
||||
minutes: Number.parseInt(minutes),
|
||||
seconds: Number.parseInt(seconds),
|
||||
milliseconds: Number.parseInt(milli)
|
||||
});
|
||||
}
|
||||
|
||||
export const createAjvFactory = (logger: Logger): Ajv => {
|
||||
const validator = new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true});
|
||||
// https://ajv.js.org/strict-mode.html#unknown-keywords
|
||||
validator.addKeyword('deprecationMessage');
|
||||
return validator;
|
||||
}
|
||||
|
||||
export const validateJson = <T>(config: object, schema: Schema, logger: Logger): T => {
|
||||
const ajv = createAjvFactory(logger);
|
||||
const valid = ajv.validate(schema, config);
|
||||
if (valid) {
|
||||
return config as unknown as T;
|
||||
} else {
|
||||
logger.error('Json config was not valid. Please use schema to check validity.', {leaf: 'Config'});
|
||||
if (Array.isArray(ajv.errors)) {
|
||||
for (const err of ajv.errors) {
|
||||
let parts = [
|
||||
`At: ${err.dataPath}`,
|
||||
];
|
||||
let data;
|
||||
if (typeof err.data === 'string') {
|
||||
data = err.data;
|
||||
} else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) {
|
||||
data = `Object named '${(err.data as any).name}'`;
|
||||
}
|
||||
if (data !== undefined) {
|
||||
parts.push(`Data: ${data}`);
|
||||
}
|
||||
let suffix = '';
|
||||
// @ts-ignore
|
||||
if (err.params.allowedValues !== undefined) {
|
||||
// @ts-ignore
|
||||
suffix = err.params.allowedValues.join(', ');
|
||||
suffix = ` [${suffix}]`;
|
||||
}
|
||||
parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`);
|
||||
|
||||
// if we have a reference in the description parse it out so we can log it here for context
|
||||
if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) {
|
||||
const desc = err.parentSchema.description as string;
|
||||
const seeIndex = desc.indexOf('[See]');
|
||||
if (seeIndex !== -1) {
|
||||
let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex);
|
||||
if (newLineIndex === -1) {
|
||||
newLineIndex = undefined;
|
||||
}
|
||||
const seeFragment = desc.slice(seeIndex + 5, newLineIndex);
|
||||
parts.push(`See:${seeFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Schema Error:\r\n${parts.join('\r\n')}`, {leaf: 'Config'});
|
||||
}
|
||||
}
|
||||
throw new Error('Config schema validity failure');
|
||||
}
|
||||
}
|
||||
|
||||
export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => {
|
||||
const remote = req.connection.remoteAddress;
|
||||
const proxyRemote = Array.isArray(req.headers["x-forwarded-for"]) ? req.headers["x-forwarded-for"][0] : req.headers["x-forwarded-for"];
|
||||
const ua = req.headers["user-agent"];
|
||||
|
||||
return {host: remote, proxy: proxyRemote, agent: ua};
|
||||
}
|
||||
|
||||
export const remoteHostStr = (req: Request): string => {
|
||||
const {host, proxy, agent} = remoteHostIdentifiers(req);
|
||||
|
||||
return `${host}${proxy !== undefined ? ` (${proxy})` : ''}${agent !== undefined ? ` (UA: ${agent})` : ''}`;
|
||||
}
|
||||
|
||||
export const closePlayDate = (existingPlay: PlayObject, newPlay: PlayObject, diffThreshold?: number): boolean => {
|
||||
|
||||
const {
|
||||
meta:{
|
||||
source,
|
||||
},
|
||||
data: {
|
||||
playDate: existingPlayDate,
|
||||
duration: existingDuration,
|
||||
}
|
||||
} = existingPlay;
|
||||
|
||||
const {
|
||||
data: {
|
||||
playDate: newPlayDate,
|
||||
duration: newDuration,
|
||||
}
|
||||
} = newPlay;
|
||||
|
||||
// cant compare!
|
||||
if(existingPlayDate === undefined || newPlayDate === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let playDiffThreshold = diffThreshold;
|
||||
if(playDiffThreshold === undefined) {
|
||||
playDiffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10;
|
||||
}
|
||||
let closeTime = false;
|
||||
// check if existing play time is same as new play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(existingPlayDate.unix() - newPlayDate.unix());
|
||||
let scrobblePlayStartDiff;
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
closeTime = true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
|
||||
if (closeTime === false && newDuration !== undefined) {
|
||||
scrobblePlayStartDiff = Math.abs(existingPlayDate.unix() - (newPlayDate.unix() - newDuration));
|
||||
if (scrobblePlayStartDiff <= playDiffThreshold) {
|
||||
closeTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
return closeTime;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
|
||||
<style>
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<title>Multi Scrobbler - Status</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="flex items-center flex-grow no-underline">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
|
||||
<span class="px-4 break-normal">
|
||||
Multi Scrobbler
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<h3>Recently Played (<%= name %>)</h3>
|
||||
</div>
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<% if (sourceType === 'spotify') { %>
|
||||
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
|
||||
<% } %>
|
||||
<ul>
|
||||
<% plays.forEach(function (play){ %>
|
||||
<li><%-play%></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let toggleSwitch = document.querySelector('#toggle-switch');
|
||||
toggleSwitch.addEventListener('change', (event) => {
|
||||
document.body.classList.toggle('dark')
|
||||
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
|
||||
});
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
if (localStorage.getItem('ms-dark') === 'yes') {
|
||||
document.body.classList.add('dark')
|
||||
toggleSwitch.checked = true
|
||||
localStorage.setItem('ms-dark', 'yes')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user