Compare commits

...
136 Commits
Author SHA1 Message Date
FoxxMD 7a0a23b7fd Merge branch 'develop' 2023-03-10 15:12:53 -05:00
FoxxMD 1207a5eec1 docs: Update wording 2023-03-10 14:58:27 -05:00
FoxxMD 48a0ecad41 feat: Implement Listenbrainz as a client #16 2023-03-10 14:38:07 -05:00
FoxxMD f792feeb6c fix: re-add accidentally delete lastfm docs 2023-03-10 14:28:50 -05:00
FoxxMD d6b682a23a fix: Do not prematurely return no existing scrobble when no recent scrobbles exist
don't want to assume we won't find an existing just because there are no scrobbles from the client itself -- we may have found it from recent recorded scrobbles sent
2023-03-10 14:26:42 -05:00
FoxxMD d77d547c15 fix: fix always adding plays to newly discovered 2023-03-10 14:25:05 -05:00
FoxxMD 0dc828fa85 fix: time frame should be valid if there were no recent scrobbles retrieved 2023-03-10 14:08:48 -05:00
FoxxMD bd00a5f414 feat: Improve error and json logging
* Properly handle ErrorWithCause stacks
* Output json when log meta exists
2023-03-10 12:41:07 -05:00
FoxxMD f587ad4bbc feat: Use fixed length array to store scrobbled plays
May reduce memory usage as we don't store all scrobbles ever
2023-03-10 11:58:57 -05:00
FoxxMD b8f2b1e3f8 feat: Increase number of recent scrobbles to fetch 2023-03-10 11:38:24 -05:00
FoxxMD bf3b81f1cd feat(listenbrainz): Add timeout for listens response 2023-03-10 11:34:41 -05:00
FoxxMD 9375a6fce4 feat: Improve listenbrainz scrobbler source of truth behavior
Use updated listenbrainz api methods and scrobbled play method
2023-03-10 11:24:15 -05:00
FoxxMD ca148f490e refactor: Make scrobbled data transform responsibility of concrete scrobbler class 2023-03-10 11:23:15 -05:00
FoxxMD 9ab0468913 feat: Improve listenbrainz api
* The response track data is different than submission data so make sure we are accurately parsing that data to PlayObjects
* Add separate method for getting listens as recently played (play objects) and refactor getUserListens to return raw payload
2023-03-10 11:22:21 -05:00
FoxxMD 99a73a80db feat: Implement Listenbrainz source 2023-03-10 09:54:20 -05:00
FoxxMD 80ba6e6c79 remove commented out code 2023-03-09 14:33:16 -05:00
FoxxMD 7a4f0f0d3f fix: Fix mopidy response handling when no track is being played 2023-03-09 13:12:31 -05:00
FoxxMD a9a6c6ae15 feat: Improve scrobble client debug logging during scrobble loop
Use client logger for cleaner labels
2023-03-09 13:09:26 -05:00
FoxxMD c5190fc2a6 feat: Improve logging
* use child loggers instead of creating new loggers for each part of the app
  * cleans up and automates nested labels in log output
  * fixes "event emitters may be leaky" node warning
* add ability to specify log levels per output type (console, ui, file) using aioconfig
* improve UI logging experience
  * use more color-blind friendly colors (borrowed from context-mod)
  * add verbose log level output
  * increase log buffer and store all logs, then filter at render time, instead of discarding based on min log level so that we always have logs to display
  * fix level selection in ui to work for all levels
2023-03-09 13:02:46 -05:00
FoxxMD 93b543e1a4 Fix mopidy json schema url 2023-03-08 15:21:10 -05:00
FoxxMD 4d77ca9c11 Add missing mopidy example 2023-03-08 15:18:24 -05:00
FoxxMD 03cf3d1e3c feat: Implement Mopidy source #24 2023-03-08 15:16:37 -05:00
FoxxMD d2134f3467 fix: fix reversing referenced array 2023-03-08 09:57:34 -05:00
FoxxMD 97fe411ac0 feat: improved last activity date tracking 2023-03-07 14:22:27 -05:00
FoxxMD ff953b4535 fix: oops left in debug statement 2023-03-07 12:31:27 -05:00
FoxxMD b68fb9a9f9 fix(youtube): Fix bad index when checking for repeat tracks 2023-03-07 12:20:02 -05:00
FoxxMD ad1895d6e1 fix: Add fallback comparison when sorting plays by playDate 2023-03-07 12:19:29 -05:00
FoxxMD 51b4ea3b16 Refactor: Consolidate formatPlayObj options 2023-03-07 11:51:36 -05:00
FoxxMD 3d86a38a2e Refactor: Create a Source of Truth for track discover by sources and consolidate/simplify scrobble behavior
* Add a 'recentDiscoveredPlays' class property for abstract source
  * recentDiscoveredPlays is platform aware and configured based on source 'multiPlatform' class property
* Consolidate scrobble behavior into abstract class
  * Check for existing discovered track before scrobbling
  * Add to discovered if not existing and increment discovered tracks counter
* Decouple sources from clients by using event emitter when a newly discovered track should be scrobbled
* Use discovered tracks for UI display instead of api data
2023-03-07 11:27:25 -05:00
FoxxMD 605aad1df3 Refactor: Consolidate scrobble client existing play check into abstract client
* Move main logic into class methods that can be overridden
* Move title, artist, and date checks into class methods that can be overridden
2023-03-07 10:36:05 -05:00
FoxxMD 3d2e3534ff Refactor: Move play date comparison into util 2023-03-07 10:34:20 -05:00
FoxxMD 2a65ffe6c4 Merge branch 'statefulSOT' into experimental 2023-03-06 13:20:37 -05:00
FoxxMD e3e3e03249 Merge branch 'spotifyConnect' into experimental
# Conflicts:
#	src/sources/AbstractSource.ts
#	src/sources/SpotifySource.ts
#	src/sources/SubsonicSource.ts
2023-03-06 13:20:30 -05:00
FoxxMD 6bd8642c91 Initial pass at IOC 2023-03-06 13:13:25 -05:00
FoxxMD 40ae842f82 Add fixed-size-list
To be used for storing a fixed length array of scrobbled plays for clients and a discovered tracks for sources
2023-03-06 11:12:11 -05:00
FoxxMD a2ab1aa49e feat: Add polling options to config schema and make polling more aggressive
Since spotify now uses currently playing with memory we need to poll more aggressively to make sure we catch tracks as they are played. Additionally need to decrease max polling interval so as not to miss activity.
2023-03-03 12:32:16 -05:00
FoxxMD 6d3cbf32df feat(spotify): Refactor spotify Source as Memory Source #51
* Use recently-played to scrobble backlogged tracks
* Use currently-playing or current-state to implement spotify as a memory source in order to track plays made from spotify connect devices
2023-03-02 15:29:31 -05:00
FoxxMD 7a8138773c feat(spotify): Implement currently playing retrieval 2023-03-02 13:38:56 -05:00
FoxxMD 5ae79deca4 feat: Implement MPRIS Source 2023-03-02 12:57:29 -05:00
FoxxMD 285f593a0f Add dbus-next dependency 2023-03-02 10:29:46 -05:00
FoxxMD e0ae621b62 feat: Implement more context awareness for memory sources to handle multi-platform/device plays
* Track candidate plays separately based on device/platform/user metadata
* Perform additional check for playback progress if metadata exists

Fixes #67
2023-02-28 12:34:09 -05:00
FoxxMD 259d4dabc8 feat: Parse device id metadata from sources to be used for multi-player awareness #67 2023-02-28 11:12:15 -05:00
FoxxMD f7b03d9622 refactor: Rename sourceId to trackId to be clearer in what it actually is 2023-02-28 10:14:06 -05:00
FoxxMD bd188a8ca7 refactor: Remove unused trackLength property 2023-02-28 09:58:56 -05:00
FoxxMD 28910a9332 Merge branch 'develop' 2023-02-27 11:13:28 -05:00
FoxxMD ee38252e1a docs: Add webhook and healthcheck endpoint documentation 2023-02-27 10:58:39 -05:00
FoxxMD 6f3595db31 feat: Add initialization and auth tests for notifiers 2023-02-27 10:58:31 -05:00
FoxxMD c5c0cd9cd2 refactor: Improve title and messages for webhook notifications 2023-02-27 10:09:25 -05:00
FoxxMD f5da32d233 fix: YT Music error throwing 2023-02-27 09:35:44 -05:00
FoxxMD 315869dafe fix: Update auth check when starting polling
Use general requiresAuth instead of only requiresAuthInteraction
2023-02-27 09:35:29 -05:00
FoxxMD 330802e29b fix: Default import for gotify library 2023-02-27 09:34:52 -05:00
FoxxMD 3d5e988ef0 Suppress error 2023-02-24 15:31:43 -05:00
FoxxMD 9a2b4b3d1e fix: Maybe fix for missing lastfm credentials configdir base 2023-02-24 15:27:16 -05:00
FoxxMD 4a1ebcdbc6 fix: Fix webhook missing initializer value 2023-02-24 13:57:08 -05:00
FoxxMD bc66fdb2b6 fix: Stop api calls if authentication failed 2023-02-24 13:56:52 -05:00
FoxxMD 6c2b0557c6 feat: Implement health endpoint #66
* HTTP Status as primary indicator with messages in json response
* No parameters in request aggregates all client/source statuses. Otherwise can use 'type' or 'name' parameters to restrict client/sources to aggregate
2023-02-24 13:51:59 -05:00
FoxxMD c24dc41811 feat: Implement webhook-based status notifications #66
* Implemented notifications for Gotify and Ntfy
* Notifications pushed on: polling started, polling retry, polling stopped on error, scrobble client scrobble failure
2023-02-24 13:19:03 -05:00
FoxxMD 0a41d32084 fix: Fix config deconstruct for polling data 2023-02-24 11:13:58 -05:00
FoxxMD b7aa087b74 feat: Add debug logging of jellyfin webhook payload with docs 2023-02-24 11:04:47 -05:00
FoxxMD 1f511d964d feat: Improve jellyfin logging
* Refactor initial connection info to be more verbose and only log once
* Refactor valid event check logging order of operations and make logging more verbose
2023-02-24 10:48:40 -05:00
FoxxMD 14eabecb7d Update google headers image 2023-02-23 12:57:11 -05:00
FoxxMD 8e31aa431d docs: Add youtube music docs and example 2023-02-23 12:54:48 -05:00
FoxxMD e47a636c2d feat: Add friendly auth error and hints for YT authentication test 2023-02-23 12:27:37 -05:00
FoxxMD bdd14b16de feat: Implement YT recent played UI 2023-02-23 12:16:44 -05:00
FoxxMD 8cc5aacb9a feat: Implement working YT Music source #34 2023-02-23 12:07:06 -05:00
FoxxMD d8e861dd54 feat: Add YTMusic api implementation and basic source 2023-02-22 16:35:09 -05:00
FoxxMD e369f9eb21 Merge branch 'develop' 2023-02-22 12:55:23 -05:00
FoxxMD ce6246e479 docs: Add FAQ to bug report template 2023-02-22 12:55:14 -05:00
FoxxMD 0c58cc6687 Merge branch 'develop' 2023-02-22 12:09:40 -05:00
FoxxMD 19687b725d fix: Fix logging empty ingress notifications 2023-02-22 12:00:13 -05:00
FoxxMD 02dee5fb6c docs: Add bug report template 2023-02-22 11:54:13 -05:00
FoxxMD e08a1ad514 fix: fix typo 2023-02-22 11:46:01 -05:00
FoxxMD 865f1064f5 docs: Add FAQ 2023-02-22 11:43:57 -05:00
FoxxMD 2b798e5a69 feat: Implement initial request logging for all sources that use ingress instead of polling
In order to make basic troubleshooting easier all ingress-based sources (plex, tautulli, jellyfin) now log initial connections and basic request validation checks:

* Request logging includes remote address and user agent
* Log initial requests before any middleware/body parsing
* Include method for checking if request is valid at a low-level (IE checking request verb)
* Include source-specific methods for checking if request payload is valid
2023-02-22 11:04:50 -05:00
FoxxMD da4d2ecade docs: Add additional properties to tautulli payload 2023-02-22 11:01:24 -05:00
FoxxMD 89540d8b76 docs: Add jellyfin example to kitchen sink and fix inconsistencies #55 2023-02-21 12:54:40 -05:00
FoxxMD 931709d277 fix: typo 2023-02-21 12:50:11 -05:00
FoxxMD 1b199750bd docs: Simplify and correct example configs
* Keep examples as valid json by removing comments and fixing trailing commas #55
* Add README pointing to configuration docs
2023-02-21 12:49:20 -05:00
FoxxMD 4b1fa710f7 docs: Rename json-based to file-based for consistent terminology 2023-02-21 12:47:56 -05:00
FoxxMD 360b6f4cf0 docs: Add links to schema explorer for json configs 2023-02-21 12:27:55 -05:00
FoxxMD e960438968 feat: Revert specific config requirement to be a top-level array
Wasn't really necessary and would cause breaking change for existing configs.
2023-02-21 12:27:27 -05:00
FoxxMD 93230c45c7 docs: Add examples and more descriptions to json schema 2023-02-21 11:52:27 -05:00
FoxxMD a3fd30c427 fix: Update project TS and node configuration in order to correctly generate json schema
* Output to commonjs using TS (remove type:module from package.json)
* Update tsconfig config
* Downgrade formidable to fix ES module import error
* Remove js assertions and use separate json schema for aio client/source validation to keep MS log output cleaner
2023-02-21 11:05:33 -05:00
FoxxMD c9bac6f7d2 fix: Use separate interface for maloja config data with inherited properties 2023-02-21 11:02:30 -05:00
FoxxMD 06c0b64ee5 fix: Correct plex data types 2023-02-21 11:01:28 -05:00
FoxxMD 70a1181ea1 chore: Add missing passport-deezer typings 2023-02-21 11:00:56 -05:00
FoxxMD be9938fe95 missed some wording 2023-02-20 11:32:47 -05:00
FoxxMD 27acfa9489 docs: Simplify oauth config by removing unused access/refresh token
Although it was nice to include the option no one is providing their own tokens from a separate flow completed elsewhere. It simplifies readability and usage in MS to remove these and always generate out own.
2023-02-20 11:31:24 -05:00
FoxxMD fc26125f80 fix anchor 2023-02-20 11:19:33 -05:00
FoxxMD 193a5c1e4d docs: Rewrite docs to include more examples and clearer usage 2023-02-20 11:18:01 -05:00
FoxxMD 700722627a feat: Allow parsing json files as json5
So users can use example json files with comments as-is
2023-02-20 09:42:16 -05:00
FoxxMD 1f6996d881 chore: Update superagent version 2023-02-14 11:35:44 -05:00
FoxxMD 1cbad9ccfd refactor: Use node from alpine packages instead of building from source
Using LTS either way but building from source makes image much larger and takes forever on build runners (github)
2023-02-14 11:23:05 -05:00
FoxxMD 04c9de648d refactor: Update s6-overlay usage for ms config and start up
Refactor s6 usage based on most recent lsio images
2023-02-14 10:44:31 -05:00
FoxxMD 2b60a0f76f fix: Fix ENV usage for jellyfin #61
Correct property names when parsing from ENV
2023-02-10 13:53:09 -05:00
FoxxMD 176ff80172 Fix docker example folder mapping 2023-02-10 12:46:20 -05:00
FoxxMD e2d150ce15 feat: add docker-compose with instructions #55 2023-02-10 12:44:22 -05:00
FoxxMD 636004a1bb Refactor: Update dockerfile and simplify configuration #55
* Update to alpine 3.17 and node 18 to match the project
* Use LSIO base to enable ui/guid usage through env
* Use build stages to reduce image size and speed up build
* Refactor build to use typescript
* Simplify config directory by using root-level /config as default
* Add bash script on startup to copy example configs if a new config folder is detected
2023-02-10 12:12:57 -05:00
FoxxMD 2258bb36b9 Refactor: Simplify logs location by using config directory #55 2023-02-10 12:09:20 -05:00
FoxxMD 0583a59177 Regenerate json schema files 2023-02-10 12:08:37 -05:00
FoxxMD fbbcfc5a57 Downgrade socket.io to avoid ESM module issue 2023-02-10 11:09:43 -05:00
FoxxMD 317da71ec3 Ignore .bak files 2023-02-10 10:18:15 -05:00
FoxxMD 22bbcfe3e5 Remove unused subsonic library 2023-02-10 10:18:05 -05:00
FoxxMD de3832f3ce More TS fixes 2023-02-09 16:09:52 -05:00
FoxxMD 04b07d6bfb Update abstract source config usage 2023-02-09 15:18:31 -05:00
FoxxMD fdf00d8acc Update deezer config usage 2023-02-09 15:18:09 -05:00
FoxxMD 3d2296858f Update subsonic source config usage 2023-02-09 15:15:24 -05:00
FoxxMD b1390cd74a Update spotify client typings 2023-02-09 15:05:14 -05:00
FoxxMD 216d92cad7 Add recentlyPlayed option interface 2023-02-09 15:04:59 -05:00
FoxxMD dcde41f89a Fix spotify source credential handling 2023-02-09 14:48:27 -05:00
FoxxMD 1e9518ac54 Fix spotify source credential handling 2023-02-09 14:48:20 -05:00
FoxxMD ac2aa8297c Remove ts-migrate 2023-02-09 14:48:12 -05:00
FoxxMD 8611798051 it works! 2023-02-09 13:51:06 -05:00
FoxxMD a659897d37 refactor: Update project to ESM
* Update relative import extension
* Remove (bad?) TSconfig extends presets
* Fix default non-relative imports
2023-02-09 12:50:37 -05:00
FoxxMD b4303fa81c refactor: add typings everywhere they are needed 2023-02-09 12:22:55 -05:00
FoxxMDandts-migrate 8f0a75a81b [ts-migrate][.] Run TS Migrate
Co-authored-by: ts-migrate <>
2023-02-08 12:56:09 -05:00
FoxxMDandts-migrate e006476b52 [ts-migrate][.] Rename files from JS/JSX to TS/TSX
Co-authored-by: ts-migrate <>
2023-02-08 12:56:04 -05:00
FoxxMDandts-migrate bb7bfc4a6e [ts-migrate][.] Init tsconfig.json file
Co-authored-by: ts-migrate <>
2023-02-08 12:56:04 -05:00
FoxxMDandts-migrate 03c3f6b6a2 [ts-migrate][.] Init tsconfig.json file
Co-authored-by: ts-migrate <>
2023-02-08 12:55:08 -05:00
FoxxMDandts-migrate 900f3c2471 [ts-migrate][.] Init tsconfig.json file
Co-authored-by: ts-migrate <>
2023-02-08 12:53:42 -05:00
FoxxMDandts-migrate cd86acdcf0 [ts-migrate][.] Init tsconfig.json file
Co-authored-by: ts-migrate <>
2023-02-08 12:53:13 -05:00
FoxxMD abf338f71a refactor: Move application files into src folder
* In preparation for documentation site and building from TS isolate app files to own folder
* Fix log/config/views folder default location
2023-02-08 12:46:46 -05:00
FoxxMD 91bb0cba45 Update to note 18 and add tsconfig 2023-02-08 12:30:39 -05:00
FoxxMD 8499e3197d feat: Add unknown route logging 2023-02-08 11:53:37 -05:00
FoxxMD bfc3568b00 feat: Add more debug logging for memory source play object tracking 2023-02-08 11:13:35 -05:00
FoxxMD 26613f8b68 docs: Update jellyfin webhook install instructions 2023-02-08 11:13:03 -05:00
FoxxMD 0b2d4b5f89 feat: Add more information to jellyfin play object and logging
* Standardize runtime stamp parsing
* Add playback position for future use
* Add source version and log first-seen server logging to jellyfin source
2023-02-08 11:12:44 -05:00
FoxxMD 6561ec9404 fix: Fix retrieving incorrect source for spotify callback when sources are unnamed
MS incorrectly chooses the first unnamed source (based on built source order) when handling spotify callback. Fixed by specify source type when retrieving source. Fixes #63
2023-02-03 11:04:49 -05:00
FoxxMD 517df8a3ad chore: remove quotes from nvmrc 2023-02-03 11:02:33 -05:00
Matt Foxx 4a0cdc0e87 Merge pull request #62 from samdoshi/lastfm-docs 2023-01-21 17:48:27 -05:00
Sam Doshi a54bc99925 add note about privacy setting for Last.fm sources 2023-01-21 15:28:21 +00:00
Matt Foxx eeae98360f Merge pull request #56 from CPU-Blanc/bugfixing
Fix: Jellyfin source
2022-12-29 08:58:08 -05:00
CPU_Blanc c5a938d4eb Fix Jellyfin source 2022-11-29 02:01:20 +00:00
FoxxMD c5b7dbd50f fix: Improve scrobble client init flow and retries
* Refactor init state to have an interim value and use getter/setter to control it
* Replace usage of 'ready' class field on maloja client with serverIsHealthy (make it client specific)
* Add some missing functions in abstract client class
* If client is not already initialized (or trying to initialize) then try to initialize client on scrobble attempt, maybe fixes #44
2022-06-16 11:06:06 -04:00
FoxxMD 26cd478c6f fix(plex): Properly handle requests without data 2022-06-15 12:01:38 -04:00
FoxxMD 670805f18a feat(plex): Improve request handling and debug info
* Replace multer with formidable as it is more up-to-date and has more granular options for tracking progress and parsing data
* Add debug logging for the lifecycle of a plex request
* throws errors if any part of plex request lifecycle is unexpected
2022-06-15 11:51:58 -04:00
117 changed files with 16159 additions and 3621 deletions
+5 -4
View File
@@ -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
+40
View File
@@ -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.
+6 -1
View File
@@ -117,6 +117,11 @@ dist
.yarn/install-state.gz
.pnp.*
*.json
config/*.json
*.txt
.idea/
src/**/**.js
src/**/**.js.map
*.bak
+1 -1
View File
@@ -1 +1 @@
"lts/fermium"
lts/hydrogen
+37 -18
View File
@@ -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" ]
+45 -50
View File
@@ -4,93 +4,88 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
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!)
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
* Supports scrobbling for many sources
* Supports scrobbling from many **Sources**
* [Spotify](/docs/configuration.md#spotify)
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Jellyfin](/docs/configuration.md#jellyfin)
* [Youtube Music](/docs/configuration.md#youtube-music)
* [Last.fm](/docs/configuration.md#lastfm-source)
* [ListenBrainz](/docs/configuration.md#listenbrainz--source-)
* [Deezer](/docs/configuration.md#deezer)
* Supports scrobbling to many clients
* [MPRIS (Linux Desktop)](/docs/configuration.md#mpris)
* [Mopidy](/docs/configuration.md#mopidy)
* Supports scrobbling to many **Clients**
* [Maloja](/docs/configuration.md#maloja)
* [Last.fm](/docs/configuration.md#lastfm)
* [ListenBrainz](/docs/configuration.md#listenbrainz)
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/configuration.md#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Built for Docker and unattended use!
* Docker images for x86/ARM
**Why should I use this over a browser extension and/or mobile app scrobbler?**
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
* **Consolidate play sources** -- Scrobble from many sources to one client with ease and without duplicating tracks.
* **Track your activity regardless of where you listen** -- Scrobble from many Sources to one Client with ease and without duplicating tracks.
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
**But I already scrobble my music to Last.fm, is multi-scrobbler for me?**
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a Source](/docs/configuration.md#lastfm-source) 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.
Yes! You can use [Last.fm as a **Source**](/docs/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docs/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src="/assets/status-ui.jpg" width="800">
## How Does multi-scrobbler (MS) Work?
You set up configurations for one or more **Sources** and one or more **Clients**. MS monitors all of your configured **Sources**. When new tracks are played by a Source it grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to scrobble to.
### Source
A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc...
Source configurations consist of:
* A friendly name.
* Any data needed to communicate or authenticate with the Source.
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
### Client
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
Client configurations consist of:
* A friendly name.
* Any data needed to communicate or authenticate with the Client.
## Installation
[See the **Installation** documentation](/docs/installation.md)
### Locally
## Configuration
Clone this repository somewhere and then install from the working directory
```bash
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
npm install
```
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
foxxmd/multi-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/multi-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
-28
View File
@@ -1,28 +0,0 @@
import {capitalize, createLabelledLogger} from "../utils.js";
export default class AbstractApiClient {
name;
type;
initialized = false;
config;
options;
logger;
client;
workingCredsPath;
redirectUri;
constructor(type, name, 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 => {
throw new Error('should be overridden');
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

-156
View File
@@ -1,156 +0,0 @@
import dayjs from "dayjs";
import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js";
export default class AbstractScrobbleClient {
name;
type;
initialized = false;
requiresAuth = false;
requiresAuthInteraction = false;
authed = false;
recentScrobbles = [];
scrobbledPlayObjs = [];
newestScrobbleTime;
oldestScrobbleTime = dayjs();
tracksScrobbled = 0;
lastScrobbleCheck = dayjs();
refreshEnabled;
checkExistingScrobbles;
verboseOptions;
config;
logger;
constructor(type, name, config = {}) {
this.type = type;
this.name = name;
const identifier = `Client ${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
}
};
}
// default init function, should be overridden if init stage is required
initialize = async () => {
this.initialized = true;
this.ready = true;
return this.initialized;
}
// 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));
}
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
} = {},
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) => {
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];
}
}
-322
View File
@@ -1,322 +0,0 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import {
buildTrackString,
playObjDataMatch, removeUndefinedKeys,
setIntersection, sleep,
sortByPlayDate,
truncateStringToLength,
} from "../utils.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
export default class LastfmScrobbler extends AbstractScrobbleClient {
api;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, options = {}) {
super('lastfm', name, config, options);
this.api = new LastfmApiClient(name, config, options)
}
formatPlayObj = obj => 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;
}
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit: 20, extended: true}));
const {
recenttracks: {
track: list = [],
}
} = resp;
this.recentScrobbles = list.reduce((acc, x) => {
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);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
}
alreadyScrobbled = (playObj, log = false) => {
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
}
existingScrobble = (playObj, logMatch = false) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => 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: 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 => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
let artistMatch;
const lowerSourceArtists = sourceArtists.map(x => 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) => {
const {
data: {
artists,
album,
track,
duration,
playDate
} = {},
data = {},
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
const sType = newFromSource ? 'New' : 'Backlog';
const rawPayload = {
artist: artists.join(', '),
duration,
track,
album,
timestamp: playDate.unix(),
};
// i don't know if its lastfm-node-client building the request params incorrectly
// or the last.fm api not handling the params correctly...
//
// ...but in either case if any of the below properties is undefined (possibly also null??)
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
// so remove all undefined keys from the object before passing to the api client
const scrobblePayload = removeUndefinedKeys(rawPayload);
try {
const response = await this.api.callApi(client => client.trackScrobble(
scrobblePayload));
const {
scrobbles: {
'@attr': {
accepted = 0,
ignored = 0,
code,
},
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) {
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), payload: scrobblePayload});
throw e;
} finally {
this.logger.debug('Raw Payload: ', rawPayload);
}
return true;
}
}
+12
View File
@@ -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
+43 -16
View File
@@ -1,32 +1,59 @@
{
"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
"maxPollRetries": 0,
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
"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
"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
}
}
]
}
+5 -7
View File
@@ -1,14 +1,12 @@
[
{
"name": "FoxxMDeezer",
"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.
"clients": [],
"data": {
"clientId": "string", // deezer APPLICATION ID -- required if not providing access token
"clientSecret": "string", // deezer SECRET KEY -- required if not providing access token
"accessToken": "string", // deezer access token -- required if not providing client id/secret
"redirectUri": "http://localhost:9078/deezer/callback",// deezer redirect URI -- required only if not the default shown here. URI must end in "deezer/callback"
"interval": 60, // optional, how long to wait before calling spotify for new tracks
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
}
}
]
+7 -4
View File
@@ -1,10 +1,13 @@
[
{
"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": "MyJellyfin",
"clients": [],
"data": {
"users": ["FoxxMD"], // optional, list of users to scrobble tracks for
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
"users": ["FoxxMD"],
"servers": ["myServer","anotherServer"],
"options": {
"logPayload": false
}
}
}
]
+5 -10
View File
@@ -1,16 +1,11 @@
[
{
"name": "myLastFm", // [As Client/Source] required if configured as "client", a name to identify your Client/Source
"configureAs": "client", // optional and default to "client", set to "source" to use this configuration as a Source
"clients": [], // [As Source] 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": "myLastFm",
"configureAs": "client",
"data": {
"apiKey": "string", // required, Lastfm api key
"secret": "string", // required, Lastfm shared secret
"session": "string", // optional, session id returned from a complete auth flow.
// if not specified will be generated during authentication
"redirectUri": "http://localhost:9078/lastfm/callback" // optional, if not different than this default
// callback for auth. Must have "lastfm/callback" in the url somewhere
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/lastfm/callback"
}
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "brainz",
"configureAs": "client"
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
}
]
+3 -4
View File
@@ -1,10 +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
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "MyMopidy",
"data": {
"url": "localhost",
"uriBlacklist": [],
"uriWhitelist": [],
"albumBlacklist": []
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "ubuntu",
"data": {
"whitelist": ["vlc", "mpd"],
"blacklist": ["spotify"]
}
}
]
+5 -5
View File
@@ -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"]
}
}
]
+6 -9
View File
@@ -1,15 +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
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/callback",
"interval": 60
}
}
]
+4 -5
View File
@@ -1,11 +1,10 @@
[
{
"name": "default", // optional, friendly name for logs
"name": "MySubsonic",
"data": {
"url": "http://localhost:4040/airsonic",// required, the url you would visit to listen to music on the web
"user": "yourUser", // required, username to login with
"password": "yourPassword", // required, password to login with
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
"password": "yourPassword",
}
}
]
+5 -5
View File
@@ -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"]
}
}
]
+10
View File
@@ -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",
}
}
]
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+446 -76
View File
@@ -1,28 +1,87 @@
# 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-)
* [Listenbrainz (Source)](#listenbrainz--source-)
* [Deezer](#deezer)
* [Youtube Music](#youtube-music)
* [MPRIS (Linux Desktop)](#mpris)
* [Mopidy](#mopidy)
* [Client Configurations](#client-configurations)
* [Maloja](#maloja)
* [Last.fm](#lastfm)
* [Listenbrainz](#listenbrainz)
* [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 +99,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 +169,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 +189,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,9 +203,9 @@ 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)
## [Subsonic](http://www.subsonic.org/)
@@ -139,16 +226,19 @@ Can use this source for any application that implements the [Subsonic API](http:
| `SUBSONIC_PASSWORD` | Yes | | |
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
### JSON-Based
### File-Based
See [`subsonic.json.example`](../config/subsonic.json.example)
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
* Add the [Webhook Plugin](https://github.com/crobibero/jellyfin-plugin-webhook) repository to your plugins, then restart your server
* In the Webhook settings:
* 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`
@@ -164,21 +254,35 @@ Must be using Jellyfin 10.7 or greater
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
### JSON-Based
### File-Based
See [`jellyfin.json.example`](../config/jellyfin.json.example)
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.
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)
### JSON-Based
### File-Based
See [`lastfm.json.example`](../config/lastfm.json.example), change `configureAs` to `source`.
See [`lastfm.json.example`](/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
# [Listenbrainz (Source)](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
**NOTE:** You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
### File-Based
See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
**Change `configureAs` to `source`**
## [Deezer](https://deezer.com/)
@@ -194,24 +298,169 @@ After application creation you should have credentials displayed in the "My Apps
* **Secret Key**
* **Redirect URL** (if not the default)
### If no access token is provided...
**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_ACCESS_TOKEN` | No | | Must include either this token or client id/secret |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:{PORT}/deezer/callback` | URI must end in `deezer/callback` |
| 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` |
### JSON-Based
### File-Based
See [`deezer.json.example`](../config/deezer.json.example)
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)
# Clients
## [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 dont 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
![Google Headers](/docs/google-header.jpg)
NOTES:
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps above to get new credentials.
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
### File-Based
See [`ytmusic.json.example`](/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
MPRIS is a standard interface for communicating with Music Players on **linux operating systems.**
If you run Linux and have a notification tray that shows what media you are listening to, you likely have access to MPRIS.
![Notification Tray](/assets/mpris.jpg)
multi-scrobbler can listen to this interface and scrobble tracks played by **any media player** that communicates to the operating system with MPRIS.
**NOTE:** multi-scrobbler needs to be running as a [**Local Installation**](/docs/installation.md#local) in order to use MPRIS. This cannot be used from docker.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|----------------------------------------------------------------------------------|
| MPRIS_ENABLE | No | | Use MPRIS as a Source (useful when you don't need any other options) |
| MPRIS_BLACKLIST | No | | Comma-delimited list of player names not to scrobble from |
| MPRIS_WHITELIST | No | | Comma-delimited list of players names to ONLY scrobble from. Overrides blacklist |
### File-Based
See [`mpris.json.example`](/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
## [Mopidy](https://mopidy.com/)
Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/)
multi-scrobbler can scrobble tracks played from any Mopidy backend source, regardless of where you listen to them.
### File-Based
See [`mopidy.json.example`](/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
Configuration Options:
##### `url`
The URL used to connect to the Mopidy server. You MUST have [Mopidy-HTTP extension](https://mopidy.com/ext/http) enabled.
If no `url` is provided a default is used which assumes Mopidy is installed on the same server as multi-scrobbler: `ws://localhost:6680/mopidy/ws/`
Make sure the hostname and port number match what is found in the Mopidy configuration file `mopidy.conf`:
```
...
[http]
hostname = localhost
port = 6680
...
```
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `ws://`
* Hostname => `localhost`
* Port => `6680`
* Path => `/mopidy/ws/`
EX
```json
{
"url": "mopidy.mydomain.com"
}
```
MS transforms this to: `ws://mopidy.mydomain.com:6680/mopidy/ws/`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/`
```json
{
"url": "mopidy.mydomain.com:80/MOPWS"
}
```
MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS`
#### URI Blacklist/Whitelist
If you wish to disallow or only allow scrobbling from some sources played through Mopidy you can specify these using `uriBlacklist` or `uriWhitelist` in your config. multi-scrobbler will check the list to see if any string matches the START of the `uri` on a track. If whitelist is used then blacklist is ignored. All strings are case-insensitive.
EX:
```json
{
"uriBlacklist": ["soundcloud"]
}
```
Will prevent multi-scrobbler from scrobbling any Mopidy track that start with a `uri` like `soundcloud:song:MySong-1234`
#### Album Blacklist
For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake Album name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. All strings are case-insensitive.
EX:
```json
{
"albumBlacklist": ["SoundCloud", "Mixcloud"]
}
```
If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist`
then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist`
# Client Configurations
## [Maloja](https://github.com/krateng/maloja)
@@ -222,9 +471,9 @@ See [`deezer.json.example`](../config/deezer.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)
@@ -238,13 +487,134 @@ 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:{PORT}/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. |
| 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. |
### JSON-Based
### File-Based
See [`lastfm.json.example`](../config/lastfm.json.example)
See [`lastfm.json.example`](/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
## [Listenbrainz](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-------------------------------|---------------------------------|
| LZ_TOKEN | Yes | | User token from your LZ profile |
| LZ_USER | Yes | | Your LZ username |
| LZ_URL | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
### File-Based
See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
# Monitoring
multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
## Webhook Configurations
Webhooks will **push** a notification to your configured servers on these events:
* Source polling started
* Source polling retry
* Source polling stopped on error
* Scrobble client scrobble failure
Webhooks are configured in the main [config.json](#all-in-one-file-configuration) file under the `webhook` top-level property. Multiple webhooks may be configured for each webhook type. EX:
```json
{
"sources": [
...
],
"clients": [
...
],
"webhooks": [
{
"name": "FirstGotifyServer",
"type": "gotify",
"url": "http://192.168.0.100:8070",
"token": "abcd"
},
{
"name": "SecondGotifyServer",
"type": "gotify",
...
},
{
"name": "NtfyServerOne",
"type": "ntfy",
...
},
...
]
}
```
### [Gotify](https://gotify.net/)
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
EX
```json
{
"type": "gotify",
"name": "MyGotifyFriendlyNameForLogs",
"url": "http://192.168.0.100:8070",
"token": "AQZI58fA.rfSZbm",
"priorities": {
"info": 5,
"warn": 7,
"error": 10
}
}
```
### [Ntfy](https://ntfy.sh/)
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
EX
```json
{
"type": "ntfy",
"name": "MyNtfyFriendlyNameForLogs",
"url": "http://192.168.0.100:9991",
"topic": "RvOwKJ1XtIVMXGLR",
"username": "Optional",
"password": "Optional",
"priorities": {
"info": 3,
"warn": 4,
"error": 5
}
}
```
## Health Endpoint
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
* Returns `200 OK` when **everything** is working or `500 Internal Server Error` if **anything** is not
* The plain url (`/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
* Use query params `type` or `name` to restrict client/sources aggregated IE `/health?type=spotify` or `/health?name=MyMaloja`
* On 500 the response returns a JSON payload with `messages` array that describes any issues
* For any clients/sources that require authentication `/health` will return 500 if they are **not authenticated**
* For sources that poll (spotify, yt music, subsonic) `/health` will 500 if they are **not polling**
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

+82
View File
@@ -0,0 +1,82 @@
# Installation
## Local
Clone this repository somewhere and then install from the working directory
```bash
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
npm build
npm start
```
### Local Usage Examples
* The web UI is served on port `9078`. This can be modified using the `PORT` environmental variable.
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
```bash
npm start
```
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
```bash
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
```
## [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
Cross-platform images are built for x86 (Intel/AMD) and ARM (IE Raspberry Pi)
```
foxxmd/multi-scrobbler:latest
```
Or use the provided [docker-compose.yml](/docker-compose.yml) after modifying it to fit your configuration.
Recommended configuration steps for docker or docker-compose usage:
* If you must **bind a host directory into the container for storing configurations and credentials:**
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
* (Optionally) map the web UI port in the container **9078** to the host
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
### Linux Host
If you are
* using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_)
* running docker on MacOS or Windows
this **DOES NOT** apply to you.
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
### Docker Usage Examples
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
```
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
```bash
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
```
See the [docker-compose.yml](/docker-compose.yml) file for how to use with docker-compose.
+54 -4
View File
@@ -6,11 +6,13 @@ Scenario:
* 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
@@ -82,6 +84,14 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"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",
@@ -92,6 +102,15 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"url": "https://airsonic.foxx.example"
}
},
{
"type": "ytmusic",
"name": "foxxYoutube",
"clients": ["foxxMaloja"],
"data": {
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
"authUser": 1
}
},
],
"clients": [
{
@@ -124,6 +143,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
"redirectUri": "http://localhost:9078/lastfm/callback"
}
}
]
@@ -143,7 +163,7 @@ In `CONFIG_DIR/spotify.json`:
"clients": ["foxxMaloja"],
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
"clientSecret": "foxxSpotifyAppSecret"
}
},
{
@@ -151,7 +171,7 @@ In `CONFIG_DIR/spotify.json`:
"clients": ["maryMaloja"],
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
"clientSecret": "foxxSpotifyAppSecret"
}
},
{
@@ -160,7 +180,7 @@ In `CONFIG_DIR/spotify.json`:
"data": {
"accessToken": "fredsToken",
"refreshToken": "fredsRefreshToken",
"interval": 120,
"interval": 120
}
},
]
@@ -188,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
}
}
]
@@ -231,6 +280,7 @@ In `CONFIG_DIR/lastfm.json`:
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
"redirectUri": "http://localhost:9078/lastfm/callback"
}
}
]
+10 -1
View File
@@ -41,7 +41,16 @@ Expand the **Watched** dropdown and add the following code block to the **JSON D
"media_type": "{media_type}",
"title": "{title}",
"duration": "{duration_sec}",
"username": "{username}"
"username": "{username}",
"server": "{server_name}",
"version": "{server_version}",
"library": "{library_name}",
"player": "{player}",
"device": "{device}",
"platform": "{platform}",
"action": "{action}",
"machine_id": "{machine_id}",
"session_key": "{session_key}"
}
```
+3458 -876
View File
File diff suppressed because it is too large Load Diff
+65 -12
View File
@@ -1,15 +1,28 @@
{
"name": "multi-scrobbler",
"version": "0.1.0",
"version": "0.4.0",
"description": "scrobble plays from multiple sources to multiple clients",
"type": "module",
"main": "index.js",
"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",
@@ -23,21 +36,61 @@
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"ajv": "^7.2.4",
"body-parser": "^1.19.0",
"compare-versions": "^4.1.2",
"concat-stream": "^2.0.0",
"dayjs": "^1.10.4",
"dbus-next": "^0.10.2",
"ejs": "^3.1.6",
"es6-error": "^4.1.1",
"express": "^4.17.1",
"express-session": "^1.17.2",
"fixed-size-list": "^0.3.0",
"formidable": "^2.1",
"gotify": "^1.1.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
"lastfm-node-client": "^2.2.0",
"multer": "^1.4.2",
"passport": "^0.5.0",
"mopidy": "^1.3.0",
"normalize-url": "^6.1.0",
"ntfy": "^1.0.5",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
"pony-cause": "^1.1.1",
"safe-stable-stringify": "^1.1.1",
"socket.io": "^4.1.3",
"socket.io": "^4.6.1",
"spotify-web-api-node": "^5.0.2",
"superagent": "^6.1.0",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0"
"superagent": "^8.0.9",
"triple-beam": "^1.3.0",
"winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
"winston-daily-rotate-file": "^4.5.0",
"winston-duplex": "^0.1.1",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"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"
}
}
}
-205
View File
@@ -1,205 +0,0 @@
import dayjs from "dayjs";
import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../utils.js";
export default class AbstractSource {
name;
type;
identifier;
config;
clients;
logger;
instantiatedAt;
initialized = false;
requiresAuth = false;
requiresAuthInteraction = false;
authed = false;
canPoll = false;
polling = false;
pollRetries = 0;
tracksDiscovered = 0;
constructor(type, name, config = {}, clients = []) {
this.type = type;
this.name = name;
this.identifier = `Source - ${capitalize(this.type)} - ${name}`;
this.logger = createLabelledLogger(this.identifier, this.identifier);
this.config = config;
this.clients = clients;
this.instantiatedAt = dayjs();
}
// 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 = {}) => {
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) => {
return true;
}
poll = async (allClients) => {
await this.startPolling(allClients);
}
startPolling = async (allClients) => {
if(this.requiresAuthInteraction && !this.authed) {
this.logger.error('Cannot start polling because user interaction is required for authentication');
return;
}
// reset poll attempts if already previously run
this.pollRetries = 0;
const {
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 reties (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
await sleep((delayFor) * 1000);
} else {
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
}
this.pollRetries++;
}
}
}
/**
* @param {ScrobbleClients} allClients
*/
doPolling = async (allClients) => {
if (this.polling === true) {
return;
}
this.logger.info('Polling started');
let lastTrackPlayedAt = this.instantiatedAt;
let checkCount = 0;
let checksOverThreshold = 0;
try {
this.polling = true;
while (true) {
if(this.polling === false) {
this.logger.info('Stopped polling due to user input');
break;
}
let playObjs = [];
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;
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;
}
}
}
-76
View File
@@ -1,76 +0,0 @@
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 if 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) => {
let newStatefulPlays = [];
// first format new plays with locked play date
const lockedPlays = plays.map((p) => {
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) {
this.candidateRecentlyPlayed = lockedPlays;
} else {
// otherwise determine new tracks (not found in prior candidates)
const newTracks = lockedPlays.filter(x => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
// filter prior candidates based on new recently played
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => lockedPlays.some(y => playObjDataMatch(x, y)));
// 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: ['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) => {
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
}
}
-175
View File
@@ -1,175 +0,0 @@
import dayjs from "dayjs";import LastFm from "lastfm-node-client";
import LastfmScrobbler from '../clients/LastfmScrobbler.js';
import {buildTrackString} from "../utils.js";
import AbstractSource from "./AbstractSource.js";
export default class PlexSource extends AbstractSource {
users;
libraries;
servers;
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('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 === undefined ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries === undefined ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
}
this.initialized = true;
}
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.tracksDiscovered++;
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
}
}
}
-343
View File
@@ -1,343 +0,0 @@
import {createLabelledLogger, isValidConfigStructure, readJson} 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";
export default class ScrobbleSources {
sources = [];
logger;
configDir;
localUrl;
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer'];
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);
}
getByNameAndType = (name, type) => {
return this.sources.find(x => x.name === name && 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');
}
let sourceDefaults = {};
if (configFile !== undefined) {
const {
sources: mainConfigSourcesConfigs = [],
sourceDefaults: sd = {},
} = configFile;
sourceDefaults = sd;
const validMainConfigs = mainConfigSourcesConfigs.reduce((acc, curr, i) => {
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 validMainConfigs) {
const {name = 'unnamed'} = c;
configs.push({...c,
name,
source: 'config.json',
configureAs: 'source' // override user value
});
}
}
for (let sourceType of this.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)) {
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;
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',
data: sub
})
}
break;
case 'jellyfin':
const j = {
user: process.env.JELLYFIN_USER,
server: process.env.JELLYFIN_SERVER,
};
if (!Object.values(j).every(x => x === undefined)) {
configs.push({
type: 'jellyfin',
name: 'unnamed',
source: 'ENV',
mode: 'single',
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,
};
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 = [];
if (Array.isArray(rawSourceConfigs)) {
sourceConfigs = rawSourceConfigs;
} else if (rawSourceConfigs === null) {
this.logger.error(`${sourceType}.json contained no data`);
continue;
} else if (typeof rawSourceConfigs === 'object') {
// backwards compatibility, assuming its single-user mode
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${sourceType}.json) must be in an array.`);
if (rawSourceConfigs.data === undefined) {
sourceConfigs = [{data: rawSourceConfigs, mode: 'single', name: 'unnamed'}];
} else {
sourceConfigs = [rawSourceConfigs];
}
} else {
this.logger.error(`All top level data from ${sourceType}.json must be an array of objects, will not parse configs from file`);
continue;
}
for (const [i,m] of sourceConfigs.entries()) {
if(m === null) {
this.logger.error(`The config entry at index ${i} from ${sourceType}.json is null`);
continue;
}
if (typeof m !== 'object') {
this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not an object, skipping`, m);
continue;
}
const {configureAs = defaultConfigureAs} = m;
if(configureAs === 'source') {
m.source = `${sourceType}.json`;
m.type = sourceType;
configs.push(m);
}
}
}
}
// 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) {
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 = validConfigs.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) {
try {
await this.addSource(c, sourceDefaults);
} 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, defaults = {}) => {
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: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
this.logger.debug(`(${name}) Constructing ${type} source`);
let newSource;
switch (type) {
case 'spotify':
newSource = new SpotifySource(name, {
...data,
localUrl: this.localUrl,
configDir: this.configDir
}, clients);
break;
case 'plex':
newSource = await new PlexSource(name, data, clients);
break;
case 'tautulli':
newSource = await new TautulliSource(name, data, clients);
break;
case 'subsonic':
newSource = new SubsonicSource(name, data, clients);
break;
case 'jellyfin':
newSource = await new JellyfinSource(name, data, clients);
break;
case 'lastfm':
newSource = await new LastfmSource(name, {...data, configDir: this.configDir}, clients);
break;
case 'deezer':
newSource = await new DeezerSource(name, {
...data,
localUrl: this.localUrl,
configDir: this.configDir
}, clients);
break;
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);
}
}
-254
View File
@@ -1,254 +0,0 @@
import dayjs from "dayjs";
import {
readJson,
writeFile,
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
} 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;
localUrl;
workingCredsPath;
configDir;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, clients = []) {
super('spotify', name, config, clients);
const {
localUrl,
configDir,
interval = 60,
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
this.config.interval = interval;
this.configDir = configDir;
this.workingCredsPath = `${configDir}/currentCreds-${name}.json`;
this.localUrl = localUrl;
this.canPoll = true;
}
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
}
}
}
}
buildSpotifyApi = async () => {
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,
} = this.config || {};
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.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((api => 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}) => {
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.callApi(func);
if (formatted === true) {
return result.body.items.map(x => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate);
}
return result;
}
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 2,
} = this.config;
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) => {
if (this.spotifyApi === undefined) {
this.logger.warn('Cannot poll spotify without valid credentials configuration')
return;
}
await this.startPolling(allClients);
}
}
-47
View File
@@ -1,47 +0,0 @@
import dayjs from "dayjs";
import PlexSource from "./PlexSource.js";
export default class TautulliSource extends PlexSource {
constructor(name, config, clients) {
super(name, config, clients, 'tautulli');
}
static formatPlayObj(obj, newFromSource = false) {
const {
artist_name,
track_name,
track_artist,
album_name,
media_type,
title,
library_name,
server,
duration,
username,
} = obj;
let artists = [artist_name];
if (track_artist !== undefined && track_artist !== artist_name) {
artists.push(track_artist);
}
return {
data: {
artists,
album: album_name,
track: track_name,
duration,
playDate: dayjs(),
},
meta: {
title,
library: library_name,
server,
mediaType: media_type,
user: username,
trackLength: duration,
source: 'Tautulli',
newFromSource,
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import {capitalize, mergeArr} from "../utils.js";
import {Logger} from "winston";
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import winston from 'winston';
export default abstract class AbstractApiClient {
name: string;
type: string;
initialized: boolean = false;
config: object;
options: object;
logger: Logger;
client: any;
workingCredsPath?: string;
redirectUri?: string;
constructor(type: any, name: any, config = {}, options = {}) {
this.type = type;
this.name = name;
const identifier = `API - ${capitalize(this.type)} - ${name}`;
this.logger = winston.loggers.get('app').child({labels: [identifier]}, mergeArr);
this.config = config;
this.options = options;
}
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
throw new Error('should be overridden');
}
}
@@ -1,7 +1,9 @@
import LastFm from "lastfm-node-client";
import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "lastfm-node-client";
import AbstractApiClient from "./AbstractApiClient.js";
import dayjs from "dayjs";
import {readJson, sleep, writeFile} from "../utils.js";
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import {LastfmData} from "../common/infrastructure/config/client/lastfm.js";
const badErrors = [
'api key suspended',
@@ -19,9 +21,10 @@ const retryErrors = [
export default class LastfmApiClient extends AbstractApiClient {
user;
user?: string;
declare config: LastfmData;
constructor(name, config = {}, options = {}) {
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}`;
@@ -29,13 +32,12 @@ export default class LastfmApiClient extends AbstractApiClient {
this.logger.warn("'apiKey' not found in config!");
}
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
this.client = new LastFm(apiKey, secret, session);
this.client = new LastFm(apiKey as string, secret, session);
}
static formatPlayObj = obj => {
static formatPlayObj = (obj: TrackObject, options: FormatPlayObjectOptions = {}): PlayObject => {
const {
artist: {
// last.fm doesn't seem consistent with which of these properties it returns...
'#text': artists,
name: artistName,
},
@@ -45,6 +47,7 @@ export default class LastfmApiClient extends AbstractApiClient {
},
duration,
date: {
// @ts-ignore
uts: time,
} = {},
'@attr': {
@@ -57,7 +60,7 @@ export default class LastfmApiClient extends AbstractApiClient {
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
return {
data: {
artists: [...new Set(artistStrings)],
artists: [...new Set(artistStrings)] as string[],
track: title,
album,
duration,
@@ -74,22 +77,22 @@ export default class LastfmApiClient extends AbstractApiClient {
}
}
callApi = async (func, retries = 0) => {
callApi = async <T>(func: any, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
} = this.config;
try {
return await func(this.client);
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) {
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);
@@ -109,8 +112,8 @@ export default class LastfmApiClient extends AbstractApiClient {
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
}
authenticate = async (token) => {
const sessionRes = await this.client.authGetSession({token});
authenticate = async (token: any) => {
const sessionRes: AuthGetSessionResponse = await this.client.authGetSession({token});
const {
session: {
key: sessionKey,
@@ -145,7 +148,7 @@ export default class LastfmApiClient extends AbstractApiClient {
return false;
}
try {
const infoResp = await this.callApi(client => client.userGetInfo());
const infoResp = await this.callApi<UserGetInfoResponse>((client: any) => client.userGetInfo());
const {
user: {
name,
+260
View File
@@ -0,0 +1,260 @@
import AbstractApiClient from "./AbstractApiClient.js";
import request, {Request} from 'superagent';
import {ListenBrainzClientData} from "../common/infrastructure/config/client/listenbrainz.js";
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import dayjs from "dayjs";
export interface ArtistMBIDMapping {
artist_credit_name: string
artist_mbid: string
join_phrase: string
}
export interface MinimumTrack {
artist_name: string;
track_name: string;
release_name?: string;
}
export interface AdditionalTrackInfo {
artist_mbids?: string[]
release_mbid?: string
release_group_mbid?: string
recording_mbid?: string
submission_client?: string
submission_client_version?: string
spotify_id?: string
media_player?: string
media_player_version?: string
music_service?: string
music_service_name?: string
origin_url?: string
tags?: string[]
duration?: number
duration_ms?: number
track_mbid?: string
work_mbids?: string[]
}
export interface Track {
artist_name: string;
track_name: string;
release_name?: string;
artist_mbids?: string[];
artist_msid?: string;
recording_mbid?: string;
release_mbid?: string;
release_msid?: string;
tags?: string[];
duration?: number
}
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: string
}
export interface TrackPayload extends MinimumTrack {
additional_info?: AdditionalTrackInfo
}
export interface ListenPayload {
listened_at: Date | number;
recording_msid?: string;
track_metadata: TrackPayload;
}
export interface SubmitPayload {
listen_type: 'single',
payload: [ListenPayload]
}
export interface TrackResponse extends MinimumTrack {
duration: number
additional_info: AdditionalTrackInfoResponse
mbid_mapping: {
artist_mbids?: string[]
artists?: ArtistMBIDMapping[]
caa_id?: number
caa_release_mbid?: string
recording_mbid?: string
release_mbid?: string
}
}
export interface ListensResponse {
count: number;
listens: ListenResponse[];
}
export interface ListenResponse {
inserted_at: number
listened_at: number;
recording_msid?: string;
track_metadata: TrackResponse;
}
export class ListenbrainzApiClient extends AbstractApiClient {
declare config: ListenBrainzClientData;
url: string;
constructor(name: any, config: ListenBrainzClientData, options = {}) {
super('ListenBrainz', name, config, options);
const {
url = 'https://api.listenbrainz.org/'
} = config;
this.url = url;
}
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
} = this.config;
try {
req.set('Authorization', `Token ${this.config.token}`);
return await req as T;
} catch (e) {
const {
message,
} = e;
throw e;
}
}
testConnection = async () => {
try {
const resp = await this.callApi(request.get(this.url))
return true;
} catch (e) {
if(e.status === 410) {
return true;
}
return false;
}
}
testAuth = async () => {
try {
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
return true;
} catch (e) {
return false;
}
}
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
deadline: 30000 // wait 30 seconds overall for request to complete
})
.query({
count: maxTracks
}));
const {body: {payload}} = resp as any;
return payload as ListensResponse;
} catch (e) {
throw e;
}
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
try {
const resp = await this.getUserListens(maxTracks, user);
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
} catch (e) {
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
return [];
}
}
submitListen = async (play: PlayObject) => {
try {
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
return listenPayload;
} catch (e) {
throw e;
}
}
static playToListenPayload = (play: PlayObject): ListenPayload => {
return {
listened_at: (play.data.playDate ?? dayjs()).unix(),
track_metadata: {
artist_name: play.data.artists[0],
track_name: play.data.track,
additional_info: {
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined
}
}
}
}
static listenResponseToPlay = (listen: ListenResponse): PlayObject => {
const {
listened_at,
recording_msid,
track_metadata: {
track_name,
artist_name,
release_name,
duration,
additional_info: {
recording_msid: aRecordingMsid,
recording_mbid: aRecordingMbid,
duration: aDuration,
duration_ms: aDurationMs,
} = {},
mbid_mapping: {
artists: artistMappings = [],
recording_mbid: mRecordingMbid
} = {}
} = {}
} = listen;
const playId = recording_msid ?? aRecordingMsid;
const trackId = aRecordingMbid ?? mRecordingMbid;
let dur = duration ?? aDuration;
if (dur === undefined && aDurationMs !== undefined) {
dur = Math.round(aDurationMs / 1000);
}
let artists: string[] = [artist_name];
if (artistMappings.length > 0) {
const secondaryArtists = artistMappings.filter(x => x.artist_credit_name !== artist_name);
artists = artists.concat(secondaryArtists.map(x => x.artist_credit_name));
}
return {
data: {
playDate: dayjs.unix(listened_at),
track: track_name,
artists: artists,
album: release_name,
duration: dur
},
meta: {
source: 'listenbrainz',
trackId,
playId
}
}
}
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
return ListenbrainzApiClient.listenResponseToPlay(obj);
}
}
+368
View File
@@ -0,0 +1,368 @@
import dayjs, {Dayjs} from "dayjs";
import {
buildTrackString,
capitalize, closePlayDate,
mergeArr,
playObjDataMatch, setIntersection,
truncateStringToLength
} from "../utils.js";
import {
ClientType, FormatPlayObjectOptions,
INITIALIZED,
INITIALIZING,
InitState,
NOT_INITIALIZED,
PlayObject, ScrobbledPlayObject, TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import winston, {Logger} from "winston";
import {CommonClientConfig} from "../common/infrastructure/config/client/index.js";
import {ClientConfig} from "../common/infrastructure/config/client/clients.js";
import {Notifiers} from "../notifier/Notifiers.js";
import {FixedSizeList} from 'fixed-size-list';
export default abstract class AbstractScrobbleClient {
name: string;
type: ClientType;
#initState: InitState = NOT_INITIALIZED;
protected MAX_STORED_SCROBBLES = 40;
requiresAuth: boolean = false;
requiresAuthInteraction: boolean = false;
authed: boolean = false;
recentScrobbles: PlayObject[] = [];
scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
newestScrobbleTime?: Dayjs
oldestScrobbleTime?: Dayjs
tracksScrobbled: number = 0;
lastScrobbleCheck: Dayjs = dayjs(0)
refreshEnabled: boolean;
checkExistingScrobbles: boolean;
verboseOptions;
config: CommonClientConfig;
logger: Logger;
notifier: Notifiers;
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, logger: Logger) {
this.type = type;
this.name = name;
const identifier = `Client ${capitalize(this.type)} - ${name}`;
this.logger = logger.child({labels: [identifier]}, mergeArr);
this.notifier = notifier;
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES);
const {
data: {
options: {
refreshEnabled = true,
checkExistingScrobbles = true,
verbose = {},
} = {},
},
} = config;
this.config = config;
this.refreshEnabled = refreshEnabled;
this.checkExistingScrobbles = checkExistingScrobbles;
const {
match: {
onNoMatch = false,
onMatch = false,
confidenceBreakdown = false,
} = {},
...vRest
} = verbose
if (onMatch || onNoMatch) {
this.logger.warn('Setting verbose matching may produce noisy logs! Use with care.');
}
this.verboseOptions = {
...vRest,
match: {
onNoMatch,
onMatch,
confidenceBreakdown
}
};
}
get initialized() {
return this.#initState === INITIALIZED;
}
set initialized(val) {
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
if(val === INITIALIZING) {
this.#initState = INITIALIZING;
// @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message
} else if(val === true || val === INITIALIZED) {
this.#initState = INITIALIZED;
} else {
this.#initState = NOT_INITIALIZED;
}
}
get initializing() {
return this.#initState === INITIALIZING;
}
// default init function, should be overridden if init stage is required
initialize = async () => {
this.initialized = true;
return true;
}
// default init function, should be overridden if auth stage is required
testAuth = async () => {
return this.authed;
}
isReady = async () => {
return this.initialized && (!this.requiresAuth || (this.requiresAuth && this.authed));
}
refreshScrobbles = async () => {
this.logger.debug('Scrobbler does not have refresh function implemented!');
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
this.logger.debug('Scrobbler does not have alreadyScrobbled check implemented!');
return false;
}
scrobblesLastCheckedAt = () => {
return this.lastScrobbleCheck;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => {
this.logger.warn('formatPlayObj should be defined by concrete class!');
return obj;
}
// time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client
// ...this is assuming the scrobble client is returning "most recent" scrobbles
timeFrameIsValid = (playObj: PlayObject) => {
if(this.oldestScrobbleTime === undefined) {
return [true, ''];
}
const {
data: {
playDate,
} = {},
} = playObj;
const validTime = playDate.isAfter(this.oldestScrobbleTime);
let log = '';
if (!validTime) {
const dur = dayjs.duration(Math.abs(playDate.diff(this.oldestScrobbleTime))).humanize(false);
log = `occurred ${dur} before the oldest scrobble returned by this client (${this.oldestScrobbleTime.format()})`;
}
return [validTime, log]
}
addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => {
this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay});
}
filterScrobbledTracks = () => {
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
}
cleanSourceSearchTitle = (playObj: PlayObject) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
};
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
const {
data: {
playDate
} = {},
meta: {
source,
} = {}
} = playObj;
const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play));
if (dtInvariantMatches.length === 0) {
return [undefined, undefined];
}
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
const {
play: {
data: {
playDate: sPlayDate
} = {},
meta: {
source: playSource
} = {},
} = {},
} = x;
// need to account for inaccurate DT from subsonic
if(source === 'Subsonic' && playSource === 'Subsonic') {
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
}
return playDate.isSame(sPlayDate);
});
return [matchPlayDate, dtInvariantMatches];
}
protected compareExistingScrobbleTime = (existing: PlayObject, candidate: PlayObject): [boolean, boolean?] => {
let closeTime = closePlayDate(existing, candidate);
let fuzzyTime = false;
if(!closeTime) {
fuzzyTime = closePlayDate(existing, candidate, {fuzzyDuration: true});
}
return [closeTime, fuzzyTime];
}
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
const {
data: {
track: scrobbleTitle,
} = {},
} = existing;
let cleanSourceTitle = this.cleanSourceSearchTitle(candidate);
let titleMatch;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
// and compare against length of scrobble title
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
return titleMatch;
}
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): number => {
const {
data: {
artists: sourceArtists = [],
} = {},
} = candidate;
const {
data: {
artists = [],
} = {},
} = candidate;
let artistMatch;
const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
return artistMatch;
}
existingScrobble = async (playObj: PlayObject) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}};
// return early if we don't care about checking existing
if (false === this.checkExistingScrobbles) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
}
return undefined;
}
let existingScrobble;
let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']};
// then check if we have already recorded this
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
if (existingExactSubmitted !== undefined) {
existingScrobble = existingExactSubmitted.scrobble;
closestMatch = {
score: 1,
breakdowns: ['Exact Match found in previously successfully scrobbled']
}
}
// if not though then we need to check recent scrobbles from scrobble api.
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
if (existingScrobble === undefined) {
// if no recent scrobbles found then assume we haven't submitted it
// (either user doesnt want to check history or there is no history to check!)
if (this.recentScrobbles.length === 0) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
}
return undefined;
}
// we have have found an existing submission but without an exact date
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
// clean source title so it matches title from the scrobble api response as closely as we can get it
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
existingScrobble = this.recentScrobbles.find((x) => {
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
const artistMatch = this.compareExistingScrobbleArtist(x, playObj);
const artistScore = .2 * artistMatch;
const titleScore = .3 * titleMatch;
const timeScore = .5 * (closeTime ? 1 : (fuzzyTime ? 0.5 : 0));
const referenceScore = .5 * (referenceMatch ? 1 : 0);
const score = artistScore + titleScore + timeScore;
let scoreBreakdowns = [
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
];
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
const scoreInfo = {
score,
scrobble: x,
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
}
if (closestMatch.score <= score && score > 0) {
closestMatch = scoreInfo
}
return score >= .7;
});
}
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
}
return existingScrobble;
}
}
+206
View File
@@ -0,0 +1,206 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import {
buildTrackString, capitalize,
playObjDataMatch, removeUndefinedKeys,
setIntersection, sleep,
sortByOldestPlayDate,
truncateStringToLength,
} from "../utils.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
import {
FormatPlayObjectOptions,
INITIALIZING,
PlayObject, ScrobbledPlayObject,
TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
export default class LastfmScrobbler extends AbstractScrobbleClient {
api: LastfmApiClient;
requiresAuth = true;
requiresAuthInteraction = true;
declare config: LastfmClientConfig;
constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers, logger: Logger) {
super('lastfm', name, config, notifier, logger);
// @ts-ignore
this.api = new LastfmApiClient(name, config.data, options)
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
initialize = async () => {
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
this.initialized = INITIALIZING;
this.initialized = await this.api.initialize();
return this.initialized;
}
testAuth = async () => {
try {
this.authed = await this.api.testAuth();
} catch (e) {
this.logger.error('Could not successfully communicate with Last.fm API');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit: this.MAX_STORED_SCROBBLES, extended: true}));
const {
recenttracks: {
track: list = [],
}
} = resp;
this.recentScrobbles = list.reduce((acc: any, x: any) => {
try {
const formatted = LastfmApiClient.formatPlayObj(x);
const {
data: {
track,
playDate,
},
meta: {
mbid,
nowPlaying,
}
} = formatted;
if(nowPlaying === true) {
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
return acc;
} else if(playDate === undefined) {
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
return acc;
}
return acc.concat(formatted);
} catch (e) {
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
this.logger.debug('Full api response object:');
this.logger.debug(x);
return acc;
}
}, []).sort(sortByOldestPlayDate);
this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.filterScrobbledTracks();
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj: PlayObject) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
return await this.existingScrobble(playObj) !== undefined;
}
scrobble = async (playObj: PlayObject) => {
const {
data: {
artists,
album,
track,
duration,
playDate
} = {},
data = {},
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
const sType = newFromSource ? 'New' : 'Backlog';
const rawPayload = {
artist: artists.join(', '),
duration,
track,
album,
timestamp: playDate.unix(),
};
// i don't know if its lastfm-node-client building the request params incorrectly
// or the last.fm api not handling the params correctly...
//
// ...but in either case if any of the below properties is undefined (possibly also null??)
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
// so remove all undefined keys from the object before passing to the api client
const scrobblePayload = removeUndefinedKeys(rawPayload);
try {
const response = await this.api.callApi<TrackScrobbleResponse>((client: any) => client.trackScrobble(
scrobblePayload));
const {
scrobbles: {
'@attr': {
accepted = 0,
ignored = 0,
code = undefined,
} = {},
scrobble: {
track: {
'#text': trackName,
} = {},
timestamp,
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
...rest
} = {}
} = {},
} = response;
if(code === 5) {
this.initialized = false;
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
}
this.addScrobbledTrack(playObj, this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName}));
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
if(ignored > 0) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)}`, priority: 'warn'});
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/errorcodes for more information`, {payload: scrobblePayload});
}
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobblePayload});
throw e;
} finally {
this.logger.debug('Raw Payload: ', rawPayload);
}
return true;
}
}
+130
View File
@@ -0,0 +1,130 @@
import dayjs from 'dayjs';
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import {
buildTrackString, capitalize,
playObjDataMatch, removeUndefinedKeys,
setIntersection, sleep,
sortByOldestPlayDate,
truncateStringToLength,
} from "../utils.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
import {
FormatPlayObjectOptions,
INITIALIZING,
PlayObject,
TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: ListenBrainzClientConfig;
constructor(name: any, config: ListenBrainzClientConfig, options = {}, notifier: Notifiers, logger: Logger) {
super('listenbrainz', name, config, notifier, logger);
this.api = new ListenbrainzApiClient(name, config.data);
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
initialize = async () => {
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
this.initialized = INITIALIZING;
if(this.config.data.token === undefined) {
this.logger.error('Must provide a User Token');
this.initialized = false;
} else {
try {
await this.api.testConnection();
this.initialized = true;
} catch (e) {
this.logger.error(e);
this.initialized = false;
}
}
return this.initialized;
}
testAuth = async () => {
try {
this.authed = await this.api.testAuth();
} catch (e) {
this.logger.error('Could not successfully communicate with Listenbrainz API');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES);
this.logger.debug(`Found ${resp.length} recent scrobbles`);
this.recentScrobbles = resp.sort(sortByOldestPlayDate);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.filterScrobbledTracks();
}
}
this.lastScrobbleCheck = dayjs();
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
return await this.existingScrobble(playObj) !== undefined;
}
scrobble = async (playObj: PlayObject) => {
const {
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
let rawPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(playObj)]};
try {
const resp = await this.api.submitListen(playObj);
rawPayload = resp;
this.addScrobbledTrack(playObj, playObj);
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
} catch (e) {
let message = e.message;
if(e.response !== undefined) {
if(e.response.body !== undefined) {
message = e.response.body.messsage;
} else if(e.response.text !== undefined) {
message = e.response.text;
}
}
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'});
this.logger.error(`Failed to scrobble => ${message}`, {payload: rawPayload});
throw e;
} finally {
this.logger.debug(`Raw Payload:`, {rawPayload});
}
return true;
}
}
@@ -7,22 +7,39 @@ import {
playObjDataMatch,
setIntersection,
sleep,
sortByPlayDate,
sortByOldestPlayDate,
truncateStringToLength,
parseRetryAfterSecsFromObj
parseRetryAfterSecsFromObj, capitalize, closePlayDate
} from "../utils.js";
import {
FormatPlayObjectOptions,
INITIALIZING,
MalojaScrobbleData,
MalojaScrobbleRequestData,
MalojaScrobbleV2RequestData,
MalojaScrobbleV3RequestData,
MalojaV2ScrobbleData,
MalojaV3ScrobbleData,
PlayObject,
TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
export default class MalojaScrobbler extends AbstractScrobbleClient {
requiresAuth = true;
ready = false;
serverVersion;
serverIsHealthy = false;
serverVersion: any;
constructor(name, config = {}, options = {}) {
super('maloja', name, config, options);
const {url, apiKey} = config;
declare config: MalojaClientConfig
constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, logger: Logger) {
super('maloja', name, config, notifier, logger);
const {url, apiKey} = config.data;
if (apiKey === undefined) {
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
}
@@ -31,13 +48,15 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
}
static formatPlayObj(obj, serverVersion = undefined) {
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
let artists,
title,
album,
duration,
time;
const {serverVersion} = options;
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
// scrobble data structure changed for v3
const {
@@ -55,11 +74,11 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} = {},
// how long the track was listened to before it was scrobbled
duration: mDuration,
} = obj;
} = obj as MalojaV3ScrobbleData;
artists = mArtists;
time = mTime;
title = mTitle;
duration = mDuration;
duration = mLength;
album = mAlbum;
} else {
// scrobble data structure for v2 and below
@@ -69,14 +88,14 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
album: mAlbum,
duration: mDuration,
time: mTime,
} = obj;
} = obj as MalojaV2ScrobbleData;
artists = mArtists;
title = mTitle;
album = mAlbum;
duration = mDuration;
time = mTime;
}
let artistStrings = artists.reduce((acc, curr) => {
let artistStrings = artists.reduce((acc: any, curr: any) => {
let aString;
if (typeof curr === 'string') {
aString = curr;
@@ -88,7 +107,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}, []);
return {
data: {
artists: [...new Set(artistStrings)],
artists: [...new Set(artistStrings)] as string[],
track: title,
album,
duration,
@@ -100,13 +119,13 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
}
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj, this.serverVersion);
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion});
callApi = async (req, retries = 0) => {
callApi = async (req: any, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
} = this.config.data;
try {
return await req;
@@ -120,8 +139,11 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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,
@@ -135,7 +157,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
testConnection = async () => {
const {url} = this.config;
const {url} = this.config.data;
try {
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
const {
@@ -172,12 +194,13 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
testHealth = async () => {
const {url} = this.config;
const {url} = this.config.data;
try {
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), {maxRequestRetries: 0});
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,
@@ -208,13 +231,15 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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;
const {url, apiKey} = this.config.data;
try {
const resp = await this.callApi(request
.get(`${url}/apis/mlj_1/test`)
@@ -223,6 +248,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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 = {},
@@ -259,51 +285,51 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
isReady = async () => {
if (this.ready) {
return this.ready;
if (this.serverIsHealthy) {
return true;
}
try {
const [isHealthy, status] = await this.testHealth();
if (!isHealthy) {
this.logger.error(`Server is not ready: ${status}`);
this.ready = false;
return this.ready;
this.serverIsHealthy = false;
} else {
this.logger.info('Server reported database is built and status is healthy');
this.serverIsHealthy = true;
}
this.logger.info('Server reported database is built and status is healthy');
this.ready = true;
return this.ready;
} catch (e) {
this.logger.error(`Testing server health failed due to an unexpected error`);
this.ready = false;
return this.ready;
this.serverIsHealthy = false;
}
return this.serverIsHealthy
}
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 {url} = this.config.data;
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=${this.MAX_STORED_SCROBBLES}`));
const {
body: {
list = [],
} = {},
} = resp;
this.recentScrobbles = list.map(x => this.formatPlayObj(x)).sort(sortByPlayDate);
this.logger.debug(`Found ${list.length} recent scrobbles`);
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
this.filterScrobbledTracks();
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj) => {
cleanSourceSearchTitle = (playObj: PlayObject) => {
const {
data: {
track,
@@ -313,8 +339,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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);
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+(?=[^()]*\\))", '')
@@ -326,146 +352,12 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
return lowerTitle;
}
alreadyScrobbled = (playObj, log = false) => {
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
alreadyScrobbled = async (playObj: any, log = false) => {
return await this.existingScrobble(playObj) !== undefined;
}
existingScrobble = (playObj, logMatch = false) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => 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: 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 => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
let artistMatch;
const lowerSourceArtists = sourceArtists.map(x => 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) => {
const {url, apiKey} = this.config;
scrobble = async (playObj: PlayObject) => {
const {url, apiKey} = this.config.data;
const {
data: {
@@ -483,7 +375,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
const sType = newFromSource ? 'New' : 'Backlog';
const scrobbleData = {
const scrobbleData: MalojaScrobbleRequestData = {
title: track,
album,
key: apiKey,
@@ -495,11 +387,11 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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.artists = artists;
(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.artist = artists.join(' / ');
(scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
}
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
@@ -511,6 +403,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
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;
@@ -525,6 +418,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
const {
album: malojaAlbum = {},
} = track;
// @ts-expect-error TS(2339): Property 'track' does not exist on type '{}'.
scrobbleResponse.track.album = {
...malojaAlbum,
name: album
@@ -532,6 +426,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
} 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,
@@ -541,16 +436,18 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} = {}} = response;
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
}
this.addScrobbledTrack(playObj, scrobbleResponse);
this.addScrobbledTrack(playObj, this.formatPlayObj(scrobbleResponse));
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
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;
} finally {
this.logger.debug('Raw Payload:', scrobbleData);
}
return true;
@@ -1,37 +1,89 @@
import dayjs from "dayjs";
import dayjs, {Dayjs} from "dayjs";
import {
createLabelledLogger,
isValidConfigStructure,
buildTrackString,
createAjvFactory,
mergeArr,
playObjDataMatch,
readJson,
returnDuplicateStrings
returnDuplicateStrings, validateJson
} from "../utils.js";
import MalojaScrobbler from "./MalojaScrobbler.js";
import LastfmScrobbler from "./LastfmScrobbler.js";
import {clientTypes, ConfigMeta, PlayObject} from "../common/infrastructure/Atomic.js";
import {AIOConfig} from "../common/infrastructure/config/aioConfig.js";
import * as aioSchema from '../common/schema/aio-client.json';
import * as clientSchema from '../common/schema/client.json';
import {ClientAIOConfig, ClientConfig} from "../common/infrastructure/config/client/clients.js";
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
import {Notifiers} from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import {EventEmitter} from "events";
import winston from "winston";
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
import {ErrorWithCause} from "pony-cause";
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
type ParsedConfig = ClientAIOConfig & ConfigMeta;
export default class ScrobbleClients {
clients = [];
/** @type AbstractScrobbleClient[] */
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
logger;
configDir;
clientTypes = ['maloja','lastfm'];
emitter: EventEmitter;
constructor(configDir) {
sourceEmitter: EventEmitter;
constructor(emitter: EventEmitter, sourceEmitter: EventEmitter, configDir: any) {
this.emitter = emitter;
this.sourceEmitter = sourceEmitter;
this.configDir = configDir;
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
this.sourceEmitter.on('scrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
await this.scrobble(payload.data, payload.options);
});
}
getByName = (name) => {
getByName = (name: any) => {
return this.clients.find(x => x.name === name);
}
getByType = (type) => {
getByType = (type: any) => {
return this.clients.filter(x => x.type === type);
}
buildClientsFromConfig = async () => {
let configs = [];
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 {
@@ -42,23 +94,24 @@ export default class ScrobbleClients {
}
let clientDefaults = {};
if (configFile !== undefined) {
const aioConfig = validateJson<AIOConfig>(configFile, aioSchema, this.logger);
const {
clients: mainConfigClientConfigs = [],
clientDefaults: cd = {},
} = configFile;
} = aioConfig;
clientDefaults = cd;
const validMainConfigs = mainConfigClientConfigs.reduce((acc, curr, i) => {
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 validMainConfigs) {
// 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,
@@ -68,7 +121,7 @@ export default class ScrobbleClients {
}
}
for (const clientType of this.clientTypes) {
for (const clientType of clientTypes) {
let defaultConfigureAs = 'client';
switch (clientType) {
case 'maloja':
@@ -78,11 +131,13 @@ export default class ScrobbleClients {
if (url !== undefined || apiKey !== undefined) {
configs.push({
type: 'maloja',
name: 'unnamed',
name: 'unnamed-mlj',
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: {
url,
// @ts-ignore
apiKey
}
})
@@ -98,13 +153,33 @@ export default class ScrobbleClients {
if (!Object.values(lfm).every(x => x === undefined)) {
configs.push({
type: 'lastfm',
name: 'unnamed',
name: 'unnamed-lfm',
source: 'ENV',
mode: 'single',
configureAs: 'client',
// @ts-ignore
data: lfm
})
}
break;
case 'listenbrainz':
const lz = {
url: process.env.LZ_URL,
token: process.env.LZ_TOKEN,
username: process.env.LZ_USER
};
if (!Object.values(lz).every(x => x === undefined)) {
configs.push({
type: 'listenbrainz',
name: 'unnamed-lz',
source: 'ENV',
mode: 'single',
configureAs: 'client',
// @ts-ignore
data: lz
})
}
break;
default:
break;
}
@@ -116,25 +191,36 @@ export default class ScrobbleClients {
continue;
}
if (rawClientConfigs !== undefined) {
let clientConfigs = [];
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') {
// backwards compatibility, assuming its single-user mode
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${clientType}.json) must be in an array.`);
if (rawClientConfigs.data === undefined) {
clientConfigs = [{data: rawClientConfigs, mode: 'single', name: 'unnamed'}];
} else {
clientConfigs = [rawClientConfigs];
}
} else if(typeof rawClientConfigs === 'object') {
clientConfigs = [rawClientConfigs];
} else {
this.logger.error(`All top level data from ${clientType}.json must be an array of objects, will not parse configs from file`);
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,m] of clientConfigs.entries()) {
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;
@@ -149,31 +235,31 @@ export default class ScrobbleClients {
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 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 = validConfigs.reduce((acc, curr) => {
const nameGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => {
const {name = 'unnamed'} = curr;
const {[name]: n = []} = acc;
return {...acc, [name]: [...n, curr]};
}, {});
let noConflictConfigs = [];
let noConflictConfigs: ParsedConfig[] = [];
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}]`);
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') {
@@ -186,13 +272,13 @@ ${sources.join('\n')}`);
// finally! all configs are valid, structurally, and can now be passed to addClient
// just need to re-map unnnamed to default
const finalConfigs = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
const finalConfigs: ParsedConfig[] = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
...x,
name
}));
for (const c of finalConfigs) {
try {
await this.addClient(c, clientDefaults);
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);
@@ -200,22 +286,25 @@ ${sources.join('\n')}`);
}
}
addClient = async (clientConfig, defaults = {}) => {
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
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...`);
this.logger.debug(`Constructing ${type} (${name}) client...`);
switch (type) {
case 'maloja':
newClient = new MalojaScrobbler(name, data);
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.logger);
break;
case 'lastfm':
newClient = new LastfmScrobbler(name, {...data, configDir: this.configDir});
newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier, this.logger);
break;
case 'listenbrainz':
newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as ListenBrainzClientConfig, {}, notifier, this.logger);
break;
default:
break;
@@ -226,15 +315,15 @@ ${sources.join('\n')}`);
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.`);
this.logger.debug(`Attempting ${type} (${name}) initialization...`);
if ((await newClient.initialize()) === false) {
this.logger.error(`${type} (${name}) client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
} else {
this.logger.info(`(${name}) ${type} client initialized`);
this.logger.info(`${type} (${name}) client initialized`);
}
}
if(newClient.requiresAuth && !newClient.authed) {
this.logger.debug(`(${name}) Checking ${type} client auth...`);
this.logger.debug(`Checking ${type} (${name}) client auth...`);
let success;
try {
success = await newClient.testAuth();
@@ -242,9 +331,9 @@ ${sources.join('\n')}`);
success = false;
}
if(!success) {
this.logger.warn(`(${name}) ${type} client auth failed.`);
this.logger.warn(`${type} (${name}) client auth failed.`);
} else {
this.logger.info(`(${name}) ${type} client auth OK`);
this.logger.info(`${type} (${name}) client auth OK`);
}
}
this.clients.push(newClient);
@@ -255,7 +344,7 @@ ${sources.join('\n')}`);
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
* @returns {Array}
*/
scrobble = async (data, options = {}) => {
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
forceRefresh = false,
@@ -264,7 +353,7 @@ ${sources.join('\n')}`);
scrobbleFrom = 'source',
} = options;
const tracksScrobbled = [];
const tracksScrobbled: any = [];
if (this.clients.length === 0) {
this.logger.warn('Cannot scrobble! No clients are configured.');
@@ -272,26 +361,32 @@ ${sources.join('\n')}`);
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}'`);
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`);
continue;
}
if(client.initialized === false) {
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not yet initialized`);
continue;
if(!client.initialized) {
if(client.initializing) {
client.logger.warn(`Cannot scrobble because it is still initializing`);
continue;
}
if(!(await client.initialize())) {
client.logger.warn(`Cannot scrobble because it could not be initialized`);
continue;
}
}
if(client.requiresAuth && !client.authed) {
if (client.requiresAuthInteraction) {
this.logger.warn(`Cannot scrobble to Client '${client.name}' because user interaction is required for authentication`);
client.logger.warn(`Cannot scrobble 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`);
client.logger.warn(`Cannot scrobble because auth test failed`);
continue;
}
}
if(!(await client.isReady())) {
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not ready`);
client.logger.warn(`Cannot scrobble because it is not ready`);
continue;
}
@@ -299,7 +394,7 @@ ${sources.join('\n')}`);
try {
await client.refreshScrobbles();
} catch(e) {
this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`);
client.logger.error(`Encountered error while refreshing scrobbles`);
this.logger.error(e);
}
}
@@ -310,7 +405,8 @@ ${sources.join('\n')}`);
newFromSource = false,
} = {}
} = playObj;
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj);
if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) {
await client.scrobble(playObj)
client.tracksScrobbled++;
// since this is what we return to the source only add to tracksScrobbled if not already in array
@@ -318,10 +414,13 @@ ${sources.join('\n')}`);
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
tracksScrobbled.push(playObj);
}
} else {
if(!timeFrameValid) {
client.logger.debug(`Will not scrobble ${buildTrackString(playObj)} from Source '${scrobbleFrom}' because it ${timeFrameValidLog}`);
}
}
} catch(e) {
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
this.logger.error(e);
client.logger.error(new ErrorWithCause(`Encountered error while in scrobble loop`, {cause: e}));
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
if(e.continueScrobbling !== true) {
break;
+8
View File
@@ -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');
+237
View File
@@ -0,0 +1,237 @@
import {Dayjs} from "dayjs";
import {FixedSizeList} from 'fixed-size-list';
import {MESSAGE} from 'triple-beam';
import {Logger} from "winston";
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz'];
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
export type ClientType = 'maloja' | 'lastfm' | 'listenbrainz';
export const clientTypes: ClientType[] = ['maloja', 'lastfm', 'listenbrainz'];
export type InitState = 0 | 1 | 2;
export const NOT_INITIALIZED: InitState = 0;
export const INITIALIZING: InitState = 1;
export const INITIALIZED: InitState = 2;
export const initStates: InitState[] = [NOT_INITIALIZED, INITIALIZING, INITIALIZED];
export type ReadyState = 0 | 1 | 2;
export const NOT_READY: ReadyState = 0;
export const GETTING_READY: ReadyState = 1;
export const READY: ReadyState = 2;
export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY];
export interface InternalConfig {
localUrl: string
configDir: string
logger: Logger
}
export interface ConfigMeta {
source: string
mode?: string
configureAs: string
}
export interface PlayData {
artists?: string[]
album?: string
track?: string
/**
* The length of the track, in seconds
* */
duration?: number
/**
* The date the track was played at
* */
playDate?: Dayjs
}
export interface PlayMeta {
source?: string
/**
* Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc...
* */
parsedFrom?: string
/**
* Unique ID for this track, given by the Source
* */
trackId?: string
/**
* Atomic ID for this instance of played tracked IE a unique ID for "this track played at this time"
* */
playId?: string
newFromSource?: boolean
url?: {
web: string
[key: string]: string
}
user?: string
mediaType?: string
server?: string
library?: string
/**
* The position the "player" is at in the track at the time the play was reported, in seconds
* */
trackProgressPosition?: number
/**
* A unique identifier for the device playing this track
* */
deviceId?: string
[key: string]: any
}
export interface PlayObject {
data: PlayData,
meta: PlayMeta
}
export interface FormatPlayObjectOptions {
newFromSource?: boolean
parsedFrom?: string
[key: string]: any
}
export interface ProgressAwarePlayObject extends PlayObject {
meta: PlayMeta & {
initialTrackProgressPosition?: number
}
}
export type GroupedPlays = Map<string, ProgressAwarePlayObject[]>;
export type GroupedFixedPlays = Map<string, FixedSizeList<ProgressAwarePlayObject>>;
export interface TrackStringOptions {
include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'trackId')[]
transformers?: {
artists?: (a: string[]) => string
track?: (t: string) => string
time?: (t: Dayjs) => string
timeFromNow?: (t: Dayjs) => string
}
}
export interface ScrobbledPlayObject {
play: PlayObject
scrobble: PlayObject
}
export interface MalojaV2ScrobbleData {
artists: string[]
title: string
album: string
/**
* Length of the track
* */
duration: number
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
}
export interface MalojaV3ScrobbleData {
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
track: {
artists: string[]
title: string
album?: {
name: string
artists: string[]
}
/**
* length of the track
* */
length: number
}
/**
* how long the track was listened to before it was scrobbled
* */
duration: number
}
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
key: string
title: string
album: string
time: number
length: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
artists: string[]
}
export interface RemoteIdentityParts {
host: string,
proxy: string | undefined,
agent: string | undefined
}
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
export const logLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
export interface LogConfig {
level?: string
file?: string | false
stream?: string
console?: string
}
export interface LogOptions {
/**
* Specify the minimum log level for all log outputs without their own level specified.
*
* Defaults to env `LOG_LEVEL` or `info` if not specified.
*
* @default 'info'
* */
level?: LogLevel
/**
* Specify the minimum log level to output to rotating files. If `false` no log files will be created.
* */
file?: LogLevel | false
/**
* Specify the minimum log level streamed to the UI
* */
stream?: LogLevel
/**
* Specify the minimum log level streamed to the console (or docker container)
* */
console?: LogLevel
}
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
return Object.entries(obj).every(([key, val]) => {
if(key !== 'file') {
return val === undefined || logLevels.includes(val.toLocaleLowerCase());
}
return val === undefined || val === false || logLevels.includes(val.toLocaleLowerCase());
});
}
export interface LogInfo {
message: string
[MESSAGE]: string,
level: string
timestamp: string
labels?: string[]
transport?: string[]
}
@@ -0,0 +1,27 @@
import {SourceRetryOptions} from "./source/index.js";
import {RequestRetryOptions} from "./common.js";
import {SourceAIOConfig} from "./source/sources.js";
import {ClientAIOConfig} from "./client/clients.js";
import {WebhookConfig} from "./health/webhooks.js";
import {LogOptions} from "../Atomic.js";
export interface AIOConfig {
sourceDefaults?: SourceRetryOptions
clientDefaults?: RequestRetryOptions
sources?: SourceAIOConfig[]
clients?: ClientAIOConfig[]
webhooks?: WebhookConfig[]
logging?: LogOptions
}
export interface AIOClientConfig {
clientDefaults?: RequestRetryOptions
clients?: ClientAIOConfig[]
}
export interface AIOSourceConfig {
sourceDefaults?: SourceRetryOptions
sources?: SourceAIOConfig[]
}
@@ -0,0 +1,7 @@
import {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.js";
import {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.js";
import {ListenBrainzClientAIOConfig, ListenBrainzClientConfig} from "./listenbrainz.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig;
@@ -0,0 +1,80 @@
import {CommonConfig, CommonData} from "../common.js";
/**
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
* */
export interface MatchLoggingOptions {
/**
* Log to DEBUG when a new track does NOT match an existing scrobble
*
* @default false
* @examples [false]
* */
onNoMatch?: boolean
/**
* Log to DEBUG when a new track DOES match an existing scrobble
*
* @default false
* @examples [false]
* */
onMatch?: boolean
/**
* Include confidence breakdowns in track match logging, if applicable
*
* @default false
* @examples [false]
* */
confidenceBreakdown?: boolean
}
export interface CommonClientData extends CommonData {
/**
* default # of http request retries a client can make before error is thrown.
*
* @default 1
* @examples [1]
* */
maxRequestRetries?: number
/**
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).
*
* @default 1.5
* @examples [1.5]
* */
retryMultiplier?: number
options?: {
/**
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
* @default true
* @examples [true]
* */
refreshEnabled?: boolean
/**
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
* @default true
* @examples [true]
* */
checkExistingScrobbles?: boolean
/**
* Options used for increasing verbosity of logging in MS (used for debugging)
* */
verbose?: {
match?: MatchLoggingOptions
}
}
}
export interface CommonClientConfig extends CommonConfig {
/**
* Unique identifier for this client. Used with sources to restrict where scrobbles are sent.
*
* @examples ["MyConfig"]
* */
name: string
/**
* Specific data required to configure this client
* */
data?: CommonClientData
}
@@ -0,0 +1,43 @@
import {CommonClientConfig, CommonClientData} from "./index.js";
import {RequestRetryOptions} from "../common.js";
export interface LastfmData extends RequestRetryOptions {
/**
* API Key generated from Last.fm account
*
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
* */
apiKey: string
/**
* Secret generated from Last.fm account
*
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
* */
secret: string
/**
* Optional session id returned from a completed auth flow
* */
session?: string
/**
* Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "lastfm/callback" in the URL somewhere.
*
* @default "http://localhost:9078/lastfm/callback"
* @examples ["http://localhost:9078/lastfm/callback"]
* */
redirectUri?: string
}
export interface LastfmClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using LastFM as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: CommonClientData & LastfmData
}
export interface LastfmClientAIOConfig extends LastfmClientConfig {
type: 'lastfm'
}
@@ -0,0 +1,40 @@
import {CommonClientConfig, CommonClientData} from "./index.js";
import {RequestRetryOptions} from "../common.js";
export interface ListenBrainzData extends RequestRetryOptions{
/**
* URL for the ListenBrainz server, if not using the default
*
* @examples ["https://api.listenbrainz.org/"]
* @default "https://api.listenbrainz.org/"
* */
url?: string
/**
* User token for the user to scrobble for
*
* @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"]
* */
token: string
/**
* Username of the user to scrobble for
* */
username: string
}
export interface ListenBrainzClientData extends ListenBrainzData, CommonClientData {}
export interface ListenBrainzClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Listenbrainz as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: ListenBrainzClientData
}
export interface ListenBrainzClientAIOConfig extends ListenBrainzClientConfig {
type: 'listenbrainz'
}
@@ -0,0 +1,25 @@
import {CommonClientConfig, CommonClientData} from "./index.js";
import {RequestRetryOptions} from "../common.js";
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
/**
* URL for maloja server
*
* @examples ["http://localhost:42010"]
* */
url: string
/**
* API Key for Maloja server
*
* @examples ["myApiKey"]
* */
apiKey: string
}
export interface MalojaClientConfig extends CommonClientConfig {
data: MalojaClientData
}
export interface MalojaClientAIOConfig extends MalojaClientConfig {
type: 'maloja'
}
@@ -0,0 +1,45 @@
export interface CommonConfig {
name?: string
data?: CommonData
}
export interface CommonData {
[key: string]: any
options?: Record<string, any>
}
export interface RequestRetryOptions {
/**
* default # of http request retries a source can make before error is thrown
*
* @default 1
* @examples [1]
* */
maxRequestRetries?: number
/**
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)
*
* @default 1.5
* @examples [1.5]
* */
retryMultiplier?: number
}
export interface PollingOptions {
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* @default 30
* @examples [30]
* */
interval?: number
/**
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
*
* @default 60
* @examples [60]
* */
maxInterval?: number
}
@@ -0,0 +1,93 @@
export interface WebhookPayload {
title?: string
message: string
priority: 'info' | 'warn' | 'error'
}
export interface PrioritiesConfig {
/**
* @examples [5]
* */
info: number
/**
* @examples [7]
* */
warn: number
/**
* @examples [10]
* */
error: number
}
export interface CommonWebhookConfig {
/**
* Webhook type. Valid values are:
*
* * gotify
* * ntfy
*
* @examples ["gotify"]
* */
type: 'gotify' | 'ntfy'
/**
* A friendly name used to identify webhook config in logs
* */
name?: string
}
export interface GotifyConfig extends CommonWebhookConfig {
/**
* The URL of the Gotify server. Same URL that would be used to reach the Gotify UI
*
* @examples ["http://192.168.0.100:8078"]
* */
url: string
/**
* The token created for this Application in Gotify
*
* @examples ["AQZI58fA.rfSZbm"]
* */
token: string
/**
* Priority of messages
*
* * Info -> 5
* * Warn -> 7
* * Error -> 10
* */
priorities?: PrioritiesConfig
}
export interface NtfyConfig extends CommonWebhookConfig {
/**
* The URL of the Ntfy server
*
* @examples ["http://192.168.0.100:8078"]
* */
url: string
/**
* The topic mutli-scrobbler should POST to
* */
topic: string
/**
* Required if topic is protected
* */
username?: string
/**
* Required if topic is protected
* */
password?: string
/**
* Priority of messages
*
* * Info -> 3
* * Warn -> 4
* * Error -> 5
* */
priorities?: PrioritiesConfig
}
export type WebhookConfig = GotifyConfig | NtfyConfig;
@@ -0,0 +1,37 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
export interface DeezerData extends CommonSourceData {
/**
* deezer client id
*
* @examples ["a89cba1569901a0671d5a9875fed4be1"]
* */
clientId: string
/**
* deezer client secret
*
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
* */
clientSecret: string
/**
* deezer redirect URI -- required only if not the default shown here. URI must end in "callback"
*
* @default "http://localhost:9078/deezer/callback"
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri: string
/**
* optional, how long to wait before calling spotify for new tracks (in seconds)
*
* @default 60
* @examples [60]
* */
interval?: number
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
}
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
type: 'deezer'
}
@@ -0,0 +1,29 @@
import {CommonConfig, CommonData, RequestRetryOptions} from "../common.js";
export interface SourceRetryOptions extends RequestRetryOptions {
/**
* default # of automatic polling restarts on error
*
* @default 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,20 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {ListenBrainzData} from "../client/listenbrainz.js";
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
}
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
/**
* When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.
*
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
data: ListenBrainzSourceData
}
export interface ListenBrainzSourceAIOConfig extends ListenBrainzSourceConfig {
type: 'listenbrainz'
}
@@ -0,0 +1,76 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface MopidyData extends CommonSourceData, PollingOptions {
/**
* URL of the Mopidy HTTP server to connect to
*
* You MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http
*
* multi-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/`
*
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
*
* Parts => [default value]
*
* * Protocol => `ws://`
* * Hostname => `localhost`
* * Port => `6680`
* * Path => `/mopidy/ws/`
*
*
* @examples ["ws://localhost:6680/mopidy/ws/"]
* @default "ws://localhost:6680/mopidy/ws/"
* */
url?: string
/**
* Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive
*
* EX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.
*
* List is ignored if uriWhitelist is used.
* */
uriBlacklist?: string[]
/**
* Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive
*
* EX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list.
*
* */
uriWhitelist?: string[]
/**
* Remove album data that matches any case-insensitive string from this list when scrobbling,
*
* For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data.
*
* @examples [["Soundcloud", "Mixcloud"]]
* @default ["Soundcloud"]
* */
albumBlacklist?: string[]
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* @default 10
* @examples [10]
* */
interval?: number
/**
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
*
* @default 30
* @examples [30]
* */
maxInterval?: number
}
export interface MopidySourceConfig extends CommonSourceConfig {
data: MopidyData
}
export interface MopidySourceAIOConfig extends MopidySourceConfig {
type: 'mopidy'
}
@@ -0,0 +1,55 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
export const PLAYBACK_STATUS_PLAYING = 'Playing';
export const PLAYBACK_STATUS_PAUSED = 'Paused';
export const PLAYBACK_STATUS_STOPPED = 'Stopped';
export type PlaybackStatus = 'Playing' | 'Paused' | 'Stopped';
export const MPRIS_IFACE = 'org.mpris.MediaPlayer2.Player';
export const MPRIS_PATH = '/org/mpris/MediaPlayer2';
export const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties';
export interface MPRISMetadata {
trackid?: string
length?: number
artUrl?: string
album?: string
albumArtist?: string[]
artist?: string[]
title?: string
url?: string
}
export interface PlayerInfo {
name: string
status: PlaybackStatus
position?: number
metadata: MPRISMetadata
}
export interface MPRISData extends CommonSourceData {
/**
* DO NOT scrobble from any players that START WITH these values, case-insensitive
*
* @examples [["spotify","vlc"]]
* */
blacklist?: string | string[]
/**
* ONLY from any players that START WITH these values, case-insensitive
*
* If whitelist is present then blacklist is ignored
*
* @examples [["spotify","vlc"]]
* */
whitelist?: string | string[]
}
export interface MPRISSourceConfig extends CommonSourceConfig {
data: MPRISData
}
export interface MPRISSourceAIOConfig extends MPRISSourceConfig {
type: 'mpris'
}
@@ -0,0 +1,36 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
export interface PlexSourceData extends CommonSourceData {
/**
* optional list of users to scrobble tracks from
*
* If none are provided tracks from all users will be scrobbled
*
* @examples [["MyUser1", "MyUser2"]]
* */
user?: string | string[]
/**
* optional list of libraries to scrobble tracks from
*
* If none are provided tracks from all libraries will be scrobbled
*
* @examples [["Audio","Music"]]
* */
libraries?: string | string[]
/**
* optional list of servers to scrobble tracks from
*
* If none are provided tracks from all servers will be scrobbled
*
* @examples [["MyServerName"]]
* */
servers?: string | string[]
}
export interface PlexSourceConfig extends CommonSourceConfig {
data: PlexSourceData
}
export interface PlexSourceAIOConfig extends PlexSourceConfig {
type: 'plex'
}
@@ -0,0 +1,15 @@
import {SpotifySourceAIOConfig, SpotifySourceConfig} from "./spotify.js";
import {PlexSourceAIOConfig, PlexSourceConfig} from "./plex.js";
import {TautulliSourceAIOConfig, TautulliSourceConfig} from "./tautulli.js";
import {DeezerSourceAIOConfig, DeezerSourceConfig} from "./deezer.js";
import {SubsonicSourceAIOConfig, SubSonicSourceConfig} from "./subsonic.js";
import {JellySourceAIOConfig, JellySourceConfig} from "./jellyfin.js";
import {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.js";
import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js";
import {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.js";
import {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.js";
import {ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig} from "./listenbrainz.js";
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig;
@@ -0,0 +1,46 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
/**
* spotify client id
*
* @examples ["787c921a2a2ab42320831aba0c8f2fc2"]
* */
clientId: string
/**
* spotify client secret
*
* @examples ["ec42e09d5ae0ee0f0816ca151008412a"]
* */
clientSecret: string
/**
* spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
*
* @default "http://localhost:9078/callback"
* @examples ["http://localhost:9078/callback"]
* */
redirectUri: string
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* It is unlikely you should need to change this unless you scrobble many very short tracks often
*
* Reading:
* * https://developer.spotify.com/documentation/web-api/guides/rate-limits/
* * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
* * The rate limit is ~180 req/min
*
* @default 30
* @examples [30]
* */
interval?: number
}
export interface SpotifySourceConfig extends CommonSourceConfig {
data: SpotifySourceData
}
export interface SpotifySourceAIOConfig extends SpotifySourceConfig {
type: 'spotify'
}
@@ -0,0 +1,47 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface SubsonicData extends CommonSourceData, PollingOptions {
/**
* URL of the subsonic media server to query
*
* @examples ["http://airsonic.local"]
* */
url: string
/**
* Username to login to the server with
*
* @example ["MyUser"]
* */
user: string
/**
* Password for the user to login to the server with
*
* @examples ["MyPassword"]
* */
password: string
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* @default 10
* @examples [10]
* */
interval?: number
/**
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
*
* @default 30
* @examples [30]
* */
maxInterval?: number
}
export interface SubSonicSourceConfig extends CommonSourceConfig {
data: SubsonicData
}
export interface SubsonicSourceAIOConfig extends SubSonicSourceConfig {
type: 'subsonic'
}
@@ -0,0 +1,8 @@
import {PlexSourceConfig} from "./plex.js";
export interface TautulliSourceConfig extends PlexSourceConfig {
}
export interface TautulliSourceAIOConfig extends TautulliSourceConfig {
type: 'tautulli'
}
@@ -0,0 +1,26 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface YTMusicData extends CommonSourceData, PollingOptions {
/**
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
*
* See https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.
*
* @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."]
* */
cookie: string
/**
* If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included
*
* @example [0]
* */
authUser?: number
}
export interface YTMusicSourceConfig extends CommonSourceConfig {
data: YTMusicData
}
export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig {
type: 'ytmusic'
}
@@ -0,0 +1,108 @@
declare module 'lastfm-node-client' {
/**
* Must REMOVE (unset) any undefined/null properties from payload before sending or LastFM will return an error
* */
export interface TrackScrobblePayload {
/**
* join multiple artists with ', '
* */
artist: string
/**
* track title
* */
track: string
/**
* Unix timestamp of time track should be scrobbled at
* */
timestamp: number
/**
* length of track in seconds
* */
duration?: number
album?: string
albumArtist?: string
}
export interface TrackScrobbleResponse {
scrobbles: {
'@attr': {
accepted: number,
ignored: number
code: number
},
scrobble?: {
track: {
'#text': string
},
timestamp: number,
ignoredMessage: {
code: number
'#text': string
}
}
}
}
export interface AuthGetSessionPayload {
token: string
}
export interface AuthGetSessionResponse {
session: {
key: string
name: string
}
}
export interface UserGetInfoResponse {
user: {
name: string
}
}
export interface UserGetRecentTracksPayload {
user: string
limit?: number
extended?: boolean
}
export interface UserGetRecentTracksResponse {
recenttracks: {
track: TrackObject[]
}
}
export interface TrackObject {
artist: {
'#text': string,
name: string,
},
name: string,
album: {
'#text': string,
},
duration: number,
date?: {
// @ts-ignore
uts: number,
},
'@attr'?: {
nowplaying: 'true' | 'false'
}
url: string,
mbid: string,
}
export default class LastFM {
constructor(apiKey: string, secret?: string, session?: string);
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
userGetInfo(): Promise<UserGetInfoResponse>
userGetRecentTracks(params: UserGetRecentTracksPayload): Promise<UserGetRecentTracksResponse>
}
}
@@ -0,0 +1,8 @@
declare module 'passport-deezer' {
//import {Strategy as Oauth2Strategy} from 'passport-oauth2';
import {Strategy as PassportStrategy} from "passport";
export class Strategy extends PassportStrategy {
constructor(options: any, verify: any);
}
}
+341
View File
@@ -0,0 +1,341 @@
import path from "path";
import {projectDir} from "./index.js";
import winston, {format, Logger} from "winston";
import {DuplexTransport} from "winston-duplex";
import {asLogOptions, LogConfig, LogInfo, LogLevel, LogOptions} from "./infrastructure/Atomic.js";
import process from "process";
import {fileOrDirectoryIsWriteable, truncateStringToLength} from "../utils.js";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {NullTransport} from 'winston-null';
import 'winston-daily-rotate-file';
import dayjs from "dayjs";
import stringify from 'safe-stable-stringify';
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
import {Symbol} from "typescript-json-schema";
const {combine, printf, timestamp, label, splat, errors} = format;
const {transports} = winston;
export let logPath = path.resolve(projectDir, `./logs`);
if (typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}
winston.loggers.add('noop', {transports: [new NullTransport()]});
export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
if (!winston.loggers.has(name)) {
const errors: (Error | string)[] = [];
let options: LogOptions = {};
if (asLogOptions(config)) {
options = config;
} else {
errors.push(`Logging levels were not valid. Must be one of: 'error', 'warn', 'info', 'verbose', 'debug' -- 'file' may be false.`);
}
const {level: configLevel} = options;
const defaultLevel = process.env.LOG_LEVEL || 'info';
const {
level = configLevel || defaultLevel,
file = configLevel || defaultLevel,
stream = configLevel || 'debug',
console = configLevel || 'debug'
} = options;
const consoleTransport = new transports.Console({level: console});
const myTransports = [
consoleTransport,
new DuplexTransport({
stream: {
transform(chunk, e, cb) {
cb(null, chunk);
},
objectMode: true,
},
name: 'duplex',
handleExceptions: true,
handleRejections: true,
level: stream,
dump: false,
}),
];
if (file !== false) {
const rotateTransport = new winston.transports.DailyRotateFile({
dirname: logPath,
createSymlink: true,
symlinkName: 'scrobble-current.log',
filename: 'scrobble-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '5m',
level: file
});
try {
fileOrDirectoryIsWriteable(logPath);
// @ts-ignore
myTransports.push(rotateTransport);
} catch (e: any) {
let msg = 'WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory';
errors.push(new ErrorWithCause<Error>(msg, {cause: e}));
}
}
const loggerOptions: winston.LoggerOptions = {
level: level,
format: labelledFormat(),
transports: myTransports,
};
winston.loggers.add(name, loggerOptions);
const logger = winston.loggers.get(name);
if (errors.length > 0) {
for (const e of errors) {
logger.error(e);
}
}
return logger;
}
return winston.loggers.get(name);
}
const breakSymbol = '<br />';
export const formatLogToHtml = (chunk: any) => {
const line = chunk.toString().replace('\n', breakSymbol)
.replace(/(debug)\s/gi, '<span class="debug blue">$1 </span>')
.replace(/(warn)\s/gi, '<span class="warn yellow">$1 </span>')
.replace(/(info)\s/gi, '<span class="info green">$1 </span>')
.replace(/(verbose)\s/gi, '<span class="verbose purple">$1 </span>')
.replace(/(error)\s/gi, '<span class="error red">$1 </span>')
.trim();
if(line.slice(-6) !== breakSymbol) {
return `${line}${breakSymbol}`;
}
return line;
}
const levelSymbol = Symbol.for('level');
const s = splat();
//const errorsFormat = errors({stack: true});
const CWD = process.cwd();
const causeKeys = ['name', 'cause']
export const defaultFormat = (defaultLabel = 'App') => printf(({
label,
[levelSymbol]: levelSym,
level,
message,
labels = [defaultLabel],
leaf,
timestamp,
durationMs,
[SPLAT]: splatObj,
stack,
...rest
}) => {
const keys = Object.keys(rest);
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify(rest) : '';
let msg = message;
let stackMsg = '';
if (stack !== undefined) {
const stackArr = stack.split('\n');
const stackTop = stackArr[0];
const cleanedStack = stackArr
.slice(1) // don't need actual error message since we are showing it as msg
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
.join('\n'); // rejoin with newline to preserve formatting
stackMsg = `\n${cleanedStack}`;
if (msg === undefined || msg === null || typeof message === 'object') {
msg = stackTop;
} else {
stackMsg = `\n${stackTop}${stackMsg}`
}
}
let nodes = Array.isArray(labels) ? labels : [labels];
if (leaf !== null && leaf !== undefined && !nodes.includes(leaf)) {
nodes.push(leaf);
}
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
});
export const labelledFormat = (labelName = 'App') => {
const l = label({label: labelName, message: false});
return combine(
timestamp(
{
format: () => dayjs().local().format(),
}
),
l,
s,
errorAwareFormat,
defaultFormat(labelName),
);
}
export const logLevels = {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
trace: 5,
silly: 6
};
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel): boolean => {
// @ts-ignore
const minLevel = logLevels[minLevelText];
let level: number;
if(typeof log === 'string') {
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
if (lineLevelMatch === null) {
return false;
}
// @ts-ignore
level = logLevels[lineLevelMatch[1]];
} else {
const lineLevelMatch = log.level;
// @ts-ignore
level = logLevels[lineLevelMatch];
}
return level <= minLevel;
}
const isProbablyError = (val: any, explicitErrorName?: string) => {
if(typeof val !== 'object' || val === null) {
return false;
}
const {name, stack} = val;
if(explicitErrorName !== undefined) {
if(name !== undefined && name.toLowerCase().includes(explicitErrorName)) {
return true;
}
if(stack !== undefined && stack.trim().toLowerCase().indexOf(explicitErrorName.toLowerCase()) === 0) {
return true;
}
return false;
} else if(stack !== undefined) {
return true;
} else if(name !== undefined && name.toLowerCase().includes('error')) {
return true;
}
return false;
}
const errorAwareFormat = {
transform: (einfo: any, {stack = true}: any = {}) => {
// because winston logger.child() re-assigns its input to an object ALWAYS the object we recieve here will never actually be of type Error
const includeStack = stack && (!isProbablyError(einfo, 'simpleerror') && !isProbablyError(einfo.message, 'simpleerror'));
if (!isProbablyError(einfo.message) && !isProbablyError(einfo)) {
return einfo;
}
let info: any = {};
if (isProbablyError(einfo)) {
const tinfo = transformError(einfo);
info = Object.assign({}, tinfo, {
// @ts-ignore
level: einfo.level,
// @ts-ignore
[LEVEL]: einfo[LEVEL] || einfo.level,
message: tinfo.message,
// @ts-ignore
[MESSAGE]: tinfo[MESSAGE] || tinfo.message
});
if(includeStack) {
// so we have to create a dummy error and re-assign all error properties from our info object to it so we can get a proper stack trace
const dummyErr = new ErrorWithCause('');
const names = Object.getOwnPropertyNames(tinfo);
for(const k of names) {
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
// @ts-ignore
dummyErr[k] = tinfo[k];
}
}
// @ts-ignore
info.stack = stackWithCauses(dummyErr);
}
} else {
const err = transformError(einfo.message);
info = Object.assign({}, einfo, err);
// @ts-ignore
info.message = err.message;
// @ts-ignore
info[MESSAGE] = err.message;
if(includeStack) {
const dummyErr = new ErrorWithCause('');
// Error properties are not enumerable
// https://stackoverflow.com/a/18278145/1469797
const names = Object.getOwnPropertyNames(err);
for(const k of names) {
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
// @ts-ignore
dummyErr[k] = err[k];
}
}
// @ts-ignore
info.stack = stackWithCauses(dummyErr);
}
}
// remove redundant message from stack and make stack causes easier to read
if(info.stack !== undefined) {
let cleanedStack = info.stack.replace(info.message, '');
cleanedStack = `${cleanedStack}`;
cleanedStack = cleanedStack.replaceAll('caused by:', '\ncaused by:');
info.stack = cleanedStack;
}
return info;
}
}
export const transformError = (err: Error): any => _transformError(err, new Set());
const _transformError = (err: Error, seen: Set<Error>) => {
if (!err || !isProbablyError(err)) {
return '';
}
if (seen.has(err)) {
return err;
}
try {
// @ts-ignore
let mOpts = err.matchOptions ?? matchOptions;
// @ts-ignore
const cause = err.cause as unknown;
if (cause !== undefined && cause instanceof Error) {
// @ts-ignore
err.cause = _transformError(cause, seen, mOpts);
}
return err;
} catch (e: any) {
// oops :(
// we're gonna swallow silently instead of reporting to avoid any infinite nesting and hopefully the original error looks funny enough to provide clues as to what to fix here
return err;
}
}
+439
View File
@@ -0,0 +1,439 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ClientAIOConfig": {
"anyOf": [
{
"$ref": "#/definitions/LastfmClientAIOConfig"
},
{
"$ref": "#/definitions/ListenBrainzClientAIOConfig"
},
{
"$ref": "#/definitions/MalojaClientAIOConfig"
}
]
},
"CommonClientData": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a client can make before error is thrown.",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
"examples": [
1.5
],
"type": "number"
}
},
"type": "object"
},
"LastfmClientAIOConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using LastFM as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"type": "string"
},
"data": {
"allOf": [
{
"$ref": "#/definitions/CommonClientData"
},
{
"$ref": "#/definitions/LastfmData"
}
],
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
},
"type": {
"enum": [
"lastfm"
],
"type": "string"
}
},
"required": [
"data",
"name",
"type"
],
"type": "object"
},
"LastfmData": {
"properties": {
"apiKey": {
"description": "API Key generated from Last.fm account",
"examples": [
"787c921a2a2ab42320831aba0c8f2fc2"
],
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"redirectUri": {
"default": "http://localhost:9078/lastfm/callback",
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
"examples": [
"http://localhost:9078/lastfm/callback"
],
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
"ec42e09d5ae0ee0f0816ca151008412a"
],
"type": "string"
},
"session": {
"description": "Optional session id returned from a completed auth flow",
"type": "string"
}
},
"required": [
"apiKey",
"secret"
],
"type": "object"
},
"ListenBrainzClientAIOConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using Listenbrainz as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"type": "string"
},
"data": {
"$ref": "#/definitions/ListenBrainzClientData",
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
},
"type": {
"enum": [
"listenbrainz"
],
"type": "string"
}
},
"required": [
"data",
"name",
"type"
],
"type": "object"
},
"ListenBrainzClientData": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
],
"type": "string"
},
"url": {
"default": "https://api.listenbrainz.org/",
"description": "URL for the ListenBrainz server, if not using the default",
"examples": [
"https://api.listenbrainz.org/"
],
"type": "string"
},
"username": {
"description": "Username of the user to scrobble for",
"type": "string"
}
},
"required": [
"token",
"username"
],
"type": "object"
},
"MalojaClientAIOConfig": {
"properties": {
"data": {
"$ref": "#/definitions/MalojaClientData",
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
},
"type": {
"enum": [
"maloja"
],
"type": "string"
}
},
"required": [
"data",
"name",
"type"
],
"type": "object"
},
"MalojaClientData": {
"properties": {
"apiKey": {
"description": "API Key for Maloja server",
"examples": [
"myApiKey"
],
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"url": {
"description": "URL for maloja server",
"examples": [
"http://localhost:42010"
],
"type": "string"
}
},
"required": [
"apiKey",
"url"
],
"type": "object"
},
"MatchLoggingOptions": {
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
"properties": {
"confidenceBreakdown": {
"default": false,
"description": "Include confidence breakdowns in track match logging, if applicable",
"examples": [
false
],
"type": "boolean"
},
"onMatch": {
"default": false,
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
"examples": [
false
],
"type": "boolean"
},
"onNoMatch": {
"default": false,
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
"examples": [
false
],
"type": "boolean"
}
},
"type": "object"
},
"RequestRetryOptions": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
}
},
"type": "object"
}
},
"properties": {
"clientDefaults": {
"$ref": "#/definitions/RequestRetryOptions"
},
"clients": {
"items": {
"$ref": "#/definitions/ClientAIOConfig"
},
"type": "array"
}
},
"type": "object"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/LastfmClientConfig"
},
{
"$ref": "#/definitions/ListenBrainzClientConfig"
},
{
"$ref": "#/definitions/MalojaClientConfig"
}
],
"definitions": {
"CommonClientData": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a client can make before error is thrown.",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
"examples": [
1.5
],
"type": "number"
}
},
"type": "object"
},
"LastfmClientConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using LastFM as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"type": "string"
},
"data": {
"allOf": [
{
"$ref": "#/definitions/CommonClientData"
},
{
"$ref": "#/definitions/LastfmData"
}
],
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
}
},
"required": [
"data",
"name"
],
"type": "object"
},
"LastfmData": {
"properties": {
"apiKey": {
"description": "API Key generated from Last.fm account",
"examples": [
"787c921a2a2ab42320831aba0c8f2fc2"
],
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"redirectUri": {
"default": "http://localhost:9078/lastfm/callback",
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
"examples": [
"http://localhost:9078/lastfm/callback"
],
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
"ec42e09d5ae0ee0f0816ca151008412a"
],
"type": "string"
},
"session": {
"description": "Optional session id returned from a completed auth flow",
"type": "string"
}
},
"required": [
"apiKey",
"secret"
],
"type": "object"
},
"ListenBrainzClientConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using Listenbrainz as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"type": "string"
},
"data": {
"$ref": "#/definitions/ListenBrainzClientData",
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
}
},
"required": [
"data",
"name"
],
"type": "object"
},
"ListenBrainzClientData": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
],
"type": "string"
},
"url": {
"default": "https://api.listenbrainz.org/",
"description": "URL for the ListenBrainz server, if not using the default",
"examples": [
"https://api.listenbrainz.org/"
],
"type": "string"
},
"username": {
"description": "Username of the user to scrobble for",
"type": "string"
}
},
"required": [
"token",
"username"
],
"type": "object"
},
"MalojaClientConfig": {
"properties": {
"data": {
"$ref": "#/definitions/MalojaClientData",
"description": "Specific data required to configure this client"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"type": "string"
}
},
"required": [
"data",
"name"
],
"type": "object"
},
"MalojaClientData": {
"properties": {
"apiKey": {
"description": "API Key for Maloja server",
"examples": [
"myApiKey"
],
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions"
}
},
"type": "object"
}
},
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"url": {
"description": "URL for maloja server",
"examples": [
"http://localhost:42010"
],
"type": "string"
}
},
"required": [
"apiKey",
"url"
],
"type": "object"
},
"MatchLoggingOptions": {
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
"properties": {
"confidenceBreakdown": {
"default": false,
"description": "Include confidence breakdowns in track match logging, if applicable",
"examples": [
false
],
"type": "boolean"
},
"onMatch": {
"default": false,
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
"examples": [
false
],
"type": "boolean"
},
"onNoMatch": {
"default": false,
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
"examples": [
false
],
"type": "boolean"
}
},
"type": "object"
}
}
}
File diff suppressed because it is too large Load Diff
+201 -150
View File
@@ -1,8 +1,7 @@
import {addAsync, Router} from '@awaitjs/express';
import express from 'express';
import bodyParser from 'body-parser';
import multer from 'multer';
import winston from 'winston';
import {Logger} from 'winston';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import isBetween from 'dayjs/plugin/isBetween.js';
@@ -10,26 +9,37 @@ 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,
labelledFormat,
longestString,
readJson, sleep,
longestString, mergeArr,
readJson,
remoteHostIdentifiers,
sleep,
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";
import PlexSource, {plexRequestMiddle} from "./sources/PlexSource.js";
import JellyfinSource from "./sources/JellyfinSource.js";
import { Server } from "socket.io";
import {Server} from "socket.io";
import * as path from "path";
import {projectDir} from "./common/index.js";
import LastfmSource from "./sources/LastfmSource.js";
import LastfmScrobbler from "./clients/LastfmScrobbler.js";
import DeezerSource from "./sources/DeezerSource.js";
import AbstractSource from "./sources/AbstractSource.js";
import {LogInfo, LogLevel, PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js";
import SpotifySource from "./sources/SpotifySource.js";
import {JellyfinNotifier} from "./sources/ingressNotifiers/JellyfinNotifier.js";
import {PlexNotifier} from "./sources/ingressNotifiers/PlexNotifier.js";
import {TautulliNotifier} from "./sources/ingressNotifiers/TautulliNotifier.js";
import {AIOConfig} from "./common/infrastructure/config/aioConfig.js";
import createRoot from "./ioc.js";
import {formatLogToHtml, getLogger, isLogLineMinLevel} from "./common/logging.js";
import {MESSAGE} from "triple-beam";
const storage = multer.memoryStorage()
const upload = multer({storage: storage})
dayjs.extend(utc)
dayjs.extend(isBetween);
@@ -40,6 +50,9 @@ 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);
@@ -50,115 +63,73 @@ app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false
app.use(passport.initialize());
app.use(passport.session());
const {transports} = winston;
let output: LogInfo[] = []
let output = []
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'];
const logPath = process.env.LOG_DIR || `${process.cwd()}/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 initLogger = getLogger({}, 'init');
initLogger.stream().on('log', (log: LogInfo) => {
output.unshift(log);
output = output.slice(0, 301);
io.emit('log', formatLogToHtml(log[MESSAGE]));
});
const consoleTransport = new transports.Console();
let logger: Logger;
const myTransports = [
consoleTransport,
streamTransport,
];
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
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`;
(async function () {
try {
// try to read a configuration file
let appConfigFail = false;
let config = {};
try {
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
logger.warn('App config file exists but could not be parsed!');
appConfigFail = true;
}
// setup defaults for other configs and general config
const {
spotify,
plex,
} = config || {};
webhooks = [],
logging = {},
} = (config || {}) as AIOConfig;
const logConfig: {level: LogLevel, sort: string, limit: number} = {
level: logging.level || (process.env.LOG_LEVEL || 'info') as LogLevel,
sort: 'descending',
limit: 50,
}
logger = getLogger(logging, 'app');
logger.stream().on('log', (log: LogInfo) => {
output.unshift(log);
output = output.slice(0, 301);
if(isLogLineMinLevel(log, logConfig.level)) {
io.emit('log', formatLogToHtml(log[MESSAGE]));
}
});
if(appConfigFail) {
logger.warn('App config file exists but could not be parsed!');
}
const root = createRoot();
const localUrl = root.get('localUrl');
const notifiers = root.get('notifiers');
await notifiers.buildWebhooks(webhooks);
const availableLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
/*
* setup clients
* */
const scrobbleClients = new Clients(configDir);
await scrobbleClients.buildClientsFromConfig();
const scrobbleClients = root.get('clients');
await scrobbleClients.buildClientsFromConfig(notifiers);
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(`DEPRECATED: Using 'spotify' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
deprecatedConfigs.push({
type: 'spotify',
name: 'unnamed',
source: 'config.json (top level)',
mode: 'single',
data: spotify
});
}
if (plex !== undefined) {
logger.warn(`DEPRECATED: Using 'plex' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
deprecatedConfigs.push({
type: 'plex',
name: 'unnamed',
source: 'config.json (top level)',
mode: 'single',
data: plex
});
}
await scrobbleSources.buildSourcesFromConfig(deprecatedConfigs);
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
await scrobbleSources.buildSourcesFromConfig([]);
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
@@ -174,13 +145,13 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
}
// initialize deezer strategies
const deezerSources = scrobbleSources.getByType('deezer');
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);
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
if (logConfig.sort === 'ascending') {
slicedLog.reverse();
}
@@ -198,6 +169,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
authed = false,
} = x;
const base = {
status: '',
type,
display: capitalize(type),
tracksDiscovered,
@@ -229,6 +201,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
authed = false,
} = x;
const base = {
status: '',
type,
display: capitalize(type),
tracksDiscovered: tracksScrobbled,
@@ -251,13 +224,16 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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(' | ')
level: availableLevels.map(x => `<a class="capitalize log-level log-${x} ${logConfig.level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
}
});
})
app.postAsync('/tautulli', async function (req, res) {
const payload = TautulliSource.formatPlayObj(req.body, true);
const tauIngress = new TautulliNotifier();
app.postAsync('/tautulli', async function(this: any, req, res) {
tauIngress.trackIngress(req, false);
const payload = TautulliSource.formatPlayObj(req.body, {newFromSource: true});
// try to get config name from payload
if (req.body.scrobblerConfig !== undefined) {
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
@@ -266,7 +242,8 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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);
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
await source.handle(payload);
return res.send('OK');
}
} else {
@@ -277,36 +254,79 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
// 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);
// @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'.
await source.handle(payload);
}
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 plexMiddle = plexRequestMiddle();
const plexLog = logger.child({labels: ['Plex Request']}, mergeArr);
const plexIngress = new PlexNotifier();
app.postAsync('/plex',
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
plexIngress.trackIngress(req, true);
next();
},
plexMiddle, async function (req, res) {
plexIngress.trackIngress(req, false);
const { payload } = req as any;
if(payload !== undefined) {
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
if(pSources.length === 0) {
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
}
const pSources = scrobbleSources.getByType('plex');
for (const source of pSources) {
await source.handle(playObj, scrobbleClients);
await source.handle(playObj);
}
}
res.send('OK');
});
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
app.postAsync('/jellyfin', jellyfinJsonParser, async function (req, res) {
const playObj = JellyfinSource.formatPlayObj(req.body, true);
const pSources = scrobbleSources.getByType('jellyfin');
const jellyIngress = new JellyfinNotifier();
app.postAsync('/jellyfin',
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
jellyIngress.trackIngress(req, true);
next();
},
jellyfinJsonParser, async function (req, res) {
jellyIngress.trackIngress(req, false);
const parts = remoteHostIdentifiers(req);
const connectionId = `${parts.host}-${parts.proxy ?? ''}`;
const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId}, {newFromSource: true});
const pSources = scrobbleSources.getByType('jellyfin') as JellyfinSource[];
if(pSources.length === 0) {
logger.warn('Received Jellyfin connection but no Jellyfin sources are configured');
}
const logPayload = pSources.some(x => {
const {
data: {
options: {
logPayload = false
} = {}
} = {},
} = x.config;
return logPayload;
});
if(logPayload) {
logger.debug(`[Jellyfin] Logging payload due to at least one Jellyfin source having 'logPayload: true`, req.body);
}
for (const source of pSources) {
await source.handle(playObj, scrobbleClients);
await source.handle(playObj);
}
res.send('OK');
});
@@ -315,7 +335,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
app.getAsync('/client/auth', async function (req, res) {
const {
scrobbleClient,
} = req;
} = req as any;
switch (scrobbleClient.type) {
case 'lastfm':
@@ -329,7 +349,9 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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;
@@ -346,6 +368,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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:
@@ -356,6 +379,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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;
@@ -363,23 +387,24 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
return res.status(400).send(`Specified source cannot poll (${source.type})`);
}
source.poll(scrobbleClients);
source.poll();
res.send('OK');
});
app.use('/recent', sourceCheckMiddle);
app.getAsync('/recent', async function (req, res) {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
} = req;
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
}
const result = 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 result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat())));
const trackLength = longestString(result.map((x: any) => x.data.track))
const plays = result.map((x: PlayObject) => {
const {
meta: {
url: {
@@ -387,11 +412,11 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
} = {}
} = {}
} = x;
const buildOpts = {
const buildOpts: TrackStringOptions = {
include: ['time', 'timeFromNow', 'track', 'artist'],
transformers: {
artists: a => artistTruncFunc(a.join(' / ')).padEnd(33),
track: t => t.padEnd(trackLength)
artists: (a: any) => artistTruncFunc(a.join(' / ')).padEnd(33),
track: (t: any) => t.padEnd(trackLength)
}
}
if (web !== undefined) {
@@ -407,20 +432,20 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
for (const [setting, val] of Object.entries(req.query)) {
switch (setting) {
case 'limit':
logConfig.limit = Number.parseInt(val);
logConfig.limit = Number.parseInt(val as string);
break;
case 'sort':
logConfig.sort = val;
logConfig.sort = val as string;
break;
case 'level':
logConfig.level = val;
for (const [key, logger] of winston.loggers.loggers) {
logger.level = val;
}
logConfig.level = val as LogLevel;
// for (const [key, logger] of winston.loggers.loggers) {
// logger.level = val as string;
// }
break;
}
}
let slicedLog = output.slice(0, logConfig.limit + 1);
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
if (logConfig.sort === 'ascending') {
slicedLog.reverse();
}
@@ -431,17 +456,19 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
// 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) {
const entity = scrobbleSources.getByName(req.session.deezerSource);
// @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) {
let entity = scrobbleSources.getByName(req.session.deezerSource);
// @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.accessToken !== undefined) {
} else if(entity.config.data.accessToken !== undefined) {
// start polling
entity.poll(entity.clients)
entity.poll()
return res.redirect('/');
} else {
await sleep(1500);
@@ -462,9 +489,9 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
token
} = {}
} = req;
let entity = scrobbleClients.getByName(state);
let entity: LastfmScrobbler | LastfmSource | undefined = scrobbleClients.getByName(state) as (LastfmScrobbler | undefined);
if(entity === undefined) {
entity = scrobbleSources.getByName(state);
entity = scrobbleSources.getByName(state) as LastfmSource;
}
try {
await entity.api.authenticate(token);
@@ -474,12 +501,15 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
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.getByName(state);
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);
source.poll();
} else {
responseContent = tokenResult;
}
@@ -487,27 +517,48 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
}
});
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.spotifyApi !== undefined) {
if (source.spotifyApi.getAccessToken() === undefined) {
if ((source as SpotifySource).spotifyApi !== undefined) {
if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) {
anyNotReady = true;
} else {
source.poll(scrobbleClients);
(source as SpotifySource).poll();
}
}
break;
case 'lastfm':
if(source.initialized === true) {
source.poll(scrobbleClients);
source.poll();
}
break;
default:
if (source.poll !== undefined) {
source.poll(scrobbleClients);
source.poll();
}
}
}
@@ -515,7 +566,7 @@ const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
logger.info(`Some sources are not ready, open ${localUrl} to continue`);
}
app.set('views', './views');
app.set('views', path.resolve(projectDir, 'src/views'));
app.set('view engine', 'ejs');
logger.info(`Server started at ${localUrl}`);
+39
View File
@@ -0,0 +1,39 @@
import {createContainer} from "iti";
import path from "path";
import {projectDir} from "./common/index.js";
import ScrobbleClients from "./clients/ScrobbleClients.js";
import ScrobbleSources from "./sources/ScrobbleSources.js";
import {Notifiers} from "./notifier/Notifiers.js";
import {EventEmitter} from "events";
import {logPath} from "./common/logging.js";
import {Container} from "winston";
//import ScrobbleClients from "./clients/ScrobbleClients.js";
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
const port = process.env.PORT ?? 9078;
/*let logPath = path.resolve(projectDir, `./logs`);
if(typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}*/
//let root: Container;
export const createRoot = () => {
return createContainer().add({
configDir: configDir,
logDir: logPath,
localUrl: `http://localhost:${port}`,
clientEmitter: () => new EventEmitter(),
sourceEmitter: () => new EventEmitter(),
notifierEmitter: () => new EventEmitter(),
}).add((items) => ({
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir),
sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir),
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
}));
}
export default createRoot;
+41
View File
@@ -0,0 +1,41 @@
import {GotifyConfig, NtfyConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
import {Logger} from "winston";
import {mergeArr} from "../utils.js";
export abstract class AbstractWebhookNotifier {
config: GotifyConfig | NtfyConfig
logger: Logger;
initialized: boolean = false;
requiresAuth: boolean = false;
authed: boolean = false;
protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) {
this.config = config;
const label = `${type} - ${config.name ?? defaultName}`
this.logger = logger.child({labels: [label]}, mergeArr);
}
initialize = async () => {
this.initialized = true;
this.logger.verbose('Initialized');
}
testAuth = async () => {
return;
}
notify = async (payload: WebhookPayload) => {
if(!this.initialized) {
this.logger.debug('Will not use notifier because it is not initialized.');
return;
}
if(this.requiresAuth && !this.authed) {
this.logger.debug('Will not use notifier because it is not correctly authenticated.');
return;
}
return await this.doNotify(payload);
}
abstract doNotify: (payload: WebhookPayload) => Promise<any>;
}
+67
View File
@@ -0,0 +1,67 @@
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
import {GotifyConfig, PrioritiesConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
import {gotify} from 'gotify';
import request from 'superagent';
import {HTTPError} from "got";
import {Logger} from "winston";
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
declare config: GotifyConfig;
priorities: PrioritiesConfig;
constructor(defaultName: string, config: GotifyConfig, logger: Logger) {
super('Gotify', defaultName, config, logger);
this.requiresAuth = true;
const {
info = 5,
warn = 7,
error = 10
} = this.config.priorities || {};
this.priorities = {
info,
warn,
error
}
}
initialize = async () => {
// check url is correct
try {
const url = this.config.url;
const resp = await request.get(`${url}/version`);
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
this.initialized = true;
} catch (e) {
this.logger.error(`Failed to contact server | Error: ${e.message}`);
}
}
testAuth = async () => {
this.authed = true;
// TODO no easy way to test token is working without also pushing a message -- instead will de-auth if we get the right error message when trying to push for the first time
}
doNotify = async (payload: WebhookPayload) => {
try {
await gotify({
server: this.config.url,
app: this.config.token,
message: payload.message,
title: payload.title,
priority: this.priorities[payload.priority]
});
this.logger.debug(`Pushed notification.`);
} catch (e: any) {
if(e instanceof HTTPError && e.response.statusCode === 401) {
this.logger.error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
this.authed = false;
} else {
this.logger.error(`Failed to push notification | Error => ${e.message}`);
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import winston, {config, format, Logger} from "winston";
import {mergeArr} from "../utils.js";
import {
GotifyConfig,
NtfyConfig,
WebhookConfig,
WebhookPayload
} from "../common/infrastructure/config/health/webhooks.js";
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
import {GotifyWebhookNotifier} from "./GotifyWebhookNotifier.js";
import {NtfyWebhookNotifier} from "./NtfyWebhookNotifier.js";
import {EventEmitter} from "events";
export class Notifiers {
logger: Logger;
webhooks: AbstractWebhookNotifier[] = [];
emitter: EventEmitter;
clientEmitter: EventEmitter;
sourceEmitter: EventEmitter;
constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter) {
this.emitter = emitter;
this.clientEmitter = clientEmitter;
this.sourceEmitter = sourceEmitter;
this.logger = winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr);
this.sourceEmitter.on('notify', async (payload: WebhookPayload) => {
await this.notify(payload);
})
}
buildWebhooks = async (webhookConfigs: WebhookConfig[]) => {
for (const [i, config] of Object.entries(webhookConfigs)) {
let webhook: AbstractWebhookNotifier;
const defaultName = `Config ${i}`
switch (config.type) {
case 'gotify':
webhook = new GotifyWebhookNotifier(defaultName, config as GotifyConfig, this.logger);
break;
case 'ntfy':
webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger);
break;
default:
this.logger.error(`'${config.type}' is not a valid webhook type`);
continue;
}
await webhook.initialize();
if(webhook.initialized) {
await webhook.testAuth();
}
this.webhooks.push(webhook);
}
}
notify = async (payload: WebhookPayload) => {
for (const webhook of this.webhooks) {
await webhook.notify(payload);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js";
import {
NtfyConfig,
PrioritiesConfig,
WebhookPayload
} from "../common/infrastructure/config/health/webhooks.js";
import {publish} from 'ntfy';
import request from "superagent";
import {Logger} from "winston";
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
declare config: NtfyConfig;
priorities: PrioritiesConfig;
constructor(defaultName: string, config: NtfyConfig, logger: Logger) {
super('Ntfy', defaultName, config, logger);
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}`)
}
}
}
@@ -1,7 +1,9 @@
export const makeSourceCheckMiddle = sources => (req, res, next) => {
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;
@@ -21,9 +23,10 @@ export const makeSourceCheckMiddle = sources => (req, res, next) => {
next();
}
export const makeClientCheckMiddle = clients => (req, res, 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;
+289
View File
@@ -0,0 +1,289 @@
import dayjs, {Dayjs} from "dayjs";
import {
buildTrackString,
capitalize, closePlayDate,
genGroupId, mergeArr,
playObjDataMatch,
sleep, sortByNewestPlayDate,
sortByOldestPlayDate
} from "../utils.js";
import {
GroupedFixedPlays,
GroupedPlays,
InternalConfig,
PlayObject, ProgressAwarePlayObject,
SourceType
} from "../common/infrastructure/Atomic.js";
import {Logger} from "winston";
import {SourceConfig} from "../common/infrastructure/config/source/sources.js";
import {EventEmitter} from "events";
import {FixedSizeList} from "fixed-size-list";
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;
instantiatedAt: Dayjs;
lastActivityAt: Dayjs;
initialized: boolean = false;
requiresAuth: boolean = false;
requiresAuthInteraction: boolean = false;
authed: boolean = false;
multiPlatform: boolean = false;
localUrl: string;
configDir: string;
canPoll: boolean = false;
polling: boolean = false;
pollRetries: number = 0;
tracksDiscovered: number = 0;
emitter: EventEmitter;
protected recentDiscoveredPlays: GroupedFixedPlays = new Map();
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {clients = [] } = config;
this.type = type;
this.name = name;
this.identifier = `Source - ${capitalize(this.type)} - ${name}`;
this.logger = internal.logger.child({labels: [`${capitalize(this.type)} - ${name}`]}, mergeArr);
this.config = config;
this.clients = clients;
this.instantiatedAt = dayjs();
this.lastActivityAt = this.instantiatedAt;
this.localUrl = internal.localUrl;
this.configDir = internal.configDir;
this.emitter = emitter;
}
// 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;
}
protected addPlayToDiscovered = (play: PlayObject) => {
const platformId = this.multiPlatform ? genGroupId(play) : 'SingleUser';
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
list.add(play);
this.recentDiscoveredPlays.set(platformId, list);
this.tracksDiscovered++;
this.logger.info(`Discovered => ${buildTrackString(play)}`);
}
getFlatRecentlyDiscoveredPlays = (): PlayObject[] => {
return Array.from(this.recentDiscoveredPlays.values()).map(x => x.data).flat(3).sort(sortByNewestPlayDate);
}
getRecentlyDiscoveredPlaysByPlatform = (platformId): PlayObject[] => {
const list = this.recentDiscoveredPlays.get(platformId);
if (list !== undefined) {
const data = [...list.data];
data.sort(sortByOldestPlayDate);
return data;
}
return [];
}
existingDiscovered = (play: PlayObject): PlayObject | undefined => {
const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : 'SingleUser');
return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
}
alreadyDiscovered = (play: PlayObject): boolean => {
return this.existingDiscovered(play) !== undefined;
}
protected scrobble = (plays: PlayObject[], options: { forceRefresh?: boolean } = {}) => {
const newDiscoveredPlays: PlayObject[] = [];
for(const play of plays) {
if(!this.alreadyDiscovered(play)) {
this.addPlayToDiscovered(play);
newDiscoveredPlays.push(play);
}
}
if(newDiscoveredPlays.length > 0) {
newDiscoveredPlays.sort(sortByOldestPlayDate);
this.emitter.emit('scrobble', {
data: newDiscoveredPlays,
options: {
...options,
checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate.add(2, 'second'),
scrobbleFrom: this.identifier,
scrobbleTo: this.clients
}
});
}
return newDiscoveredPlays;
}
protected notify = (payload) => {
this.emitter.emit('notify', payload);
}
poll = async () => {
await this.startPolling();
}
startPolling = async () => {
if(this.requiresAuth && !this.authed) {
if(this.requiresAuthInteraction) {
this.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 {
this.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();
} 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...`);
this.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!`);
this.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++;
}
}
}
doPolling = async () => {
if (this.polling === true) {
return;
}
this.logger.info('Polling started');
this.notify({title: `${this.identifier} - Polling Started`, message: 'Polling Started', priority: 'info'});
this.lastActivityAt = dayjs();
let checkCount = 0;
let checksOverThreshold = 0;
const {interval = 30, checkActiveFor = 300, maxInterval = 60} = this.config.data;
const maxBackoff = maxInterval - interval;
let sleepTime = interval;
try {
this.polling = true;
while (true) {
// @ts-ignore
if(this.polling === false) {
this.logger.info('Stopped polling due to user input');
break;
}
this.logger.debug('Refreshing recently played');
const playObjs = await this.getRecentlyPlayed({formatted: true});
let newDiscovered: PlayObject[] = [];
if(playObjs.length > 0) {
const now = dayjs().unix();
const closeToInterval = playObjs.some(x => now - x.data.playDate.unix() < 5);
if (playObjs.length > 0 && 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('Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
await sleep(10 * 1000);
}
newDiscovered = this.scrobble(playObjs,
{
forceRefresh: closeToInterval
});
}
if(newDiscovered.length > 0) {
// only update date if the play date is after the current activity date (in the case of backlogged plays)
this.lastActivityAt = newDiscovered[0].data.playDate.isAfter(this.lastActivityAt) ? newDiscovered[0].data.playDate : this.lastActivityAt;
checkCount = 0;
checksOverThreshold = 0;
} else {
this.logger.debug(`No new tracks discovered`);
}
const activeThreshold = this.lastActivityAt.add(checkActiveFor, 's');
const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false);
if (activeThreshold.isBefore(dayjs())) {
checksOverThreshold++;
if(sleepTime < maxInterval) {
const checkVal = Math.min(checksOverThreshold, 1000);
const backoff = Math.round(Math.max(Math.min(Math.min(checkVal, 1000) * 2 * (1.1 * checkVal), maxBackoff), 5));
sleepTime = interval + backoff;
this.logger.debug(`Last activity was at ${this.lastActivityAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for interval ${interval} + ${backoff} seconds.`);
} else {
this.logger.debug(`Last activity was at ${this.lastActivityAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for max interval ${maxInterval} seconds.`);
}
} else {
sleepTime = interval;
this.logger.debug(`Last activity was at ${this.lastActivityAt.format()}. Will sleep for interval ${sleepTime} seconds.`);
}
this.logger.verbose(`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;
}
}
}
@@ -1,45 +1,47 @@
import request from 'superagent';
import {parseRetryAfterSecsFromObj, readJson, sleep, sortByPlayDate, writeFile} from "../utils.js";
import {parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile} from "../utils.js";
import {Strategy as DeezerStrategy} from 'passport-deezer';
import AbstractSource from "./AbstractSource.js";
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
import dayjs from "dayjs";
import {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
export default class DeezerSource extends AbstractSource {
localUrl;
workingCredsPath;
configDir;
error;
error: any;
requiresAuth = true;
requiresAuthInteraction = true;
baseUrl = 'https://api.deezer.com';
constructor(name, config = {}, clients = []) {
super('deezer', name, config, clients);
declare config: DeezerSourceConfig;
constructor(name: any, config: DeezerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('deezer', name, config, internal, emitter);
const {
localUrl,
configDir,
interval = 60,
data: {
interval = 60,
} = {},
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
this.config.interval = interval;
this.config.data.interval = interval;
this.configDir = configDir;
this.workingCredsPath = `${configDir}/currentCreds-${name}.json`;
this.localUrl = localUrl;
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
this.canPoll = true;
}
static formatPlayObj(obj, newFromSource = false) {
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
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,
@@ -47,6 +49,7 @@ export default class DeezerSource extends AbstractSource {
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;
@@ -59,9 +62,8 @@ export default class DeezerSource extends AbstractSource {
playDate: dayjs(timestamp * 1000),
},
meta: {
trackLength: duration,
source: 'Deezer',
sourceId: id,
trackId: id,
newFromSource,
url: {
web: link
@@ -73,14 +75,14 @@ export default class DeezerSource extends AbstractSource {
initialize = async () => {
try {
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
this.config.accessToken = credFile.accessToken;
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.accessToken === undefined) {
if(this.config.clientId === undefined) {
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.clientSecret === undefined) {
} else if(this.config.data.clientSecret === undefined) {
throw new Error('clientSecret must be defined when accessToken is not present');
}
}
@@ -99,23 +101,19 @@ export default class DeezerSource extends AbstractSource {
return this.authed;
}
getRecentlyPlayed = async (options = {}) => {
const {formatted = false} = options;
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history`));
if(formatted) {
return resp.data.map(x => DeezerSource.formatPlayObj(x)).sort(sortByPlayDate)
}
return resp.data;
return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
callApi = async (req, retries = 0) => {
callApi = async (req: any, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
} = this.config.data;
req.query({
access_token: this.config.accessToken,
access_token: this.config.data.accessToken,
output: 'json'
});
try {
@@ -123,13 +121,17 @@ export default class DeezerSource extends AbstractSource {
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;
}
@@ -144,17 +146,23 @@ export default class DeezerSource extends AbstractSource {
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,
@@ -168,11 +176,11 @@ export default class DeezerSource extends AbstractSource {
generatePassportStrategy = () => {
return new DeezerStrategy({
clientID: this.config.clientId,
clientSecret: this.config.clientSecret,
callbackURL: this.config.redirectUri || `${this.localUrl}/deezer/callback`,
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, refreshToken, profile, done) => {
}, (accessToken: any, refreshToken: any, profile: any, done: any) => {
// return done(null, {
// accessToken,
// refreshToken,
@@ -191,7 +199,7 @@ export default class DeezerSource extends AbstractSource {
});
}
handleAuthCodeCallback = async (res) => {
handleAuthCodeCallback = async (res: any) => {
const {error, accessToken, id, displayName} = res;
if (error === undefined) {
await writeFile(this.workingCredsPath, JSON.stringify({
@@ -199,7 +207,7 @@ export default class DeezerSource extends AbstractSource {
id,
displayName,
}));
this.config.accessToken = accessToken;
this.config.data.accessToken = accessToken;
this.logger.info('Got token Deezer SDK callback!');
return true;
} else {
@@ -1,36 +1,44 @@
import MemorySource from "./MemorySource.js";
import dayjs from "dayjs";
import {buildTrackString} from "../utils.js";
import {buildTrackString, combinePartsToString, parseDurationFromTimestamp, truncateStringToLength} from "../utils.js";
import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
const shortDeviceId = truncateStringToLength(10, '');
export default class JellyfinSource extends MemorySource {
users;
servers;
constructor(name, config, clients, type = 'jellyfin') {
super(type, name, config, clients);
const {users, servers} = config
multiPlatform: boolean = true;
declare config: JellySourceConfig;
constructor(name: any, config: JellySourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('jellyfin', name, config, internal, emitter);
const {data: {users, servers} = {}} = config;
if (users === undefined || users === null) {
this.users = undefined;
} else {
if (!Array.isArray(users)) {
this.users = [users];
this.users = users.split(',')
} else {
this.users = users;
}
this.users = this.users.map(x => x.toLocaleLowerCase())
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];
this.servers = servers.split(',')
} else {
this.servers = servers;
}
this.servers = this.servers.map(x => x.toLocaleLowerCase())
this.servers = this.servers.map((x: any) => x.toLocaleLowerCase())
}
if (users === undefined && servers === undefined) {
@@ -41,11 +49,13 @@ export default class JellyfinSource extends MemorySource {
this.initialized = true;
}
static formatPlayObj(obj, newFromSource = false) {
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const {
ServerId,
ServerName,
Username,
ServerVersion,
NotificationUsername,
UserId,
NotificationType,
UtcTimestamp,
@@ -55,36 +65,52 @@ export default class JellyfinSource extends MemorySource {
RunTime,
ItemId,
ItemType,
PlaybackPosition,
connectionId,
DeviceId = '',
DeviceName,
ClientName,
} = obj;
const parsedRuntime = RunTime.split(':');
const dur = dayjs.duration({
hours: Number.parseInt(parsedRuntime[0]),
minutes: Number.parseInt(parsedRuntime[1]),
seconds: Number.parseInt(parsedRuntime[2])
});
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: [Artist],
artists,
album: Album,
track: Name,
duration: dur.as('seconds'),
duration: dur !== undefined ? dur.as('seconds') : undefined,
playDate: dayjs(),
},
meta: {
event: NotificationType,
mediaType: ItemType,
sourceId: ItemId,
user: Username,
server: ServerName,
trackId: ItemId,
user: NotificationUsername ?? UserId,
server,
source: 'Jellyfin',
newFromSource,
trackProgressPosition: PlaybackPosition !== undefined ? parseDurationFromTimestamp(PlaybackPosition).asSeconds() : undefined,
sourceVersion: ServerVersion,
deviceId: combinePartsToString([shortDeviceId(DeviceId), DeviceName])
}
}
}
isValidEvent = (playObj) => {
isValidEvent = (playObj: PlayObject) => {
const {
meta: {
mediaType, event, user, server
@@ -95,11 +121,31 @@ export default class JellyfinSource extends MemorySource {
} = {}
} = 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(`Config defined users but payload contained no user info${hint}`);
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.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
this.logger.warn(`Will not scrobble event because author was not an allowed user: ${user}`, {
artists,
track
})
@@ -107,55 +153,23 @@ export default class JellyfinSource extends MemorySource {
}
}
if (event !== undefined && !['PlaybackProgress','PlaybackStarted'].includes(event)) {
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
artists,
track
})
return false;
}
if (mediaType !== 'Audio') {
this.logger.debug(`Will not scrobble event because media type was not 'Audio' (${mediaType})`, {
artists,
track
});
return false;
}
if (this.servers !== undefined && !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;
}
getRecentlyPlayed = async (options = {}) => {
return this.statefulRecentlyPlayed;
return this.getFlatRecentlyDiscoveredPlays();
}
handle = async (playObj, allClients) => {
handle = async (playObj: 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++;
this.scrobble(newPlays);
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
@@ -1,21 +1,27 @@
import AbstractSource from "./AbstractSource.js";
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
import {sortByPlayDate} from "../utils.js";
import {sortByOldestPlayDate} from "../utils.js";
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import {UserGetRecentTracksResponse} from "lastfm-node-client";
import EventEmitter from "events";
export default class LastfmSource extends AbstractSource {
api;
api: LastfmApiClient;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, clients = []) {
super('lastfm', name, config, clients);
declare config: LastfmClientConfig;
constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, emitter: EventEmitter) {
super('lastfm', name, config, internal, emitter);
this.canPoll = true;
this.api = new LastfmApiClient(name, config);
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir});
}
static formatPlayObj(obj) {
return LastfmApiClient.formatPlayObj(obj);
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
return LastfmApiClient.formatPlayObj(obj, options);
}
initialize = async () => {
@@ -35,16 +41,16 @@ export default class LastfmSource extends AbstractSource {
}
getRecentlyPlayed = async(options = {}) => {
const {limit = 20, formatted = false} = options;
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
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, x) => {
return list.reduce((acc: any, x: any) => {
try {
const formatted = LastfmApiClient.formatPlayObj(x);
const {
@@ -74,6 +80,6 @@ export default class LastfmSource extends AbstractSource {
this.logger.debug(x);
return acc;
}
}, []).sort(sortByPlayDate);
}, []).sort(sortByOldestPlayDate);
}
}
+57
View File
@@ -0,0 +1,57 @@
import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js";
import {FormatPlayObjectOptions, INITIALIZING, InternalConfig} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
import {ListenBrainzSourceConfig} from "../common/infrastructure/config/source/listenbrainz.js";
import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js";
export default class ListenbrainzSource extends AbstractSource {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: ListenBrainzSourceConfig;
constructor(name: any, config: ListenBrainzSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('listenbrainz', name, config, internal, emitter);
this.canPoll = true;
this.api = new ListenbrainzApiClient(name, config.data);
}
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
initialize = async () => {
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
this.initialized = INITIALIZING;
if(this.config.data.token === undefined) {
this.logger.error('Must provide a User Token');
this.initialized = false;
} else {
try {
await this.api.testConnection();
this.initialized = true;
} catch (e) {
this.logger.error(e);
this.initialized = false;
}
}
return this.initialized;
}
testAuth = async () => {
try {
this.authed = await this.api.testAuth();
} catch (e) {
this.logger.error('Could not successfully communicate with Listenbrainz API');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
return await this.api.getRecentlyPlayed(limit);
}
}
+198
View File
@@ -0,0 +1,198 @@
import dbus, {ClientInterface, Variant} from 'dbus-next';
import dayjs from "dayjs";
import {
MPRIS_IFACE,
MPRIS_PATH,
MPRISMetadata, MPRISSourceConfig, PLAYBACK_STATUS_STOPPED,
PlaybackStatus, PlayerInfo,
PROPERTIES_IFACE
} from "../common/infrastructure/config/source/mpris.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import MemorySource from "./MemorySource.js";
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import {removeDuplicates} from "../utils.js";
import EventEmitter from "events";
export class MPRISSource extends MemorySource {
declare config: MPRISSourceConfig;
whitelist: string[] = [];
blacklist: string[] = [];
multiPlatform: boolean = true;
constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('mpris', name, config, internal, emitter);
this.canPoll = true;
const {data: {whitelist = [], blacklist = []} = {}} = config;
if(!Array.isArray(whitelist)) {
this.whitelist = whitelist.split(',')
} else {
this.whitelist = whitelist;
}
if(!Array.isArray(blacklist)) {
this.blacklist = blacklist.split(',');
} else {
this.blacklist = blacklist;
}
}
static formatPlayObj(obj: PlayerInfo, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const {
name,
position,
metadata: {
length,
album,
artist = [],
albumArtist = [],
title,
trackid,
url,
} = {}
} = obj;
return {
data: {
track: title,
album,
artists: Array.from(new Set(artist.concat(albumArtist))),
duration: length,
playDate: dayjs()
},
meta: {
source: 'dbus',
trackId: trackid,
newFromSource,
url: {
web: url
},
trackProgressPosition: position,
deviceId: name,
}
}
}
initialize = async () => {
// test if we can get DBus
try {
await this.getDBus();
return true;
} catch (e) {
this.logger.error('Could not get DBus interface from operating system');
this.logger.error(e);
return false;
}
}
protected getDBus = async () => {
const bus = dbus.sessionBus();
const obj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus');
return obj.getInterface('org.freedesktop.DBus');
}
protected listAll = async () => {
let iface = await this.getDBus();
let names = await iface.ListNames();
return names.filter((n) => n.startsWith('org.mpris.MediaPlayer2'))
}
getPlayersInfo = async (activeOnly = true): Promise<PlayerInfo[]> => {
const list = await this.listAll();
let bus = dbus.sessionBus();
const playerInfos: PlayerInfo[] = [];
for (const playerName of list) {
let obj = await bus.getProxyObject(playerName, MPRIS_PATH);
const plainPlayerName = playerName.replace('org.mpris.MediaPlayer2.', '');
//let player = obj.getInterface(MPRIS_IFACE);
let props = obj.getInterface(PROPERTIES_IFACE);
const pos = await this.getPlayerPosition(props);
const status = await this.getPlayerStatus(props);
if (status === PLAYBACK_STATUS_STOPPED && activeOnly) {
continue;
}
const metadata = await this.getPlayerMetadata(props);
playerInfos.push({
name: plainPlayerName,
status,
position: pos,
metadata
});
}
return playerInfos;
}
protected getPlayerPosition = async (props: ClientInterface): Promise<number> => {
const pos = await props.Get(MPRIS_IFACE, 'Position');
return dayjs.duration({milliseconds: Number(pos.value / 1000n)}).asSeconds();
}
protected getPlayerStatus = async (props: ClientInterface): Promise<PlaybackStatus> => {
const status = await props.Get(MPRIS_IFACE, 'PlaybackStatus');
return status.value as PlaybackStatus;
}
protected getPlayerMetadata = async (props: ClientInterface): Promise<MPRISMetadata> => {
const metadata = await props.Get(MPRIS_IFACE, 'Metadata');
return this.metadataToPlain(metadata.value);
}
metadataToPlain = (metadataVariant): MPRISMetadata => {
let metadataPlain = {};
for (let k of Object.keys(metadataVariant)) {
let value = metadataVariant[k];
if (value === undefined || value === null) {
//logging.warn(`ignoring a null metadata value for key ${k}`);
continue;
}
const plainKey = k.replace(/mpris:|xesam:/, '');
if (value instanceof Variant) {
if (typeof value.value === 'bigint') {
// in this context we're using it as a duration (track length or playback position)
metadataPlain[plainKey] = dayjs.duration({milliseconds: Number(value.value / 1000n)}).asSeconds();
} else {
metadataPlain[plainKey] = value.value;
}
} else {
metadataPlain[plainKey] = value;
}
}
return metadataPlain;
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const infos = await this.getPlayersInfo();
let plays: PlayObject[] = [];
for(const info of infos) {
const lowerName = info.name.toLocaleLowerCase();
if(this.whitelist.length > 0) {
if(!this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) {
this.logger.debug(`No name in whitelist was found in Player Name '${info.name}', skipping player`);
continue;
}
} else if(this.blacklist.length > 0) {
if(this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) {
this.logger.debug(`A name in blacklist was found in Player Name '${info.name}', skipping player`);
continue;
}
}
plays.push(MPRISSource.formatPlayObj(info));
}
const deduped = removeDuplicates(plays);
if(options.display === true) {
return deduped;
}
return this.processRecentPlays(deduped);
}
}

Some files were not shown because too many files have changed in this diff Show More