Compare commits

...
279 Commits
Author SHA1 Message Date
FoxxMD 8b27890254 Merge branch 'develop' 2024-02-05 11:53:18 -05:00
FoxxMD 8747c8375e chore: Bump version 2024-02-05 11:52:47 -05:00
FoxxMD 50f27c110c Merge branch 'develop' 2024-02-05 11:47:40 -05:00
FoxxMD c1f8997c01 feat: Implement remaining recently played endpoints 2024-02-05 11:40:06 -05:00
FoxxMD 12ed767961 ci: Bump action versions to fix node deprecations 2024-02-05 10:47:58 -05:00
FoxxMD 335808217b docs: Add debian docker image network workaround to docs #126 #134 2024-02-05 10:38:15 -05:00
FoxxMD b5bb7024fc docs: Add workarounds for rollup issues and flatpak disclaimer 2024-02-05 10:28:40 -05:00
FoxxMD ac2806dc66 fix(frontend): Fix scss division deprecation warnings 2024-02-05 10:14:11 -05:00
FoxxMD 9d149dab49 Try to use direct node binary distribution #126 2024-02-02 12:55:58 -05:00
FoxxMD 451404c0f9 Disable arm64 debian builds and document issues in dockerfile #126 2024-02-02 11:05:09 -05:00
FoxxMD a3f1f077ce Revert fancy default negation
Doesn't work
2024-02-01 12:18:30 -05:00
FoxxMD dcd13833ae Ignore secrets files 2024-02-01 12:16:03 -05:00
FoxxMD 78b732b75c Update metadata-action version 2024-02-01 11:55:57 -05:00
FoxxMD 3152933937 chore: Add matrix for building debian flavor tag 2024-02-01 11:54:40 -05:00
FoxxMD 6c6126bb27 chore: make container image build tags agnostic to default branch name 2024-02-01 11:48:15 -05:00
FoxxMD ad13b7687d feat: Implement debian-based image #126 2024-02-01 11:39:05 -05:00
FoxxMD 633e366e82 feat: Improved logging for last.fm api network errors #115 2024-01-31 15:52:25 -05:00
FoxxMD 69b1786859 fix: Add patch-package as postinstall script so packages actually get patched 2024-01-31 14:09:31 -05:00
FoxxMD 41839a961a chore: Ignore apple cert extensions 2024-01-31 14:09:06 -05:00
FoxxMD ccb50c64bb fix(lastfm): Patch lfm library to actually implement a request timeout #134 2024-01-31 13:15:20 -05:00
FoxxMD d88ac1b424 feat(source): Start implementing recent from API 2024-01-31 12:36:35 -05:00
FoxxMD c807611e55 fix: Revert debug statement 2024-01-31 11:51:20 -05:00
FoxxMD 155ec1403e feat(sources): Improve Source of Truth usage and presentation to user #134
* Refactor SOT to be a type (may have more in the future)
* Set player stale/orphan interval based on SOT type
* Add note in log when initializing MemorySource which does not use player as SOT
* Add tooltip to UI player when it is not SOT
2024-01-31 11:46:22 -05:00
FoxxMD ecde657c3e feat(lastfm): Implement nowPlaying lfm api call 2024-01-31 10:01:12 -05:00
FoxxMD 6aa8374f25 feat: Add debug logging for user api requests 2024-01-31 09:30:58 -05:00
FoxxMD c2f01473fd fix(scrobbler): Fix erasing non-english characters #121 2024-01-30 14:47:19 -05:00
FoxxMD 41e607c995 refactor(scrobbler): Use simpler method for handling queued scrobbles #130 2024-01-30 13:40:52 -05:00
FoxxMD fee754f15f feat(chrome): Used improved library 2024-01-30 13:24:09 -05:00
FoxxMD d62d18232d chore: Remove some unused packages 2024-01-30 10:30:33 -05:00
FoxxMD a1acab0fed Merge branch 'develop' 2024-01-29 10:35:27 -05:00
FoxxMD d39a7099c7 docs(cast): Add disclaimer about status of source support 2024-01-29 10:34:13 -05:00
FoxxMD 7b2c4f99f3 chore: Bump version for release 2024-01-29 10:33:57 -05:00
FoxxMD b941ff08dc Merge branch 'develop' 2024-01-29 10:25:06 -05:00
FoxxMD 210a74c75f chore: Bump version for release 2024-01-29 10:24:50 -05:00
FoxxMD eafce71cd0 chore(flatpak): Update flatpak release version 2024-01-29 10:24:08 -05:00
FoxxMD d5777b501d chore(flatpak): Update flatpak manifest to work with new app structure 2024-01-29 10:21:44 -05:00
FoxxMD dd60e5eb21 fix(flatpak): Fix missing destination when building locally 2024-01-29 09:49:33 -05:00
FoxxMD c57dbf23e1 chore(maloja): Deprecate support for versions below 3.0.0 2024-01-26 10:31:23 -05:00
FoxxMD cb52adb4de feat: Log when heartbeat tasks start 2024-01-26 10:17:17 -05:00
FoxxMD a75b0fc549 feat(chrome): Reduce device rejection logging noise
Change device rejection log to verbose if we've seen the blacklisted device before
2024-01-26 10:12:20 -05:00
FoxxMD 1a546b72d2 docs(chrome): Add more google cast configuration docs and update TOCs 2024-01-26 09:55:50 -05:00
FoxxMD fd13d268d0 fix(chrome): Typo 2024-01-25 16:47:10 -05:00
FoxxMD 5837fc11a5 fix: Better handling of uncaught exceptions and rejections 2024-01-25 10:54:44 -05:00
FoxxMD efdbbe40e8 fix(chromecast): Remove debugging interval 2024-01-25 09:52:39 -05:00
FoxxMD 5103cf1c12 feat(chromecast): Catch buffering media 2024-01-25 09:50:23 -05:00
FoxxMD e855a3de7d feat(chromecast): Try to connect all discovered interfaces in the event one fails 2024-01-25 09:43:15 -05:00
FoxxMD b90e38d595 fix(chromecast): Fix black/white app name variable assign 2024-01-25 09:15:53 -05:00
FoxxMD 1864f39be0 fix(chromecast): Fix unknown play tracking 2024-01-25 09:11:44 -05:00
FoxxMD 89e80b6f8b fix(chromecast): Debounce avahi discovery 2024-01-24 14:47:15 -05:00
FoxxMD 564295ee64 feat(spotify): Prepare for future spotify lib migration by writing completing credential data
Migrating to @spotify/spotify-web-api-ts-sdk requires that the credentials we provide have valid expires_in/expires data and grant type. Make this migration easier by writing that data now so users upgrading later most likely do not need to re-authenticate.
2024-01-23 15:10:33 -05:00
FoxxMD 61c7f26007 fix: Fix where retries are checked 2024-01-23 11:37:51 -05:00
FoxxMD 54b9ece5b6 fix: Fix when to check connection
Since all sources can now control if they need to check connection or not do not restrict to just those that can poll
2024-01-23 11:27:54 -05:00
FoxxMD 335136561a fix(chromecast): Better handling of client offline events
* Actually remove device if it fails after X retries
* Properly close controllers/platform/client to prevent uncaught errors from castv2
* Move manually configured devices to device discovery function so they can be reconnected on heartbeat (if previously removed)
2024-01-23 10:45:12 -05:00
FoxxMD 56f1cacd07 fix: Fix return values for source init startup 2024-01-23 09:41:40 -05:00
FoxxMD 558e358d0b fix: Fix default import usage 2024-01-22 13:25:31 -05:00
FoxxMD 290e6eecc4 fix: Fix import missing extension 2024-01-22 13:25:17 -05:00
FoxxMD f512f55782 feat(deezer): Fix credentials check during build data stage 2024-01-22 13:21:00 -05:00
FoxxMD ad6eba58d2 feat: improve data init stage log wording 2024-01-22 13:13:29 -05:00
FoxxMD b675aee8ff fix: Fix status text based on initialization OK values 2024-01-22 13:10:12 -05:00
FoxxMD c29ea9c89c fix: Fix status indicator color based on status text 2024-01-22 13:09:43 -05:00
FoxxMD 50016a31a8 fix: Fix isReady logic 2024-01-22 13:09:32 -05:00
FoxxMD 1cecffc30f feat(subsonic): Improve error/response parsing and report errors correcetly on startup #129
* Implement interfaces for response shape and getters for data
* Parse version data from response and use ping endpoint to return info on connection test
* Fix throwing auth/connection errors to include actual cause along with more human-friendly parsed error info
2024-01-22 12:00:53 -05:00
FoxxMD 833273c19d feat: Improve discovery for network exceptions in error causes 2024-01-22 12:00:50 -05:00
FoxxMD 35603d0caf chore: update ChromecastSource to comply with new initialization flow 2024-01-22 12:00:47 -05:00
FoxxMD de1377afdd fix: actually throw auth failure so initialization fails correctly 2024-01-22 12:00:43 -05:00
FoxxMD a22ddd10cc Merge branch 'develop' into sourceInitializeRefactor
# Conflicts:
#	src/backend/scrobblers/LastfmScrobbler.ts
#	src/backend/sources/DeezerSource.ts
#	src/backend/sources/MopidySource.ts
#	src/backend/sources/SpotifySource.ts
#	src/backend/sources/SubsonicSource.ts
#	src/backend/tasks/heartbeatSources.ts
2024-01-22 11:59:59 -05:00
FoxxMD 280add7107 Prepare for reverse proxy usage #125
Not working correctly yet but no BC for regular, non suburl, usage
2024-01-19 11:40:15 -05:00
FoxxMD b34abcab56 Update index.html 2024-01-19 11:05:34 -05:00
FoxxMD 788b26949e Fix uncaught Vite startup errors 2024-01-19 11:00:09 -05:00
FoxxMD c11da3df3d Working dockerfile 2024-01-18 16:59:45 -05:00
FoxxMD 0d68fea0eb Working app version indicator 2024-01-18 16:41:35 -05:00
FoxxMD c98672bcef Simplify port usage 2024-01-18 15:23:56 -05:00
FoxxMD bb0c079757 Cleanup package.json 2024-01-18 15:23:47 -05:00
FoxxMD 891144546e Simplify (and fix) test tooling for new esm stuff 2024-01-18 15:06:25 -05:00
FoxxMD 86611d764d Cleanup 2024-01-18 14:36:28 -05:00
FoxxMD e604268004 It works! 2024-01-18 14:18:08 -05:00
FoxxMD 396c3da277 Convert all relative backend endings to js 2024-01-18 13:58:31 -05:00
FoxxMD 0591b90089 somewhat working esm conversion 2024-01-18 13:15:06 -05:00
FoxxMD 6763be53ac Use vite-express to migrate from CRA
TODO convert backend to esmodule?
2024-01-18 11:52:13 -05:00
FoxxMD aee23f7550 fix(chrome): Remove device if it fails to refresh after 6 retries 2024-01-17 10:11:53 -05:00
FoxxMD 57b5528d43 docs(chrome): Update device connection instructions 2024-01-16 11:40:19 -05:00
FoxxMD bd8cf18a1a feat(chrome): refactor mdns discovery to run on source heartbeat as well as startup 2024-01-16 11:29:43 -05:00
FoxxMD 84acdd901a refactor: Move mdns discovery into own functions
May be useful for other sources...
2024-01-16 11:10:49 -05:00
FoxxMD e213a95996 fix(chrome): Refactor error handling for client and mdns libraries to catch errors properly 2024-01-16 09:52:41 -05:00
FoxxMD 78cd2eecf9 feat(chrome): Allow manual device connections 2024-01-15 16:11:33 -05:00
FoxxMD ebfeea1087 feat(chrome): Implement user filtering media by type 2024-01-15 16:02:10 -05:00
FoxxMD ddca623792 Fix error causing schema to generate 2024-01-15 16:01:36 -05:00
FoxxMD f0da35d115 docs: Add local source reference to flatpak manifest 2024-01-15 14:18:29 -05:00
FoxxMD 10eb355f92 chore(chromecast): Switch to scoped chromecast-client to fix flatpak source generation limitation 2024-01-15 14:18:14 -05:00
FoxxMD 85702c534a feat(maloja): Improve error handling
* Build error causes based on known error response types and expected maloja error body
* Only retry requests if error is network-related
* Set upstream error as NOT showstopping if we find expected error structure and status is less than 500
* Better maloja error parsing and logging
2024-01-15 12:45:01 -05:00
FoxxMD e2dfe317a9 feat(chrome): Improve mdns discovery for docker
* Switch to lower-level mdns library for more control during debugging
* Use avahi-browse with host avahi-daemon to circumvent musl (alpline) issues with mdns resolution https://johnsiu.com/blog/alpine-linux/
2024-01-12 17:00:05 -05:00
FoxxMD 7008ae8672 feat(chrome): Handle applications with transportId context
* Add transportId shortname to logger label for better differentiation of "per session" apps
* Fix handling of MS player deletion when pruning apps by checking for other valid apps before deletion
2024-01-12 13:26:53 -05:00
FoxxMD 10d9fa3581 feat(chrome): Improved app logging
Use child winston logger for better readability
2024-01-12 12:58:14 -05:00
FoxxMD 2b85927739 feat(chrome): Don't use bad media data as a play
* Check media data for missing artist or track and skip play if either is missing
* Remove player if bad data for 60 seconds
2024-01-12 12:31:35 -05:00
FoxxMD 8c402238ed feat(chrome): Add user debugging 2024-01-12 11:38:52 -05:00
FoxxMD 758770d278 refactor(chrome): Use forked chromecast-client lib
* Instead of patching using forked lib with validation fixes and improvements
* Print raw data if validation error is found
2024-01-12 11:12:21 -05:00
FoxxMD ee7ebb159b fix(chrome): Fix ENV names 2024-01-12 10:14:31 -05:00
FoxxMD f47a66060f refactor(chrome): Proper package patch for optional/nullish validation fixes
https://github.com/dantaylor3/chromecast-client/pull/14
2024-01-12 10:14:17 -05:00
FoxxMD 8929a630ed docs: chromecast docs and schema 2024-01-11 15:25:06 -05:00
FoxxMD 077f5d7547 feat: Improve chromecast handling 2024-01-11 14:51:52 -05:00
FoxxMD 369ad42946 feat: Add initial Chromecast source 2024-01-10 16:43:34 -05:00
FoxxMD 4bd996eb46 Merge branch 'develop' 2024-01-10 08:45:47 -05:00
FoxxMD a6ddc2b9f5 chore: Bump version for release 2024-01-10 08:45:10 -05:00
FoxxMD afb2e76b2d feat: Improve track credits parsing to recognize post-feat suffixes
* Refactor credit parsing into two stages: joiner separation and credits parsing
  * Break credit parsing into wrapped vs. non-wrapped for simpler regexes
  * Implement suffix matching after credits
* Add tests for wrapped vs. non-wrapped credits and with suffixes
2024-01-09 11:50:42 -05:00
FoxxMD 47812546e3 Merge branch 'playCompletedContext' into develop 2024-01-08 14:07:02 -05:00
FoxxMD 5ab6b85b30 refactor(source): Implement more granular initialization
* Break up initialize into build data => check connection => check auth
* Implement stub methods in AbstractSource with default return for steps
* Refactor existing initialize overrides into steps for each source
2024-01-05 13:19:34 -05:00
FoxxMD 9709d6348a refactor(source): Start consolidating isReady behavior 2024-01-05 11:15:30 -05:00
FoxxMD 10fea9b23d feat(source): Improve (re)start behavior and ui
* Move (re)start to same location on status card as client and change verbage to be the same
* Fix api behavior to use correct poll function so that backlog is triggered
2024-01-05 11:06:09 -05:00
FoxxMD 051dd5d128 feat(player): Improved player deletion behavior
* Consolidate playerDelete event emitter to deletePlayer function
* Consolidate logging for player deletion to deletePlayer function
* Add removal reason when transferring player state for single-platform sources
2024-01-04 11:10:39 -05:00
FoxxMD 369e18b1c2 feat(player): Handle state transfer for single-platform sources
The assumption is that single platform sources (like spotify) can only play from one platform at a time. If they are smart (like Spotify) the "new" platform picks up where the old platform left off WRT playing music. This commit implements the same behavior for MS player state by transferring existing state from the existing (previous) platform to the newly discovered platform.
2024-01-04 10:57:03 -05:00
FoxxMD fd49d17de4 Remove unused import 2024-01-04 08:49:14 -05:00
FoxxMD e0f034fa99 feat: Implement play completion context #121
* Track datetime player "completes" play at
* Add scrobble datetime SOC metadata
  * Log SOC used when printing play datetime
* Use hinted SOC scrobble datetime when comparing existing scrobbles
* Use hinted SOC scrobble datetime when building scrobbler client scrobble payload
* Hint Spotify backlog plays use END play date for scrobble SOC
2024-01-03 12:42:27 -05:00
Matt Foxx 80bc629ac2 Merge pull request #123 from Bujiraso/issue-122
Correct json-schema.app links
2024-01-02 20:54:39 -05:00
Bujiraso 1a90c23e76 fix: correct json-schema.app links
* Add missing 'backend' folder
  * Change 'develop' branch to 'master'
2024-01-02 18:41:44 -04:00
FoxxMD 48ddd4272f feat(scrobbler): Improve log readability for duplicate checking 2024-01-02 15:38:13 -05:00
FoxxMD ea408ac7a4 feat(scrobbler): Use play data to extract non-joined track name when comparing for duplicate check 2024-01-02 15:25:38 -05:00
FoxxMD 79c48af3e9 chore(tests): Fix TS compile warning on test for bad data 2024-01-02 15:24:24 -05:00
FoxxMD 12c2c0a132 feat(scrobbler): Change fuzzy diff threshold to be inclusive 2024-01-02 14:46:33 -05:00
FoxxMD 78abfc2168 refactor: Temporal comparison result data improvements #121
* Refactor using 'close' boolean to 'match' granularity
  * Makes using granularity for future logic easier
  * Easier logging for granularity in summary
* Remove intermediate temporal functions in classes for DRY and so we can use comparison results
* Add Time Detail to match breakdown for more visibility during logging
2024-01-02 13:55:53 -05:00
FoxxMD 0f721b94c2 refactor: Move Play time comparison code into own file 2024-01-02 13:02:44 -05:00
FoxxMD f126bf0ec0 refactor: Improve string comparison robustness
* Fix undefined token inclusion caused by token lists with differing lengths
* Always compare longer to shorter string so sameness parameter order is invariant
* Add comments to make logic easier to understand
* Add tests to test new functionality
2024-01-02 12:38:24 -05:00
FoxxMD 064a435c79 Simplify debug mode usage for testing 2024-01-02 12:34:19 -05:00
FoxxMD c0e5bb872a fix(lastfm): Fix handling of non-string mbid string values #114 2024-01-02 11:09:51 -05:00
FoxxMD e9561b5783 fix(jellyfin): Split musicbrainz mbid artist identifier semicolon #114
Jellyfin can apparently return multiple artist mbids but does so in one string deliminated by a semicolon
2023-12-28 10:07:11 -05:00
FoxxMD 77c3a2f0c7 fix(listenbrainz): Treat 400 responses as non-showstopping #114 2023-12-28 09:56:25 -05:00
FoxxMD db4e64dc2a Merge remote-tracking branch 'origin/develop' into develop 2023-12-27 11:55:45 -05:00
FoxxMD fcc05a8b19 feat(scrobblers): Implement user-initiated (re)start mechanism #114
* Add api endpoint for restarting client
* Implement (re)start link on client card
2023-12-27 11:50:57 -05:00
FoxxMD c37d94e71b refactor(scrobblers): Consolidate scrobbler client startup surface area
* Move logging for init/auth into client instead of builder class
* Move startup from builder class to main backend and use initScrobbleMonitoring() instead of calling init/auth individually

Paves the way for manual restarting #114
2023-12-27 11:02:15 -05:00
FoxxMD aa59bc4607 Fix service documentation link #119 2023-12-20 13:31:40 -05:00
FoxxMD a993df4f0b docs: Add contributing guidelines and PR template 2023-12-12 10:01:58 -05:00
Matt Foxx 7e83e0f085 Merge pull request #118 from brunohpaiva/develop
fix(spotify): undefined player state data when in private session
2023-12-12 09:40:17 -05:00
Bruno Henrique Paiva 5b2449adb0 fix(spotify): undefined player state data when in private session 2023-12-12 11:37:47 -03:00
FoxxMD 5811912745 feat(webscrobbler): Log the best-guess URL for requests using configured slug 2023-12-04 11:13:20 -05:00
FoxxMD b7d6e9e497 docs: Update schema for logging enums 2023-12-04 11:06:14 -05:00
FoxxMD 5fd0cc3fe4 docs(webscrobbler): Move slug into data #111 2023-12-04 11:06:02 -05:00
FoxxMD 6e2e6f4492 fix(webscrobbler): Correct ENV names to match those documented #111 2023-12-04 11:02:19 -05:00
FoxxMD ed6f7cfe5f docs(webscrobbler): Add where to find connector list #112 2023-12-04 10:47:27 -05:00
FoxxMD 6b17babd7a fix(mopidy): Fix album artist parsing #110
Should be using mapped artist string name rather than model but cannot test due to mopidy being difficult. Hopefully this works?
2023-11-30 11:15:49 -05:00
FoxxMD c77e4355fc fix(player): Fix listen range default value 2023-11-30 11:14:52 -05:00
FoxxMD 25de65f41f chore: bump package-lock version 2023-11-30 11:13:59 -05:00
FoxxMD af037ee215 Merge branch 'develop' 2023-11-29 11:41:17 -05:00
FoxxMD 39c4764b40 chore: Bump releases in flatpak 2023-11-29 11:40:59 -05:00
FoxxMD ec28d2aeb5 Merge branch 'develop' 2023-11-29 11:22:22 -05:00
FoxxMD baf8635587 chore: Bump version for release 2023-11-29 11:22:07 -05:00
FoxxMD a3839a28a2 feat(scrobbler): Use listenedFor when checking for existing scrobbles
* Parse listenedFor from maloja response data
* Compare listenedFor against playdate diff when comparing fuzzy times for existing scrobbles
2023-11-28 11:43:45 -05:00
FoxxMD 4247f545c5 feat(player): Pad scrobble listen duration when confidence is high #108
If player is calculated as playing and tracks have positional data then start and end positions can be fudged to completion if jitter is small enough

* At beginning of new track if position is within 3 seconds of start of track fudge to 1 second
* At end of old track if position is within 3 seconds of end track fudge to completion

This can be achieved with high confidence due to smart polling near end of tracks added in 1d84f07a40
2023-11-16 13:24:26 -05:00
FoxxMD c54c3ff5de docs(spotify): Add note about duration accuracy when using Spotify's Automix feature 2023-11-16 12:38:45 -05:00
FoxxMD 1d84f07a40 feat(source): Smart interval calculation based on track position and end time #108
If player has an absolute position and play has duration we can determine if polling interval will occur after play would end. In this scenario we can shorten interval to before play ends to get more accurate data about play state close to end of track and report more accurate play time.
2023-11-16 12:29:55 -05:00
FoxxMD b47c80dc11 fix(source): Fix polling interval drift
* Wake up timestamp was based on ts AFTER polling finished + interval. This means real-time interval between polling drifted by the amount of time it takes to execute polling loop. Fixed by getting ts at beginning of loop instead of at end.
* Implement interval function that can be overriding by child classes
* Format interval to make floats less ugly
2023-11-16 12:27:03 -05:00
FoxxMD 75e2b2b337 fix(player): Correctly output value of play timestamps for player based on undefined or not #107
Fixes #107
2023-11-15 10:35:00 -05:00
FoxxMD 08ca7fff35 feat: Add app version output
Output to logs and display in front end next to header
2023-11-13 12:25:07 -05:00
FoxxMD 3140df28b7 non working version display 2023-11-13 11:50:05 -05:00
FoxxMD 14546b8d74 fix(lastfm): Fix api error when listening history is private by sending session key #105
Thanks @FozzieHi for doing the legwork on finding the solution for this

Closes #105
2023-11-13 10:40:06 -05:00
FoxxMD 341e896eca feat: Implement album artist for all sources #104 2023-11-07 11:44:36 -05:00
FoxxMD b3b4636e7f feat: Implement album artist and scrobble client payload function
* Implement abstract playToClientPayload function to make future testing easier
* Refactor play object to include album artist in data
   * Refactor scrobble client payloads to include album artist, if applicable
2023-11-07 10:54:14 -05:00
FoxxMD 43f5aeb5dc fix(plex): Use album artist if present and remove "Various" artist #104 2023-11-06 12:41:05 -05:00
FoxxMD d38aabf7b8 chore(flatpak): Update metainfo and sources 2023-11-03 15:58:37 -04:00
FoxxMD c64b8d16e6 fix(spotify): Improve api error handling #103
* Discriminate between proper errors (permissions) and network errors
* Fix return value and playbackState flag set during post-auth hook
* Improve error logging using cause and upstream error
* Fix undefined destructure when device is not present in playingNow payload
2023-11-02 11:20:42 -04:00
FoxxMD 1938fc3a27 fix: Catch errors during source backlog processing #103 2023-11-02 11:16:54 -04:00
FoxxMD 36181396c7 refactor: don't stringify error when using upstream options 2023-11-02 11:16:26 -04:00
FoxxMD bb76a3b214 fix: test auth correctly on auth flow completion 2023-11-02 11:16:02 -04:00
FoxxMD 339bb7fcd2 refactor: Simplify network error detection during auth test 2023-11-02 10:07:02 -04:00
FoxxMD 3a25057d05 fix(maloja): Fix missing return value for auth test 2023-11-02 09:49:03 -04:00
FoxxMD 14ab6032f5 feat: Refactor auth testing to differentiate between network issues and actual auth errors #102
* Implement type/guard for node network error
* Refactor auth in sources & clients to flag if error is network or api based
* Retry auth on heartbeat if auth failure was caused by network
* Implement initial tests for testing client auth
* Fix tests hanging due to running async functions
2023-11-01 15:15:09 -04:00
FoxxMD 3e1f785437 Add pony-cause find by function helper 2023-11-01 15:09:06 -04:00
FoxxMD 91bb9d6089 feat: Allow disabling console and file logging through ENV
Used internally for controlling logging when testing
2023-11-01 15:08:26 -04:00
FoxxMD 9cf373e9f4 chore: Update string-sameness version 2023-11-01 09:39:37 -04:00
FoxxMD 9ec22e9000 Merge branch 'develop' 2023-10-24 09:20:25 -04:00
FoxxMD 9616860e23 chore: Bump version for release 2023-10-24 09:20:04 -04:00
FoxxMD 0f6638dfdf docs: Add queued scrobble feature highlight 2023-10-24 08:56:14 -04:00
FoxxMD fe9f49779c feat: Implement queued scrobbles display
* Show number of queued scrobbles for clients in ui
* Implement live updates to queue count
2023-10-19 13:40:43 -04:00
FoxxMD 3aab6a207b feat: Implement live client/source status updates 2023-10-19 11:53:48 -04:00
FoxxMD 49e9fd4b3b feat: Implement live updates for dead letter and scrobble counts 2023-10-19 11:41:06 -04:00
FoxxMD 0143ae62aa feat: Implement retry/remove all dead letter scrobbles
* Implement API endpoints
* Implement front-end logic and actions on dead letter page
2023-10-19 11:03:04 -04:00
FoxxMD 2882f65b69 refactor: Reduce noisy logging at INFO level 2023-10-19 10:36:57 -04:00
FoxxMD 62e9f7f620 feat: Log storage improvements and limit controls
* Store logs by levels, fixes issue where rarer warn/info are pushed out of buffer by frequent debug/verbose
  * Use FixedSizeList instead of homegrown buffer
* Implement API and ui controls for setting log limit
2023-10-13 14:28:24 -04:00
FoxxMD 57f2ebf2b5 feat: Add dead letter error to data 2023-10-13 12:12:46 -04:00
FoxxMD 884134cab9 Simplify dead letter data clear
Just reset state on GET pending instead of using effect cleanup in react component
2023-10-13 11:38:38 -04:00
FoxxMD 0c90de78ab Implement full-fat deadletter reducer
couldn't figure out a better way to handle this with just the api endpoint code
2023-10-13 11:34:22 -04:00
FoxxMD 1c5530ee2a fix: deadletter endpoint responses
Send empty response so RTK Query correctly parses response
2023-10-13 11:33:47 -04:00
FoxxMD fbb67a8fe6 feat: Implement dead letter endpoints
* Implement backend api endpoints for dead letter CRUD
* Include dead letter queue count in client status data
* Implement dead letter client view
* Partially working retry/remove actions for client (request is OK, RTK not working for update yet)
2023-10-11 12:38:43 -04:00
FoxxMD c8c5f5dcb7 docs(webscrobbler): Add disclaimer about hostname for firefox 2023-10-06 09:44:29 -04:00
FoxxMD eb449eeca6 feat: Implement WebScrobbler source 2023-10-05 13:58:20 -04:00
FoxxMD 7cfdb10813 chore: Update flatpak commit 2023-10-05 10:10:58 -04:00
FoxxMD be599b5562 Merge branch 'develop'
# Conflicts:
#	flatpak/io.github.foxxmd.multiscrobbler.metainfo.xml
2023-10-04 10:05:50 -04:00
FoxxMD 07d97a4937 chore: Bump version numbers 2023-10-04 10:05:08 -04:00
FoxxMD 7037d6f833 Merge branch 'develop' 2023-10-04 09:13:21 -04:00
FoxxMD 2deca322e7 fix(ui): For non-polling sources use last activity date for status
So that status changes if player is updated instead of waiting for track to be discovered
2023-10-03 14:16:07 -04:00
FoxxMD ab84cb396d docs(jellyfin): Add request header workaround for jellyfin #101 2023-10-03 14:08:07 -04:00
FoxxMD bb63c61ca8 docs: Clarify subsonic api compatibility 2023-10-03 14:00:57 -04:00
FoxxMD f4256d1084 docs: Update dashboard screenshot 2023-10-03 13:56:27 -04:00
FoxxMD fc87b86f52 test(player): Add player tests 2023-10-03 12:36:00 -04:00
FoxxMD 5dff617ed6 fix(player): Fix some bugs and add param for easier testing
* Fix repeat end position percent test (should be 85% not 15%)
* Fix current play/range not being reset correctly on repeat
* Add reportedTS param to facilitate testing
2023-10-03 12:35:48 -04:00
FoxxMD f2a171c596 refactor(player): Move listen progress/range functionality into own class (SRP) 2023-10-03 11:06:44 -04:00
FoxxMD 336970c1f7 feat(ui): Improve status display
* Use running/idle for scrobble client to indicate processing status
* Change polling sources status to polling/idle to better reflect behavior
* Add status indicator color to statuses
2023-10-03 10:15:18 -04:00
FoxxMD d04bc038d4 feat(ui): Implement status indicator 2023-10-03 10:13:45 -04:00
FoxxMD 1ab12355f3 feat(player): Implement seek and repeat detection
* Start new listening ranges if player has seeked forward/backwards
* Treat track repeats as new plays
2023-10-02 17:22:59 -04:00
FoxxMD fb5d9fb7b0 feat(player): Log when play fails discovery on player change 2023-10-02 13:54:55 -04:00
FoxxMD 9aae57f966 test(scrobble): Add tests for scrobble processing queue and existing comparisons 2023-10-02 13:39:33 -04:00
FoxxMD fefc82c521 feat(scrobble): Improve existing scrobble comparison logic
* Use existing time comparison function with "close" matching
* Fix wording and return value
2023-10-02 13:39:14 -04:00
FoxxMD 43ebdcb10c refactor(scrobble): Refactor scrobble delay and scrobble processing sleep behavior
* Delay scrobble based on last scrobble attempt TS
  * rename abstract scrobble func to doScrobble
  * Implement scrobble() in abstract client and track last scrobble attempt time, delay based on arg or client property
    * This way first scrobble isn't delayed and we only delay if subsequent scrobble attempts are close in time
* Make scrobble processing sleep time on empty queue configurable
2023-10-02 13:38:08 -04:00
FoxxMD adfb166320 feat(scrobble): Implement heartbeat task for scrobblers
Task does scrobble processing restart and dead letter queue processing
2023-09-29 15:07:44 -04:00
FoxxMD 1c54ae6465 feat(scrobble): Implement dead letter queue processing
* Implemented logic for processing and removing scrobbles from dead letter queue
  * Only retry scrobbles under X retries based on config or argument
  * Add removal-only function (for later use with UI)
2023-09-29 15:07:08 -04:00
FoxxMD 9309f7d8cf feat(scrobble): Initial scrobble queue implementation
* Move scrobble checking into client (out of client manager) for single responsibility
* Implement scrobble queue/deadletter
  * Less chance of duplicate processing since sources can't scrobble concurrently
  * Better throttling since multiple sources can't scrobble concurrently
  * When scrobbling fails we can retry on delay just like polling and *keep* the failed scrobbles for later retry rather than dropping them
  * Deadletter (to be fully implemented) for scrobbles that fail multiple times or are signalled as not upstream api issues by client
2023-09-29 14:15:32 -04:00
FoxxMD 8717fad08a feat(source): Control backlogging via config #100 2023-09-29 11:28:32 -04:00
FoxxMD 983b4c7417 feat(maloja): Add listened duration to scrobble payload 2023-09-28 15:23:14 -04:00
FoxxMD a663e9af14 feat(player): Defer play discovery so listenedFor is accurate
Wait until player gets a new play or becomes stale before trying to discover the (previous) play so that we store a more accurate amount of time the user actually listened to that play
2023-09-28 15:22:22 -04:00
FoxxMD 249a606bf5 feat(scrobble): Implement artist dup bonus scoring heuristic
When looking for dups and finding a fuzzy timestamp match, close title, and mismatch (but some matched) multiple artist -- we give a bonus to artist score since its likely a dup.
2023-09-28 12:19:11 -04:00
FoxxMD 0ec8d8814d fix(scrobble): Fix not using reference duration for fuzzy comparison 2023-09-28 11:25:33 -04:00
FoxxMD f0cee72f43 test(scrobble): Improve test durability by using mixed duration sources 2023-09-28 11:01:07 -04:00
FoxxMD a82aafa6b8 test(scrobble): Add test for duplicate when exact title/time 2023-09-28 10:47:07 -04:00
FoxxMD 3b624205ad fix: Fix missing destructure default value 2023-09-28 10:35:38 -04:00
FoxxMD c6d38e40d0 refactor(scrobble): Adjust dup score weights 2023-09-28 10:31:45 -04:00
FoxxMD 1ca7834feb test(scrobble): Add more tests for similar-but-unique scrobble detection 2023-09-28 10:31:06 -04:00
FoxxMD 77c9977d27 Use string highScore instead of weighted since cosine isn't messing things up anymore 2023-09-28 10:29:34 -04:00
FoxxMD 521130562d feat(scrobble): Use new string approach for artists 2023-09-28 09:55:09 -04:00
FoxxMD 9512ca7e84 feat(scrobble): Use new string approach for track
And move weights to constants
2023-09-27 16:01:09 -04:00
FoxxMD 159901f79e refactor(tests): Move jellyfin/listenbrainz test into own folders 2023-09-27 15:44:22 -04:00
FoxxMD 9758568ded feat: Implement new string comparison logic 2023-09-27 15:43:44 -04:00
FoxxMD a953f83d07 test(scrobble): Add more tests 2023-09-27 14:14:49 -04:00
FoxxMD 2bbe2e6ba0 refactor(scrobble): Print score breakdown in different statement
Is more readable
2023-09-27 14:14:34 -04:00
FoxxMD ec4ab8c078 test(scrobble): Implement basic scrobble tests 2023-09-27 13:20:03 -04:00
FoxxMD 7885c4ca9c fix(scrobble): Fix wrong artists used for comparison 2023-09-27 13:19:25 -04:00
FoxxMD 8371ec3a01 fix(scrobble): Fix config destructing default value 2023-09-27 13:18:58 -04:00
FoxxMD 43cbadcd2d refactor(scrobble): Clean up base design a little
* Make alreadyScrobbled abstract function
* Make sorting recentScrobbles automatic by refactoring into a getter/setter
2023-09-27 13:18:20 -04:00
FoxxMD 6e1e2c3534 Add verify example for body parser 2023-09-26 15:38:04 -04:00
FoxxMD a46389e745 Remove unused import 2023-09-26 14:08:02 -04:00
FoxxMD ce5f8d5fca fix(jellyfin): Enable parsing requests with more content-types and add logging on invalid types
* Add text/* wildcard so any text-prefixed content-type is valid (Jellyfin should always be sending json anyway)
* Add logging WARN when body is empty

Fixes #101
2023-09-26 14:03:03 -04:00
FoxxMD bdbbce4cb0 feat(jellyfin): Initial testing suite 2023-09-26 11:39:34 -04:00
FoxxMD 034cb321ef fix(flatpak): Add missing release node 2023-09-21 09:12:07 -04:00
FoxxMD ac2d453aa2 feat(config): Implement property to control whether a source/client is used
* QoL improvement -- Instead of needing to rename a config file so it is not read by MS a user can now use the 'enable' property in all source/client configs to enable or disable loading the config
* Added property to all config examples
2023-09-20 11:31:15 -04:00
FoxxMD 860d2d953f fix(youtube): Use updated yt music library #98 2023-09-19 09:22:22 -04:00
FoxxMD a486102747 feat(lastfm): Add player support
* Use lastfm 'nowPlaying' property in user listens for MS player implementation
* Do not use MS player as source of truth -- continue to use lfm listen history
2023-09-18 15:21:25 -04:00
FoxxMD 735264cb2b feat(listenbrainz): Add player support #74
* Use listenbrainz 'playing-now' endpoint for MS player implementation
* Do not use MS player as source of truth -- continue to use LZ listen history
2023-09-18 14:58:18 -04:00
FoxxMD 7dece447df feat(player): Add sourceOfTruth variable to control logging and future use
Will not be used by listenbrainz source for actual scrobbling so make logs less noisy by suppressing what would normally be logging on newely added tracks from player
2023-09-18 14:56:23 -04:00
FoxxMD 3fb5d6aae9 fix(ui): Remove duration percent display when duration is zero 2023-09-18 14:40:22 -04:00
FoxxMD d5dfc33fce fix(deezer): Fix deezer passport generation 2023-09-14 12:51:16 -04:00
FoxxMD 6dc40e50ad feat: Implement Base URL
* Allows users to define a base URL to be used for default redirect URI generation
  * Convenience mostly for docker users so they do not have to explicitly define redirect uri for each source/client
* Use in UI start up logging hints
2023-09-14 12:38:20 -04:00
FoxxMD b8ebd8ca7e chore: Ensure production mode for webpack without relying on env 2023-09-14 10:34:07 -04:00
FoxxMD e9eb7d357f fix*(scrobblers): Test auth when client completes auth callback 2023-09-14 10:07:44 -04:00
FoxxMD 3f2cb8ac0c fix: Pass localUrl to scrobble clients for use by lastfm 2023-09-14 10:07:12 -04:00
FoxxMD 8e36c27ec9 fix(backend): Fix how port is determined and used for localUrl 2023-09-14 10:06:42 -04:00
FoxxMD fa90a3b821 fix: Load variables from .env for backend
Was being used for frontend (cra) but not applied to backend
2023-09-14 10:05:24 -04:00
FoxxMD 2d2319fa27 fix(ui): Fix wrong route for client auth #97 2023-09-14 09:08:01 -04:00
FoxxMD ba161b7b92 chore: Remove unused dashboard backend endpoint 2023-09-14 09:07:36 -04:00
FoxxMD d438c8b256 fix(docker): Normalize ownership of node_modules files
files in node_module can have wierd ownership based on how a package owner published the package. If this occurs a rootless container build system could potentially have issues with subui/gid mapping for non-standard (high number) ids.

During image build recursively chown all node_modules files to normalize ownership and avoid this problem.

Fixes #96
2023-09-13 14:55:04 -04:00
FoxxMD bbb4555c0e chore(docker)!: Bump lsio base image version to 3.18
* Updates alpine 3.17 -> 3.18
  * Main reason is to potentially fix DNS issues related to musl #88
    * https://github.com/nodejs/node/issues/15780#issuecomment-334496907 and https://bell-sw.com/blog/how-to-deal-with-alpine-dns-issues/
    * Resolved in musl 1.2.4
      * https://gitlab.alpinelinux.org/alpine/tsc/-/issues/43
      * https://www.openwall.com/lists/musl/2023/05/02/1
  * Bumps npm 9.1.2 -> 9.6.6

New/changed ENVs used by LSIO may require removing "stored" ENVs if using portainer
2023-09-13 13:21:44 -04:00
FoxxMD 6e8ea96f32 fix: Fix js endings 2023-09-13 12:19:58 -04:00
FoxxMD b06de77f42 Merge branch 'player' into develop 2023-09-13 12:13:17 -04:00
FoxxMD 3aa8279914 Merge branch 'develop' 2023-09-13 12:02:51 -04:00
FoxxMD a64724e114 chore: Bump version for release 2023-09-13 12:02:38 -04:00
FoxxMD 052234a3d9 Merge branch 'develop' 2023-09-13 12:02:04 -04:00
FoxxMD aa2c565afd fix(ui): Log line parsing missing line terminator capture 2023-09-13 11:59:27 -04:00
FoxxMD d0fd225d99 chore: Use constant for default retry multiplier 2023-09-13 11:40:29 -04:00
FoxxMD c40acdd7c7 feat!: Decrease default polling interval
* Due to increased scrobbled thresholds in 590610022d we need to pol more frequently to make sure more of the track is accurately monitored
* Decreased default from 30 to 10 seconds
* Decreased max interval from 60 to 30 seconds
2023-09-13 11:36:22 -04:00
FoxxMD 590610022d feat!: Set default scrobble thresholds to standard values #73
https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
https://www.last.fm/api/scrobbling

The defaults are 50% of the track or 4 minutes.
2023-09-13 10:49:54 -04:00
FoxxMD f7dae32f50 feat(maloja): Generate web links for scrobbled tracks 2023-09-12 16:01:32 -04:00
FoxxMD 3cbbc0837f feat(ui): Expand displayed plays
* Implement API for fetching client recently scrobbled
* Reduce text noise by combining track stats and link for recent
2023-09-12 15:46:49 -04:00
FoxxMD 0197e70a1a feat(ui): Remove unnecessary wording for track stats 2023-09-12 14:58:20 -04:00
FoxxMD 40a7d9e398 feat(ui): Make player dates human friendly 2023-09-12 14:57:41 -04:00
FoxxMD c7b2dcbbbe fix(ui): Use normal match parameters
groups are undefined probably due to minification?
2023-09-12 12:11:39 -04:00
FoxxMD a524e18820 feat(ui): More ui compaction
* Better use of header
  * Show name on separate line
  * Only show client/source if ambiguous
  * Move status into right side of header
* Reduce padding/margin on card grid
2023-09-12 12:03:49 -04:00
FoxxMD 81894d691b feat(ui): Compact ui a bit
* Reduce padding on card headers and bodies
* Improve log experience
  * Reduce log font size and convert to mono
  * Use proper log message parsing to remove dangerous html set
2023-09-12 11:07:39 -04:00
FoxxMD 0fb192dd41 refactor(ui): Prevent nodemon from restarting on tailwind config change 2023-09-12 11:05:56 -04:00
FoxxMD 3f608c6c8e fix(listenbrainz): Artist parsing joiner phrases must begin with non-word character
Prevents artists with joiner phrases in their name (EX 'Daft Punk' contains 'ft') from erroneously being split

Fixes #94
2023-09-12 09:29:04 -04:00
FoxxMD dcff9ee73d feat(player state): Implement player status and listened duration in UI 2023-09-11 13:21:24 -04:00
FoxxMD f1cd66d3e8 feat(player state): Preserve position when player is not in stopped status
Makes for a nicer UI experience to see where the player was paused
2023-09-11 13:20:58 -04:00
FoxxMD d95fef09a3 refactor(player state): Remove old play logic
Recent plays are dead. Long live recent plays.
2023-09-11 12:38:10 -04:00
FoxxMD d1060e724e feat(ui): Remove player when dead 2023-09-11 12:36:10 -04:00
FoxxMD a7faaaac86 refactor(player state): Refactor handling of non-updated players
* Check for stale/orphaned/dead in independent loop so they are not dependent on getting source updates to update
* Only emit events if player state has updated
2023-09-11 12:35:41 -04:00
FoxxMD 3762f95bae fix(player state): Fix stale/dead logic checks
* Stale should not reset to stale when status is orphaned
* Dead check should be in seconds, not minutes
2023-09-11 12:34:09 -04:00
FoxxMD b9f04deb28 Always emit player state 2023-09-08 16:35:20 -04:00
FoxxMD 9cc820c76e feat: More player polish and additional info pane 2023-09-08 16:33:23 -04:00
FoxxMD f7195fb40c feat: Rework source api to include player states
* Create and implement api interface for player state data
* Serialize memory source players to object indexed by platform id
* Refactor event emitters to separate event data better from event metadata
* Update source slice when player update event is broadcast
2023-09-08 14:47:53 -04:00
FoxxMD 9c0f029992 feat: POC player
Based on https://codepen.io/keithpickering/pen/wjYJKM
2023-09-08 13:40:43 -04:00
FoxxMD f370b8ad1e fix: Update heartbeat job to 20 minutes
1 minute was for debugging...
2023-09-05 11:44:22 -04:00
FoxxMD 7c962ec43a fix: Fix more import endings 2023-09-05 11:19:13 -04:00
FoxxMD 2013a6692e fix: Fix import ending 2023-09-05 11:18:15 -04:00
FoxxMD 4a5eb91f16 feat: Schedule heartbeat task to restart sources that should be polling
Addresses #5 and #65 -- in default config use-cases where:

 * request/polling retry attempts are low
 * source was initially OK (auth OK, initial communication OK)
 * and there is a non-auth related *upstream* communication issue

 there is no fallback path for restarting source polling after upstream has normalized.

 This feat implements a scheduled heartbeat task that attempts to restart polling every 20 minutes if this scenario is valid so that a good source config will recover without user intervention.
2023-09-05 11:11:10 -04:00
219 changed files with 15041 additions and 18649 deletions
+2
View File
@@ -10,6 +10,7 @@ Dockerfile
.idea
config/*.json
config/*.bak
config/*.p8
/docs
/logs
.flatpak-builder
@@ -20,3 +21,4 @@ docsite/node_modules
docsite/.docusaurus
docsite/.cache-loader
build
*.secrets
+20
View File
@@ -0,0 +1,20 @@
## Checklist before requesting a review
- [ ] **I am opening this PR for the [`develop` branch](https://github.com/FoxxMD/multi-scrobbler/tree/develop) and NOT `master`.**
- [ ] I have read the [contributing guidelines.](../CONTRIBUTING.md)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## Describe your changes
## Issue number and link, if applicable
+3 -3
View File
@@ -12,10 +12,10 @@ jobs:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: npm
- name: Install dependencies
+25 -11
View File
@@ -15,24 +15,36 @@ jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./Dockerfile
suffix: ''
platforms: 'linux/amd64,linux/arm64'
- dockerfile: ./debian.Dockerfile
suffix: '-debian'
# can't build arm64 due to a TLS issue when running npm install??
# https://github.com/FoxxMD/multi-scrobbler/issues/126
platforms: 'linux/amd64'
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -40,30 +52,32 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: |
foxxmd/multi-scrobbler
ghcr.io/foxxmd/multi-scrobbler
# generate Docker tags based on the following events/attributes
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}},suffix=${{ matrix.suffix }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
flavor: |
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platforms }}
+2
View File
@@ -125,8 +125,10 @@ src/**/**.js
src/**/**.js.map
*.bak
*.p8
.flatpak-builder
flatpak/generated-sources.json
*.secrets
build
!setupProxy.js
-1
View File
@@ -1,4 +1,3 @@
{
"require": ["./register.js", "source-map-support/register"],
"reporter": "dot"
}
+8
View File
@@ -0,0 +1,8 @@
# Creating a Pull Request
Please follow these guidelines when contributing code to this repository:
* The PR **must be for the [`develop` branch.](https://github.com/FoxxMD/multi-scrobbler/tree/develop)** The `master` branch is for releases only.
* Use [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format when creating commits.
* Preferably, please use a [feature branch](https://stackoverflow.com/a/39586780/1469797) instead of committing directly to `develop`.
* Ensure that if your code is covered by [an existing test](./src/backend/tests) that you have updated the test accordingly
+10 -7
View File
@@ -1,4 +1,4 @@
FROM lsiobase/alpine:3.17 as base
FROM lsiobase/alpine:3.18 as base
ENV TZ=Etc/GMT
@@ -6,6 +6,8 @@ RUN \
echo "**** install build packages ****" && \
apk add --no-cache \
alpine-base \
avahi \
avahi-tools \
git \
nodejs \
npm \
@@ -24,7 +26,8 @@ ENV CONFIG_DIR=$data_dir
COPY docker/root/ /
RUN npm install -g patch-package
RUN npm install -g patch-package \
&& chown -R root:root /usr/local/lib/node_modules/patch-package
WORKDIR /app
@@ -39,7 +42,8 @@ COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
RUN npm install
RUN npm install \
&& chown -R root:root node_modules
#RUN yarn install
COPY --chown=abc:abc . /app
@@ -55,7 +59,8 @@ FROM base as app
#COPY --chown=abc:abc package.json yarn.lock ./
COPY --chown=abc:abc package*.json ./
COPY --chown=abc:abc patches ./patches
COPY --from=build --chown=abc:abc /app/build /app/build
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
COPY --from=base /usr/local/bin /usr/local/bin
COPY --from=base /usr/local/lib /usr/local/lib
@@ -72,9 +77,7 @@ ENV IS_DOCKER=true
RUN npm install --omit=dev \
&& npm cache clean --force \
&& chown abc:abc node_modules \
&& rm -rf node_modules/ts-node \
&& rm -rf node_modules/typescript \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/@types
ARG webPort=9078
+5 -2
View File
@@ -12,8 +12,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Supports scrobbling from many **Sources**
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
* [WebScrobbler](/docsite/docs/configuration/configuration.md#webscrobbler)
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz--source-)
@@ -22,6 +23,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Mopidy](/docsite/docs/configuration/configuration.md#mopidy)
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
* [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-)
* Supports scrobbling to many **Clients**
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
@@ -29,6 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Install using [Docker images for x86/ARM](/docsite/docs/installation/installation.md#docker), [flatpak](/docsite/docs/installation/installation.md#flatpak), or [locally with NodeJS](/docsite/docs/installation/installation.md#nodejs)
@@ -44,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src="/assets/status-ui.jpg" width="800">
<img src="/assets/status-ui.png" width="800">
## How Does multi-scrobbler (MS) Work?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+2 -2
View File
@@ -77,7 +77,7 @@ export default function transformer(
if(hasNoExt) {
const dirFiles = fs.readdirSync(dir)
filenameFromDir = dirFiles.find(x => path.parse(x).name === pathInfo.name);
if(filenameFromDir !== undefined) {
if(filenameFromDir === undefined) {
fileExists = false;
isRelative = false;
} else {
@@ -159,7 +159,7 @@ export default function transformer(
}
break;
default:
transformedImport = combineDirFile(pathInfo.dir, `.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
transformedImport = combineDirFile(pathInfo.dir, `${path.parse(filenameFromDir).name}.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
break;
}
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "MyCast",
"data": {
"blacklistedDevices": [],
"whitelistedDevices": [],
"blacklistedApps": [],
"whitelistedApps": []
}
}
]
+2
View File
@@ -21,6 +21,7 @@
"sources": [
{
"type": "spotify",
"enable": true,
"clients": ["myConfig"],
"name": "mySpotifySource",
"data": {
@@ -33,6 +34,7 @@
"clients": [
{
"type": "maloja",
"enable": true,
"name": "myConfig",
"data": {
"url": "http://localhost:42010",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "FoxxMDeezer",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyJellyfin",
"enable": true,
"clients": [],
"data": {
"users": ["FoxxMD"],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyJriver",
"enable": true,
"data": {
"url": "0.0.0.0",
"username": "auser",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyKodi",
"enable": true,
"data": {
"url": "http://localhost:8080/jsonrpc",
"username": "myUser",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "myLastFm",
"enable": true,
"configureAs": "client",
"data": {
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "brainz",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "myMaloja",
"enable": true,
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyMopidy",
"enable": true,
"data": {
"url": "localhost",
"uriBlacklist": [],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "ubuntu",
"enable": true,
"data": {
"whitelist": ["vlc", "mpd"],
"blacklist": ["spotify"]
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyPlex",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MySpotify",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
+2 -1
View File
@@ -1,10 +1,11 @@
[
{
"name": "MySubsonic",
"enable": true,
"data": {
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
"password": "yourPassword",
"password": "yourPassword"
}
}
]
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyTautuilli",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "MyWebScrobbler",
"data": {
"slug": null,
"whitelist": [],
"blacklist": []
}
}
]
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyYTMusic",
"enable": true,
"clients": [],
"data": {
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
+116
View File
@@ -0,0 +1,116 @@
#FROM ghcr.io/linuxserver/baseimage-ubuntu:jammy as base
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
ENV TZ=Etc/GMT
ENV NODE_VERSION 18.19.0
# borrowing openssl header removal trick from offical docker-node
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
RUN \
ARCH= OPENSSL_ARCH= && dpkgArch="$(dpkg --print-architecture)" \
&& case "${dpkgArch##*-}" in \
amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \
ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \
s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \
arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \
armhf) ARCH='armv7l' OPENSSL_ARCH='linux-armv4';; \
i386) ARCH='x86' OPENSSL_ARCH='linux-elf';; \
*) echo "unsupported architecture"; exit 1 ;; \
esac && \
set -ex && \
echo "**** install build packages ****" && \
apt-get update && \
apt-get install --no-install-recommends -y \
#ca-certificates \
xz-utils \
curl && \
echo "**** Fetch and install node****" && \
# get node/npm directly from nodejs dist \
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L41
curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr --strip-components=1 --no-same-owner && \
rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
ln -s /usr/bin/node /usr/bin/nodejs && \
# curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
# apt-get install --no-install-recommends -y nodejs && \
npm update -g npm && \
echo "**** cleanup ****" && \
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L49
# Remove unused OpenSSL headers to save ~34MB
# (does not affect arm64 issue below)
find /usr/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && \
apt-get purge --auto-remove -y perl xz-utils && \
apt-get autoclean && \
apt-get autoremove && \
rm -rf \
/config/.cache \
/root/cache \
/var/lib/apt/lists/* \
/var/tmp/* \
/tmp/*
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ARG data_dir=/config
VOLUME $data_dir
ENV CONFIG_DIR=$data_dir
COPY docker/root /
RUN npm install -g \
patch-package \
&& chown -R root:root /usr/lib/node_modules/patch-package
WORKDIR /app
FROM base as build
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
# for debugging, so the build fails faster when timing out (arm64)
#RUN npm config set fetch-retries 1 && \
# npm config set fetch-retry-mintimeout 5000 && \
# npm config set fetch-retry-maxtimeout 5000
# https://www.npmjs.com/package/tls-test
# used to test that the OS supports downloading packages over HTTPS with TLS 1.2 enforced
# -- this always succeeds but a good sanity check
#RUN npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
# This FAILS when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
RUN npm ci \
--verbose \
# --no-audit \
&& chown -R root:root node_modules
# running with --no-audit does not have any affect even though the error only logs with prefix 'npm verb audit error'
COPY --chown=abc:abc . /app
# need to set before build so server/client build is optimized and has constants (if needed)
ENV NODE_ENV=production
RUN npm run build && rm -rf node_modules
FROM base as app
COPY --chown=abc:abc package*.json ./
COPY --chown=abc:abc patches ./patches
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
COPY --from=base /usr/bin /usr/bin
COPY --from=base /usr/lib /usr/lib
ENV NODE_ENV=production
ENV IS_DOCKER=true
RUN npm ci --omit=dev \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/@types \
/root/.cache
ARG webPort=9078
ENV PORT=$webPort
EXPOSE $PORT
+4
View File
@@ -13,6 +13,10 @@ services:
#- MALOJA_API_KEY=
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
#- BASE_URL=http://MyHostIP:9078
volumes:
- /path_on_host/multi-scrobbler-config:/config
#networks:
@@ -4,6 +4,7 @@
# NODE_ARGS can be passed by ENV in docker command like "docker run foxxmd/multi-scrobbler -e NODE_ARGS=--optimize_for_size"
echo -e "\nmulti-scrobbler is starting!"
export NODE_ENV=production
cd /app || exit
exec \
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/build/server.js
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/node_modules/.bin/tsx /app/src/backend/index.ts
+56
View File
@@ -1,12 +1,18 @@
<!-- TOC -->
* [Connection Issues](#connection-issues)
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
* [Sporadic network problems on docker](#sporadic-network-problems-on-docker)
* [Configuration Issues](#configuration-issues)
* [Config could not be parsed](#config-could-not-be-parsed)
* [Scrobbling Issues](#scrobbling-issues)
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
* [Google Cast track information is missing/incorrect or MS player has weird times](#google-cast-track-information-is-missingincorrect-or-ms-player-has-weird-times)
* [Google Cast device does not track media](#google-cast-device-does-not-track-media)
<!-- TOC -->
# Connection Issues
@@ -95,6 +101,22 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
## Jellyfin has warnings about missing headers
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
```
[API] Jellyfin is not sending a request with valid headers...
```
A workaround that may fix this:
* In Webhook settings:
* [In the webhook you have already configured...](configuration/configuration.md#jellyfin)
* Add Request Header...
* **Key:** `Content-Type`
* **Value:** `application/json`
* Then Save
## Spotify/Deezer/LastFM won't authenticate
@@ -109,6 +131,16 @@ If multi-scrobbler is not running on the same machine your browser is on then th
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
## Sporadic network problems on docker
If you encounter networking issues like:
* sporadic timeouts (`ETIMEDOUT`) without a pattern
* DNS errors (`EAI_AGAIN`) that do no occur consistently
* Failures to reach a host that was previously fine (`EHOSTUNREACH`)
there may be an issue with the underlying docker image OS (alpine) that may be solved by switching to a different image. Try switching to a `*-debian` variant tag (only available for ARM hosts) to see if this resolves your issue. IE `multi-scrobbler:latest-debian` or `multi-scrobbler:develop-debian`
# Configuration Issues
## Config could not be parsed
@@ -134,3 +166,27 @@ Multi-scrobbler works the same was the official Spotify-Last.fm integration work
## Jellyfin does not scrobble tracks with multiple artists correctly
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
## Google Cast track information is missing/incorrect or MS player has weird times
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
To diagnose these issues you [**must enable payload logging**](configuration/configuration.md#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
## Google Cast device does not track media
It is likely the app playing on the cast device is incorrectly reporting the media type as **not music**.
MS logs will tell you what type the media is reported as with lines like:
```
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
```
Refer to [Allow Unknown Media Type](configuration/configuration.md#allow-unknown-media-type) section to fix this
```
My Artist - Example Track has 'movie' media type so will not track
```
Refer to [Force Media Tracking](configuration/configuration.md#forcing-media-tracking) section to fix this
+241 -22
View File
@@ -5,11 +5,14 @@ title: Overview
# Configuration
<!-- TOC -->
* [Overview](#overview)
* [ENV-Based Configuration](#env-based-configuration)
* [File-Based Configuration](#file-based-configuration)
* [All-in-One File Configuration](#all-in-one-file-configuration)
* [Specific File Configuration](#specific-file-configuration)
* [Application Options](#application-options)
* [Base URL](#base-url)
* [Source Configurations](#source-configurations)
* [Spotify](#spotify)
* [Plex](#plex)
@@ -20,17 +23,26 @@ title: Overview
* [Listenbrainz (Source)](#listenbrainz--source-)
* [Deezer](#deezer)
* [Youtube Music](#youtube-music)
* [MPRIS (Linux Desktop)](#mpris)
* [MPRIS](#mpris)
* [Mopidy](#mopidy)
* [JRiver](#jriver)
* [Kodi](#kodi)
* [WebScrobbler](#webscrobbler)
* [Multiple Users](#multiple-users)
* [Google Cast (Chromecast)](#google-cast--chromecast-)
* [Connecting Devices](#connecting-devices)
* [What Media Does MS Scrobble?](#what-media-does-ms-scrobble)
* [Cast Troubleshooting](#cast-troubleshooting)
* [Client Configurations](#client-configurations)
* [Maloja](#maloja)
* [Last.fm](#lastfm)
* [Listenbrainz](#listenbrainz)
* [Monitoring](#monitoring)
* [Webhooks](#webhook-configurations)
* [Webhook Configurations](#webhook-configurations)
* [Gotify](#gotify)
* [Ntfy](#ntfy)
* [Health Endpoint](#health-endpoint)
<!-- TOC -->
# Overview
@@ -80,7 +92,7 @@ There is also a [**kitchensink example**](kitchensink.md) that provides examples
### All-in-One File Configuration
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fcommon%2Fschema%2Faio.json)
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
Example directory structure:
@@ -168,6 +180,21 @@ Example directory structure:
See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
# Application Options
These options affect multi-scrobbler's behavior and are not specific to any source/client.
#### Base URL
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
* Default => `http://localhost`
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
# Source Configurations
## [Spotify](https://www.spotify.com)
@@ -175,6 +202,8 @@ See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config)
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a
Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
**NOTE:** If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-is-Automix/ta-p/5257278) enabled and Spotify uses it for your playlist/queue then MS cannot accurately determine when a track will end. This is because the track is "mixed" in your queue with a shorter play time than its actual length and [Spotify does not report this modified play time in its API.](https://community.spotify.com/t5/Spotify-for-Developers/Wrong-duration-ms-of-track-with-Automix/m-p/5429147) This **does not affect MS's ability to scrobble** from Spotify but it will affect the accuracy of the duration MS reports was played.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
@@ -185,7 +214,7 @@ Client ID/Secret. Make sure to also whitelist your redirect URI in the applicati
### File-Based
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Plex](https://plex.tv)
@@ -199,7 +228,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
### File-Based
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Tautulli](https://tautulli.com)
@@ -213,11 +242,11 @@ Check the [instructions](plex.md) on how to setup a notification agent.
### File-Based
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Subsonic](http://www.subsonic.org/)
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
**Known Issues:**
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
@@ -236,7 +265,7 @@ Can use this source for any application that implements the [Subsonic API](http:
### File-Based
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Jellyfin](https://jellyfin.org/)
@@ -255,6 +284,8 @@ Must be using Jellyfin 10.7 or greater
* Check `Send All Properties`
* Save
If you see errors in the MS logs regarding `missing headers` when using Jellyfin [see this workaround.](../FAQ.md#jellyfin-has-warnings-about-missing-headers)
### ENV-Based
| Environmental Variable | Required? | Default | Description |
@@ -264,7 +295,7 @@ Must be using Jellyfin 10.7 or greater
### File-Based
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Last.fm (Source)](https://www.last.fm)
@@ -276,7 +307,7 @@ No support for ENV based for Last.fm as a client (only source)
### File-Based
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Listenbrainz (Source)](https://listenbrainz.org)
@@ -288,7 +319,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
### File-Based
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
**Change `configureAs` to `source`**
@@ -320,7 +351,7 @@ After starting multi-scrobbler with credentials in-place open the dashboard (`ht
### File-Based
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Youtube Music](https://music.youtube.com)
@@ -346,7 +377,7 @@ NOTES:
### File-Based
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
@@ -370,7 +401,7 @@ multi-scrobbler can listen to this interface and scrobble tracks played by **any
### File-Based
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Mopidy](https://mopidy.com/)
@@ -380,7 +411,7 @@ multi-scrobbler can scrobble tracks played from any Mopidy backend source, regar
### File-Based
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
Configuration Options:
@@ -537,7 +568,7 @@ MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
### File-Based
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Kodi](https://kodi.tv/)
@@ -605,7 +636,195 @@ MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
### File-Based
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [WebScrobbler](https://web-scrobbler.com/)
After installing the extension open the preferences/settings for it:
* Under **Accounts**
* **Add Webhook**
* API URL: `http://localhost:9078/api/webscrobbler`
* Application name: `(whatever you want)`
Reload the extension after adding the webhook.
* **On Firefox** - Only FQNs (domain.tld), `localhost`, and `127.0.0.1` are supported for API URL due to [firefox requiring https](https://github.com/web-scrobbler/web-scrobbler/issues/4183#issuecomment-1749222006)
* **On Chromium-based Browsers** - Any domain will work for API URL
* All Other browsers are untested
#### Multiple Users
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](#file-based-configuration)
Example:
In `webscrobbler.json`
```json
[
{
"name": "aUserWS",
"clients": [
"client1Maloja"
],
"data": {
"slug": "usera"
}
},
{
"name": "bUserWS",
"clients": [
"client2Maloja"
],
"data": {
"slug": "userb"
}
}
]
```
* To use `aUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/usera`
* To use `bUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/userb`
Note: `http://localhost:9078/api/webscrobbler` is matched with the first source that _that does not have a slug defined._
##### Connectors Black/Whitelist
MS can be configured to only scrobble, or NOT scrobble, from some WS connectors. Use the name of the website from the [supported websites](https://web-scrobbler.com/) or from the **Connectors** tab in the extension. Note that this **only** affects MS's behavior and does not affect the general connector behavior you have configured within the WebScrobbler extension.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------------------------------------------|
| WS_ENABLE | No | | Set to 'true' to enable WS without needing to define other ENVs |
| WS_WHITELIST | No | | Only scrobble from these WebScrobbler Connectors. Comma-delimited list |
| WS_BLACKLIST | No | | Do not scrobble from these WebScrobbler Connectors. Comma-delimited list |
### File-Based
See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/webscrobbler.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FWebScrobblerSourceConfig/%23%2Fdefinitions%2FWebScrobblerData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Google Cast (Chromecast)](https://www.google.com/chromecast/built-in/)
**NOTE:** Google Cast support is **experimental**. You may experience crashes and errors while using this Source. Please open an issue if you experience problems and include all information detailed in the issue template to help debug your issue.
If your media device can be **Cast** to using this button ![Chromecast Icon](https://upload.wikimedia.org/wikipedia/commons/2/26/Chromecast_cast_button_icon.svg) on your phone/computer then multi-scrobbler can monitor it in order to scrobble music you play.
**Note:** This source relies on common, **basic** music data provided by the cast device which will always be less exhaustive than data parsed from full source integrations. If there is an existing [Source](#source-configurations) it is recommended to configure for it and blacklist the app on Google Cast, rather than relying solely on Google Cast for scrobbling.
### Connecting Devices
Cast devices can be manually configured using [File-based configuration](#file-based-14) OR automatically discovered using **mDNS.**
##### mDNS Discovery
The host machine running multi-scrobbler must be configured to allow [mDNS traffic on port 5353/UDP](https://book.hacktricks.xyz/network-services-pentesting/5353-udp-multicast-dns-mdns).
##### Linux
**Docker**
The host machine must have [avahi-daemon](https://avahi.org/) running to circumvent limitations with DNS resolution due to musl in Alpine. Most major linux distributions package avahi and many have it built-in. Once avahi is running you must pass D-Bus and the avahi daemon socket to your container like so:
```
docker run ... -v /var/run/dbus:/var/run/dbus -v /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket ... foxxmd/multi-scrobbler
```
**Flatpak/Nodejs**
No additional steps are required.
##### Windows
**Docker**
Unsupported at this time.
**Nodejs**
No additional steps are required.
### What Media Does MS Scrobble?
Cast devices report what type of media the current activity is [(see `metadata` property here)](https://developers.google.com/cast/docs/media/messages#MediaInformation). The reported type is dependent on the application playing the media to correctly report it, the cast device does not magically know what the media is. If an application does not report a type it is always classified as `unknown`.
**By default, MS will only track media that is reported as `MusicTrack`.**
#### Allow Unknown Media Type
Media with an Unknown (`Generic`) media type can be explicitly allowed by setting `"allowUnknownMedia": true` in the [file-based configuration.](#file-based-14) This can also be configured to only allow unknown media types for specific applications by using a list of application names like:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
// only allow unknown if app name contains any of these phrases
"allowUnknownMedia": ["smarttube", "default media receiver"]
},
}
]
```
#### Forcing Media Tracking
MS can be forced to track media from an application regardless of media type. This is useful if an application incorrectly reports a media type you are sure should be music. Set `"forceMediaRecognitionOn"` in the [file-based configuration.](#file-based-14) to a list of application names that should always be tracked like:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
// media from applications that contains these phrases will always be tracked, regardless of media type reported
"forceMediaRecognitionOn": ["smarttube", "default media receiver"]
},
}
]
```
### Cast Troubleshooting
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
//...
},
"options": {
"logPayload": true
}
}
]
```
### ENV-Based
Note: [Manually configuring cast device connections](#connecting-devices) is only available through [File-based config.](#file-based-14)
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------------------------------------------------------|
| CC_ENABLE | No | | Set to 'true' to enable Cast monitoring without needing to define other ENVs |
| CC_WHITELIST_DEVICES | No | | Only scrobble from these Cast devices. Comma-delimited list. EX mini-home, family-tv |
| CC_BLACKLIST_DEVICES | No | | Do not scrobble from these Cast devices. Comma-delimited list |
| CC_WHITELIST_APPS | No | | Only scrobble from these casted Apps. Comma-delimited list. EX spotify, pandora |
| CC_BLACKLIST_APPS | No | | Do not scrobble from these casted Apps. Comma-delimited list |
### File-Based
See [`chromecast.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FChromecastSourceConfig/%23%2Fdefinitions%2FChromecastData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
# Client Configurations
@@ -620,7 +839,7 @@ See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/
### File-Based
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
## [Last.fm](https://www.last.fm)
@@ -643,7 +862,7 @@ or replace `localhost:9078` with your own base URL
### File-Based
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
## [Listenbrainz](https://listenbrainz.org)
@@ -662,7 +881,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
### File-Based
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
# Monitoring
@@ -711,7 +930,7 @@ Webhooks are configured in the main [config.json](#all-in-one-file-configuration
### [Gotify](https://gotify.net/)
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
@@ -733,7 +952,7 @@ EX
### [Ntfy](https://ntfy.sh/)
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
+51 -1
View File
@@ -22,6 +22,25 @@ npm run build
npm run start
```
#### Rollup build error
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
```json
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
}
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.9.6"
}
}
```
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
### Usage Examples
* The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
@@ -46,6 +65,11 @@ You must have [Flatpak](https://flatpak.org/) installed on your system.
flatpak install flathub io.github.foxxmd.multiscrobbler
```
**Note:** Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
* to use a [Docker](#docker) installation if possible or
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
### Usage Examples
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
@@ -84,7 +108,7 @@ Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise these will be lost when the container is updated.
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
@@ -98,6 +122,20 @@ The default container port is `9078`. To map container to host port:
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
@@ -118,8 +156,20 @@ To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
### Network Issues
If you encounter networking issues like:
* sporadic timeouts (`ETIMEDOUT`) without a pattern
* DNS errors (`EAI_AGAIN`) that do no occur consistently
* Failures to reach a host that was previously fine (`EHOSTUNREACH`)
there may be an issue with the underlying docker image OS (alpine) that may be solved by switching to a different image. Try switching to a `*-debian` variant tag (only available for ARM hosts) to see if this resolves your issue. IE `multi-scrobbler:latest-debian` or `multi-scrobbler:develop-debian`
## Docker Usage Examples
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
```bash
+5 -2
View File
@@ -15,8 +15,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Supports scrobbling from many **Sources**
* [Spotify](docs/configuration#spotify)
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](docs/configuration#jellyfin)
* [WebScrobbler](docs/configuration#webscrobbler)
* [Youtube Music](docs/configuration#youtube-music)
* [Last.fm](docs/configuration#lastfm-source)
* [ListenBrainz](docs/configuration#listenbrainz--source-)
@@ -25,6 +26,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
* [Kodi](docs/configuration#kodi)
* [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
@@ -32,6 +34,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
@@ -47,7 +50,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src={require('/img/status-ui.jpg').default} width="800"/>
<img src={require('/img/status-ui.png').default} width="800"/>
## How Does multi-scrobbler (MS) Work?
+1
View File
@@ -8,6 +8,7 @@
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
"baseUrl": "http://localhost",
"sources": [
{
"type": "spotify",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+1 -1
View File
@@ -36,7 +36,7 @@ From MS project root:
```shell
cd flatpak
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
```
Add `--install --user` to have the app installed immediately.
# 5. Run (Locally)
@@ -7,8 +7,8 @@
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
<name>multi-scrobbler</name>
<summary>Scrobbles music from many sources to many clients</summary>
<url type="homepage">https://github.com/FoxxMD/multi-scrobbler</url>
<url type="help">https://github.com/FoxxMD/multi-scrobbler/blob/flatpak-clean/docs/configuration.md</url>
<url type="homepage">https://foxxmd.github.io/multi-scrobbler</url>
<url type="help">https://foxxmd.github.io/multi-scrobbler/docs/configuration</url>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<description>
@@ -20,11 +20,15 @@
<li>Plex (Tautulli)</li>
<li>Any subsonic-based music player (Airsonic)</li>
<li>Jellyfin</li>
<li>WebScrobbler</li>
<li>Mopidy</li>
<li>Deezer</li>
<li>JRiver</li>
<li>Kodi</li>
<li>Webscrobbler</li>
<li>Youtube Music</li>
<li>MPRIS (linux desktop)</li>
<li>Google Cast (Chromecast)</li>
</ul>
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
<ul>
@@ -37,10 +41,17 @@
<screenshots>
<screenshot type="default">
<caption>Browser-based UI</caption>
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/7b0f5801a1aa728986f440f4c67fe158cecd763d/assets/status-ui.jpg</image>
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/f4256d1084f4bdea2945c03e523af6d0ed89b3ae/assets/status-ui.png</image>
</screenshot>
</screenshots>
<releases>
<release version="0.6.5" date="2024-02-05"/>
<release version="0.6.4" date="2024-01-29"/>
<release version="0.6.3" date="2024-01-10"/>
<release version="0.6.2" date="2023-11-29"/>
<release version="0.6.1" date="2023-10-24"/>
<release version="0.6.0" date="2023-10-04"/>
<release version="0.5.2" date="2023-09-21"/>
<release version="0.5.0" date="2023-08-29"/>
<release version="0.4.6" date="2023-06-07"/>
<release version="0.4.4" date="2023-03-20"/>
+18 -8
View File
@@ -27,7 +27,7 @@ modules:
- NODE_ENV=production npm run build
# makes node and npm available
- mkdir -p /app/bin /app/lib /app/lib/build
- mkdir -p /app/bin /app/lib /app/lib/dist /app/lib/src /app/lib/assets
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
- rm -r /app/lib/node_modules/npm/{docs,man}
@@ -36,11 +36,18 @@ modules:
- npm prune --production
# copy node_modules needed to run app
- cp -r node_modules/* /app/lib/node_modules
- cp -r node_modules/. /app/lib/node_modules
# copy app files to runtime dir
- cp -r build/* /app/lib/build
- cp -r dist/. /app/lib/dist
- cp -r src/. /app/lib/src
- cp -r config /app/lib/config
- cp -r assets /app/lib/assets
- cp -r public /app/lib/public
- cp vite.config.ts /app/lib/vite.config.ts
- cp tsconfig.json /app/lib/tsconfig.json
- cp tailwind.config.js /app/lib/tailwind.config.js
- cp postcss.config.js /app/lib/postcss.config.js
- cp flatpak/defaultConfig.json /app/lib/config/config.json
- install -Dm644 flatpak/${FLATPAK_ID}.desktop /app/share/applications/${FLATPAK_ID}.desktop
@@ -51,13 +58,16 @@ modules:
- install -Dm755 ../run.sh /app/bin/multiscrobbler
subdir: main
sources:
# use for official releases
- type: git
url: https://github.com/FoxxMD/multi-scrobbler
branch: develop
# in official builds use tag/commit instead of branch
# tag: 0.4.6
# commit: ed6bcd60b3448397424ed7c14075479452ff566f
tag: 0.6.3
commit: 4bd996eb466137ffc5c3f48305afabde03720525
dest: main
# use if developing locally
# - type: dir
# path: /home/yourUser/multi-scrobbler
# dest: main
# Wrapper to launch the app
- type: script
dest-filename: run.sh
@@ -72,6 +82,6 @@ modules:
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
fi
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node build/server.js
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME LOG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node_modules/.bin/tsx src/backend/index.ts
- generated-sources.json
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="description"
content="Dashboard for Multi Scrobbler"
/>
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi Scrobbler</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/client/index.tsx"></script>
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
{
"ext": "js"
}
+2796 -16326
View File
File diff suppressed because it is too large Load Diff
+45 -33
View File
@@ -1,6 +1,7 @@
{
"name": "multi-scrobbler",
"version": "0.5.1",
"version": "0.6.5",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
@@ -11,18 +12,13 @@
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"typedoc": "typedoc",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "react-scripts test",
"eject": "react-scripts eject",
"dev": "concurrently -p name -c \"yellow,magenta,blue\" -n \"webpack-server,nodemon-server,CRA\" \"npm run dev:server:webpack\" \"npm run dev:server:nodemon\" \"npm run dev:client\"",
"dev:client": "BROWSER=none react-scripts start",
"dev:server": "concurrently -p name -c \"yellow,magenta\" -n \"webpack-server,nodemon-server\" \"npm run dev:server:webpack\" \"npm run dev:server:nodemon\"",
"dev:server:webpack": "webpack --config webpack.config.server.js",
"dev:server:nodemon": "rm -f build/server.js && wait-on -l build/server.js && nodemon build/server.js",
"build": "npm run -s build:client && npm run -s build:server",
"build:client": "react-scripts build",
"build:server": "webpack --config webpack.config.server.js",
"start": "node build/server.js",
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend"
"test": "npm run -s test:backend",
"test:backend": "NODE_OPTIONS='--loader tsx' mocha --extension ts --reporter spec --recursive src/backend/tests/**/*.test.ts",
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend",
"dev": "APP_VERSION=$npm_package_version nodemon -w src/backend -x tsx src/backend/index.ts",
"start": "APP_VERSION=$npm_package_version NODE_ENV=production tsx src/backend/index.ts",
"build": "APP_VERSION=$npm_package_version vite build",
"postinstall": "patch-package"
},
"exports": {
".": {
@@ -46,22 +42,32 @@
"private": true,
"homepage": ".",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
"@awaitjs/express": "^0.6.3",
"@foxxmd/string-sameness": "^0.1.3",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/string-sameness": "^0.2.0",
"@foxxmd/winston": "3.3.31",
"@foxxmd/youtube-music-ts-api": "1.5.1",
"@kenyip/backoff-strategies": "^1.0.4",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
"address": "^1.2.2",
"ajv": "^7.2.4",
"ajv": "^8.12.0",
"autoprefixer": "^10.4.17",
"avahi-browse": "^1.1.4",
"better-sse": "^0.8.0",
"body-parser": "^1.19.0",
"castv2": "^0.1.10",
"clsx": "^2.0.0",
"common-tags": "^1.8.2",
"compare-versions": "^4.1.2",
"concat-stream": "^2.0.0",
"dayjs": "^1.10.4",
"dbus-next": "0.10.2",
"dotenv": "^10.0.0",
"es6-error": "^4.1.1",
"express": "^4.17.1",
"express-session": "^1.17.2",
@@ -73,31 +79,38 @@
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
"nanoid": "^3.3.1",
"normalize-url": "^6.1.0",
"ntfy": "1.0.5",
"object-hash": "^3.0.0",
"patch-package": "^8.0.0",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
"pony-cause": "^1.1.1",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"safe-stable-stringify": "^1.1.1",
"sass": "^1.70.0",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^3.3.3",
"toad-scheduler": "^3.0.0",
"triple-beam": "^1.3.0",
"wait-on": "^7.0.1",
"web-vitals": "^2.1.4",
"tsx": "^4.7.0",
"vite-express": "^0.13.0",
"winston-daily-rotate-file": "^4.5.0",
"winston-duplex": "0.1.3",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"xml2js": "^0.4.23"
"xml2js": "0.5.0",
"youtube-music-ts-api": "^1.7.0"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@faker-js/faker": "^8.1.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@@ -105,6 +118,7 @@
"@tsconfig/node18": "^1.0.1",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.5",
"@types/clone": "^2.1.2",
"@types/common-tags": "^1.8.1",
"@types/concat-stream": "^2.0.0",
"@types/express": "^4.17.13",
@@ -114,6 +128,7 @@
"@types/jscodeshift": "^0.11.6",
"@types/mocha": "^9.1.0",
"@types/node": "^18.0.0",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
@@ -122,25 +137,22 @@
"@types/superagent": "^4.1.16",
"@types/triple-beam": "^1.3.2",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"concurrently": "^8.2.0",
"clone": "^2.1.2",
"jscodeshift": "^0.15.0",
"mocha": "^9.2.1",
"nodemon": "^3.0.1",
"mocha": "10.1.0",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"nyc": "^15.1.0",
"patch-package": "^8.0.0",
"react-scripts": "5.0.1",
"source-map-support": "^0.5.21",
"ts-essentials": "^9.1.2",
"ts-loader": "^9.4.4",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.13.0",
"typedoc": "^0.23.28",
"typescript": "^4.9.5",
"typedoc": "^0.25",
"typescript": "^5.3.3",
"typescript-json-schema": "~0.55",
"webpack-cli": "^5.1.4",
"webpack-node-externals": "^3.0.0"
"vite": "^5.0.11",
"vite-tsconfig-paths": "^4.3.1",
"wtfnode": "^0.9.1"
},
"browserslist": {
"production": [
@@ -0,0 +1,186 @@
diff --git a/node_modules/chromecast-client/dist/cjs/src/apps/application.js b/node_modules/chromecast-client/dist/cjs/src/apps/application.js
index d91a6ff..dfc417e 100644
--- a/node_modules/chromecast-client/dist/cjs/src/apps/application.js
+++ b/node_modules/chromecast-client/dist/cjs/src/apps/application.js
@@ -4,7 +4,9 @@ exports.join = exports.launchAndJoin = void 0;
const receiver_1 = require("../controllers/receiver");
const utils_1 = require("../utils");
const _getJoinableTransportId = (status) => {
- const app = status.applications.find(a => a.namespaces.map(e => e.name).includes('urn:x-cast:com.google.cast.media'));
+ const app = status.applications === undefined
+ ? undefined
+ : status.applications.find(a => a.namespaces.map(e => e.name).includes('urn:x-cast:com.google.cast.media'));
return app === undefined ? utils_1.Result.Err(new Error('failed to find joinable application')) : utils_1.Result.Ok(app.transportId);
};
const _join = async (status, factory) => {
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
index cbc2af8..e0f60dc 100644
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.d.ts
@@ -63,7 +63,7 @@ export declare const Application$: z.ZodObject<{
}>;
export type Application = z.infer<typeof Application$>;
export declare const ReceiverStatus$: z.ZodObject<{
- applications: z.ZodArray<z.ZodObject<{
+ applications: z.ZodOptional<z.ZodArray<z.ZodObject<{
appId: z.ZodString;
displayName: z.ZodString;
iconUrl: z.ZodNullable<z.ZodOptional<z.ZodString>>;
@@ -103,7 +103,7 @@ export declare const ReceiverStatus$: z.ZodObject<{
}[];
sessionId: string;
transportId: string;
- }>, "many">;
+ }>, "many">>;
volume: z.ZodObject<{
controlType: z.ZodEffects<z.ZodEnum<["ATTENUATION", "FIXED", "MASTER"]>, "ATTENUATION" | "FIXED" | "MASTER", unknown>;
level: z.ZodNullable<z.ZodOptional<z.ZodNumber>>;
@@ -121,13 +121,7 @@ export declare const ReceiverStatus$: z.ZodObject<{
stepInterval: number;
}>;
}, "strip", z.ZodTypeAny, {
- volume: {
- level?: number | null | undefined;
- muted?: boolean | null | undefined;
- controlType: "ATTENUATION" | "FIXED" | "MASTER";
- stepInterval: number;
- };
- applications: {
+ applications?: {
iconUrl?: string | null | undefined;
isIdleScreen?: boolean | null | undefined;
launchedFromCloud?: boolean | null | undefined;
@@ -139,15 +133,15 @@ export declare const ReceiverStatus$: z.ZodObject<{
}[];
sessionId: string;
transportId: string;
- }[];
-}, {
+ }[] | undefined;
volume: {
level?: number | null | undefined;
muted?: boolean | null | undefined;
- controlType?: unknown;
+ controlType: "ATTENUATION" | "FIXED" | "MASTER";
stepInterval: number;
};
- applications: {
+}, {
+ applications?: {
iconUrl?: string | null | undefined;
isIdleScreen?: boolean | null | undefined;
launchedFromCloud?: boolean | null | undefined;
@@ -159,6 +153,12 @@ export declare const ReceiverStatus$: z.ZodObject<{
}[];
sessionId: string;
transportId: string;
- }[];
+ }[] | undefined;
+ volume: {
+ level?: number | null | undefined;
+ muted?: boolean | null | undefined;
+ controlType?: unknown;
+ stepInterval: number;
+ };
}>;
export type ReceiverStatus = z.infer<typeof ReceiverStatus$>;
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
index 33bfb4b..29e79fe 100644
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/index.js
@@ -51,7 +51,7 @@ exports.Application$ = zod_1.z.object({
transportId: zod_1.z.string(),
});
exports.ReceiverStatus$ = zod_1.z.object({
- applications: zod_1.z.array(exports.Application$),
+ applications: zod_1.z.array(exports.Application$).optional(),
volume: exports.Volume$,
});
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
index 53e42b2..0d77bb2 100644
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.d.ts
@@ -80,7 +80,7 @@ export declare const MediaStatus$: z.ZodObject<{
playbackRate: z.ZodNumber;
playerState: z.ZodEffects<z.ZodEnum<["IDLE", "PLAYING", "BUFFERING", "PAUSED"]>, "IDLE" | "PLAYING" | "BUFFERING" | "PAUSED", unknown>;
supportedMediaCommands: z.ZodNumber;
- volume: z.ZodObject<{
+ volume: z.ZodNullable<z.ZodOptional<z.ZodObject<{
level: z.ZodNullable<z.ZodOptional<z.ZodNumber>>;
muted: z.ZodNullable<z.ZodOptional<z.ZodBoolean>>;
}, "strip", z.ZodTypeAny, {
@@ -89,7 +89,7 @@ export declare const MediaStatus$: z.ZodObject<{
}, {
level?: number | null | undefined;
muted?: boolean | null | undefined;
- }>;
+ }>>>;
}, "strip", z.ZodTypeAny, {
media?: {
metadata?: Record<string, unknown> | null | undefined;
@@ -102,15 +102,15 @@ export declare const MediaStatus$: z.ZodObject<{
} | null | undefined;
customData?: Record<string, unknown> | null | undefined;
idleReason?: "CANCELLED" | "INTERRUPTED" | "FINISHED" | "ERROR" | null | undefined;
+ volume?: {
+ level?: number | null | undefined;
+ muted?: boolean | null | undefined;
+ } | null | undefined;
currentTime: number;
mediaSessionId: number;
playbackRate: number;
playerState: "IDLE" | "PLAYING" | "BUFFERING" | "PAUSED";
supportedMediaCommands: number;
- volume: {
- level?: number | null | undefined;
- muted?: boolean | null | undefined;
- };
}, {
media?: {
metadata?: Record<string, unknown> | null | undefined;
@@ -124,13 +124,13 @@ export declare const MediaStatus$: z.ZodObject<{
customData?: Record<string, unknown> | null | undefined;
idleReason?: unknown;
playerState?: unknown;
+ volume?: {
+ level?: number | null | undefined;
+ muted?: boolean | null | undefined;
+ } | null | undefined;
currentTime: number;
mediaSessionId: number;
playbackRate: number;
supportedMediaCommands: number;
- volume: {
- level?: number | null | undefined;
- muted?: boolean | null | undefined;
- };
}>;
export type MediaStatus = z.infer<typeof MediaStatus$>;
diff --git a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
index 2af6558..138f550 100644
--- a/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
+++ b/node_modules/chromecast-client/dist/cjs/src/cast-types/media.types.js
@@ -28,6 +28,6 @@ exports.MediaStatus$ = zod_1.z.object({
playbackRate: zod_1.z.number(),
playerState: exports.PlayerState$,
supportedMediaCommands: zod_1.z.number(),
- volume: exports.Volume$,
+ volume: exports.Volume$.nullish(),
});
//# sourceMappingURL=media.types.js.map
\ No newline at end of file
diff --git a/node_modules/chromecast-client/dist/cjs/src/platform.d.ts b/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
index 2f9770b..1247d8a 100644
--- a/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
+++ b/node_modules/chromecast-client/dist/cjs/src/platform.d.ts
@@ -65,7 +65,7 @@ export declare const createPlatform: (client: PersistentClient) => {
controlType: "ATTENUATION" | "FIXED" | "MASTER";
stepInterval: number;
};
- applications: {
+ applications?: {
iconUrl?: string | null | undefined;
isIdleScreen?: boolean | null | undefined;
launchedFromCloud?: boolean | null | undefined;
+38
View File
@@ -0,0 +1,38 @@
diff --git a/node_modules/lastfm-node-client/lib/ApiRequest.js b/node_modules/lastfm-node-client/lib/ApiRequest.js
index ecd39d0..c8e1cc5 100644
--- a/node_modules/lastfm-node-client/lib/ApiRequest.js
+++ b/node_modules/lastfm-node-client/lib/ApiRequest.js
@@ -91,7 +91,7 @@ class ApiRequest {
const paramsStr = querystring.stringify(paramsObj);
const options = {
hostname: "ws.audioscrobbler.com",
- path: "/2.0"
+ path: "/2.0",
};
if (method === "POST") {
@@ -113,7 +113,11 @@ class ApiRequest {
httpResponse.on("data", chunk => data += chunk);
httpResponse.on("end", () => resolve(data));
httpResponse.on("error", err => reject(err));
- });
+ })
+ // stop waiting for request if it takes longer than 3 seconds
+ .setTimeout(3000, () => {
+ httpRequest.destroy(new Error('ETIMEDOUT - socket hang up'));
+ });
httpRequest.on("error", err => reject(err));
diff --git a/node_modules/lastfm-node-client/lib/LastFm.js b/node_modules/lastfm-node-client/lib/LastFm.js
index 2393a4d..75bdf37 100644
--- a/node_modules/lastfm-node-client/lib/LastFm.js
+++ b/node_modules/lastfm-node-client/lib/LastFm.js
@@ -1045,7 +1045,6 @@ class LastFm {
* @param {callback} [callback]
* @returns {(Promise|LastFm)}
*/
-
trackUpdateNowPlaying(params, callback) {
const apiRequest = new ApiRequest()
.set(params)
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.ico"/>
<meta
name="description"
content="Dashboard for Multi Scrobbler"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Multi Scrobbler</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
/**
* Overrides the tsconfig used for the app.
* In the test environment we need some tweaks.
*/
const tsNode = require('ts-node');
const tsConfigPaths = require('tsconfig-paths');
const mainTSConfig = require('./src/backend/tsconfig.json');
tsConfigPaths.register({
baseUrl: './src/backend/tests',
paths: {
...mainTSConfig.compilerOptions.paths,
}
});
tsNode.register({
files: true,
transpileOnly: true,
project: './src/backend/tsconfig.json'
});
+13
View File
@@ -0,0 +1,13 @@
import {ResponseError} from "superagent";
export const isSuperAgentResponseError = (e: any): e is ResponseError => {
return typeof e === 'object'
&& (
('timeout' in e && typeof e.timeout === 'boolean')
||
('status' in e
&& (typeof e.status === 'number' || e.status === undefined)
&& 'response' in e
&& (typeof e.response === 'object' || e.response === undefined))
);
}
+33
View File
@@ -0,0 +1,33 @@
import { isArbitraryObject } from "../infrastructure/Atomic.js";
import ErrnoException = NodeJS.ErrnoException;
import { findCauseByFunc } from "../../utils.js";
export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH';
export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const isErrnoException = (error: unknown): error is ErrnoException => {
return isArbitraryObject(error) &&
error instanceof Error &&
(typeof error.errno === "number" || typeof error.errno === "undefined") &&
(typeof error.code === "string" || typeof error.code === "undefined") &&
(typeof error.path === "string" || typeof error.path === "undefined") &&
(typeof error.syscall === "string" || typeof error.syscall === "undefined");
}
export interface NodeNetworkException extends ErrnoException {
code: NodeNetworkErrorCode
}
export const isNodeNetworkException = (error: unknown): error is NodeNetworkException => {
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code as NodeNetworkErrorCode);
}
export const hasNodeNetworkException = (error: unknown): boolean => {
return getNodeNetworkException(error) !== undefined;
}
export const getNodeNetworkException = (error: unknown): Error | undefined => {
return findCauseByFunc(error, isNodeNetworkException);
}
@@ -0,0 +1,29 @@
import {ErrorWithCause} from "pony-cause";
import { findCauseByFunc } from "../../utils.js";
import {Response} from 'superagent';
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
showStopper: boolean = false;
response?: Response
constructor(message: string, options?: { cause?: T, showStopper?: boolean, response?: Response } | undefined) {
super(message, options);
const {showStopper = false, response} = options;
this.showStopper = showStopper;
this.response = response;
}
}
export const hasUpstreamError = (err: any, showStopping?: boolean): boolean => {
return findCauseByFunc(err, (e) => {
if (e instanceof UpstreamError) {
if (showStopping === undefined) {
return true;
} else {
return e.showStopper === showStopping;
}
}
return false;
}) !== undefined;
}
+47 -7
View File
@@ -1,13 +1,13 @@
import {Dayjs} from "dayjs";
import {FixedSizeList} from 'fixed-size-list';
import {Logger} from '@foxxmd/winston';
import TupleMap from "../TupleMap";
import TupleMap from "../TupleMap.js";
import {Request, Response} from "express";
import {NextFunction, ParamsDictionary, Query} from "express-serve-static-core";
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic";
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic.js";
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi'];
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast'];
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
@@ -109,7 +109,7 @@ export interface LogConfig {
level?: string
file?: string | false
stream?: string
console?: string
console?: string | false
}
export interface LogOptions {
@@ -132,7 +132,7 @@ export interface LogOptions {
/**
* Specify the minimum log level streamed to the console (or docker container)
* */
console?: LogLevel
console?: LogLevel | false
}
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
@@ -147,7 +147,21 @@ export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 30;
/**
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
* */
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 240;
/**
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
* */
export const DEFAULT_SCROBBLE_PERCENT_THRESHOLD: number = 50;
export const DEFAULT_POLLING_INTERVAL: number = 10;
export const DEFAULT_POLLING_MAX_INTERVAL = 30;
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
export interface ScrobbleThresholdResult {
passes: boolean
@@ -192,3 +206,29 @@ export interface numberFormatOptions {
}
export const DELIMITERS = [',','&','\/','\\'];
export const ARTIST_WEIGHT = 0.3;
export const TITLE_WEIGHT = 0.4;
export const TIME_WEIGHT = 0.5;
export const REFERENCE_WEIGHT = 0.5;
export const DUP_SCORE_THRESHOLD = 1;
// https://stackoverflow.com/a/70887388/1469797
export type ArbitraryObject = { [key: string]: unknown; };
export function isArbitraryObject(potentialObject: unknown): potentialObject is ArbitraryObject {
return typeof potentialObject === "object" && potentialObject !== null;
}
export interface Authenticatable {
requiresAuth: boolean
requiresAuthInteraction: boolean
authed: boolean
authFailure?: boolean
testAuth: () => Promise<any>
}
export interface MdnsDeviceInfo {
name: string
type: string
addresses: string[]
}
@@ -1,9 +1,9 @@
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index";
import { RequestRetryOptions } from "./common";
import { SourceAIOConfig } from "./source/sources";
import { ClientAIOConfig } from "./client/clients";
import { WebhookConfig } from "./health/webhooks";
import { LogOptions } from "../Atomic";
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index.js";
import { RequestRetryOptions } from "./common.js";
import { SourceAIOConfig } from "./source/sources.js";
import { ClientAIOConfig } from "./client/clients.js";
import { WebhookConfig } from "./health/webhooks.js";
import { LogOptions } from "../Atomic.js";
export interface SourceDefaults extends SourceRetryOptions {
/**
@@ -28,6 +28,20 @@ export interface AIOConfig {
* */
port?: number
/**
* Set the Base URL the application should assume the UI is served from.
*
* This will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.
*
* It will NOT set the actual interface/IP that the application is listening on.
*
* This can also be set using the BASE_URL environmental variable.
*
* @default "http://localhost"
* @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"]
* */
baseUrl?: string
logging?: LogOptions
/**
@@ -1,6 +1,6 @@
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja";
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm";
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz";
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
@@ -1,4 +1,4 @@
import { CommonConfig, CommonData } from "../common";
import { CommonConfig, CommonData } from "../common.js";
/**
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
@@ -63,6 +63,14 @@ export interface CommonClientData extends CommonData {
match?: MatchLoggingOptions
}
/**
* Number of times MS should automatically retry scrobbles in dead letter queue
*
* @default 1
* @examples [1]
* */
deadLetterRetries?: number
}
}
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index";
import { RequestRetryOptions } from "../common";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
export interface LastfmData extends RequestRetryOptions {
/**
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index";
import { RequestRetryOptions } from "../common";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
export interface ListenBrainzData extends RequestRetryOptions{
/**
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index";
import { RequestRetryOptions } from "../common";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
/**
@@ -1,6 +1,13 @@
export interface CommonConfig {
name?: string
data?: CommonData
/**
* Should MS use this client/source? Defaults to true
*
* @default true
* @examples [true]
* */
enable?: boolean
}
export interface CommonData {
@@ -30,16 +37,16 @@ export interface PollingOptions {
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* @default 30
* @examples [30]
* @default 10
* @examples [10]
* */
interval?: number
/**
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
*
* @default 60
* @examples [60]
* @default 30
* @examples [30]
* */
maxInterval?: number
}
@@ -0,0 +1,105 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface ChromecastData extends CommonSourceData {
/**
* DO NOT scrobble from any cast devices that START WITH these values, case-insensitive
*
* Useful when used with auto discovery
*
* @examples [["home-mini","family-tv"]]
* */
blacklistDevices?: string | string[]
/**
* ONLY scrobble from any cast device that START WITH these values, case-insensitive
*
* If whitelist is present then blacklist is ignored
*
* Useful when used with auto discovery
*
* @examples [["home-mini","family-tv"]]
* */
whitelistDevices?: string | string[]
/**
* DO NOT scrobble from any application that START WITH these values, case-insensitive
*
* @examples [["spotify","pandora"]]
* */
blacklistApps?: string | string[]
/**
* ONLY scrobble from any application that START WITH these values, case-insensitive
*
* If whitelist is present then blacklist is ignored
*
* @examples [["spotify","pandora"]]
* */
whitelistApps?: string | string[]
/**
* Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying
*
* Useful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.
*
* @default false
* */
useAvahi?: boolean
/**
* Use mDNS to discovery Google Cast devices on your next automatically?
*
* If not explicitly set then it is TRUE if `devices` is not set
* */
useAutoDiscovery?: boolean | undefined
/**
* A list of Google Cast devices to monitor
*
* If this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set
* */
devices?: ChromecastDeviceInfo[]
/**
* Chromecast Apps report a "media type" in the status info returned for whatever is currently playing
*
* * If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS
* * If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS
* * If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked
*
* See https://developers.google.com/cast/docs/media/messages#MediaInformation "metadata" property
*
* @default false
* */
allowUnknownMedia?: boolean | string[]
/**
* Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the "media type" reported
*
* Apps will be recognized if they CONTAIN any of these values, case-insensitive
* */
forceMediaRecognitionOn?: string[]
}
export interface ChromecastDeviceInfo {
/**
* A friendly name to identify this device
*
* @examples ["MySmartTV"]
* */
name: string
/**
* The IP address of the device
*
* @examples ["192.168.0.115"]
* */
address: string
}
export interface ChromecastSourceConfig extends CommonSourceConfig {
data: ChromecastData
}
export interface ChromecastSourceAIOConfig extends ChromecastSourceConfig {
type: 'chromecast'
}
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface DeezerData extends CommonSourceData {
/**
@@ -1,4 +1,4 @@
import { CommonConfig, CommonData, RequestRetryOptions } from "../common";
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
export interface SourceRetryOptions extends RequestRetryOptions {
/**
@@ -12,18 +12,31 @@ export interface SourceRetryOptions extends RequestRetryOptions {
export interface ScrobbleThresholds {
/**
* The number of seconds before a track should be considered scrobbled.
* The number of seconds a track has been listened to before it should be considered scrobbled.
*
* @default 30
* @examples [30]
* Set to null to disable.
*
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* @seehttps://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
*
* @default 240
* @examples [240]
* */
duration?: number
duration?: number | null
/**
* The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
* The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
*
* Set to null to disable.
*
* NOTE: This should be used with care when the Source is a "polling" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not "seen" the track for long enough from first discovery, even if you have been playing the track for longer.
*
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* @see https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
*
* @default 50
* @examples [50]
* */
percent?: number
percent?: number | null
}
export interface CommonSourceOptions {
@@ -57,6 +70,19 @@ export interface CommonSourceOptions {
* @examples [false]
* */
logPlayerState?: boolean
/**
* If this source
*
* * supports fetching a listen history
* * and this option is enabled
*
* then on startup MS will attempt to scrobble the recent listens from that history
*
* @default true
* @examples [true, false]
* */
scrobbleBacklog?: boolean
}
export interface CommonSourceData extends CommonData, SourceRetryOptions {
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface JellyData extends CommonSourceData {
/**
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface JRiverData extends CommonSourceData, PollingOptions {
/**
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface KodiData extends CommonSourceData, PollingOptions {
@@ -1,7 +1,8 @@
import { LastfmData } from "../client/lastfm";
import { CommonSourceConfig, CommonSourceData } from "./index";
import { LastfmData } from "../client/lastfm.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
export interface LastfmSourceConfig extends CommonSourceConfig {
/**
@@ -1,7 +1,8 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { ListenBrainzData } from "../client/listenbrainz";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { ListenBrainzData } from "../client/listenbrainz.js";
import { PollingOptions } from "../common.js";
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
}
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface MopidyData extends CommonSourceData, PollingOptions {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export const PLAYBACK_STATUS_PLAYING = 'Playing';
export const PLAYBACK_STATUS_PAUSED = 'Paused';
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface PlexSourceData extends CommonSourceData {
/**
@@ -1,17 +1,20 @@
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify";
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex";
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic";
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi";
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { ChromecastSourceConfig, ChromecastSourceAIOConfig } from "./chromecast.js";
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig;
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig;
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
/**
@@ -29,10 +29,12 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
* Reading:
* * https://developer.spotify.com/documentation/web-api/guides/rate-limits/
* * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
* * The rate limit is ~180 req/min
* * Rate limit may ~180 req/min
* * https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930
* * Informally indicated as 20 req/sec? Probably for burstiness
*
* @default 30
* @examples [30]
* @default 10
* @examples [10]
* */
interval?: number
}
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface SubsonicData extends CommonSourceData, PollingOptions {
/**
@@ -1,4 +1,4 @@
import { PlexSourceConfig } from "./plex";
import { PlexSourceConfig } from "./plex.js";
export interface TautulliSourceConfig extends PlexSourceConfig {
}
@@ -0,0 +1,68 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface WebScrobblerData extends CommonSourceData {
/**
* The URL ending that should be used to identify scrobbles for this source
*
* In WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:
*
* http://localhost:9078/api/webscrobbler
*
* If you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.
*
* Example:
*
* * slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera
* * slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb
*
* If no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used
* */
slug?: string | null
/**
* Block scrobbling from specific WebScrobbler Connectors
*
* @examples [["youtube"]]
* */
blacklist?: string | string[]
/**
* Only allow scrobbling from specific WebScrobbler Connectors
*
* @examples [["mixcloud","soundcloud","bandcamp"]]
* */
whitelist?: string | string[]
/**
* Additional options for WebScrobbler logging and tuning
* */
options?: {
/**
* Log raw WebScrobbler webhook payload to debug
*
* @default false
* @examples [false]
* */
logPayload?: boolean
/**
* How MS should log when a WebScrobbler event fails a defined filter
*
* * `false` => do not log
* * `debug` => log to DEBUG level
* * `warn` => log to WARN level (default)
*
* @default warn
* @examples ["warn"]
* */
logFilterFailure?: false | 'debug' | 'warn'
}
}
export interface WebScrobblerSourceConfig extends CommonSourceConfig {
data?: WebScrobblerData
}
export interface WebScrobblerSourceAIOConfig extends WebScrobblerSourceConfig {
type: 'webscrobbler'
}
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { PollingOptions } from "../common";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
export interface YTMusicData extends CommonSourceData, PollingOptions {
/**
@@ -25,6 +25,9 @@ declare module 'lastfm-node-client' {
album?: string
albumArtist?: string
/** MusicBrainz track ID */
mbid?: string
}
export interface TrackScrobbleResponse {
@@ -47,6 +50,17 @@ declare module 'lastfm-node-client' {
}
}
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
export interface NowPlayingResponse {
nowplaying: {
ignoredMessage: {
code: number
'#text': string
}
}
}
export interface AuthGetSessionPayload {
token: string
}
@@ -80,10 +94,12 @@ declare module 'lastfm-node-client' {
artist: {
'#text': string,
name: string,
mbid: string,
},
name: string,
album: {
'#text': string,
mbid: string
},
duration: number,
date?: {
@@ -100,6 +116,7 @@ declare module 'lastfm-node-client' {
export default class LastFM {
constructor(apiKey: string, secret?: string, session?: string);
trackUpdateNowPlaying(params: NowPlayingPayload): Promise<NowPlayingResponse>
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
userGetInfo(): Promise<UserGetInfoResponse>
+97 -26
View File
@@ -1,33 +1,36 @@
import path from "path";
import { projectDir } from "./index";
import winston, {format, Logger} from '@foxxmd/winston';
import { projectDir } from "./index.js";
import * as winstonNs from '@foxxmd/winston';
import winstonDef from '@foxxmd/winston';
import {DuplexTransport} from "winston-duplex";
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic";
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic.js";
import process from "process";
import { fileOrDirectoryIsWriteable, parseBool } from "../utils";
import { fileOrDirectoryIsWriteable, mergeArr, parseBool } from "../utils.js";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {NullTransport} from 'winston-null';
import DailyRotateFile from 'winston-daily-rotate-file';
import dayjs from "dayjs";
import stringify from 'safe-stable-stringify';
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
import { LogInfo, LogLevel } from "../../core/Atomic";
import { LogInfo, LogLevel } from "../../core/Atomic.js";
import TransportStream from "winston-transport";
import {format} from 'logform';
const {combine, printf, timestamp, label, splat, errors} = format;
const {transports} = winston;
//const {transports} = winstonNew;
const {loggers, transports} = winstonDef;
export let logPath = path.resolve(projectDir, `./logs`);
if (typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}
winston.loggers.add('noop', {transports: [new NullTransport()]});
loggers.add('noop', {transports: [new NullTransport()]});
export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
export const getLogger = (config: LogConfig = {}, name = 'app'): winstonNs.Logger => {
if (!winston.loggers.has(name)) {
if (!loggers.has(name)) {
const errors: (Error | string)[] = [];
let options: LogOptions = {};
@@ -39,17 +42,22 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
const {level: configLevel} = options;
const defaultLevel = process.env.LOG_LEVEL || (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'info');
let consoleLevel: string | boolean = process.env.CONSOLE_LEVEL || 'debug';
if(consoleLevel === 'false') {
consoleLevel = false;
}
let fileLevel: string | boolean = process.env.FILE_LEVEL || defaultLevel;
if(fileLevel === 'false') {
fileLevel = false;
}
const {
level = configLevel || defaultLevel,
file = configLevel || defaultLevel,
file = configLevel || fileLevel,
stream = configLevel || 'debug',
console = configLevel || 'debug'
console = configLevel || consoleLevel
} = options;
const consoleTransport = new transports.Console({level: console});
const myTransports = [
consoleTransport,
const myTransports: TransportStream[] = [
new DuplexTransport({
stream: {
transform(chunk, e, cb) {
@@ -58,13 +66,17 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
objectMode: true,
},
name: 'duplex',
handleExceptions: true,
handleRejections: true,
level: stream,
dump: false,
}),
})
];
if(console !== false) {
myTransports.push(new transports.Console({
level: console,
}));
}
if (file !== false) {
const rotateTransport = new DailyRotateFile({
dirname: logPath,
@@ -73,7 +85,7 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
filename: 'scrobble-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '5m',
level: file
level: file,
});
try {
@@ -86,15 +98,15 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
}
}
const loggerOptions: winston.LoggerOptions = {
const loggerOptions: winstonNs.LoggerOptions = {
level: level,
format: labelledFormat(),
transports: myTransports,
};
winston.loggers.add(name, loggerOptions);
loggers.add(name, loggerOptions);
const logger = winston.loggers.get(name);
const logger = loggers.get(name);
if (errors.length > 0) {
for (const e of errors) {
logger.error(e);
@@ -102,7 +114,7 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
}
return logger;
}
return winston.loggers.get(name);
return loggers.get(name);
}
const breakSymbol = '<br />';
@@ -125,7 +137,7 @@ const s = splat();
//const errorsFormat = errors({stack: true});
const CWD = process.cwd();
const causeKeys = ['name', 'cause']
const causeKeys = ['name', 'cause', 'showStopper']
export const defaultFormat = (defaultLabel = 'App') => printf(({
label,
@@ -138,10 +150,13 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
durationMs,
[SPLAT]: splatObj,
stack,
id,
cause,
showStopper,
...rest
}) => {
const keys = Object.keys(rest);
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify(rest) : '';
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify.default(rest) : '';
let msg = message;
let stackMsg = '';
if (stack !== undefined) {
@@ -168,6 +183,9 @@ export const defaultFormat = (defaultLabel = 'App') => printf(({
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
});
// https://knowyourmeme.com/memes/cereal-guy
// this number will never overflow
let seqId: number = 0;
export const labelledFormat = (labelName = 'App') => {
const l = label({label: labelName, message: false});
return combine(
@@ -176,6 +194,13 @@ export const labelledFormat = (labelName = 'App') => {
format: () => dayjs().local().format(),
}
),
{
transform: (info, opts) => {
info.id = seqId;
seqId++;
return info;
}
},
l,
s,
errorAwareFormat,
@@ -215,6 +240,10 @@ export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel)
return level <= minLevel;
}
export const isLogLevelMinLevel = (levelStr: LogLevel, minLevelStr: LogLevel): boolean => {
return logLevels[levelStr] <= logLevels[minLevelStr];
}
const isProbablyError = (val: any, explicitErrorName?: string) => {
if(typeof val !== 'object' || val === null) {
return false;
@@ -339,3 +368,45 @@ const _transformError = (err: Error, seen: Set<Error>) => {
return err;
}
}
export class MaybeLogger {
logger?: winstonNs.Logger
constructor(logger?: winstonNs.Logger, label?: string) {
if (logger !== undefined && label !== undefined) {
this.logger = logger.child({labels: [label]}, mergeArr);
} else {
this.logger = logger;
}
}
public info(first: any, ...rest: any) {
if (this.logger) {
this.logger.info(first, ...rest);
}
}
public debug(first: any, ...rest: any) {
if (this.logger) {
this.logger.debug(first, ...rest);
}
}
public warn(first: any, ...rest: any) {
if (this.logger) {
this.logger.warn(first, ...rest);
}
}
public verbose(first: any, ...rest: any) {
if (this.logger) {
this.logger.verbose(first, ...rest);
}
}
public error(first: any, ...rest: any) {
if (this.logger) {
this.logger.error(first, ...rest);
}
}
}
+54
View File
@@ -37,6 +37,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -101,6 +110,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -203,6 +221,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -249,6 +276,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -319,6 +355,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -373,6 +418,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
+608 -21
View File
@@ -1,6 +1,254 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ChromecastData": {
"properties": {
"allowUnknownMedia": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "boolean"
}
],
"default": false,
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
"title": "allowUnknownMedia"
},
"blacklistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "blacklistApps"
},
"blacklistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "blacklistDevices"
},
"devices": {
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
"items": {
"$ref": "#/definitions/ChromecastDeviceInfo"
},
"title": "devices",
"type": "array"
},
"forceMediaRecognitionOn": {
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
"items": {
"type": "string"
},
"title": "forceMediaRecognitionOn",
"type": "array"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>",
"title": "options"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"useAutoDiscovery": {
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
"title": "useAutoDiscovery",
"type": "boolean"
},
"useAvahi": {
"default": false,
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
"title": "useAvahi",
"type": "boolean"
},
"whitelistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "whitelistApps"
},
"whitelistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "whitelistDevices"
}
},
"title": "ChromecastData",
"type": "object"
},
"ChromecastDeviceInfo": {
"properties": {
"address": {
"description": "The IP address of the device",
"examples": [
"192.168.0.115"
],
"title": "address",
"type": "string"
},
"name": {
"description": "A friendly name to identify this device",
"examples": [
"MySmartTV"
],
"title": "name",
"type": "string"
}
},
"required": [
"address",
"name"
],
"title": "ChromecastDeviceInfo",
"type": "object"
},
"ChromecastSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/ChromecastData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
},
"type": {
"enum": [
"chromecast"
],
"title": "type",
"type": "string"
}
},
"required": [
"data",
"type"
],
"title": "ChromecastSourceAIOConfig",
"type": "object"
},
"CommonSourceOptions": {
"properties": {
"logFilterFailure": {
@@ -33,6 +281,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -139,6 +397,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -166,19 +433,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -265,6 +532,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -417,6 +693,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -444,19 +729,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -545,6 +830,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -601,6 +895,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -635,6 +938,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -733,6 +1054,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -759,6 +1089,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -929,6 +1277,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1075,6 +1432,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1119,6 +1485,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1270,16 +1645,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -1327,6 +1706,12 @@
},
{
"$ref": "#/definitions/KodiSourceAIOConfig"
},
{
"$ref": "#/definitions/WebScrobblerSourceAIOConfig"
},
{
"$ref": "#/definitions/ChromecastSourceAIOConfig"
}
],
"title": "SourceAIOConfig"
@@ -1384,6 +1769,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1427,19 +1821,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1604,6 +1998,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1648,6 +2051,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1672,6 +2084,172 @@
"title": "TautulliSourceAIOConfig",
"type": "object"
},
"WebScrobblerData": {
"properties": {
"blacklist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Block scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"youtube"
]
],
"title": "blacklist"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"description": "Additional options for WebScrobbler logging and tuning",
"properties": {
"logFilterFailure": {
"default": "warn",
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
"enum": [
"debug",
false,
"warn"
],
"examples": [
"warn"
],
"title": "logFilterFailure"
},
"logPayload": {
"default": false,
"description": "Log raw WebScrobbler webhook payload to debug",
"examples": [
false
],
"title": "logPayload",
"type": "boolean"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"slug": {
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
"title": "slug",
"type": "string"
},
"whitelist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"mixcloud",
"soundcloud",
"bandcamp"
]
],
"title": "whitelist"
}
},
"title": "WebScrobblerData",
"type": "object"
},
"WebScrobblerSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/WebScrobblerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
},
"type": {
"enum": [
"webscrobbler"
],
"title": "type",
"type": "string"
}
},
"required": [
"type"
],
"title": "WebScrobblerSourceAIOConfig",
"type": "object"
},
"YTMusicData": {
"properties": {
"authUser": {
@@ -1688,19 +2266,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1768,6 +2346,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
+681 -22
View File
@@ -1,6 +1,254 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ChromecastData": {
"properties": {
"allowUnknownMedia": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "boolean"
}
],
"default": false,
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
"title": "allowUnknownMedia"
},
"blacklistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "blacklistApps"
},
"blacklistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "blacklistDevices"
},
"devices": {
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
"items": {
"$ref": "#/definitions/ChromecastDeviceInfo"
},
"title": "devices",
"type": "array"
},
"forceMediaRecognitionOn": {
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
"items": {
"type": "string"
},
"title": "forceMediaRecognitionOn",
"type": "array"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>",
"title": "options"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"useAutoDiscovery": {
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
"title": "useAutoDiscovery",
"type": "boolean"
},
"useAvahi": {
"default": false,
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
"title": "useAvahi",
"type": "boolean"
},
"whitelistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "whitelistApps"
},
"whitelistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "whitelistDevices"
}
},
"title": "ChromecastData",
"type": "object"
},
"ChromecastDeviceInfo": {
"properties": {
"address": {
"description": "The IP address of the device",
"examples": [
"192.168.0.115"
],
"title": "address",
"type": "string"
},
"name": {
"description": "A friendly name to identify this device",
"examples": [
"MySmartTV"
],
"title": "name",
"type": "string"
}
},
"required": [
"address",
"name"
],
"title": "ChromecastDeviceInfo",
"type": "object"
},
"ChromecastSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/ChromecastData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
},
"type": {
"enum": [
"chromecast"
],
"title": "type",
"type": "string"
}
},
"required": [
"data",
"type"
],
"title": "ChromecastSourceAIOConfig",
"type": "object"
},
"ClientAIOConfig": {
"anyOf": [
{
@@ -37,6 +285,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -106,6 +363,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -212,6 +479,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -288,19 +564,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -387,6 +663,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -539,6 +824,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -566,19 +860,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -667,6 +961,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -723,6 +1026,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -757,6 +1069,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -850,6 +1180,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -952,6 +1291,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -998,6 +1346,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -1093,6 +1450,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1119,6 +1485,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -1199,8 +1583,15 @@
"LogOptions": {
"properties": {
"console": {
"$ref": "#/definitions/LogLevel",
"description": "Specify the minimum log level streamed to the console (or docker container)",
"enum": [
"debug",
"error",
false,
"info",
"verbose",
"warn"
],
"title": "console"
},
"file": {
@@ -1334,6 +1725,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1365,6 +1765,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -1419,6 +1828,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -1624,6 +2042,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1724,6 +2151,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1931,16 +2367,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -1988,6 +2428,12 @@
},
{
"$ref": "#/definitions/KodiSourceAIOConfig"
},
{
"$ref": "#/definitions/WebScrobblerSourceAIOConfig"
},
{
"$ref": "#/definitions/ChromecastSourceAIOConfig"
}
],
"title": "SourceAIOConfig"
@@ -2054,6 +2500,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2097,19 +2552,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -2274,6 +2729,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2318,6 +2782,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2342,6 +2815,172 @@
"title": "TautulliSourceAIOConfig",
"type": "object"
},
"WebScrobblerData": {
"properties": {
"blacklist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Block scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"youtube"
]
],
"title": "blacklist"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"description": "Additional options for WebScrobbler logging and tuning",
"properties": {
"logFilterFailure": {
"default": "warn",
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
"enum": [
"debug",
false,
"warn"
],
"examples": [
"warn"
],
"title": "logFilterFailure"
},
"logPayload": {
"default": false,
"description": "Log raw WebScrobbler webhook payload to debug",
"examples": [
false
],
"title": "logPayload",
"type": "boolean"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"slug": {
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
"title": "slug",
"type": "string"
},
"whitelist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"mixcloud",
"soundcloud",
"bandcamp"
]
],
"title": "whitelist"
}
},
"title": "WebScrobblerData",
"type": "object"
},
"WebScrobblerSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/WebScrobblerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
},
"type": {
"enum": [
"webscrobbler"
],
"title": "type",
"type": "string"
}
},
"required": [
"type"
],
"title": "WebScrobblerSourceAIOConfig",
"type": "object"
},
"WebhookConfig": {
"anyOf": [
{
@@ -2369,19 +3008,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -2449,6 +3088,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2475,6 +3123,17 @@
}
},
"properties": {
"baseUrl": {
"default": "http://localhost",
"description": "Set the Base URL the application should assume the UI is served from.\n\nThis will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.\n\nIt will NOT set the actual interface/IP that the application is listening on.\n\nThis can also be set using the BASE_URL environmental variable.",
"examples": [
"http://localhost",
"http://192.168.0.101",
"https://ms.myDomain.tld"
],
"title": "baseUrl",
"type": "string"
},
"clientDefaults": {
"$ref": "#/definitions/RequestRetryOptions",
"title": "clientDefaults"
+54
View File
@@ -34,6 +34,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -98,6 +107,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -192,6 +210,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -230,6 +257,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -300,6 +336,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -346,6 +391,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
+590 -21
View File
@@ -39,9 +39,255 @@
},
{
"$ref": "#/definitions/KodiSourceConfig"
},
{
"$ref": "#/definitions/WebScrobblerSourceConfig"
},
{
"$ref": "#/definitions/ChromecastSourceConfig"
}
],
"definitions": {
"ChromecastData": {
"properties": {
"allowUnknownMedia": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "boolean"
}
],
"default": false,
"description": "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing\n\n* If set to TRUE then Music AND Generic/Unknown media will be tracked for ALL APPS\n* If set to FALSE then only media explicitly typed as Music will be tracked for ALL APPS\n* If set to a list then only Apps whose name contain one of these values, case-insensitive, will have Music AND Generic/Unknown tracked\n\nSee https://developers.google.com/cast/docs/media/messages#MediaInformation \"metadata\" property",
"title": "allowUnknownMedia"
},
"blacklistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any application that START WITH these values, case-insensitive",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "blacklistApps"
},
"blacklistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "blacklistDevices"
},
"devices": {
"description": "A list of Google Cast devices to monitor\n\nIf this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set",
"items": {
"$ref": "#/definitions/ChromecastDeviceInfo"
},
"title": "devices",
"type": "array"
},
"forceMediaRecognitionOn": {
"description": "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported\n\nApps will be recognized if they CONTAIN any of these values, case-insensitive",
"items": {
"type": "string"
},
"title": "forceMediaRecognitionOn",
"type": "array"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>",
"title": "options"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"useAutoDiscovery": {
"description": "Use mDNS to discovery Google Cast devices on your next automatically?\n\nIf not explicitly set then it is TRUE if `devices` is not set",
"title": "useAutoDiscovery",
"type": "boolean"
},
"useAvahi": {
"default": false,
"description": "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying\n\nUseful for docker (alpine) container where mDNS resolution is not yet supported. Avahi socket must be exposed to the container and avahi-tools must be installed.",
"title": "useAvahi",
"type": "boolean"
},
"whitelistApps": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any application that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored",
"examples": [
[
"spotify",
"pandora"
]
],
"title": "whitelistApps"
},
"whitelistDevices": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "ONLY scrobble from any cast device that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored\n\nUseful when used with auto discovery",
"examples": [
[
"home-mini",
"family-tv"
]
],
"title": "whitelistDevices"
}
},
"title": "ChromecastData",
"type": "object"
},
"ChromecastDeviceInfo": {
"properties": {
"address": {
"description": "The IP address of the device",
"examples": [
"192.168.0.115"
],
"title": "address",
"type": "string"
},
"name": {
"description": "A friendly name to identify this device",
"examples": [
"MySmartTV"
],
"title": "name",
"type": "string"
}
},
"required": [
"address",
"name"
],
"title": "ChromecastDeviceInfo",
"type": "object"
},
"ChromecastSourceConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/ChromecastData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
}
},
"required": [
"data"
],
"title": "ChromecastSourceConfig",
"type": "object"
},
"CommonSourceOptions": {
"properties": {
"logFilterFailure": {
@@ -74,6 +320,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -180,6 +436,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -199,19 +464,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -298,6 +563,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -442,6 +716,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -461,19 +744,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -562,6 +845,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -588,6 +880,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -686,6 +996,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -734,6 +1053,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -752,6 +1080,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -922,6 +1268,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1060,6 +1415,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1096,6 +1460,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1239,16 +1612,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -1276,6 +1653,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1311,19 +1697,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1402,6 +1788,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1524,6 +1919,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1540,6 +1944,162 @@
"title": "TautulliSourceConfig",
"type": "object"
},
"WebScrobblerData": {
"properties": {
"blacklist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Block scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"youtube"
]
],
"title": "blacklist"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"title": "maxPollRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"description": "Additional options for WebScrobbler logging and tuning",
"properties": {
"logFilterFailure": {
"default": "warn",
"description": "How MS should log when a WebScrobbler event fails a defined filter\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)",
"enum": [
"debug",
false,
"warn"
],
"examples": [
"warn"
],
"title": "logFilterFailure"
},
"logPayload": {
"default": false,
"description": "Log raw WebScrobbler webhook payload to debug",
"examples": [
false
],
"title": "logPayload",
"type": "boolean"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
"title": "scrobbleThresholds"
},
"slug": {
"description": "The URL ending that should be used to identify scrobbles for this source\n\nIn WebScrobbler's Webhook you must set an 'API URL'. All MS WebScrobbler sources must start like:\n\nhttp://localhost:9078/api/webscrobbler\n\nIf you are using multiple WebScrobbler sources (scrobbles for many users) you must use a slug to match Sources with each users extension.\n\nExample:\n\n* slug: 'usera' => API URL: http://localhost:9078/api/webscrobbler/usera\n* slug: 'userb' => API URL: http://localhost:9078/api/webscrobbler/userb\n\nIf no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used",
"title": "slug",
"type": "string"
},
"whitelist": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Only allow scrobbling from specific WebScrobbler Connectors",
"examples": [
[
"mixcloud",
"soundcloud",
"bandcamp"
]
],
"title": "whitelist"
}
},
"title": "WebScrobblerData",
"type": "object"
},
"WebScrobblerSourceConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"title": "clients",
"type": "array"
},
"data": {
"$ref": "#/definitions/WebScrobblerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonSourceOptions",
"title": "options"
}
},
"title": "WebScrobblerSourceConfig",
"type": "object"
},
"YTMusicData": {
"properties": {
"authUser": {
@@ -1556,19 +2116,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1636,6 +2196,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
+4 -3
View File
@@ -1,8 +1,9 @@
import { capitalize, mergeArr } from "../../utils";
import { mergeArr } from "../../utils.js";
import {Logger} from '@foxxmd/winston';
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import winston from '@foxxmd/winston';
import { PlayObject } from "../../../core/Atomic";
import { PlayObject } from "../../../core/Atomic.js";
import { capitalize } from "../../../core/StringUtils.js";
export default abstract class AbstractApiClient {
name: string;
+6 -6
View File
@@ -1,8 +1,9 @@
import AbstractApiClient from "./AbstractApiClient";
import {JRiverData} from "../infrastructure/config/source/jriver";
import AbstractApiClient from "./AbstractApiClient.js";
import {JRiverData} from "../infrastructure/config/source/jriver.js";
import request, {Request, Response} from 'superagent';
import xml2js from 'xml2js';
import {ErrorWithCause} from "pony-cause";
import {DEFAULT_RETRY_MULTIPLIER} from "../infrastructure/Atomic.js";
const parser = new xml2js.Parser({'async': true});
@@ -107,7 +108,7 @@ export class JRiverApiClient extends AbstractApiClient {
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
if (this.token !== undefined) {
@@ -126,15 +127,14 @@ export class JRiverApiClient extends AbstractApiClient {
}
}
testConnection = async () => {
testConnection = async (): Promise<true> => {
try {
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
const {body: { data } = {}} = resp;
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
return true;
} catch (e) {
this.logger.error(new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e}));
return false;
throw new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e});
}
}
+7 -6
View File
@@ -1,13 +1,13 @@
import AbstractApiClient from "./AbstractApiClient";
import AbstractApiClient from "./AbstractApiClient.js";
import {ErrorWithCause} from "pony-cause";
import { KodiData } from "../infrastructure/config/source/kodi";
import { KodiData } from "../infrastructure/config/source/kodi.js";
import { KodiClient } from 'kodi-api'
import normalizeUrl from "normalize-url";
import {URL} from "url";
import { RecentlyPlayedOptions } from "../../sources/AbstractSource";
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js";
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import dayjs from "dayjs";
import { PlayObject } from "../../../core/Atomic";
import { PlayObject } from "../../../core/Atomic.js";
interface KodiDuration {
hours: number
@@ -47,7 +47,7 @@ export class KodiApiClient extends AbstractApiClient {
token?: string;
client: KodiClient;
declare client: KodiClient;
constructor(name: any, config: KodiData, options = {}) {
super('Kodi', name, config, options);
@@ -107,6 +107,7 @@ export class KodiApiClient extends AbstractApiClient {
data: {
track: title,
album: album,
albumArtists: albumartist,
artists,
duration,
playDate: dayjs()
+130 -20
View File
@@ -1,10 +1,21 @@
import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "lastfm-node-client";
import AbstractApiClient from "./AbstractApiClient";
import LastFm, {
AuthGetSessionResponse,
NowPlayingResponse,
TrackObject,
TrackScrobblePayload,
UserGetInfoResponse
} from "lastfm-node-client";
import AbstractApiClient from "./AbstractApiClient.js";
import dayjs from "dayjs";
import { readJson, sleep, writeFile } from "../../utils";
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
import { LastfmData } from "../infrastructure/config/client/lastfm";
import { PlayObject } from "../../../core/Atomic";
import {readJson, removeUndefinedKeys, sleep, writeFile} from "../../utils.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
import { PlayObject } from "../../../core/Atomic.js";
import {getNodeNetworkException, isNodeNetworkException} from "../errors/NodeErrors.js";
import {nonEmptyStringOrDefault, splitByFirstFound} from "../../../core/StringUtils.js";
import {ErrorWithCause} from "pony-cause";
import {getScrobbleTsSOCDate} from "../../utils/TimeUtils.js";
import {UpstreamError} from "../errors/UpstreamError.js";
const badErrors = [
'api key suspended',
@@ -25,10 +36,10 @@ export default class LastfmApiClient extends AbstractApiClient {
user?: string;
declare config: LastfmData;
constructor(name: any, config: Partial<LastfmData> & {configDir: string}, options = {}) {
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: string}, options = {}) {
super('lastfm', name, config, options);
const {redirectUri, apiKey, secret, session, configDir} = config;
this.redirectUri = `${redirectUri}?state=${name}`;
this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`;
if (apiKey === undefined) {
this.logger.warn("'apiKey' not found in config!");
}
@@ -41,10 +52,12 @@ export default class LastfmApiClient extends AbstractApiClient {
artist: {
'#text': artists,
name: artistName,
mbid: artistMbid,
},
name: title,
album: {
'#text': album,
mbid: albumMbid,
},
duration,
date: {
@@ -58,7 +71,7 @@ export default class LastfmApiClient extends AbstractApiClient {
mbid,
} = obj;
// arbitrary decision yikes
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
let artistStrings = splitByFirstFound(artists, [','], [artistName]);
return {
data: {
artists: [...new Set(artistStrings)] as string[],
@@ -66,6 +79,13 @@ export default class LastfmApiClient extends AbstractApiClient {
album,
duration,
playDate: time !== undefined ? dayjs.unix(time) : undefined,
meta: {
brainz: {
album: nonEmptyStringOrDefault<undefined>(albumMbid),
artist: splitByFirstFound<undefined>(artistMbid, [',',';'], undefined),
track: nonEmptyStringOrDefault<undefined>(mbid)
}
}
},
meta: {
nowPlaying: nowplaying === 'true',
@@ -81,7 +101,7 @@ export default class LastfmApiClient extends AbstractApiClient {
callApi = async <T>(func: any, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
@@ -92,15 +112,27 @@ export default class LastfmApiClient extends AbstractApiClient {
} = e;
// for now check for exceptional errors by matching error code text
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
if (undefined !== retryError) {
let networkError = null;
if(retryError === undefined) {
const nError = getNodeNetworkException(e);
if(nError !== undefined) {
networkError = nError.message;
} else if(message.includes('ETIMEDOUT')) {
networkError = 'request timed out after 3 seconds'
}
}
if (undefined !== retryError || networkError !== undefined) {
if (retries < maxRequestRetries) {
const delay = (retries + 1) * retryMultiplier;
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
if(networkError !== undefined) {
this.logger.warn(`API call failed due to network issue (${networkError}), retrying in ${delay} seconds...`);
} else {
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
}
await sleep(delay * 1000);
return this.callApi(func, retries + 1);
} else {
this.logger.warn('Could not recover!');
throw e;
throw new UpstreamError(`API call failed due -> ${retryError ?? 'API call timed out'} <- after max retries hit ${maxRequestRetries}`, {cause: e})
}
}
@@ -109,8 +141,7 @@ export default class LastfmApiClient extends AbstractApiClient {
}
getAuthUrl = () => {
const redir = `${this.config.redirectUri}?state=${this.name}`;
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
}
authenticate = async (token: any) => {
@@ -128,7 +159,7 @@ export default class LastfmApiClient extends AbstractApiClient {
}));
}
initialize = async () => {
initialize = async (): Promise<true> => {
try {
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
@@ -138,14 +169,14 @@ export default class LastfmApiClient extends AbstractApiClient {
}
return true;
} catch (e) {
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
return false;
throw new ErrorWithCause('Current lastfm credentials file exists but could not be parsed', {cause: e});
}
}
testAuth = async () => {
if (this.client.sessionKey === undefined) {
this.logger.info('No session key found. User interaction for authentication required.');
this.logger.warn('No session key found. User interaction for authentication required.');
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
return false;
}
try {
@@ -161,8 +192,87 @@ export default class LastfmApiClient extends AbstractApiClient {
return true;
} catch (e) {
this.logger.error('Testing auth failed');
if(isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Last.fm API');
}
throw e;
}
}
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
const {
data: {
artists = [],
album,
albumArtists = [],
track,
duration,
playDate,
meta: {
brainz: {
track: mbid
} = {},
} = {}
} = {}
} = playObj;
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
let artist: string;
if (artists.length === 0) {
artist = "";
} else {
artist = artists[0];
}
const rawPayload: TrackScrobblePayload = {
artist: artist,
duration,
track,
album,
timestamp: getScrobbleTsSOCDate(playObj).unix(),
mbid,
};
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
if (albumArtists.length > 0) {
rawPayload.albumArtist = albumArtists[0];
}
// I don't know if its lastfm-node-client building the request params incorrectly
// or the last.fm api not handling the params correctly...
//
// ...but in either case if any of the below properties is undefined (possibly also null??)
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
// so remove all undefined keys from the object before passing to the api client
return removeUndefinedKeys(rawPayload);
}
updateNowPlaying = async (play: PlayObject) => {
try {
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
return client.trackUpdateNowPlaying(rest)
});
const {
nowplaying: {
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
}
return response;
} catch (e) {
if (!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
} else {
throw e;
}
}
}
}
+84 -17
View File
@@ -1,22 +1,22 @@
import AbstractApiClient from "./AbstractApiClient";
import AbstractApiClient from "./AbstractApiClient.js";
import request, {Request} from 'superagent';
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz";
import { DELIMITERS, FormatPlayObjectOptions } from "../infrastructure/Atomic";
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import dayjs from "dayjs";
import { stringSameness } from '@foxxmd/string-sameness';
import { combinePartsToString } from "../../utils.js";
import { PlayObject } from "../../../core/Atomic.js";
import { slice } from "../../../core/StringUtils.js";
import {
containsDelimiters,
findDelimiters,
normalizeStr,
parseArtistCredits,
parseCredits,
parseStringList,
parseTrackCredits,
unique,
uniqueNormalizedStrArr,
} from "../../utils";
import { PlayObject } from "../../../core/Atomic";
import { slice } from "../../../core/StringUtils";
} from "../../utils/StringUtils.js";
import { UpstreamError } from "../errors/UpstreamError.js";
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
export interface ArtistMBIDMapping {
@@ -130,7 +130,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
@@ -139,7 +139,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
} catch (e) {
const {
message,
err,
status,
response: {
body = undefined,
text = undefined,
} = {}
} = e;
// TODO check err for network exception
if(status !== undefined) {
const msgParts = [`(HTTP Status ${status})`];
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
let showStopper = status !== 400;
if(body !== undefined) {
if(typeof body === 'object') {
if('code' in body) {
msgParts.push(`Code ${body.code}`);
}
if('error' in body) {
msgParts.push(`Error => ${body.error}`);
}
if('message' in body) {
msgParts.push(`Message => ${body.error}`);
}
// if('track_metadata' in body) {
// msgParts.push(`Track Metadata => ${JSON.stringify(body.track_metadata)}`);
// }
} else if(typeof body === 'string') {
msgParts.push(`Response => ${body}`);
}
} else if (text !== undefined) {
msgParts.push(`Response => ${text}`);
}
throw new UpstreamError(`Listenbrainz API Request Failed => ${msgParts.join(' | ')}`, {cause: e, showStopper});
}
throw e;
}
}
@@ -161,7 +194,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
return true;
} catch (e) {
return false;
throw e;
}
}
@@ -185,6 +218,26 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
deadline: 30000 // wait 30 seconds overall for request to complete
}));
const {body: {payload}} = resp as any;
// const data = payload as ListensResponse;
// if(data.listens.length > 0) {}
// return data.listens[0];
return payload as ListensResponse;
} catch (e) {
throw e;
}
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
try {
const resp = await this.getUserListens(maxTracks, user);
@@ -208,20 +261,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
static playToListenPayload = (play: PlayObject): ListenPayload => {
const {
data: {
playDate,
artists = [],
// MB doesn't use this during submission AFAIK
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
albumArtists = [],
album,
track,
duration,
meta: {
brainz = {}
} = {}
}
} = play;
return {
listened_at: (play.data.playDate ?? dayjs()).unix(),
listened_at: getScrobbleTsSOCDate(play).unix(),
track_metadata: {
artist_name: play.data.artists[0],
track_name: play.data.track,
artist_name: artists[0],
track_name: track,
release_name: album,
additional_info: {
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined,
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
track_mbid: brainz.track,
artist_mbids: brainz.artist !== undefined ? [brainz.artist] : undefined,
artist_mbids: brainz.artist,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup
}
@@ -464,6 +526,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
recording_mbid: aRecordingMbid,
duration: aDuration,
duration_ms: aDurationMs,
music_service_name,
music_service,
submission_client,
submission_client_version
} = {},
mbid_mapping: {
recording_mbid: mRecordingMbid
@@ -509,7 +575,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
meta: {
source: 'listenbrainz',
trackId,
playId
playId,
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
}
}
}
@@ -0,0 +1,76 @@
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../infrastructure/Atomic.js";
import { PlatformApplication, PlatformType } from "./interfaces.js";
import {Media, MediaController, Result} from "@foxxmd/chromecast-client";
import {ErrorWithCause} from "pony-cause";
import objectHash from "object-hash";
import { PlayObject } from "../../../../core/Atomic.js";
export const chromePlayerStateToReported = (state: string): ReportedPlayerStatus => {
switch (state) {
case 'PLAYING':
return REPORTED_PLAYER_STATUSES.playing;
case 'PAUSED':
case 'BUFFERING':
return REPORTED_PLAYER_STATUSES.paused;
case 'IDLE':
return REPORTED_PLAYER_STATUSES.stopped;
default:
return REPORTED_PLAYER_STATUSES.unknown;
}
}
export const getCurrentPlatformApplications = async (platform: PlatformType): Promise<PlatformApplication[]> => {
//https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.Session
let statusRes: Result<{applications?: PlatformApplication[]}>;
try {
statusRes = await platform.getStatus()
} catch (e) {
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
}
let status: {applications?: PlatformApplication[]};
try {
status = statusRes.unwrapAndThrow();
if(status.applications === undefined) {
return [];
}
return status.applications;
} catch (e) {
throw new ErrorWithCause('Unable to fetch platform statuses', {cause: e});
}
}
export const getMediaStatus = async (controller: MediaController.MediaController) => {
let status: Media.MediaStatus;
try {
const statusRes = await controller.getStatus();
status = statusRes.unwrapAndThrow();
return status;
} catch (e) {
throw new ErrorWithCause('Unable to fetch media status', {cause: e});
}
}
export const genDeviceId = (deviceName: string, appName: string) => {
return `${deviceName.substring(0, 25)}-${appName.substring(0,25)}`;
}
export const genPlayHash = (play: PlayObject) => {
const {
data: {
artists,
track,
album,
albumArtists
} = {},
meta: {
mediaType
}
} = play;
return objectHash.sha1({artists, track, album, albumArtists, mediaType});
}
+37
View File
@@ -0,0 +1,37 @@
import {createPlatform, MediaController, PersistentClient} from "@foxxmd/chromecast-client";
import { FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
import {Dayjs} from "dayjs";
import {Logger} from "@foxxmd/winston";
export type PlatformType = ReturnType<typeof createPlatform>;
export interface PlatformApplication {
iconUrl?: string | null | undefined;
isIdleScreen?: boolean | null | undefined;
launchedFromCloud?: boolean | null | undefined;
statusText?: string | null | undefined;
appId: string;
displayName: string;
namespaces: {
name: string;
}[];
sessionId: string;
transportId: string;
}
export interface PlatformApplicationWithContext extends PlatformApplication {
filtered: boolean
controller?: MediaController.MediaController
stale: boolean
staleAt?: Dayjs | undefined
badData: boolean
badDataAt?: Dayjs | undefined
validAppType: boolean
playerId: string
logger: Logger
lastPlayHash?: string
}
export interface ChromecastFormatPlayObjectOptions extends FormatPlayObjectOptions {
deviceId: string
source: string
}
+55 -3
View File
@@ -1,3 +1,7 @@
import {ResponseError} from "superagent";
import { findCauseByFunc } from "../../../utils.js";
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
export interface MalojaV2ScrobbleData {
artists: string[]
title: string
@@ -41,19 +45,32 @@ export interface MalojaV3ScrobbleData {
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
/** The auth key used to scrobble */
key: string
/** name of the track */
title: string
album: string
/** name of the album */
album?: string
/** unix timestamp of when the track was scrobbled*/
time: number
length: number
/** length of the track in seconds */
length?: number
/** number of seconds the track was listened to */
duration?: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
/** comma-separated list of artists for this track */
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
/** a list of artists for this track */
artists: string[]
/** a list of artists for the album the track is on */
albumartists: string[]
/** skip server-side metadata parsing */
nofix?: boolean
}
interface MalojaScrobbleWarning {
@@ -66,7 +83,7 @@ export interface MalojaResponseV3CommonData {
status: 'failure' | 'error' | 'success' | 'ok'
error?: {
type: string
value?: string | object
value?: string | MalojaV3ScrobbleData & { [key: string]: any }
desc: string
}
}
@@ -80,3 +97,38 @@ export interface MalojaScrobbleV3ResponseData extends MalojaResponseV3CommonData
desc: string
warnings?: MalojaScrobbleWarning[]
}
export const isMalojaAPIErrorBody = (body: any): body is MalojaResponseV3CommonData => {
return typeof body === 'object'
&& 'status' in body
&& typeof body.status === 'string'
&& 'error' in body
&& typeof body.error === 'object'
&& 'type' in body.error;
}
export const getMalojaResponseError = (e: Error, asObject: boolean = false): ResponseError | undefined => {
return findCauseByFunc(e, (err) => {
if (!isSuperAgentResponseError(err)) {
return false;
}
return isMalojaAPIErrorBody(err.response.body);
}) as ResponseError | undefined;
}
export const getMalojaRawResponse = (e: Error, asObject: boolean = false): string | object | undefined => {
const err = getMalojaResponseError(e);
if (err === undefined) {
return undefined;
}
const {
response: {
body,
text
} = {}
} = err;
if (asObject) {
return body as object;
}
return text;
}
+62
View File
@@ -0,0 +1,62 @@
import {Response} from 'superagent';
export interface SubsonicResponseCommon {
status: 'failed' | 'ok'
version: string
type: string,
serverVersion: string
openSubsonic: boolean
error?: {
code: number,
message: string
}
[key: string]: any
}
export interface SubsonicResponseBody<T extends SubsonicResponseCommon = SubsonicResponseCommon> {
"subsonic-response": T
}
export interface SubsonicResponse extends Response {
body: SubsonicResponseBody
}
export interface SubsonicNowPlayingResponse extends SubsonicResponseCommon {
nowPlaying: {
entry?: EntryData[]
}
}
export interface EntryData {
id: string,
title: string,
album?: string,
artist: string,
/**
* Length of track in seconds
* */
duration: number,
/**
* When track began playing relative to now
*
* Returned as *whole* minutes so it isn't very accurate
* */
minutesAgo: number,
playerId: string,
username: string,
}
export const asSubsonicResponseCommon = (obj: unknown): obj is SubsonicResponseCommon => {
return obj !== null && typeof obj === 'object' && 'status' in obj;
}
export const asSubsonicResponseBody = (obj: unknown): obj is SubsonicResponse => {
return obj !== null && typeof obj === 'object' && 'subsonic-response' in obj;
}
export const getSubsonicResponse = (resp: Response): SubsonicResponseCommon | undefined => {
if(asSubsonicResponseBody(resp.body) && asSubsonicResponseCommon(resp.body['subsonic-response'])) {
return resp.body['subsonic-response'];
}
return undefined;
}
+72
View File
@@ -0,0 +1,72 @@
export type WebScrobblerHookEvent = 'scrobble' | 'paused' | 'resumedplaying' | 'nowplaying' | string;
export interface WebScrobblerPayload {
eventName: WebScrobblerHookEvent
time?: number
data: {
song: WebScrobblerSong
songs?: WebScrobblerSong[]
currentlyPlaying?: boolean
}
}
interface ProcessedSongData {
artist?: string | null;
album?: string | null;
albumArtist?: string | null;
track?: string | null;
duration?: number | null;
}
interface ParsedSongData extends ProcessedSongData {
trackArt?: string | null;
uniqueID?: string | null;
originUrl?: string | null;
isPodcast?: boolean | null;
isPlaying?: boolean | null;
currentTime?: number | null;
isScrobblingAllowed?: boolean | null;
}
export type Flags =
| {
isScrobbled: boolean;
isCorrectedByUser: boolean;
isRegexEditedByUser: {
track: boolean;
artist: boolean;
album: boolean;
albumArtist: boolean;
};
isAlbumFetched: boolean;
isValid: boolean;
isMarkedAsPlaying: boolean;
isSkipped: boolean;
isReplaying: boolean;
}
| Record<string, never>;
export type Metadata =
| {
label: string;
startTimestamp: number;
albumMbId?: string;
albumUrl?: string;
artistUrl?: string;
notificationId?: string;
trackArtUrl?: string;
trackUrl?: string;
userPlayCount?: number;
userloved?: boolean;
}
| Record<string, never>;
export interface WebScrobblerSong {
controllerTabId: string | number;
parsed: ParsedSongData;
processed: ProcessedSongData;
noRegex: ProcessedSongData;
flags: Flags;
metadata: Metadata;
connectorLabel: string;
}
+54 -36
View File
@@ -1,3 +1,4 @@
import 'dotenv/config';
import {Logger} from '@foxxmd/winston';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
@@ -5,19 +6,20 @@ import isBetween from 'dayjs/plugin/isBetween.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import duration from 'dayjs/plugin/duration.js';
import timezone from 'dayjs/plugin/timezone.js';
import {
parseBool,
readJson,
sleep
} from "./utils";
import { parseBool, readJson, sleep } from "./utils.js";
import * as path from "path";
import {projectDir} from "./common/index";
import SpotifySource from "./sources/SpotifySource";
import { AIOConfig } from "./common/infrastructure/config/aioConfig";
import { getRoot } from "./ioc";
import {getLogger} from "./common/logging";
import {LogInfo} from "../core/Atomic";
import {initServer} from "./server/index";
import { projectDir } from "./common/index.js";
import SpotifySource from "./sources/SpotifySource.js";
import { AIOConfig } from "./common/infrastructure/config/aioConfig.js";
import { getRoot } from "./ioc.js";
import { getLogger } from "./common/logging.js";
import { LogInfo } from "../core/Atomic.js";
import { initServer } from "./server/index.js";
import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
import {ErrorWithCause} from "pony-cause";
import LastfmSource from "./sources/LastfmSource.js";
dayjs.extend(utc)
@@ -28,6 +30,8 @@ dayjs.extend(timezone);
(async function () {
const scheduler = new ToadScheduler()
let output: LogInfo[] = []
const initLogger = getLogger({file: false}, 'init');
@@ -38,6 +42,15 @@ initLogger.stream().on('log', (log: LogInfo) => {
let logger: Logger;
process.on('uncaughtExceptionMonitor', (err, origin) => {
const appError = new ErrorWithCause(`Uncaught exception is crashing the app! :( Type: ${origin}`, {cause: err});
if(logger !== undefined) {
logger.error(appError)
} else {
initLogger.error(appError);
}
})
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
try {
@@ -53,7 +66,6 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
const {
webhooks = [],
port,
logging = {},
debugMode,
} = (config || {}) as AIOConfig;
@@ -67,14 +79,15 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
process.env.DEBUG_MODE = b.toString();
}
const root = getRoot(port);
const root = getRoot(config);
initLogger.info(`Version: ${root.get('version')}`);
logger = getLogger(logging, 'app');
initServer(logger, output);
if(process.env.IS_LOCAL === 'true') {
logger.info('multi-scrobbler can be run as a background service! See: https://github.com/FoxxMD/multi-scrobbler/blob/develop/docs/service.md');
logger.info('multi-scrobbler can be run as a background service! See: https://foxxmd.github.io/multi-scrobbler/docs/installation/service');
}
if(appConfigFail !== undefined) {
@@ -92,6 +105,11 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
await scrobbleClients.buildClientsFromConfig(notifiers);
if (scrobbleClients.clients.length === 0) {
logger.warn('No scrobble clients were configured!')
} else {
logger.info('Starting scrobble clients...');
}
for(const client of scrobbleClients.clients) {
await client.initScrobbleMonitoring();
}
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
@@ -110,34 +128,34 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
let anyNotReady = false;
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
switch (source.type) {
case 'spotify':
if ((source as SpotifySource).spotifyApi !== undefined) {
if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) {
anyNotReady = true;
} else {
(source as SpotifySource).poll();
}
}
break;
case 'lastfm':
if(source.initialized === true) {
source.poll();
}
break;
default:
if (source.poll !== undefined) {
source.poll();
}
if(source.isReady()) {
source.poll();
} else {
anyNotReady = true;
}
}
if (anyNotReady) {
logger.info(`Some sources are not ready, open the dashboard to continue`);
}
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatClientsTask(scrobbleClients, logger)));
logger.info('Scheduler started.');
} catch (e) {
logger.error('Exited with uncaught error');
logger.error(e);
const appError = new ErrorWithCause('Exited with uncaught error', {cause: e});
if(logger !== undefined) {
logger.error(appError);
} else {
initLogger.error(appError);
}
process.exit(1);
}
}());
+67 -31
View File
@@ -1,53 +1,89 @@
import {createContainer} from "iti";
import path from "path";
import {projectDir} from "./common/index";
import ScrobbleClients from "./scrobblers/ScrobbleClients";
import ScrobbleSources from "./sources/ScrobbleSources";
import {Notifiers} from "./notifier/Notifiers";
import { configDir, projectDir } from "./common/index.js";
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
import ScrobbleSources from "./sources/ScrobbleSources.js";
import { Notifiers } from "./notifier/Notifiers.js";
import {EventEmitter} from "events";
import {logPath} from "./common/logging";
import {Container} from '@foxxmd/winston';
import { WildcardEmitter } from "./common/WildcardEmitter";
//import ScrobbleClients from "./clients/ScrobbleClients.js";
import { logPath } from "./common/logging.js";
import { WildcardEmitter } from "./common/WildcardEmitter.js";
import normalizeUrl from 'normalize-url';
import fs from 'fs';
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
//const port = process.env.PORT ?? 9078;
let version = 'Unknown';
/*let logPath = path.resolve(projectDir, `./logs`);
if(typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}*/
if(process.env.APP_VERSION === undefined) {
if(fs.existsSync('./package.json')) {
try {
const pkg = fs.readFileSync('./package.json') as unknown as string;
try {
version = JSON.parse(pkg).version || 'unknown'
} catch (e) {
// don't care
}
} catch (e) {
// don't care
}
} else if(fs.existsSync('./package-lock.json')) {
try {
const pkg = fs.readFileSync('./package-lock.json') as unknown as string;
try {
version = JSON.parse(pkg).version || 'unknown'
} catch (e) {
// don't care
}
} catch (e) {
// don't care
}
}
process.env.APP_VERSION = version;
} else {
version = process.env.APP_VERSION;
}
let root: ReturnType<typeof createRoot>;
const createRoot = (port: number | string | undefined) => {
export interface RootOptions {
baseUrl?: string,
port?: string | number
}
const createRoot = (options?: RootOptions) => {
const {
port = 9078,
baseUrl = process.env.BASE_URL,
} = options || {};
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
return createContainer().add({
version,
configDir: configDir,
logDir: logPath,
localUrl: `http://localhost:${port}`,
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
configPort: port,
apiPort: process.env.API_PORT ?? 9079,
mainPort: process.env.PORT ?? 3000,
port: process.env.PORT ?? port,
clientEmitter: () => new WildcardEmitter(),
sourceEmitter: () => new WildcardEmitter(),
notifierEmitter: () => new EventEmitter(),
}).add((items) => ({
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir),
sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir),
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
port: () => {
if(items.configPort !== undefined) {
return items.configPort;
}
return items.isProd ? items.mainPort : items.apiPort;
}).add((items) => {
const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true});
const u = new URL(base);
let localUrl = u.toString();
if(u.port === '' && u.pathname === '/') {
localUrl = `${u.origin}:${items.mainPort}`;
}
}));
return {
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir),
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir),
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
localUrl,
hasDefinedBaseUrl: baseUrl !== undefined,
isSubPath: u.pathname !== '/' && u.pathname.length > 0
}
});
}
export const getRoot = (port?: number | string) => {
export const getRoot = (options?: RootOptions) => {
if(root === undefined) {
root = createRoot(port);
root = createRoot(options);
}
return root;
}
@@ -1,6 +1,6 @@
import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks";
import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import {Logger} from '@foxxmd/winston';
import { mergeArr } from "../utils";
import { mergeArr } from "../utils.js";
export abstract class AbstractWebhookNotifier {
@@ -1,5 +1,5 @@
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier";
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import {gotify} from 'gotify';
import request from 'superagent';
import {HTTPError} from "got";

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