Compare commits

...
144 Commits
Author SHA1 Message Date
FoxxMD 71a8ad2418 Fix typo on users specification for jellyfin source setup 2021-07-05 10:43:44 -04:00
FoxxMD 49bd1c8836 Refactor initialization and authentication stages for client/source building
* Break up into different functions and add properties to concrete classes signalling its capabilities
* More detailed logging based on which fails/succeeds
* Simplify client/source init/auth step (no more need for case switch specifics other than creating object)
2021-04-14 16:22:05 -04:00
FoxxMD fc3dd62858 Refactor source building and initialization to log failures but continue
* Instead of stopping the whole application for one misconfigured config just log the issue and continue with any valid/working configs.
* Add better logging for type.json issues (add index)
* Add deprecation warnings for single-user mode structures in type.json (to be removed in 0.4)
2021-04-14 11:28:55 -04:00
FoxxMD ff114a0ae5 Refactor client building and initialization to log failures but continue
* Instead of stopping the whole application for one misconfigured config just log the issue and continue with any valid/working configs.
* Add better logging for type.json issues (add index)
* Add deprecation warnings for single-user mode structures in type.json (to be removed in 0.4)
2021-04-14 11:14:57 -04:00
FoxxMD 8bfcb6cd9a Showcase last.fm as a source in readme 2021-04-14 10:19:23 -04:00
FoxxMD 8154e30939 Add last.fm as source to readme 2021-04-13 17:05:46 -04:00
FoxxMD 506440825f Implement Last.fm as a source
For #23
2021-04-13 17:04:40 -04:00
FoxxMD 646723fcf8 Add ui screenshot to readme 2021-03-17 10:34:51 -04:00
FoxxMD a560a49aac Fix typo 2021-03-16 15:44:38 -04:00
FoxxMD de763d68f3 Add Jellyfin source #15
* Implement Jellyfin source similar to Plex, but using MemorySource
* Add configuration docs and json example
2021-03-16 15:36:51 -04:00
FoxxMD f731c25332 Return new plays when memory source adds them to stateful
Will make it easier for non-polling sources to see what is new from state
2021-03-16 15:33:41 -04:00
FoxxMD f738eb92e8 Merge branch 'develop' into feat-jellyfin 2021-03-16 14:39:26 -04:00
FoxxMD b9f4f43d30 Refactor subsonic source to use MemorySource
* Should stabilize subsonic as a source and prevents duplicate scrobbles
* Reduce polling interval to 10 seconds for more accurate recently played data and restrict max backoff time to 30 seconds
2021-03-16 14:38:59 -04:00
FoxxMD e42f00604e Refactor polling source backoff approach
Use time-based approach rather than check count approach so active period is not sensitive to interval value
2021-03-16 14:37:15 -04:00
FoxxMD 74eed98b9b Fix filter method 2021-03-16 14:25:55 -04:00
FoxxMD cef6f5864a Implement "in memory" recently played tracking for sources that don't support a source of truth
If sources don't support a scrobble action or don't return a sane "recently played" data source we need to keep track of these plays ourselves. Do it in memory based on currently playing and return that as source "source of truth" for recently played.
2021-03-16 14:01:17 -04:00
FoxxMD d5e816b1d3 Use a persistent datetime based on source instantiation or last track play as client refresh check threshold
We don't need to refresh clients on every source poll, only when first creating a source or when a new track play is discovered.
2021-03-16 13:59:43 -04:00
FoxxMD dcc4201019 Improve parsing of last.fm track responses and handling recent tracks
* Include some metadata (mbid, url, nowplaying)
* Check for more artist properties since responses seem inconsistent now
* Check for timestamp to handle nowplaying use case
* Handle invalid scrobbles from last.fm on recents refresh WRT nowplaying and missing timestamps #22
2021-03-16 10:57:44 -04:00
FoxxMD 9c093a8455 POC jellyfin implementation #15
* Hopefully can get artist from webhook payload in the future so we can actually use this
* Add basic instructions for using Webhook plugin in configuration docs
2021-03-12 11:24:33 -05:00
FoxxMD 9a56a3ee4d Merge branch 'develop' into feat-jellyfin
# Conflicts:
#	index.js
2021-03-12 10:24:54 -05:00
Matt Foxx f2b0714dea Merge pull request #21 from christophernewton/master
Feature: Fix padding on client box
2021-03-02 09:19:19 -05:00
Chris Newton 0a1357acc7 feat: fix padding on client boxes 2021-03-02 08:54:47 +11:00
Matt Foxx 403af711eb Merge pull request #20 from christophernewton/master
Frontend update
2021-03-01 16:36:07 -05:00
Chris Newton 31cd72cd15 feat: fixed typo in debug output, added active state for links 2021-03-02 08:24:32 +11:00
Chris Newton 55afe876e0 feat: fixed typo in readme for docker 2021-02-27 14:31:41 +11:00
Chris Newton 49fadd8c9a feat: added styles, dark mode to frontend 2021-02-27 14:30:39 +11:00
Chris Newton 58157ddfee wip: updating styles for front end 2021-02-26 16:54:08 +11:00
FoxxMD 2a9cd87848 Update package properties 2021-02-25 09:20:37 -05:00
FoxxMD 7b31285f89 Fix missing throw error from polling try-catch
Without it the polling continues forever on loop! oops
2021-02-19 09:14:10 -05:00
FoxxMD 89db858289 Fix import error for Errors from spotify-web-api-node 2021-02-19 09:13:16 -05:00
FoxxMD 2fec6aff6e Implement request and polling retries
* Hierarchical retries and delay options for sources and clients (override general config => individual config)
* Logging for retry attempts
* Respect Retry-After header on responses if present
2021-02-18 15:01:21 -05:00
FoxxMD 9beadfaf0f Bump dependencies to fix spotify-web-api-node crash
spotify-web-api-node#340
2021-02-18 10:04:12 -05:00
FoxxMD dd7e971e71 Fix how scrobbled tracks are returned to source
Returns tracks array should only show that was a track was scrobbled or not (by existing in the array) -- and only be in the array once.
2021-01-12 09:47:47 -05:00
FoxxMD 1aee74bfc1 Fix scrobbled tracks statistic for scrobble clients 2021-01-11 10:11:40 -05:00
FoxxMD 123c171d07 Fix case insensitive check 2021-01-05 17:10:18 -05:00
FoxxMD 764b490cfd Fix includes usage 2021-01-05 10:20:35 -05:00
FoxxMD aec3398edb Return a proper response on lastfm cb 2021-01-04 17:21:52 -05:00
FoxxMD e552820b4d Add missing session property for lastfm config 2021-01-04 17:07:54 -05:00
FoxxMD 1bc8edb07c Update documentation for last.fm
* Add last.fm configuration docs, kitchen sink, and json example
* Update readme
2021-01-04 17:02:36 -05:00
FoxxMD 085e61db5c Refactor app for lastfm client and multiple clients (!)
* Pass config dir to client handler
* Rename auth routes to be source/client specific
* Pass client info to status page to enable displaying client stats and actions (auth)
* Handle auth callback from lastfm
2021-01-04 16:35:38 -05:00
FoxxMD 1df6a8d6f7 Add lastfm to scrobble clients handler with some refactoring
* Refactor scrobble clients to be more granular on error handling for scrobble call
* use initialized param on clients to additionally check if they should be used
* Pass config dir to constructor so we can use it any in the class (for lastfm)
* Use error property to determine if we she keep trying to scrobble plays after caught error
2021-01-04 16:34:13 -05:00
FoxxMD d0e1e83ebd Implement Last.fm scrobble client
* Authentication is user-interaction required with saved session file
* Use initialized to signal auth is done and client is ready to scrobble/get tracks
* Add some retry attempts based on error returned from api
2021-01-04 16:27:23 -05:00
FoxxMD e0a2ada59b Add lastfm node client 2021-01-04 16:25:30 -05:00
FoxxMD 855d3f6144 Jellyfin initial code 2021-01-04 12:06:45 -05:00
FoxxMD 0508551dc7 Add airsonic advanced guidance to docs #10 2020-12-09 11:02:37 -05:00
FoxxMD f4a310cda9 Fix variable typo 2020-12-09 11:00:32 -05:00
FoxxMD 6ac8938644 Add subsonic docs #10 2020-12-09 10:51:02 -05:00
FoxxMD f41b3eea55 Implement subsonic ENV based config 2020-12-09 10:38:53 -05:00
FoxxMD db34d92329 Relax time thresholds for finding existing tracks when source is from Subsonic
Due to inaccuracy of subsonic DT need to relax threshold from 10 seconds (or same) to 60 seconds since granularity is only down to a minute #10
2020-12-09 10:27:51 -05:00
FoxxMD a60a0f89ad Filter out recently played subsonic tracks so that only tracks played for a minute or more are valid #10 2020-12-09 10:26:55 -05:00
FoxxMD 1de9c5cdc1 Refactor polling behavior
* Use plain ol async because it gets the job done and i don't need no fancy generator/yield. Can wait until loop is done to detect signal to stop polling
* Refactor polling into AbstractSource, remove duplicated code for Spotify/Subsonic sources
* Add 'canPoll' property to Sources
* Stagger polling invocation on app start so log messages don't get jumbled
* Refactor auth/poll/recent endpoints to be generic (based on source properties)
* Refactor status page to show functionality based on canAuth/canPoll rather than type
2020-12-08 16:49:10 -05:00
FoxxMD 299e37e32a Implement subsonic polling and scrobbling 2020-12-08 15:36:57 -05:00
FoxxMD 4921f963a8 Implement basic Subsonic source with api wrapper and authentication #10 2020-12-08 14:52:51 -05:00
FoxxMD 22cc300733 More doc updates
* Format example json comments to be cleaner to read
* Rewrite intro to better reflect new functionality
2020-12-08 10:05:56 -05:00
FoxxMD 46ee5b89c6 Add a kitchen sink example 2020-12-08 09:34:35 -05:00
FoxxMD e660b8f835 Merge branch 'multiUser' into develop 2020-12-07 16:39:16 -05:00
Matt Foxx 0050d114ce Merge pull request #14 from FoxxMD/add-license-1
Add license
2020-12-07 16:38:59 -05:00
FoxxMD e7ed5d9381 Simplify initializing message for plex/tautulli 2020-12-07 16:36:37 -05:00
FoxxMD a1848c0a44 Fix config example 2020-12-07 16:33:31 -05:00
FoxxMD 49827c80bc Implement better non-authed spotify html on status page 2020-12-07 16:26:01 -05:00
FoxxMD 1c6427b8a7 Fix some markdown issues 2020-12-07 16:12:08 -05:00
FoxxMD ce35b67f37 Rewrite docs #13
* Refactor examples to all be multi-user structured
* Move configuration into its own file
* Separate config approaches into env/json approaches with guidance on which one to use
* Add more comments to example json
* Simplify main readme and provide a more opinionated, minimal example there
2020-12-07 16:05:47 -05:00
FoxxMD 4c0ff1c9ce Fix client naming so it matches sources (for unnamed) 2020-12-07 14:28:00 -05:00
FoxxMD f29e224631 More debug logging for checking existing 2020-12-07 14:19:38 -05:00
FoxxMD d4bdab1f8d Correctly destructure objects when checking for existing submitted 2020-12-07 13:42:29 -05:00
FoxxMD 8f1808e24f Always rename unnamed sources
Derp
2020-12-07 13:21:33 -05:00
FoxxMD 1e5a427a3d Use identifier to make filtering debug statement more descriptive 2020-12-07 13:18:32 -05:00
FoxxMD b8ba0fe224 Add identifier property to abstract source 2020-12-07 13:18:07 -05:00
FoxxMD 058473a595 Add deprecated source configs with warning message 2020-12-07 13:13:59 -05:00
FoxxMD 5696d5598b Refactor source building to only warn about dup names if they are also the same source
Also allow additional configs to be passed before building starts
2020-12-07 13:13:37 -05:00
FoxxMD 937f22e643 Refactor Maloja existing scrobble check to be MUCH more robust
* Refactor comparison methodology to use weighted scoring system (psuedo-fuzzy) instead of all-or-nothing if statements
* Use reference scrobbles (playObjs we have submitted while app is running) to check for existing
* Make title cleaning more robust by removing parenthesis and all "feat" strings
* Add artists matching
* Rewrite debugging info to show score/breakdowns and add more granular debugging options (show on match/no match)
2020-12-07 12:44:48 -05:00
FoxxMD ec6b952110 Implement more generic scrobble client functionality
* Add scrobbledPlayObjs to so we can keep track of formatted playObjs we *know* we have scrobbled (for more accurate comparisons elsewhere)
* Filter scrobbledPlayObjs based on oldest returned recent scrobble from client
* Add instanced generic formatPlayObj for use in other methods
* Add generic source title cleaning function
* Add generic function to search for scrobbled playObj from known submitted playObjs
2020-12-07 12:41:12 -05:00
FoxxMD bbb0adf448 Add source id for Spotify 2020-12-07 12:38:55 -05:00
FoxxMD 67a35cb81a More granular track building functionality
* Change time to use @ to be more compact
* Add artist and track to include options
2020-12-07 12:38:42 -05:00
FoxxMD 44b40cc35f Implement playObj comparison function
Check by source/sourceId, artists, and track
2020-12-07 12:38:01 -05:00
Matt Foxx 8eb4289b18 Create LICENSE 2020-12-06 21:51:20 -05:00
FoxxMD ee614e1a61 Big ol rewrite for multi user
* Refactor source building into a new class to hold all sources
* ScrobbleSources supports single and multi user config variant parsing (based on client parsing)
* Refactor client/source abstract classes to accept a type and name -- logger is based off of both
* Refactor config file parsing into ScrobbleSources
* Refactor status page to use array of source data instead of hardcoding
* Status page now uses flex css to layout sources instead of hardcoding
* Refactor spotify endpoints to require a name from querystring in order to determine which source to work on (middleware with this)
* Refactor ScrobbleClients to use optional scrobble filter name, passed by configured source
* Implement library and server configuration params for plex/tautulli to help with multi-user
* Make library, server, and user checks on plex/tautulli case insensitive and include more debug information
* Consolidate validEvent checks for plex/tautulli
2020-12-04 18:47:05 -05:00
FoxxMD e0a274a314 Update scrobble client to integrate config name into logging label #13 2020-12-04 15:22:33 -05:00
FoxxMD 71c26f1964 Refactor client configuration parsing to support multiple users
* Add sane defaults for single-user mode client configs (naming)
* Refactor client building into a two-stage process of 1) parsing and checking for valid config 2) validating config per client
* Refactor client ENVs config into separate config (don't mix/overwrite env and json configs)
* Check for unique names
* Implement more descriptive and thorough config structure validation including hinting at config source (location)

Relates to #13
2020-12-04 15:09:58 -05:00
FoxxMD 2c83364b9b Merge remote-tracking branch 'origin/master' 2020-12-04 09:34:32 -05:00
FoxxMD 20da4bd0b8 Fix artist concat for spotify recently played 2020-12-03 15:56:49 -05:00
FoxxMD 307d52fbf0 Improve existing scrobble checking for Maloja
* Strip source track of feat and [artist] since Maloja does this as well
* Compare position-agnostic and de-duped tokens from source/scrobble titles for a better match
2020-12-03 15:41:12 -05:00
FoxxMD 101e2c8657 Refactor handling of artists values
* Refactor playObj artist (string) => artists (array) so we have more info to work with and less ambiguity in naming
* Use forward slash as artist deliminator for Maloja scrobbling because it parses better
* Track building the artists uses forward slash for less ambiguity as well
2020-12-03 14:50:57 -05:00
FoxxMD 8bdeca9089 Add spotify backlog activity disclaimer 2020-12-03 11:13:55 -05:00
FoxxMD 5eb82f0da2 Implement recently played Spotify tracks view
Format returned tracks with padding and "X from now" time for easier reading
2020-12-03 11:10:41 -05:00
FoxxMD 8c31b65525 Implement a bunch of formatting options for building track strings
* Implement func for finding longest string length (for use with padding plays)
* Implement func for truncating string based on a fixed length (for use with padding plays)
* Implement play string building transformers for all parts of the string for finer control
* Implement "X from now" time formatting
2020-12-03 11:08:46 -05:00
FoxxMD f0d439a785 Refactor spotifyApi usage to make token refresh functionality more reusable
* Move all spotifyApi invocation into a wrapped function that handles token refresh so we can just call the api from anywhere without having to worry about re-authenticating
* Implement "get recent played" function using new wrapper and also support formatting results to playObjs before returning
* add open.spotify.com web url to playObj meta if it exists
2020-12-03 11:06:29 -05:00
Matt Foxx 29cdf34ef8 Merge pull request #7 from FoxxMD/develop
Develop
2020-11-27 19:11:27 -05:00
FoxxMD 5a1fb1c08e Attempt to fix use-case when maloja history is empty and provide more logging for not scrobbling
* Fix an empty maloja history always causing oldest scrobble to now() time, preventing time frame from ever being valid
* Add debug logging to timeframe and existing scrobble checks for new tracks
2020-11-25 18:49:23 -05:00
FoxxMD 66dcdc30d2 Add argument for including extra info when building a track string 2020-11-25 18:46:05 -05:00
FoxxMD f74940800b Fix bad return type for existing scrobble
Should only be returning undefined or object
2020-11-25 17:49:16 -05:00
FoxxMD 8a46890b20 Refactor/improve error handling for configuration in spotify and maloja
* Implement api wrapping for maloja and handle formatting error
* Implement testing maloja connection to make sure configuration is valid (check server info and test endpoint)
* Provide better defaults for maloja scrobbles list when empty  (maybe fixes #5)
* Better formatting for maloja scrobble api calls
* Better handling of maloja and spotify configuration issues during initialization (And logging for it)
2020-11-25 14:09:56 -05:00
FoxxMD a49212ea24 Refactor app to use read json handling
Also fix spotify auth request when config is invalid
2020-11-25 14:07:11 -05:00
FoxxMD ce63a9b775 Refactor json reading to handle not found and parsing errors 2020-11-25 14:06:07 -05:00
FoxxMD eb1ee5cfed More try-catch blocks for scrobble clients 2020-11-25 09:46:35 -05:00
FoxxMD 0be4fbb8c5 Move try-catch for main function into async execution 2020-11-25 09:33:45 -05:00
FoxxMD 3437519ef4 Implement error stack logging
And replace users CWD directory with placeholder to make it easier to debugging in public (Github)
2020-11-25 09:33:19 -05:00
FoxxMD 02791d7aba Fix dockerhub links 2020-11-24 15:47:45 -05:00
FoxxMD f8eeebe29f Fix splat arg usage for now
Should be able to just throw arguments at it but for some reason it needs to be in an object for now?
2020-11-24 15:06:10 -05:00
FoxxMD aa95604cc3 Use splat args for media identification on plex/tautulli debug 2020-11-24 14:56:25 -05:00
FoxxMD f981f9e207 Implement splat serialization 2020-11-24 14:56:13 -05:00
FoxxMD eff9cfbdb8 Refactor logging to simplify usage and clean up actual logged statement structure
* Move winston format combining and final formatter function into utils
* Use individual loggers for every "area" they are needed by using winston.loggers
* Create a default logger with final transport options
* util convenience function for creating a new labelled format combo
* util convenience function for created new logger with labelled formatter
* remove label usage everywhere! so much cleaner
* remove logger passing everywhere and replace with winston get call for logger we want (from util function)
2020-11-24 14:12:17 -05:00
FoxxMD 37b4aca6be Move spotify auth code handling into SpotifySource
Just cleaning up index
2020-11-24 12:35:05 -05:00
FoxxMD 77ffcae41b Pass name to plex/tautulli constructor so initialized shows correct label 2020-11-24 12:33:38 -05:00
FoxxMD e50623c9d4 Fix module import 2020-11-24 12:27:09 -05:00
FoxxMD 82f89b51ea Simplify plex/tautulli codebase
* TautulliSource extends Plex class, just changes name and valid event check
* Standardize log levels and verbiage for valid event check
2020-11-24 12:13:23 -05:00
FoxxMD 5b4a53121d Don't log debug for already scrobbled tracks with diff due to noise
Too much logging since we are now checking tracks on every spotify polling interval. Really only need to log if we decide its *not* a dupe because of track diffs
2020-11-24 11:29:20 -05:00
FoxxMD 7c0014b590 Refactor source and client play object handling to be more date independent
* Map all tracks from source/clients immediately to playObjs to reduce cognitive load
* Move responsibility for source and "newFromSource" meta from source -> scrobble clients -- to source -> playObj formatting so playObjs hold all their own meta
* Always immediately sort source/client track lists by playDate ascending for less cognitive load
* Relax valid time frame for maloja client to be based on oldest returned scrobble (since we are always returning most recent scrobbles)
* Refactor debugging for already scrobbled so all diff statements are lumped at the end and formatted
* Simplify spotify source fetching since meta is handled by play objects now
* Fix new track recognition for spotify source by using sorted playObjs instead of relying on order spotify returns tracks
2020-11-24 11:26:47 -05:00
FoxxMD b22d96bbec Implement function for sorting by playDate ascending 2020-11-24 11:21:28 -05:00
FoxxMD f2396c7955 Update plex/tautulli usage for new scrobble function on clients 2020-11-23 17:07:00 -05:00
FoxxMD e4e398ac99 Implement scrobbling backlogged tracks
Check all returned tracked from recent spotify plays against scrobbles from clients to see if any have not been scrobbled. This helps compensate for when Spotify has backlogged their own recent plays (plays being "added" to recent response long after they were actually played)

* Check time frame for returned scrobbles from client before checking if a source track has been scrobbled to make sure only source tracks played within valid time frame are scrobbled
* Remove debug logging for recent tracks checks for now -- since all tracks are now checked
* Use different verbiage in logging when track is from backlog vs. new from source client
2020-11-23 17:02:48 -05:00
FoxxMD 433e077eba Fix backoff interval not reset on new track 2020-11-23 15:46:24 -05:00
FoxxMD 51339cf74d Remove extra level specification outside of log config
Oops forgot this one
2020-11-23 15:24:24 -05:00
FoxxMD eb5ded164c Log to debug when spotify polling doesn't find any new tracks
Also show the most recent track spotify returns
2020-11-23 14:27:06 -05:00
FoxxMD c06f7b4695 Implement basic logging config updates and info
* Show current level/sort/limit for logging on status page
* Render log config values as links and implement endpoint to update them

Now a user can switch to debug level to check more detailed info without restarting the app
2020-11-23 13:47:20 -05:00
FoxxMD 65a211df8b Fix destructuring default object when no spotify config is present 2020-11-23 09:40:52 -05:00
FoxxMD 0efeeafb8c Fix it again 2020-11-18 20:40:41 -05:00
FoxxMD c8408e8e00 Fix user comparison
I'm tired
2020-11-18 20:40:20 -05:00
FoxxMD 4ebe9427a4 Fix async oopsie 2020-11-18 19:55:37 -05:00
FoxxMD ce366dbb7b Fix typo 2020-11-18 19:43:54 -05:00
FoxxMD 5dbebf662b More readme cleanup 2020-11-18 19:42:41 -05:00
FoxxMD c398c3f97f Add config example and instructions in readme 2020-11-18 19:36:07 -05:00
FoxxMD 554327d8d0 Implement plex/tautulli source user restriction 2020-11-18 19:29:05 -05:00
FoxxMD fc80953094 Implement Plex webhook source 2020-11-18 17:26:40 -05:00
FoxxMD 4c2ec559d8 More readme cleanup 2020-11-18 16:52:09 -05:00
FoxxMD 1e1977a07c Change name 2020-11-18 16:39:56 -05:00
FoxxMD 2b0cb894d8 Add Tautulli instructions 2020-11-18 16:34:06 -05:00
FoxxMD cfd128a779 Refactor spotify polling to check for yield error 2020-11-18 16:29:22 -05:00
FoxxMD 658fd9f5ec return yield value as caught error in generator so we can handle inline 2020-11-18 16:29:08 -05:00
FoxxMD 444aa80b9e Refactor to a more class-based structure and decouple spotify from app "running" state
* Refactor spotify config and api building into a Source class
* Move spotify polling loop into Source class and use generator to handle one-run only restriction (Closes #2)
* Signal polling status using promise finally/catch
* Implement formatPlayObj static method to standardize play data
* Implement Tautulli source class
* Refactor client scrobbling and building/config into ScrobbleClients class
* Implement Tautulli endpoint for scrobbling from notification agent webhook
* Add Tautulli information to status page
* Log number of tracks discovered by each source on status page
2020-11-18 15:53:13 -05:00
FoxxMD ce9e09c3a6 Add examples and update readme 2020-11-16 11:55:09 -05:00
FoxxMD 7b45ad6e9e More cleanup on readme 2020-11-16 11:31:48 -05:00
FoxxMD 44ea06020b Use whole numbers for scrobble time diffs for better readability 2020-11-16 11:30:56 -05:00
FoxxMD 89ea2e5ee1 Add badges to readme 2020-11-16 11:29:46 -05:00
FoxxMD 9fb915067e Use max param for returning maloja scrobbles
Still keep since/to in case user is running an older server version

krateng/maloja#53
2020-11-16 11:14:06 -05:00
FoxxMD 55ca8762f2 Improve datetime handling
* Switch to dayjs from date-fns
* Format timestamps and play time to local timezone
2020-11-16 11:12:06 -05:00
FoxxMD ca8589891a Implement index route to display polling status and buffered log data
* (Crude) detection of running poll loop
* Show auth url if not detected (from initialization)
* Output last 50 buffered log statements in descending order

Closes #4
2020-11-16 10:58:03 -05:00
FoxxMD 5383fb8280 Implement back off behavior if listening behavior is idle 2020-11-15 00:03:10 -05:00
FoxxMD 569778d69f Fix path 2020-11-14 20:38:17 -05:00
FoxxMD 9fd769513d Add some docker env var documentation 2020-11-14 20:36:55 -05:00
FoxxMD aa782d3bf7 Move spotify creds file into CONFIG_DIR
* Easier to keep track of all files in one folder in code
* Keeping in CONFIG_DIR means creds file will persist after docker container rebuild on image update
2020-11-14 20:31:14 -05:00
FoxxMD b0fd0c6959 Refactor client config building so that a json file is not required 2020-11-14 20:19:25 -05:00
FoxxMD f05ddd2e3e Improve documentation 2020-11-14 19:44:29 -05:00
FoxxMD 63b73a5ea4 Simplify configuration options and improve configuration documentation
* Removed individual config file location vars in favor of using just CONFIG_DIR or json objects to reduce complexity
* Justified json example comments and removed unused variables
* Tables for env variable documentation in readme (and remove unused)
* Implement LOG_DIR docker volume
* Implement LOG_LEVEL environment for setting global log level
2020-11-14 19:36:02 -05:00
39 changed files with 6098 additions and 449 deletions
+5 -1
View File
@@ -5,5 +5,9 @@ Dockerfile
.dockerignore
.gitignore
.git
spotifyCreds.json
config/currentCreds.json
*.log
config/maloja.json
config/spotify.json
config/config.json
/docs
+10 -1
View File
@@ -1,5 +1,9 @@
FROM node:fermium-alpine3.10
ENV TZ=Etc/GMT
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node
WORKDIR /home/node/app
@@ -8,7 +12,7 @@ COPY package*.json ./
USER node
RUN npm install
RUN npm install --production
COPY --chown=node:node . .
@@ -19,6 +23,11 @@ RUN mkdir -p $config_dir
VOLUME $config_dir
ENV CONFIG_DIR=$config_dir
ARG log_dir=/home/node/logs
RUN mkdir -p $log_dir
VOLUME $log_dir
ENV LOG_DIR=$log_dir
ARG webPort=9078
ENV PORT=$webPort
EXPOSE $PORT
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 FoxxMD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63 -51
View File
@@ -1,83 +1,95 @@
# spotify-scrobbler
# multi-scrobbler
A single-user, javascript app to scrobble your recent plays to [Maloja](https://github.com/krateng/maloja) (and other clients, eventually)
[![Latest Release](https://img.shields.io/github/v/release/foxxmd/multi-scrobbler)](https://github.com/FoxxMD/multi-scrobbler/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients (eventually!)
* Supports scrobbling for many sources
* [Spotify](/docs/configuration.md#spotify)
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Jellyfin](/docs/configuration.md#jellyfin)
* [Last.fm](/docs/configuration.md#lastfm-source)
* Supports scrobbling to many clients
* [Maloja](/docs/configuration.md#maloja)
* [Last.fm](/docs/configuration.md#lastfm)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Built for Docker and unattended use!
**Why should I use this over a browser extension and/or mobile app scrobbler?**
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
* **Consolidate play sources** -- Scrobble from many sources to one client with ease and without duplicating tracks.
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
**But I already scrobble my music to Last.fm, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a Source](/docs/configuration.md#lastfm-source) to mirror scrobbles from your Last.fm profile to Maloja. That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src="/assets/status-ui.jpg" width="800">
## Installation
### Locally
Clone this repository somewhere and then install from the working directory
```bash
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
npm install
```
## Setup/Configuration
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
All configuration is done through json files or environment variables. Reference the [examples in the config folder](https://github.com/FoxxMD/spotify-scrobbler/tree/master/config) more detailed explanations and structure.
```
foxxmd/multi-scrobbler:latest
```
**A property from a json config will override the environmental variable.**
## Setup
### General
Some setup is required! See the [configuration](docs/configuration.md) docs for a full reference.
[JSON config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/config.json.example)
### TLDR, Minimal Example
Environment Variables
* CONFIG_DIR - Default `./config` - Sets configuration directory to look for all other configuration files (if they are not specified)
* CONFIG_PATH - Default `CONFIG_DIR/config.json`
* LOG_PATH - Default `true` - If `false` no logs will be written. If `string` will be the directory logs are written to
* PORT - Default 9078 - Port to run web server on (for authentication callbacks)
You want to use multi-scrobbler to scrobble your plays from Spotify to Maloja:
#### Local
```bash
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://domain.tld MALOJA_API_KEY=1234 node index.js
```
### Spotify
#### Docker
[Spotify config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/spotify.json.example)
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/multi-scrobbler
```
Environment Variables
* SPOTIFY_CONFIG_PATH - Optional, defaults to `CONFIG_DIR/spotify.json`
* SPOTIFY_CLIENT_ID - **Required**
* SPOTIFY_CLIENT_SECRET - **Required**
* SPOTIFY_ACCESS_TOKEN - Optional if client/secret provided
* SPOTIFY_REFRESH_TOKEN - Optional if client/secret provided
* SPOTIFY_REDIRECT_URI - Optional, default is `http://localhost:{port}/callback`
**But I want to use json for configuration?**
The app will automatically obtain new access/refresh token if needed and possible. These will override values from configuration.
Then use [config.json.example](/config/config.json.example) and drop it in your `CONFIG_DIR` directory
### Maloja
**Is there an example configuration using everything?**
[Maloja config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/maloja.json.example)
Environment Variables
* MALOJA_CONFIG_PATH - Optional, defaults to `CONFIG_DIR/maloja.json`
* MALOJA_URL - **Required** - Base Url of your Maloja installation
* MALOJA_API_KEY - **Required** - Api Key for scrobbling
Yes, check out the [kitchen sink example](/docs/kitchensink.md)
## Usage
Output is provided to stdout/stderr as well as file if specified in configuration.
On first startup you may need to authroize Spotify by visiting a callback URL. The default url to open is:
A status page with statistics, recent logs, and some runtime configuration options can be found at
```
https://localhost:9078/authSpotify
https://localhost:9078
```
Output is also provided to stdout/stderr as well as file if specified in configuration.
### Running Directly
```
node index.js
```
### Docker
[Docker repository](https://hub.docker.com/repository/docker/foxxmd/spotify-scrobbler)
```
foxxmd/spotify-scrobbler:latest
```
Minimal configuration requires you to bind a host directory for the configuration directory in the container:
```
docker run ... -v /path/on/host/config:/home/node/config ...
```
On first startup you may need to authorize Spotify by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
## License
+28
View File
@@ -0,0 +1,28 @@
import {capitalize, createLabelledLogger} from "../utils.js";
export default class AbstractApiClient {
name;
type;
initialized = false;
config;
options;
logger;
client;
workingCredsPath;
redirectUri;
constructor(type, name, config = {}, options = {}) {
this.type = type;
this.name = name;
const identifier = `API - ${capitalize(this.type)} - ${name}`;
this.logger = createLabelledLogger(identifier, identifier);
this.config = config;
this.options = options;
}
static formatPlayObj = obj => {
throw new Error('should be overridden');
}
}
+164
View File
@@ -0,0 +1,164 @@
import LastFm from "lastfm-node-client";
import AbstractApiClient from "./AbstractApiClient.js";
import dayjs from "dayjs";
import {readJson, sleep, writeFile} from "../utils.js";
const badErrors = [
'api key suspended',
'invalid session key',
'invalid api key',
'authentication failed'
];
const retryErrors = [
'operation failed',
'service offline',
'temporarily unavailable',
'rate limit'
]
export default class LastfmApiClient extends AbstractApiClient {
user;
constructor(name, config = {}, options = {}) {
super('lastfm', name, config, options);
const {redirectUri, apiKey, secret, session, configDir} = config;
this.redirectUri = `${redirectUri}?state=${name}`;
if (apiKey === undefined) {
this.logger.warn("'apiKey' not found in config!");
}
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
this.client = new LastFm(apiKey, secret, session);
}
static formatPlayObj = obj => {
const {
artist: {
// last.fm doesn't seem consistent with which of these properties it returns...
'#text': artists,
name: artistName,
},
name: title,
album: {
'#text': album,
},
duration,
date: {
uts: time,
} = {},
'@attr': {
nowplaying = 'false',
} = {},
url,
mbid,
} = obj;
// arbitrary decision yikes
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
return {
data: {
artists: [...new Set(artistStrings)],
track: title,
album,
duration,
playDate: time !== undefined ? dayjs.unix(time) : undefined,
},
meta: {
nowPlaying: nowplaying === 'true',
mbid,
source: 'Lastfm',
url: {
web: url,
}
}
}
}
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
} = this.config;
try {
return await func(this.client);
} catch (e) {
const {
message,
} = e;
// for now check for exceptional errors by matching error code text
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
if(undefined !== retryError) {
if(retries < maxRequestRetries) {
const delay = (retries + 1) * retryMultiplier;
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
await sleep(delay * 1000);
return this.callApi(func, retries + 1);
} else {
this.logger.warn('Could not recover!');
throw e;
}
}
throw e;
}
}
getAuthUrl = () => {
const redir = `${this.config.redirectUri}?state=${this.name}`;
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
}
authenticate = async (token) => {
const sessionRes = await this.client.authGetSession({token});
const {
session: {
key: sessionKey,
name, // username
} = {}
} = sessionRes;
this.client.sessionKey = sessionKey;
await writeFile(this.workingCredsPath, JSON.stringify({
sessionKey,
}));
}
initialize = async () => {
try {
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
const {sessionKey} = creds || {};
if (this.client.sessionKey === undefined && sessionKey !== undefined) {
this.client.sessionKey = sessionKey;
}
return true;
} catch (e) {
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
return false;
}
}
testAuth = async () => {
if (this.client.sessionKey === undefined) {
this.logger.info('No session key found. User interaction for authentication required.');
return false;
}
try {
const infoResp = await this.callApi(client => client.userGetInfo());
const {
user: {
name,
} = {}
} = infoResp;
this.user = name;
this.initialized = true;
this.logger.info(`Client authorized for user ${name}`)
return true;
} catch (e) {
this.logger.error('Testing auth failed');
throw e;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+151
View File
@@ -0,0 +1,151 @@
import dayjs from "dayjs";
import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js";
export default class AbstractScrobbleClient {
name;
type;
initialized = false;
requiresAuth = false;
requiresAuthInteraction = false;
authed = false;
recentScrobbles = [];
scrobbledPlayObjs = [];
newestScrobbleTime;
oldestScrobbleTime = dayjs();
tracksScrobbled = 0;
lastScrobbleCheck = dayjs();
refreshEnabled;
checkExistingScrobbles;
verboseOptions;
config;
logger;
constructor(type, name, config = {}) {
this.type = type;
this.name = name;
const identifier = `Client ${capitalize(this.type)} - ${name}`;
this.logger = createLabelledLogger(identifier, identifier);
const {
options: {
refreshEnabled = true,
checkExistingScrobbles = true,
verbose = {},
} = {},
...rest
} = config;
this.config = rest;
this.refreshEnabled = refreshEnabled;
this.checkExistingScrobbles = checkExistingScrobbles;
const {
match: {
onNoMatch = false,
onMatch = false,
confidenceBreakdown = false,
} = {},
...vRest
} = verbose
if (onMatch || onNoMatch) {
this.logger.warn('Setting verbose matching may produce noisy logs! Use with care.');
}
this.verboseOptions = {
...vRest,
match: {
onNoMatch,
onMatch,
confidenceBreakdown
}
};
}
// default init function, should be overridden if init stage is required
initialize = async () => {
this.initialized = true;
return this.initialized;
}
// default init function, should be overridden if auth stage is required
testAuth = async () => {
return this.authed;
}
scrobblesLastCheckedAt = () => {
return this.lastScrobbleCheck;
}
formatPlayObj = obj => {
this.logger.warn('formatPlayObj should be defined by concrete class!');
return obj;
}
// time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client
// ...this is assuming the scrobble client is returning "most recent" scrobbles
timeFrameIsValid = (playObj, log = false) => {
const {
data: {
playDate,
} = {},
} = playObj;
const validTime = playDate.isAfter(this.oldestScrobbleTime);
if (log && !validTime) {
this.logger.debug(`${buildTrackString(playObj)} was in an invalid time frame (played before the oldest scrobble found)`);
}
return validTime;
}
addScrobbledTrack = (playObj, scrobbleResp) => {
this.scrobbledPlayObjs.push({play: playObj, scrobble: this.formatPlayObj(scrobbleResp)});
}
cleanSourceSearchTitle = (playObj) => {
const {
data: {
track,
} = {},
} = playObj;
return track;
};
findExistingSubmittedPlayObj = (playObj) => {
const {
data: {
playDate
} = {},
meta: {
source,
} = {}
} = playObj;
const dtInvariantMatches = this.scrobbledPlayObjs.filter(x => playObjDataMatch(playObj, x.play));
if (dtInvariantMatches.length === 0) {
return [undefined, undefined];
}
const matchPlayDate = dtInvariantMatches.find((x) => {
const {
play: {
data: {
playDate: sPlayDate
} = {},
meta: {
source: playSource
} = {},
} = {},
} = x;
// need to account for inaccurate DT from subsonic
if(source === 'Subsonic' && playSource === 'Subsonic') {
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
}
return playDate.isSame(sPlayDate);
});
return [matchPlayDate, dtInvariantMatches];
}
}
+311
View File
@@ -0,0 +1,311 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import {
buildTrackString,
playObjDataMatch,
setIntersection, sleep,
sortByPlayDate,
truncateStringToLength,
} from "../utils.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
export default class LastfmScrobbler extends AbstractScrobbleClient {
api;
initialized = false;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, options = {}) {
super('lastfm', name, config, options);
this.api = new LastfmApiClient(name, config, options)
}
formatPlayObj = obj => LastfmApiClient.formatPlayObj(obj);
initialize = async () => {
this.initialized = await this.api.initialize();
return this.initialized;
}
testAuth = async () => {
try {
this.authed = await this.api.testAuth();
} catch (e) {
this.logger.error('Could not successfully communicate with Last.fm API');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit: 20, extended: true}));
const {
recenttracks: {
track: list = [],
}
} = resp;
this.recentScrobbles = list.reduce((acc, x) => {
try {
const formatted = LastfmApiClient.formatPlayObj(x);
const {
data: {
track,
playDate,
},
meta: {
mbid,
nowPlaying,
}
} = formatted;
if(nowPlaying === true) {
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
return acc;
} else if(playDate === undefined) {
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
return acc;
}
return acc.concat(formatted);
} catch (e) {
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
this.logger.debug('Full api response object:');
this.logger.debug(x);
return acc;
}
}, []).sort(sortByPlayDate);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
}
alreadyScrobbled = (playObj, log = false) => {
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
}
existingScrobble = (playObj, logMatch = false) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
// return early if we don't care about checking existing
if (false === this.checkExistingScrobbles) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
}
return undefined;
}
let existingScrobble;
let closestMatch = {score: 0, breakdowns: ['None']};
// then check if we have already recorded this
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
if (existingExactSubmitted !== undefined) {
existingScrobble = existingExactSubmitted.scrobble;
closestMatch = {
score: 1,
breakdowns: ['Exact Match found in previously successfully scrobbled']
}
}
// if not though then we need to check recent scrobbles from scrobble api.
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
// if no recent scrobbles found then assume we haven't submitted it
// (either user doesnt want to check history or there is no history to check!)
if (this.recentScrobbles.length === 0) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
}
return undefined;
}
if (existingScrobble === undefined) {
// we have have found an existing submission but without an exact date
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
const {
data: {
artists: sourceArtists = [],
playDate
} = {},
meta: {
trackLength,
source,
} = {},
} = playObj;
// clean source title so it matches title from the scrobble api response as closely as we can get it
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
existingScrobble = this.recentScrobbles.find((x) => {
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
let closeTime = false;
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
let scrobblePlayStartDiff;
if (scrobblePlayDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
if (closeTime === false && trackLength !== undefined) {
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
if (scrobblePlayStartDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
}
let titleMatch;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
// and compare against length of scrobble title
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter(x => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
let artistMatch;
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
const artistScore = .2 * artistMatch;
const titleScore = .3 * titleMatch;
const timeScore = .5 * (closeTime ? 1 : 0);
const referenceScore = .5 * (referenceMatch ? 1 : 0);
const score = artistScore + titleScore + timeScore;
let scoreBreakdowns = [
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
];
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
const scoreInfo = {
score,
scrobble: x,
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
}
if (closestMatch.score <= score && score > 0) {
closestMatch = scoreInfo
}
return score >= .7;
});
}
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
}
return existingScrobble;
}
scrobble = async (playObj) => {
const {
data: {
artists,
album,
track,
duration,
playDate
} = {},
data = {},
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
const sType = newFromSource ? 'New' : 'Backlog';
try {
const response = await this.api.callApi(client => client.trackScrobble(
{
artist: artists.join(', '),
duration,
track,
album,
timestamp: playDate.unix(),
}));
const {
scrobbles: {
'@attr': {
accepted = 0,
ignored = 0,
code,
},
scrobble: {
track: {
'#text': trackName,
} = {},
timestamp,
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
...rest
} = {}
} = {},
} = response;
if(code === 5) {
this.initialized = false;
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
}
this.addScrobbledTrack(playObj, {...rest, date: { uts: timestamp}, name: trackName});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
if(ignoreMsg !== '') {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${ignoreMsg}`)
}
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
} catch (e) {
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
throw e;
}
return true;
}
}
+382 -48
View File
@@ -1,74 +1,408 @@
import ScrobbleClient from "./ScrobbleClient.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import request from 'superagent';
import format from 'date-fns/format/index.js';
import dayjs from 'dayjs';
import {
buildTrackString,
playObjDataMatch,
setIntersection,
sleep,
sortByPlayDate,
truncateStringToLength,
parseRetryAfterSecsFromObj
} from "../utils.js";
export default class MalojaScrobbler extends ScrobbleClient {
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
name = 'Maloja';
export default class MalojaScrobbler extends AbstractScrobbleClient {
refreshScrobbles = async () => {
const {url} = this.config;
const resp = await request.get(`${url}/apis/mlj_1/scrobbles?since=${format(new Date(), 'yyyy/MM/dd')}&to=${format(new Date(), 'yyyy/MM/dd')}`)
this.recentScrobbles = resp.body.list.slice(0, 10);
this.lastScrobbleCheck = new Date();
requiresAuth = true;
constructor(name, config = {}, options = {}) {
super('maloja', name, config, options);
const {url, apiKey} = config;
if (apiKey === undefined) {
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
}
if (url === undefined) {
throw new Error("Missing 'url' for Maloja config");
}
}
alreadyScrobbled = (title, playDate, duration) => {
const playUnix = playDate.getTime() / 1000;
const lowerTitle = title.toLocaleLowerCase();
return this.recentScrobbles.some((x) => {
const {time: scrobbleTime, title: scrobbleTitle} = x;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase();
if (lowerTitle.includes(lowerScrobbleTitle) || lowerScrobbleTitle.includes(lowerTitle)) {
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
let scrobblePlayDiff = Math.abs(playUnix - scrobbleTime);
if (scrobblePlayDiff < 10) {
this.logger.debug(`Scrobble with same name found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`, {label: this.name});
return true;
}
// also need to check that scrobble time isn't the BEGINNING of the track
let scrobblePlayStartDiff = Math.abs(playUnix - (scrobbleTime - duration));
if (scrobblePlayStartDiff < 10) {
this.logger.debug(`Scrobble with same name found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`, {label: this.name});
return true;
}
this.logger.debug(`Scrobble with same name found but the start/finish times vs scrobble time diffs were too large to consider dups (Start Diff ${scrobblePlayStartDiff}s) (End Diff ${scrobblePlayDiff}s)`, {label: this.name});
static formatPlayObj(obj) {
const {
artists,
title,
album,
duration,
time,
} = obj;
let artistStrings = artists.reduce((acc, curr) => {
let aString;
if (typeof curr === 'string') {
aString = curr;
} else if (typeof curr === 'object') {
aString = curr.name;
}
const aStrings = aString.split(',');
return [...acc, ...aStrings];
}, []);
return {
data: {
artists: [...new Set(artistStrings)],
track: title,
album,
duration,
playDate: dayjs.unix(time),
},
meta: {
source: 'Maloja',
}
}
}
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj);
callApi = async (req, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
try {
return await req;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response: {
status,
body,
text,
} = {},
response,
} = e;
let msg = response !== undefined ? `API Call failed: Server Response => ${message}` : `API Call failed: ${message}`;
const responseMeta = body ?? text;
this.logger.error(msg, {status, response: responseMeta});
throw e;
}
}
testConnection = async () => {
const {url} = this.config;
try {
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
const {
statusCode,
body: {
version = [],
versionstring = '',
} = {},
} = serverInfoResp;
if (statusCode >= 300) {
this.logger.info('Test connection failed');
return false;
}
this.logger.info('Test connection succeeded!');
if (version.length === 0) {
this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old :(');
} else {
this.logger.info(`Maloja Server Version: ${versionstring}`);
if (version[0] < 2 || version[1] < 7) {
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
}
}
return true;
} catch (e) {
this.logger.error('Testing connection failed');
this.logger.error(e);
return false;
})
}
}
initialize = async () => {
// just checking that we can get a connection
this.initialized = await this.testConnection();
return this.initialized;
}
testAuth = async (withKey = true) => {
const {url, apiKey} = this.config;
try {
const resp = await this.callApi(request
.get(`${url}/apis/mlj_1/test`)
.query({key: apiKey}));
const {
status,
body: {
status: bodyStatus,
} = {},
body = {},
text = '',
} = resp;
if (bodyStatus.toLocaleLowerCase() === 'ok') {
this.logger.info('Auth test passed!');
this.authed = true;
} else {
this.authed = false;
this.logger.error('Testing connection failed => Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {
status,
body,
text: text.slice(0, 50)
});
}
} catch (e) {
this.logger.error('Auth test failed');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const {url} = this.config;
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=20`));
const {
body: {
list = [],
} = {},
} = resp;
this.recentScrobbles = list.map(x => MalojaScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj) => {
const {
data: {
track,
artists: sourceArtists = [],
} = {},
} = playObj;
let lowerTitle = track.toLocaleLowerCase();
lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
// also remove [artist] from the track if found since that gets removed as well
const lowerArtists = sourceArtists.map(x => x.toLocaleLowerCase());
lowerTitle = lowerArtists.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
// remove any whitespace in parenthesis
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
// replace parenthesis
.replace('()', '')
.replace('( )', '')
.trim();
return lowerTitle;
}
alreadyScrobbled = (playObj, log = false) => {
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
}
existingScrobble = (playObj, logMatch = false) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
// return early if we don't care about checking existing
if (false === this.checkExistingScrobbles) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
}
return undefined;
}
let existingScrobble;
let closestMatch = {score: 0, breakdowns: ['None']};
// then check if we have already recorded this
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
if (existingExactSubmitted !== undefined) {
existingScrobble = existingExactSubmitted.scrobble;
closestMatch = {
score: 1,
breakdowns: ['Exact Match found in previously successfully scrobbled']
}
}
// if not though then we need to check recent scrobbles from scrobble api.
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
// if no recent scrobbles found then assume we haven't submitted it
// (either user doesnt want to check history or there is no history to check!)
if (this.recentScrobbles.length === 0) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
}
return undefined;
}
if (existingScrobble === undefined) {
// we have have found an existing submission but without an exact date
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
const {
data: {
artists: sourceArtists = [],
playDate
} = {},
meta: {
trackLength,
source,
} = {},
} = playObj;
// clean source title so it matches title from the scrobble api response as closely as we can get it
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
existingScrobble = this.recentScrobbles.find((x) => {
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
let closeTime = false;
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
let scrobblePlayStartDiff;
if (scrobblePlayDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
if (closeTime === false && trackLength !== undefined) {
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
if (scrobblePlayStartDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
}
let titleMatch;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
// and compare against length of scrobble title
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter(x => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
let artistMatch;
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
const artistScore = .2 * artistMatch;
const titleScore = .3 * titleMatch;
const timeScore = .5 * (closeTime ? 1 : 0);
const referenceScore = .5 * (referenceMatch ? 1 : 0);
const score = artistScore + titleScore + timeScore;
let scoreBreakdowns = [
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
];
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
const scoreInfo = {
score,
scrobble: x,
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
}
if (closestMatch.score <= score && score > 0) {
closestMatch = scoreInfo
}
return score >= .7;
});
}
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
}
return existingScrobble;
}
scrobble = async (playObj) => {
const {url, apiKey} = this.config;
const {
track: {
artists = [],
name,
id,
external_urls: {
spotify,
} = {}
data: {
artists,
album,
track,
duration,
playDate
} = {},
played_at
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
const time = new Date(played_at);
const sType = newFromSource ? 'New' : 'Backlog';
try {
await request.post(`${url}/apis/mlj_1/newscrobble`)
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
.type('json')
.send({
artist: artistString,
title: name,
// maloja seems to detect this deliminator much better than commas
// also less likely artist has a forward slash in their name than a comma
artist: artists.join(' / '),
seconds: duration,
title: track,
album,
key: apiKey,
time: time.getTime() / 1000
});
this.logger.info('Scrobbled', {label: this.name});
time: playDate.unix(),
}));
const {body: {
track: {
time: mTime = playDate.unix(),
duration: mDuration = duration,
album: mAlbum = album,
...rest
}
} = {}} = response;
this.addScrobbledTrack(playObj, {...rest, album: mAlbum, time: mTime, duration: mDuration});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
} catch (e) {
this.logger.error('Error while scrobbling', {label: this.name});
this.logger.log(e);
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
throw e;
}
return true;
-20
View File
@@ -1,20 +0,0 @@
export default class ScrobbleClient {
name;
recentScrobbles = [];
lastScrobbleCheck = new Date();
config;
logger;
constructor(logger, config = {}) {
this.logger = logger;
this.config = config;
}
scrobblesLastCheckedAt = () => {
return this.lastScrobbleCheck;
}
}
+323
View File
@@ -0,0 +1,323 @@
import dayjs from "dayjs";
import {
createLabelledLogger,
isValidConfigStructure,
playObjDataMatch,
readJson,
returnDuplicateStrings
} from "../utils.js";
import MalojaScrobbler from "./MalojaScrobbler.js";
import LastfmScrobbler from "./LastfmScrobbler.js";
export default class ScrobbleClients {
clients = [];
logger;
configDir;
clientTypes = ['maloja','lastfm'];
constructor(configDir) {
this.configDir = configDir;
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
}
getByName = (name) => {
return this.clients.find(x => x.name === name);
}
getByType = (type) => {
return this.clients.filter(x => x.type === type);
}
buildClientsFromConfig = async () => {
let configs = [];
let configFile;
try {
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
// think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore
throw new Error('config.json could not be parsed');
}
let clientDefaults = {};
if (configFile !== undefined) {
const {
clients: mainConfigClientConfigs = [],
clientDefaults: cd = {},
} = configFile;
clientDefaults = cd;
const validMainConfigs = mainConfigClientConfigs.reduce((acc, curr, i) => {
if(curr === null) {
this.logger.error(`The client config entry at index ${i} in config.json is null but should be an object, will not parse`);
return acc;
}
if(typeof curr !== 'object') {
this.logger.error(`The client config entry at index ${i} in config.json should be an object, will not parse`);
return acc;
}
return acc.concat(curr);
}, []);
for (const c of validMainConfigs) {
const {name = 'unnamed'} = c;
configs.push({...c,
name,
source: 'config.json',
configureAs: 'client', //override user value
});
}
}
for (const clientType of this.clientTypes) {
let defaultConfigureAs = 'client';
switch (clientType) {
case 'maloja':
// env builder for single user mode
const url = process.env.MALOJA_URL;
const apiKey = process.env.MALOJA_API_KEY;
if (url !== undefined || apiKey !== undefined) {
configs.push({
type: 'maloja',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: {
url,
apiKey
}
})
}
break;
case 'lastfm':
const lfm = {
apiKey: process.env.LASTFM_API_KEY,
secret: process.env.LASTFM_SECRET,
redirectUri: process.env.LASTFM_REDIRECT_URI,
session: process.env.LASTFM_SESSION,
};
if (!Object.values(lfm).every(x => x === undefined)) {
configs.push({
type: 'lastfm',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: lfm
})
}
break;
default:
break;
}
let rawClientConfigs;
try {
rawClientConfigs = await readJson(`${this.configDir}/${clientType}.json`, {throwOnNotFound: false});
} catch (e) {
this.logger.error(`${clientType}.json config file could not be parsed`);
continue;
}
if (rawClientConfigs !== undefined) {
let clientConfigs = [];
if (Array.isArray(rawClientConfigs)) {
clientConfigs = rawClientConfigs;
} else if(rawClientConfigs === null) {
this.logger.error(`${clientType}.json contained no data`);
continue;
} else if (typeof rawClientConfigs === 'object') {
// backwards compatibility, assuming its single-user mode
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${clientType}.json) must be in an array.`);
if (rawClientConfigs.data === undefined) {
clientConfigs = [{data: rawClientConfigs, mode: 'single', name: 'unnamed'}];
} else {
clientConfigs = [rawClientConfigs];
}
} else {
this.logger.error(`All top level data from ${clientType}.json must be an array of objects, will not parse configs from file`);
continue;
}
for (const [i,m] of clientConfigs.entries()) {
if(m === null) {
this.logger.error(`The config entry at index ${i} from ${clientType}.json is null`);
continue;
}
if (typeof m !== 'object') {
this.logger.error(`The config entry at index ${i} from ${clientType}.json was not an object, skipping`, m);
continue;
}
const {configureAs = defaultConfigureAs} = m;
if(configureAs === 'client') {
m.source = `${clientType}.json`;
m.type = clientType;
configs.push(m);
}
}
}
}
// we have all possible client configurations so we'll check they are minimally valid
const validConfigs = configs.reduce((acc, c) => {
const isValid = isValidConfigStructure(c, {type: true, data: true});
if (isValid !== true) {
this.logger.error(`Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`);
return acc;
}
return acc.concat(c);
}, []);
// all client configs are minimally valid
// now check that names are unique
const nameGroupedConfigs = validConfigs.reduce((acc, curr) => {
const {name = 'unnamed'} = curr;
const {[name]: n = []} = acc;
return {...acc, [name]: [...n, curr]};
}, {});
let noConflictConfigs = [];
for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
if (configs.length > 1) {
const sources = configs.map(c => `Config object from ${c.source} of type [${c.type}]`);
this.logger.error(`The following clients will not be built because of config naming conflicts (they have the same name of "${name}"):
${sources.join('\n')}`);
if (name === 'unnamed') {
this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
}
} else {
noConflictConfigs = [...noConflictConfigs, ...configs];
}
}
// finally! all configs are valid, structurally, and can now be passed to addClient
// just need to re-map unnnamed to default
const finalConfigs = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
...x,
name
}));
for (const c of finalConfigs) {
try {
await this.addClient(c, clientDefaults);
} catch(e) {
this.logger.error(`Client ${c.name} was not added because it had unrecoverable errors`);
this.logger.error(e);
}
}
}
addClient = async (clientConfig, defaults = {}) => {
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}
const {type, name, data: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
let newClient;
this.logger.debug(`(${name}) Constructing ${type} client...`);
switch (type) {
case 'maloja':
newClient = new MalojaScrobbler(name, data);
break;
case 'lastfm':
newClient = new LastfmScrobbler(name, {...data, configDir: this.configDir});
break;
default:
break;
}
if(newClient === undefined) {
// really shouldn't get here!
throw new Error(`Client of type ${type} was not recognized??`);
}
if(newClient.initialized === false) {
this.logger.debug(`(${name}) Attempting ${type} initialization...`);
if (await newClient.initialize() === false) {
this.logger.error(`(${name}) ${type} client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
} else {
this.logger.info(`(${name}) ${type} client initialized`);
}
}
if(newClient.requiresAuth && !newClient.authed) {
this.logger.debug(`(${name}) Checking ${type} client auth...`);
let success;
try {
success = await newClient.testAuth();
} catch (e) {
success = false;
}
if(!success) {
this.logger.warn(`(${name}) ${type} client auth failed.`);
} else {
this.logger.warn(`(${name}) ${type} client auth OK`);
}
}
this.clients.push(newClient);
}
/**
* @param {*} data
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
* @returns {Array}
*/
scrobble = async (data, options = {}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
forceRefresh = false,
checkTime = dayjs(),
scrobbleTo = [],
scrobbleFrom = 'source',
} = options;
const tracksScrobbled = [];
if (this.clients.length === 0) {
this.logger.warn('Cannot scrobble! No clients are configured.');
}
for (const client of this.clients) {
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`);
continue;
}
if(client.initialized === false) {
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not yet initialized`);
continue;
}
if(client.requiresAuthInteraction === true && !client.authed) {
this.logger.warn(`Cannot scrobble to Client '${client.name}' because user interaction is required for authentication`);
continue;
}
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
try {
await client.refreshScrobbles();
} catch(e) {
this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`);
this.logger.error(e);
}
}
for (const playObj of playObjs) {
try {
const {
meta: {
newFromSource = false,
} = {}
} = playObj;
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
await client.scrobble(playObj)
client.tracksScrobbled++;
// since this is what we return to the source only add to tracksScrobbled if not already in array
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
tracksScrobbled.push(playObj);
}
}
} catch(e) {
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
this.logger.error(e);
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
if(e.continueScrobbling !== true) {
break;
}
}
}
}
return tracksScrobbled;
}
}
+25 -16
View File
@@ -1,23 +1,32 @@
{
// string specifies a directory location to log to.
// boolean FALSE means do not log to file
// boolean TRUE means log to working directory
"logPath": true, // optional
"interval": 60, // optional, number of seconds to wait before checking spotify for new tracks
"port": 9078, // optional, port for server to start on
"spotify": 'object or string', // can specify full spotify config object here OR a string location of json file OR do not include to use config dir location
// array of client configurations
"sourceDefaults": {
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"clientDefaults": {
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"sources": [
{
"type": "spotify", // required, source type
"clients": ["myConfig"], // optional, a list of Client config names this Source should scrobble to. Using an empty list or not including this property will make this Source scrobble to all Clients.
"name": "mySpotifySource", // optional, friendly name for the log
"data": { // required, the data for your config
"clientId": "example",
//...
}
}
],
"clients": [
{
"type": "maloja", // client name
"data": "/someLocation/aFile.json" // location to look for json configuration in
},
{
"type": "maloja", // client name
"data": {
// client configuration can also be an object
"type": "maloja", // required, Client type
"name": "myConfig", // required, a name to identifier your Client
"data": { // required, the data for your config
"url": "http://example.com",
//...
}
},
"maloja" // client can also be just the type if the configuration is located in the default configuration directory
]
}
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "default", // optional, friendly name for logs
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
"data": {
"users": ["FoxxMD"], // optional, list of users to scrobble tracks for
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
}
}
]
+16
View File
@@ -0,0 +1,16 @@
[
{
"name": "myLastFm", // [As Client/Source] required if configured as "client", a name to identify your Client/Source
"configureAs": "client", // optional and default to "client", set to "source" to use this configuration as a Source
"clients": [], // [As Source] optional, list of scrobble Clients (by config name) that this Source should scrobble to. Using an empty list or not including this property will make this Source scrobble to all Clients.
"data": {
"apiKey": "string", // required, Lastfm api key
"secret": "string", // required, Lastfm shared secret
"session": "string", // optional, session id returned from a complete auth flow.
// if not specified will be generated during authentication
"redirectUri": "http://localhost:9078/lastfm/callback" // optional, if not different than this default
// callback for auth. Must have "lastfm/callback" in the url somewhere
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
}
}
]
+10 -4
View File
@@ -1,4 +1,10 @@
{
"url": "https://domain.tld", // the base url of your maloja installation
"apiKey": "string" // your maloja api key
}
[
{
"name": "myMaloja", // required, a name to identify your Client
"data": {
"url": "https://domain.tld", // required, the base url of your maloja installation
"apiKey": "string" // required, your maloja api key
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "default", // optional, friendly name for logs
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
}
}
]
+15 -6
View File
@@ -1,6 +1,15 @@
{
"clientId": "string", // spotify client id
"clientSecret": "string", // spotify client secret
"redirectUri": "http://localhost:9078/callback", // optional, spotify callback url. useful if this installation is behind a reverse proxy
"callbackPath": "callback" // optional, if redirectUri does not end with /callback then must specify path here
}
[
{
"name": "default", // optional, friendly name for logs
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
"data": {
"clientId": "string", // spotify client id -- required if not providing access token
"clientSecret": "string", // spotify client secret -- required if not providing access token
"accessToken": "string", // spotify access token -- required if not providing client id/secret
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
"interval": 60, // optional, how long to wait before calling spotify for new tracks
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "default", // optional, friendly name for logs
"data": {
"url": "http://localhost:4040/airsonic",// required, the url you would visit to listen to music on the web
"user": "yourUser", // required, username to login with
"password": "yourPassword", // required, password to login with
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "default", // optional, friendly name for logs
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
}
}
]
+219
View File
@@ -0,0 +1,219 @@
# General
General configuration options. These must be set through environmental variables because they affect initial startup of
the app. **These variables are also available to Docker containers.**
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|--------------|-------------------------------------------------------------------------------------------|
| `CONFIG_DIR` | No | `CWD/config` | Directory to look for all other configuration files |
| `LOG_PATH` | No | `CWD/logs` | If `false` no logs will be written. If `string` will be the directory logs are written to |
| `PORT` | No | 9078 | Port to run web server on |
**The app must have permission to write to `CONFIG_DIR` in order to store the current spotify access token.**
# Sources and (Scrobble) Clients
The app has two types of configurations:
* **Sources** -- Where plays are parsed from
* **Clients** -- Scrobble clients that plays are scrobbled to
All configurations can be configured through:
* environmental variables
* individual **json** files for each source/client type found in the `CONFIG_DIR` directory IE `config/spotify.json`
* or through the main `config.json` (also found in `CONFIG_DIR` directory) using the `clients` or `sources` property under `data`:
```json5
// in config.json
{
//...
"sources": [
{
"name": "myConfig",
"type": "spotify",
"clients": [
"myMalojaClient"
],
"data": {
"clientId": "anExample"
//...
}
}
]
}
```
See [config.json.example](../config/config.json.example) for a short example of this or check out [the kitchen sink example](kitchensink.md).
### ENV-Based or JSON-Based?
multi-scrobbler can be configured differently depending on how you will use it. See which use-case fits you the best and then use that approach when setting up each configuration:
#### ENV-Based (Single User)
* You are the only person for whom the application is scrobbling
* You may have many sources (Plex, Spotify, Tautulli...) but you only have one of each type of source
* You have only one scrobble client
* **Easier for small setups. Difficult for larger, multi-sourced setups (may want to switch to json)**
* **Will not work for multi-user setups**
#### JSON-Based (Multi User)
* You are a single user but want to set up many sources
* You want to use multi-scrobbler to scrobble for yourself and others IE family, friends, etc.
* You may have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
Funkwhale...
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers, one for each person
* You want only some **Sources** to scrobble to some **Clients** IE Fred's Spotify account scrobbles to Fred's Maloja
server, but not Mary's Maloja server
Note: While you may mix and match configuration approaches it is recommended to **only use ENV-based configs if you are
doing everything in ENV-based configurations.**
# Sources
## [Spotify](https://www.spotify.com)
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a
Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
| `SPOTIFY_CLIENT_ID` | Yes | | |
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
| `SPOTIFY_ACCESS_TOKEN` | No | | Must include either this token or client id/secret |
| `SPOTIFY_REFRESH_TOKEN` | No | | If using access token this is also recommended |
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:{PORT}/callback` | URI must end in `callback` |
### JSON-Based
See [`spotify.json.example`](../config/spotify.json.example)
## [Plex](https://plex.tv)
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------|-------------------------------------------------|
| `PLEX_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
### JSON-Based
See [`plex.json.example`](../config/plex.json.example)
## [Tautulli](https://tautulli.com)
Check the [instructions](plex.md) on how to setup a notification agent.
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------|-------------------------------------------------|
| `TAUTULLI_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
### JSON-Based
See [`tautulli.json.example`](../config/tautulli.json.example)
## [Subsonic](http://www.subsonic.org/)
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
**Known Issues:**
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
* "played X minutes ago" sometimes is also not reported correctly
* Multiple artists are reported as one value and cannot be separated
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
| `SUBSONIC_USER` | Yes | | |
| `SUBSONIC_PASSWORD` | Yes | | |
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
### JSON-Based
See [`subsonic.json.example`](../config/subsonic.json.example)
## [Jellyfin](https://jellyfin.org/)
Must be using Jellyfin 10.7 or greater
* Add the [Webhook Plugin](https://github.com/crobibero/jellyfin-plugin-webhook) repository to your plugins, then restart your server
* In the Webhook settings:
* `Add Generic Destination`
* In the new `Generic` dropdown:
* Webhook Url: `http://localhost:9078/jellyfin`
* Notification Type: `Playback Progress`
* Item Type: `Songs`
* Check `Send All Properties`
* Save
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|-------------------------------------------------------------------|
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
### JSON-Based
See [`jellyfin.json.example`](../config/jellyfin.json.example)
## [Last.fm (Source)](https://www.last.fm)
See the [Last.fm (Client)](#lastfm) setup for registration instructions.
### ENV-Based
No support for ENV based for Last.fm as a client (only source)
### JSON-Based
See [`lastfm.json.example`](../config/lastfm.json.example), change `configureAs` to `source`.
# Clients
## [Maloja](https://github.com/krateng/maloja)
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|---------|-------------------------------|
| `MALOJA_URL` | Yes | | Base URL of your installation |
| `MALOJA_API_KEY` | Yes | | Api Key |
### JSON-Based
See [`maloja.json.example`](../config/maloja.json.example)
## [Last.fm](https://www.last.fm)
[Register for an API account here.](https://www.last.fm/api/account/create)
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
```
http://localhost:9078/lastfm/callback
```
or replace `localhost:9078` with your own base URL
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|---------|-------------------------------|
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
| `LASTFM_REDIRECT_URI` | No | `http://localhost:{PORT}/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
### JSON-Based
See [`lastfm.json.example`](../config/lastfm.json.example)
+238
View File
@@ -0,0 +1,238 @@
# Example Config using all Possible Features
Scenario:
* You want to scrobble plays for yourself (Foxx), Fred, and Mary
* Each person has their own Maloja server
* Each person has their own Spotify account
* You have your own Airsonic (subsonic) server you to scrobble from
* Mary has her own Last.fm account she also wants to scrobble to
* Fred has his own Spotify application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
* Fred has a Plex server and wants to scrobble everything he plays
* Mary uses Fred's Plex server but only wants to scrobble her plays from the `podcast` library
* The three of you have a shared library on Plex called `party` that you only play when you are hanging out. You want plays from that library to be scrobbled to everyone's servers.
### All-in-one Config
Using just one config file located at `CONFIG_DIR/config.json`:
```json5
{
"sourceDefaults": {
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"clientDefaults": {
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"sources": [
{
"type": "spotify",
"name": "foxxSpot",
"clients": ["foxxMaloja"],
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
}
},
{
"type": "spotify",
"name": "marySpot",
"clients": ["maryMaloja"],
"data": {
"clientId": "foxxSpotifyAppId", // only need one application, it can be used by all users of this multi-scrobbler instance
"clientSecret": "foxxSpotifyAppSecret",
}
},
{
"type": "spotify",
"name": "fredSpot",
"clients": ["fredMaloja"],
"data": {
"accessToken": "fredsToken",
"refreshToken": "fredsRefreshToken",
"interval": 120, // he also wants a slower check interval because his application already has heavy api usage
}
},
{
"type": "plex",
"name": "fredPlex",
"clients": ["fredMaloja"],
"data": {
"user": ["fred@email.com"]
}
},
{
"type": "plex",
"name": "maryPlex",
"clients": ["maryMaloja"],
"data": {
"user": ["mary@email.com"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
"libraries": ["podcasts"]
}
},
{
"type": "plex",
"name": "partyPlex",
// omitting clients (or making it empty) will make this Source scrobble to all Clients
"data": {
"libraries": ["party"],
}
},
{
"type": "subsonic",
"name": "foxxAirsonic",
"clients": ["foxxMaloja"],
"data": {
"user": "foxx",
"password": "foxxPassword",
"url": "https://airsonic.foxx.example"
}
},
],
"clients": [
{
"type": "maloja",
"name": "foxxMaloja",
"data": {
"url": "https://maloja.foxx.example",
"apiKey": "foxxApiKey"
}
},
{
"type": "maloja",
"name": "fredMaloja",
"data": {
"url": "https://maloja.fred.example",
"apiKey": "fredApiKey"
}
},
{
"type": "maloja",
"name": "maryMaloja",
"data": {
"url": "https://maloja.mary.example",
"apiKey": "maryApiKey"
}
},
{
"type": "lastfm",
"name": "maryLFM",
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
}
}
]
}
```
### Separate JSON files
In `CONFIG_DIR/spotify.json`:
```json5
[
{
// may omit 'type' property since app knows this is file is for spotify configs
"name": "foxxSpot",
"clients": ["foxxMaloja"],
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
}
},
{
"name": "marySpot",
"clients": ["maryMaloja"],
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
}
},
{
"name": "fredSpot",
"clients": ["fredMaloja"],
"data": {
"accessToken": "fredsToken",
"refreshToken": "fredsRefreshToken",
"interval": 120,
}
},
]
```
In `CONFIG_DIR/plex.json`
```json5
[
{
"name": "fredPlex",
"clients": ["fredMaloja"],
"data": {
"user": ["fred@email.com"]
}
},
{
"name": "maryPlex",
"clients": ["maryMaloja"],
"data": {
"user": ["mary@email.com"],
"libraries": ["podcasts"]
}
},
{
"name": "partyPlex",
"data": {
"libraries": ["party"],
}
}
]
```
In `CONFIG_DIR/maloja.json`:
```json5
[
{
"name": "foxxMaloja",
"data": {
"url": "https://maloja.foxx.example",
"apiKey": "foxxApiKey"
}
},
{
"name": "fredMaloja",
"data": {
"url": "https://maloja.fred.example",
"apiKey": "fredApiKey"
}
},
{
"name": "maryMaloja",
"data": {
"url": "https://maloja.mary.example",
"apiKey": "maryApiKey"
}
}
]
```
In `CONFIG_DIR/lastfm.json`:
```json5
[
{
"name": "maryLFM",
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
}
}
]
```
+59
View File
@@ -0,0 +1,59 @@
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
# Using Tautulli
## Create a new Notification Agent
* Navigate to the **Notification Agents** page in **Settings**
* Click **Add a new notification agent**
* Select **Webhook**
## Configure the Agent
The below sections correspond with the tabs available in the notification agent configuration popup.
### Configuration
* Webhook URL -- `http://localhost:9078/tautulli` (substitute your domain if different than the default)
* Webhook Method -- POST
### Triggers
Select **Watched**
### Conditions
Refer to [Tautulli's documentation](https://github.com/Tautulli/Tautulli-Wiki/wiki/Custom-Notification-Conditions) if you need help here. It may be a good idea to restrict notifications to only one library (if you have a Music library, for instance)
**This app will only scrobble an item if `media_type` is a "track", which is the default for all music.**
### Data
Expand the **Watched** dropdown and add the following code block to the **JSON Data** text field:
```
{
"artist_name": "{artist_name}",
"track_name": "{track_name}",
"track_artist": "{track_artist}",
"album_name": "{album_name}",
"media_type": "{media_type}",
"title": "{title}",
"duration": "{duration}",
"username": "{username}"
}
```
**Click the Save button to finish.**
Your agent is now configured and ready to scrobble.
# Using Plex Webhooks
* Navigate to your **Account/Settings** and find the **Webhooks** page
* Click **Add Webhook**
* URL -- `http://localhost:9078/plex` (substitute your domain if different than the default)
* **Save Changes**
Plex is now configured to scrobble.
+423 -260
View File
@@ -1,316 +1,479 @@
import fs from "fs";
import {addAsync} from '@awaitjs/express';
import {addAsync, Router} from '@awaitjs/express';
import express from 'express';
import bodyParser from 'body-parser';
import multer from 'multer';
import winston from 'winston';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import isBetween from 'dayjs/plugin/isBetween.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import duration from 'dayjs/plugin/duration.js';
import {Writable} from 'stream';
import 'winston-daily-rotate-file';
import {readJson, sleep, writeFile, buildTrackString} from "./utils.js";
import SpotifyWebApi from "spotify-web-api-node";
import MalojaScrobbler from "./clients/MalojaScrobbler.js";
import {
buildTrackString,
capitalize,
labelledFormat,
longestString,
readJson, sleep,
truncateStringToLength
} from "./utils.js";
import Clients from './clients/ScrobbleClients.js';
import ScrobbleSources from "./sources/ScrobbleSources.js";
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js";
import TautulliSource from "./sources/TautulliSource.js";
import PlexSource from "./sources/PlexSource.js";
import JellyfinSource from "./sources/JellyfinSource.js";
const {format, createLogger, transports} = winston;
const {combine, printf, timestamp} = format;
const storage = multer.memoryStorage()
const upload = multer({storage: storage})
dayjs.extend(utc)
dayjs.extend(isBetween);
dayjs.extend(relativeTime);
dayjs.extend(duration);
const {transports} = winston;
let output = []
const stream = new Writable()
stream._write = (chunk, encoding, next) => {
output.unshift(chunk.toString());
output = output.slice(0, 51);
let formatString = chunk.toString().replace('\n', '<br />')
.replace(/(debug)/gi, '<span class="debug text-pink-400">$1</span>')
.replace(/(warn)/gi, '<span class="warn text-blue-400">$1</span>')
.replace(/(info)/gi, '<span class="info text-yellow-500">$1</span>')
.replace(/(error)/gi, '<span class="error text-red-400">$1</span>')
output.unshift(formatString);
output = output.slice(0, 101);
next()
}
const streamTransport = new winston.transports.Stream({stream})
const streamTransport = new winston.transports.Stream({
stream,
})
const myFormat = printf(({level, message, label = 'App', timestamp}) => {
return `${timestamp} [${label}] ${level}: ${message}`;
const logConfig = {
level: process.env.LOG_LEVEL || 'info',
sort: 'descending',
limit: 50,
}
const availableLevels = ['info', 'debug'];
const logPath = process.env.LOG_DIR || `${process.cwd()}/logs`;
const port = process.env.PORT ?? 9078;
const localUrl = `http://localhost:${port}`;
const rotateTransport = new winston.transports.DailyRotateFile({
dirname: logPath,
createSymlink: true,
symlinkName: 'scrobble-current.log',
filename: 'scrobble-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '5m'
});
const logger = createLogger({
level: 'debug',
format: combine(
timestamp(),
myFormat
),
transports: [
new transports.Console(),
streamTransport,
]
});
const consoleTransport = new transports.Console();
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
const state = 'random';
let lastTrackPlayedAt = new Date();
const myTransports = [
consoleTransport,
streamTransport,
];
if (typeof logPath === 'string') {
myTransports.push(rotateTransport);
}
const loggerOptions = {
level: logConfig.level,
format: labelledFormat(),
transports: myTransports,
};
winston.loggers.add('default', loggerOptions);
const logger = winston.loggers.get('default');
const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
const configLocation = process.env.CONFIG_PATH || `${configDir}/config.json`;
const app = addAsync(express());
const router = Router();
try {
(async function () {
let spotifyAsyncFunc = null;
app.use(router);
app.use(bodyParser.json());
(async function () {
try {
// try to read a configuration file
let config = {};
try {
config = await readJson(configLocation);
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
logger.warn('Could not read config file');
logger.error(e);
logger.warn('App config file exists but could not be parsed!');
}
// setup defaults for other configs and general config
const {
logPath: logPathRaw = process.env.LOG_PATH || true,
interval = 60,
port = process.env.PORT ?? 9078,
spotify: spotifyConfigRaw = process.env.SPOTIFY_CONFIG_PATH || `${configDir}/spotify.json`,
clients = [],
spotify,
plex,
} = config || {};
const localUrl = `http://localhost:${port}`;
/*
* setup clients
* */
const scrobbleClients = new Clients(configDir);
await scrobbleClients.buildClientsFromConfig();
if (scrobbleClients.clients.length === 0) {
logger.warn('No scrobble clients were configured!')
}
// first thing, if user wants to log to file set it up now
if (logPathRaw !== false) {
let logPath = `${process.cwd()}/logs`;
if (typeof logPathRaw === 'string') {
logPath = logPathRaw;
const scrobbleSources = new ScrobbleSources(localUrl, configDir);
let deprecatedConfigs = [];
if (spotify !== undefined) {
logger.warn(`DEPRECATED: Using 'spotify' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
deprecatedConfigs.push({
type: 'spotify',
name: 'unnamed',
source: 'config.json (top level)',
mode: 'single',
data: spotify
});
}
if (plex !== undefined) {
logger.warn(`DEPRECATED: Using 'plex' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
deprecatedConfigs.push({
type: 'plex',
name: 'unnamed',
source: 'config.json (top level)',
mode: 'single',
data: plex
});
}
await scrobbleSources.buildSourcesFromConfig(deprecatedConfigs);
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
// check ambiguous client/source types like this for now
const lastfmSources = scrobbleSources.getByType('lastfm');
const lastfmScrobbles = scrobbleClients.getByType('lastfm');
const scrobblerNames = lastfmScrobbles.map(x => x.name);
const nameColl = lastfmSources.filter(x => scrobblerNames.includes(x.name));
if(nameColl.length > 0) {
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
}
app.getAsync('/', async function (req, res) {
let slicedLog = output.slice(0, logConfig.limit + 1);
if (logConfig.sort === 'ascending') {
slicedLog.reverse();
}
logger.add(new winston.transports.DailyRotateFile({
level: 'info', // don't need to add a bunch of noise to files
dirname: logPath,
createSymlink: true,
symlinkName: 'scrobble-current.log',
filename: 'scrobble-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '5m'
}))
}
// TODO links for re-trying auth and variables for signalling it (and API recently played)
const sourceData = scrobbleSources.sources.map((x) => {
const {
type,
tracksDiscovered = 0,
name,
canPoll = false,
polling = false,
initialized = false,
requiresAuth = false,
requiresAuthInteraction = false,
authed = false,
} = x;
const base = {
type,
display: capitalize(type),
tracksDiscovered,
name,
canPoll,
hasAuth: requiresAuth,
hasAuthInteraction: requiresAuthInteraction,
};
if(!initialized) {
base.status = 'Not Initialized';
} else if(requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else if(canPoll) {
base.status = polling ? 'Running' : 'Idle';
} else {
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
}
return base;
});
const clientData = scrobbleClients.clients.map((x) => {
const {
type,
tracksScrobbled = 0,
name,
initialized = false,
requiresAuth = false,
requiresAuthInteraction = false,
authed = false,
} = x;
const base = {
type,
display: capitalize(type),
tracksDiscovered: tracksScrobbled,
name,
hasAuth: requiresAuth,
};
if(!initialized) {
base.status = 'Not Initialized';
} else if(requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else {
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
}
return base;
})
res.render('status', {
sources: sourceData,
clients: clientData,
logs: {
output: slicedLog,
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
}
});
})
if (interval < 15) {
console.warn('Interval should be above 30 seconds...😬');
}
let spotifyCreds = {};
try {
spotifyCreds = await readJson('./spotifyCreds.json');
} catch (e) {
logger.warn('Current spotify access token was not parsable or file does not exist (this could be normal)');
}
let spotifyConfig = spotifyConfigRaw;
if (typeof spotifyConfigRaw === 'string') {
try {
spotifyConfig = await readJson(spotifyConfigRaw);
} catch (e) {
logger.warn('Could not read spotify config file');
logger.error(e);
app.postAsync('/tautulli', async function (req, res) {
const payload = TautulliSource.formatPlayObj(req.body, true);
// try to get config name from payload
if (req.body.scrobblerConfig !== undefined) {
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
if (source !== undefined) {
if (source.type !== 'tautulli') {
this.logger.warn(`Tautulli event specified a config name but the configured source was not a Tautulli type: ${req.body.scrobblerConfig}`);
return res.send('OK');
} else {
await source.handle(payload, scrobbleClients);
return res.send('OK');
}
} else {
this.logger.warn(`Tautulli event specified a config name but no configured source found: ${req.body.scrobblerConfig}`);
return res.send('OK');
}
}
}
const {
accessToken = process.env.SPOTIFY_ACCESS_TOKEN,
clientId = process.env.SPOTIFY_CLIENT_ID,
clientSecret = process.env.SPOTIFY_CLIENT_SECRET,
redirectUri = process.env.SPOTIFY_REDIRECT_URI,
refreshToken = process.env.SPOTIFY_REFRESH_TOKEN,
} = spotifyConfig;
const rdUri = redirectUri || `${localUrl}/callback`;
const {token = accessToken, refreshToken: rt = refreshToken} = spotifyCreds;
if (token === undefined) {
if (clientId === undefined) {
throw new Error('ClientId not defined');
// if none specified we'll iterate through all tautulli sources and hopefully the user has configured them with filters
const tSources = scrobbleSources.getByType('tautulli');
for (const source of tSources) {
await source.handle(payload, scrobbleClients);
}
if (clientSecret === undefined) {
throw new Error('ClientSecret not defined');
}
}
const spotifyApi = new SpotifyWebApi({
clientId,
clientSecret,
accessToken: token,
redirectUri: rdUri,
refreshToken: rt,
});
const scrobbleClients = await createClients(clients, configDir);
if (scrobbleClients.length === 0) {
throw new Error('No scrobble clients were configured');
}
app.getAsync('/authSpotify', async function (req, res) {
logger.info('Redirecting to spotify authorization url');
res.redirect(spotifyApi.createAuthorizeURL(scopes, state));
});
app.postAsync('/pollSpotify', async function (req, res) {
spotifyAsyncFunc = pollSpotify(spotifyApi, interval, scrobbleClients);
res.send('OK');
});
app.getAsync(`/callback`, async function (req, res, next) {
const {error, code} = req.query;
if (error === undefined) {
const tokenResponse = await spotifyApi.authorizationCodeGrant(code);
spotifyApi.setAccessToken(tokenResponse.body['access_token']);
spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
await writeFile('spotifyCreds.json', JSON.stringify({
token: tokenResponse.body['access_token'],
refreshToken: tokenResponse.body['refresh_token']
}));
logger.info('Got auth code from callback!');
spotifyAsyncFunc = pollSpotify(spotifyApi, interval, scrobbleClients);
return res.send('OK');
} else {
throw new Error('User denied oauth access');
app.postAsync('/plex', upload.any(), async function (req, res) {
const {
body: {
payload
} = {}
} = req;
if (payload !== undefined) {
const playObj = PlexSource.formatPlayObj(JSON.parse(payload), true);
const pSources = scrobbleSources.getByType('plex');
for (const source of pSources) {
await source.handle(playObj, scrobbleClients);
}
}
res.send('OK');
});
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
app.postAsync('/jellyfin', jellyfinJsonParser, async function (req, res) {
const playObj = JellyfinSource.formatPlayObj(req.body, true);
const pSources = scrobbleSources.getByType('jellyfin');
for (const source of pSources) {
await source.handle(playObj, scrobbleClients);
}
res.send('OK');
});
app.use('/client/auth', clientCheckMiddle);
app.getAsync('/client/auth', async function (req, res) {
const {
scrobbleClient,
} = req;
switch (scrobbleClient.type) {
case 'lastfm':
res.redirect(scrobbleClient.api.getAuthUrl());
break;
default:
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
}
});
if (token === undefined) {
logger.info('No access token found');
logger.info(`Open ${localUrl}/authSpotify to continue`);
} else {
spotifyAsyncFunc = pollSpotify(spotifyApi, interval, scrobbleClients)
logger.info(`Server started at ${localUrl}`);
}
app.use('/source/auth', sourceCheckMiddle);
app.getAsync('/source/auth', async function (req, res) {
const {
scrobbleSource: source,
sourceName: name,
} = req;
const server = await app.listen(port)
}());
} catch (e) {
logger.error('Exited with uncaught error');
logger.error(e);
}
const pollSpotify = async function (spotifyApi, interval = 60, clients = []) {
logger.info('Starting spotify polling', {label: 'Spotify'});
try {
while (true) {
let data = {};
logger.debug('Refreshing recently played', {label: 'Spotify'})
try {
data = await spotifyApi.getMyRecentlyPlayedTracks({
limit: 20
});
} catch (e) {
if (e.statusCode === 401) {
logger.info('Access token was not valid, attempting to refresh', {label: 'Spotify'});
const tokenResponse = await spotifyApi.refreshAccessToken();
const {body: {
access_token,
// spotify may return a new refresh token
// if it doesn't then continue to use the last refresh token we received
refresh_token = spotifyApi.getRefreshToken(),
} = {}} = tokenResponse;
spotifyApi.setAccessToken(access_token);
await writeFile('spotifyCreds.json', JSON.stringify({
token: access_token,
refreshToken: refresh_token,
}));
data = await spotifyApi.getMyRecentlyPlayedTracks({
limit: 20
});
} else {
throw e;
}
}
let newLastPLayedAt = undefined;
const now = new Date();
for (const playObj of data.body.items) {
const {track: {name: trackName, duration_ms }, played_at} = playObj;
const playDate = new Date(played_at);
// compare play time to most recent track played_at scrobble
if (playDate.getTime() > lastTrackPlayedAt.getTime()) {
logger.info(`New Track => ${buildTrackString(playObj)}`, {label: 'Spotify'});
// so we always get just the most recent played_at
if (newLastPLayedAt === undefined) {
newLastPLayedAt = playDate;
switch (source.type) {
case 'spotify':
if (source.spotifyApi === undefined) {
res.status(400).send('Spotify configuration is not valid');
} else {
logger.info('Redirecting to spotify authorization url');
res.redirect(source.createAuthUrl());
}
const closeToInterval = Math.abs(now.getTime() - playDate.getTime()) / 1000 < 5;
if (closeToInterval) {
// because the interval check was so close to the play date we are going to delay client calls for a few secs
// this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups)
// additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully
logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first', {label: 'Spotify'});
await sleep(10 * 1000);
}
for (const client of clients) {
if (closeToInterval || client.scrobblesLastCheckedAt().getTime() < now.getTime()) {
await client.refreshScrobbles();
}
if (!client.alreadyScrobbled(trackName, playDate, duration_ms / 1000)) {
await client.scrobble(playObj);
}
}
} else {
break;
case 'lastfm':
res.redirect(source.api.getAuthUrl());
break;
default:
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
}
});
app.use('/poll', sourceCheckMiddle);
app.getAsync('/poll', async function (req, res) {
const {
scrobbleSource: source,
} = req;
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot poll (${source.type})`);
}
source.poll(scrobbleClients);
res.send('OK');
});
app.use('/recent', sourceCheckMiddle);
app.getAsync('/recent', async function (req, res) {
const {
scrobbleSource: source,
} = req;
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
}
const result = await source.getRecentlyPlayed({formatted: true});
const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map(x => x.data.artists.join(' / ')).flat())));
const trackLength = longestString(result.map(x => x.data.track))
const plays = result.map((x) => {
const {
meta: {
url: {
web
} = {}
} = {}
} = x;
const buildOpts = {
include: ['time', 'timeFromNow', 'track', 'artist'],
transformers: {
artists: a => artistTruncFunc(a.join(' / ')).padEnd(33),
track: t => t.padEnd(trackLength)
}
}
if (newLastPLayedAt !== undefined) {
lastTrackPlayedAt = newLastPLayedAt;
if (web !== undefined) {
buildOpts.transformers.track = t => `<a href="${web}">${t}</a>${''.padEnd(Math.max(trackLength - t.length, 0))}`;
}
return buildTrackString(x, buildOpts);
});
res.render('recent', {plays, name: source.name, sourceType: source.type});
});
app.getAsync('/logs/settings/update', async function (req, res) {
const e = req.query;
for (const [setting, val] of Object.entries(req.query)) {
switch (setting) {
case 'limit':
logConfig.limit = Number.parseInt(val);
break;
case 'sort':
logConfig.sort = val;
break;
case 'level':
logConfig.level = val;
for (const [key, logger] of winston.loggers.loggers) {
logger.level = val;
}
break;
}
}
// sleep for interval
logger.debug(`Sleeping for interval (${interval}s)`, {label: 'Spotify'});
await sleep(interval * 1000);
}
} catch (e) {
logger.error('Error occurred while in spotify polling loop', {label: 'Spotify'});
logger.error(e, {label: 'Spotify'});
}
};
res.send('OK');
});
const createClients = async function (clientConfigs = [], configDir = '.') {
const clients = [];
for (const config of clientConfigs) {
const dataType = typeof config;
if (!['object', 'string'].includes(dataType)) {
throw new Error('All client configs must be objects or strings');
}
const clientType = dataType === 'string' ? config : config.type;
switch (clientType) {
case 'maloja':
let data = `${configDir}/maloja.json`;
if (dataType === 'object') {
const {data: dataProp = process.env.MALOJA_CONFIG_PATH || `${configDir}/maloja.json`} = config;
data = dataProp;
}
let malojaConfig;
if (typeof data === 'string') {
try {
malojaConfig = await readJson(data);
} catch (e) {
logger.warn('Maloja config was not parsable or file does not exist');
}
} else {
malojaConfig = data;
}
app.getAsync(/.*callback$/, async function (req, res) {
const {
query: {
state
} = {}
} = req;
if (req.url.includes('lastfm')) {
const {
url = process.env.MALOJA_URL,
apiKey = process.env.MALOJA_API_KEY
} = malojaConfig;
if (url === undefined && apiKey === undefined) {
// the user probably didn't set anything up for this client at all, don't log
continue;
query: {
token
} = {}
} = req;
let entity = scrobbleClients.getByName(state);
if(entity === undefined) {
entity = scrobbleSources.getByName(state);
}
if (url === undefined) {
logger.warn('Maloja url not found in config');
continue;
try {
await entity.api.authenticate(token);
await entity.initialize();
return res.send('OK');
} catch (e) {
return res.send(e.message);
}
if (apiKey === undefined) {
logger.warn('Maloja api key not found in config');
continue;
} else {
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
const source = scrobbleSources.getByName(state);
const tokenResult = await source.handleAuthCodeCallback(req.query);
let responseContent = 'OK';
if (tokenResult === true) {
source.poll(scrobbleClients);
} else {
responseContent = tokenResult;
}
clients.push(new MalojaScrobbler(logger, malojaConfig));
break;
default:
break;
return res.send(responseContent);
}
});
let anyNotReady = false;
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
switch (source.type) {
case 'spotify':
if (source.spotifyApi !== undefined) {
if (source.spotifyApi.getAccessToken() === undefined) {
anyNotReady = true;
} else {
source.poll(scrobbleClients);
}
}
break;
case 'lastfm':
if(source.initialized === true) {
source.poll(scrobbleClients);
}
break;
default:
if (source.poll !== undefined) {
source.poll(scrobbleClients);
}
}
}
if (anyNotReady) {
logger.info(`Some sources are not ready, open ${localUrl} to continue`);
}
app.set('views', './views');
app.set('view engine', 'ejs');
logger.info(`Server started at ${localUrl}`);
const server = await app.listen(port)
} catch (e) {
logger.error('Exited with uncaught error');
logger.error(e);
}
return clients;
}
}());
+1581 -9
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -1,7 +1,7 @@
{
"name": "maloja-spotify-scrobbler",
"name": "multi-scrobbler",
"version": "0.1.0",
"description": "",
"description": "scrobble plays from multiple sources to multiple clients",
"type": "module",
"main": "index.js",
"scripts": {
@@ -13,19 +13,24 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/FoxxMD/maloja-spotify-scrobbler.git"
"url": "git+https://github.com/FoxxMD/multi-scrobbler.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/FoxxMD/maloja-spotify-scrobbler/issues"
"url": "https://github.com/FoxxMD/multi-scrobbler/issues"
},
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"date-fns": "^2.16.1",
"body-parser": "^1.19.0",
"dayjs": "^1.10.4",
"ejs": "^3.1.6",
"express": "^4.17.1",
"spotify-web-api-node": "^5.0.0",
"lastfm-node-client": "^2.2.0",
"multer": "^1.4.2",
"safe-stable-stringify": "^1.1.1",
"spotify-web-api-node": "^5.0.2",
"superagent": "^6.1.0",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0"
+43
View File
@@ -0,0 +1,43 @@
export const makeSourceCheckMiddle = sources => (req, res, next) => {
const {
query: {
name,
type
} = {}
} = req;
if (name === undefined) {
return res.status(404).send('Source name must be defined');
}
const source = sources.getByNameAndType(name, type);
if (source === undefined) {
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
}
req.sourceName = name;
req.scrobbleSource = source;
next();
}
export const makeClientCheckMiddle = clients => (req, res, next) => {
const {
query: {
name
} = {}
} = req;
if (name === undefined) {
return res.status(404).send('Client name must be defined');
}
const client = clients.getByName(name);
if (client === undefined) {
return res.status(404).send(`No client with the name: ${name}`);
}
req.scrobbleClient = client;
next();
}
+201
View File
@@ -0,0 +1,201 @@
import dayjs from "dayjs";
import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../utils.js";
export default class AbstractSource {
name;
type;
identifier;
config;
clients;
logger;
instantiatedAt;
initialized = false;
requiresAuth = false;
requiresAuthInteraction = false;
authed = false;
canPoll = false;
polling = false;
pollRetries = 0;
tracksDiscovered = 0;
constructor(type, name, config = {}, clients = []) {
this.type = type;
this.name = name;
this.identifier = `Source - ${capitalize(this.type)} - ${name}`;
this.logger = createLabelledLogger(this.identifier, this.identifier);
this.config = config;
this.clients = clients;
this.instantiatedAt = dayjs();
}
// default init function, should be overridden if init stage is required
initialize = async () => {
this.initialized = true;
return this.initialized;
}
// default init function, should be overridden if auth stage is required
testAuth = async () => {
return this.authed;
}
getRecentlyPlayed = async (options = {}) => {
return [];
}
// by default if the track was recently played it is valid
// this is useful for sources where the track doesn't have complete information like Subsonic
// TODO make this more descriptive? or move it elsewhere
recentlyPlayedTrackIsValid = (playObj) => {
return true;
}
poll = async (allClients) => {
await this.startPolling(allClients);
}
startPolling = async (allClients) => {
if(this.requiresAuthInteraction && !this.authed) {
this.logger.error('Cannot start polling because user interaction is required for authentication');
return;
}
// reset poll attempts if already previously run
this.pollRetries = 0;
const {
maxPollRetries = 0,
retryMultiplier = 1.5,
} = this.config;
// can't have negative retries!
const maxRetries = Math.max(0, maxPollRetries);
while (this.pollRetries <= maxRetries) {
try {
await this.doPolling(allClients);
} catch (e) {
if (this.pollRetries < maxRetries) {
const delayFor = (this.pollRetries + 1) * retryMultiplier;
this.logger.info(`Poll reties (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
await sleep((delayFor) * 1000);
} else {
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
}
this.pollRetries++;
}
}
}
/**
* @param {ScrobbleClients} allClients
*/
doPolling = async (allClients) => {
if (this.polling === true) {
return;
}
this.logger.info('Polling started');
let lastTrackPlayedAt = this.instantiatedAt;
let checkCount = 0;
let checksOverThreshold = 0;
try {
this.polling = true;
while (true) {
if(this.polling === false) {
this.logger.info('Stopped polling due to user input');
break;
}
let playObjs = [];
this.logger.debug('Refreshing recently played')
playObjs = await this.getRecentlyPlayed({formatted: true});
checkCount++;
let newTracksFound = false;
let closeToInterval = false;
const now = dayjs();
const playInfo = playObjs.reduce((acc, playObj) => {
if(this.recentlyPlayedTrackIsValid(playObj)) {
const {data: {playDate} = {}} = playObj;
if (playDate.unix() > lastTrackPlayedAt.unix()) {
newTracksFound = true;
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
if (closeToInterval === false) {
closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
}
return {
plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}],
lastTrackPlayedAt: playDate
}
}
return {
...acc,
plays: [...acc.plays, playObj]
}
}
return acc;
}, {plays: [], lastTrackPlayedAt});
playObjs = playInfo.plays;
lastTrackPlayedAt = playInfo.lastTrackPlayedAt;
if (closeToInterval) {
// because the interval check was so close to the play date we are going to delay client calls for a few secs
// this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups)
// additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully
this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
await sleep(10 * 1000);
}
if (newTracksFound === false) {
if (playObjs.length === 0) {
this.logger.debug(`No new tracks found and no tracks returned from API`);
} else {
this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`);
}
} else {
checkCount = 0;
checksOverThreshold = 0;
}
// use the source instantiation time or the last track play time to determine if we should refresh clients..
// we only need to refresh clients when the source has "newer" information otherwise we're just refreshing clients for no reason
const scrobbleResult = await allClients.scrobble(playObjs, {
checkTime: lastTrackPlayedAt.add(2, 's'),
forceRefresh: closeToInterval,
scrobbleFrom: this.identifier,
scrobbleTo: this.clients
});
if (scrobbleResult.length > 0) {
checkCount = 0;
this.tracksDiscovered += scrobbleResult.length;
}
const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config;
let sleepTime = interval;
// don't need to do back off calc if interval is 5 minutes or greater since its already pretty light on API calls
// and don't want to back off if we just started the app
const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's');
if (activeThreshold.isBefore(dayjs()) && sleepTime < 300) {
checksOverThreshold++;
const backoffMultiplier = Math.min(checksOverThreshold, 1000) * 1.5;
sleepTime = Math.min(interval * backoffMultiplier, maxSleep);
}
// sleep for interval
this.logger.debug(`Sleeping for ${sleepTime}s`);
await sleep(sleepTime * 1000);
}
} catch (e) {
this.logger.error('Error occurred while polling');
this.logger.error(e);
this.polling = false;
throw e;
}
}
}
+165
View File
@@ -0,0 +1,165 @@
import MemorySource from "./MemorySource.js";
import dayjs from "dayjs";
import {buildTrackString} from "../utils.js";
export default class JellyfinSource extends MemorySource {
users;
servers;
constructor(name, config, clients, type = 'jellyfin') {
super(type, name, config, clients);
const {users, servers} = config
if (users === undefined || users === null) {
this.users = undefined;
} else {
if (!Array.isArray(users)) {
this.users = [users];
} else {
this.users = users;
}
this.users = this.users.map(x => x.toLocaleLowerCase())
}
if (servers === undefined || servers === null) {
this.servers = undefined;
} else {
if (!Array.isArray(servers)) {
this.servers = [servers];
} else {
this.servers = servers;
}
this.servers = this.servers.map(x => x.toLocaleLowerCase())
}
if (users === undefined && servers === undefined) {
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers will be scrobbled.');
} else {
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
}
this.initialized = true;
}
static formatPlayObj(obj, newFromSource = false) {
const {
ServerId,
ServerName,
Username,
UserId,
NotificationType,
UtcTimestamp,
Album,
Artist,
Name,
RunTime,
ItemId,
ItemType,
} = obj;
const parsedRuntime = RunTime.split(':');
const dur = dayjs.duration({
hours: Number.parseInt(parsedRuntime[0]),
minutes: Number.parseInt(parsedRuntime[1]),
seconds: Number.parseInt(parsedRuntime[2])
});
return {
data: {
artists: [Artist],
album: Album,
track: Name,
duration: dur.as('seconds'),
playDate: dayjs(),
},
meta: {
event: NotificationType,
mediaType: ItemType,
sourceId: ItemId,
user: Username,
server: ServerName,
source: 'Jellyfin',
newFromSource,
}
}
}
isValidEvent = (playObj) => {
const {
meta: {
mediaType, event, user, server
},
data: {
artists,
track,
} = {}
} = playObj;
if (this.users !== undefined) {
if (user === undefined) {
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
} else if (!this.users.includes(user.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
artists,
track
})
return false;
}
}
if (event !== undefined && !['PlaybackProgress','PlaybackStarted'].includes(event)) {
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
artists,
track
})
return false;
}
if (mediaType !== 'Audio') {
this.logger.debug(`Will not scrobble event because media type was not 'Audio' (${mediaType})`, {
artists,
track
});
return false;
}
if (this.servers !== undefined && !this.servers.includes(server.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because server was not on allowed list: ${server}`, {
artists,
track
})
return false;
}
return true;
}
getRecentlyPlayed = async (options = {}) => {
return this.statefulRecentlyPlayed;
}
handle = async (playObj, allClients) => {
if (!this.isValidEvent(playObj)) {
return;
}
const newPlays = this.processRecentPlays([playObj]);
for(const p of newPlays) {
this.logger.info(`New Track => ${buildTrackString(p)}`);
}
if(newPlays.length > 0) {
const recent = await this.getRecentlyPlayed();
const newestPlay = recent[recent.length - 1];
try {
await allClients.scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate});
// only gets hit if we scrobbled ok
this.tracksDiscovered++;
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import AbstractSource from "./AbstractSource.js";
import LastfmApiClient from "../apis/LastfmApiClient.js";
import {sortByPlayDate} from "../utils.js";
export default class LastfmSource extends AbstractSource {
api;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, clients = []) {
super('lastfm', name, config, clients);
this.canPoll = true;
this.api = new LastfmApiClient(name, config);
}
static formatPlayObj(obj) {
return LastfmApiClient.formatPlayObj(obj);
}
initialize = async () => {
this.initialized = await this.api.initialize();
return this.initialized;
}
testAuth = async () => {
try {
this.authed = await this.api.testAuth();
} catch (e) {
this.logger.error('Could not successfully communicate with Last.fm API');
this.logger.error(e);
this.authed = false;
}
return this.authed;
}
getRecentlyPlayed = async(options = {}) => {
const {limit = 20, formatted = false} = options;
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
const {
recenttracks: {
track: list = [],
}
} = resp;
return list.reduce((acc, x) => {
try {
const formatted = LastfmApiClient.formatPlayObj(x);
const {
data: {
track,
playDate,
},
meta: {
mbid,
nowPlaying,
}
} = formatted;
if(nowPlaying === true) {
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
return acc;
} else if(playDate === undefined) {
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
return acc;
}
return acc.concat(formatted);
} catch (e) {
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
this.logger.debug('Full api response object:');
this.logger.debug(x);
return acc;
}
}, []).sort(sortByPlayDate);
}
}
+76
View File
@@ -0,0 +1,76 @@
import AbstractSource from "./AbstractSource.js";
import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js";
import dayjs from "dayjs";
export default class MemorySource extends AbstractSource {
/*
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track if valid.
* This is necessary for any source that
* * doesn't have its own source of truth for "recently played" or
* * that does not return "started at" and "duration" timestamps for recent plays or
* * where these timestamps don't have enough granularity (IE second accuracy)
* such as subsonic and jellyfin */
statefulRecentlyPlayed = [];
candidateRecentlyPlayed = [];
processRecentPlays = (plays) => {
let newStatefulPlays = [];
// first format new plays with locked play date
const lockedPlays = plays.map((p) => {
const {data: {playDate, ...restData}, ...rest} = p;
return {data: {...restData, playDate: dayjs()}, ...rest};
})
// if no candidates exist new plays are new candidates
if(this.candidateRecentlyPlayed.length === 0) {
this.candidateRecentlyPlayed = lockedPlays;
} else {
// otherwise determine new tracks (not found in prior candidates)
const newTracks = lockedPlays.filter(x => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
// filter prior candidates based on new recently played
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => lockedPlays.some(y => playObjDataMatch(x, y)));
// and then combine still playing with new tracks
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.concat(newTracks);
this.candidateRecentlyPlayed.sort(sortByPlayDate);
for(const candidate of this.candidateRecentlyPlayed) {
const {data: {playDate, track}} = candidate;
if(playDate.isBefore(dayjs().subtract(30, 's'))) {
// a prior candidate has been playing for more than 30 seconds, time to check statefuls
const matchingRecent = this.statefulRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['artist', 'track']})}`;
if(matchingRecent === undefined) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and not matching any prior plays`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
} else {
const {data: { playDate, duration }} = candidate;
const {data: { playDate: rplayDate }} = matchingRecent;
if(!playDate.isSame(rplayDate)) {
if(duration !== undefined) {
if(playDate.isAfter(rplayDate.add(duration, 's'))) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and having a different timestamp than a prior play`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
}
} else if(!playObjDataMatch(this.statefulRecentlyPlayed[0], candidate)) {
// if most recent stateful play is not this track we'll add it
this.logger.debug(`${stPrefix} added after being seen for 30 seconds. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
}
}
}
}
}
this.statefulRecentlyPlayed.sort(sortByPlayDate);
}
return newStatefulPlays;
}
recentlyPlayedTrackIsValid = (playObj) => {
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
}
}
+175
View File
@@ -0,0 +1,175 @@
import dayjs from "dayjs";import LastFm from "lastfm-node-client";
import LastfmScrobbler from '../clients/LastfmScrobbler.js';
import {buildTrackString} from "../utils.js";
import AbstractSource from "./AbstractSource.js";
export default class PlexSource extends AbstractSource {
users;
libraries;
servers;
constructor(name, config, clients, type = 'plex') {
super(type, name, config, clients);
const {user, libraries, servers} = config
if (user === undefined || user === null) {
this.users = undefined;
} else {
if (!Array.isArray(user)) {
this.users = [user];
} else {
this.users = user;
}
this.users = this.users.map(x => x.toLocaleLowerCase())
}
if (libraries === undefined || libraries === null) {
this.libraries = undefined;
} else {
if (!Array.isArray(libraries)) {
this.libraries = [libraries];
} else {
this.libraries = libraries;
}
this.libraries = this.libraries.map(x => x.toLocaleLowerCase())
}
if (servers === undefined || servers === null) {
this.servers = undefined;
} else {
if (!Array.isArray(servers)) {
this.servers = [servers];
} else {
this.servers = servers;
}
this.servers = this.servers.map(x => x.toLocaleLowerCase())
}
if (user === undefined && libraries === undefined && servers === undefined) {
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers and libraries will be scrobbled.');
} else {
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries === undefined ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
}
this.initialized = true;
}
static formatPlayObj(obj, newFromSource = false) {
const {
event,
Account: {
title: user,
} = {},
Metadata: {
type,
title: track,
parentTitle: album,
grandparentTitle: artist,
librarySectionTitle: library
} = {},
Server: {
title: server
} = {},
} = obj;
return {
data: {
artists: [artist],
album,
track,
playDate: dayjs(),
},
meta: {
event,
mediaType: type,
user,
library,
server,
source: 'Plex',
newFromSource,
}
}
}
isValidEvent = (playObj) => {
const {
meta: {
mediaType, event, user, library, server
},
data: {
artists,
track,
} = {}
} = playObj;
const hint = this.type === 'tautulli' ? ' (Check notification agent json data configuration)' : '';
if (this.users !== undefined) {
if (user === undefined) {
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
} else if (!this.users.includes(user.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
artists,
track
})
return false;
}
}
if (event !== undefined && event !== 'media.scrobble') {
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
artists,
track
})
return false;
}
if (mediaType !== 'track') {
this.logger.debug(`Will not scrobble event because media type was not a track (${mediaType})`, {
artists,
track
});
return false;
}
if (this.libraries !== undefined) {
if (library === undefined) {
this.logger.warn(`Config defined libraries but payload contained no library info${hint}`);
} else if (!this.libraries.includes(library.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because library was not on allowed list: ${library}`, {
artists,
track
})
return false;
}
}
if (this.servers !== undefined) {
if (server === undefined) {
this.logger.warn(`Config defined server but payload contained no server info${hint}`);
} else if (!this.servers.includes(server.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because server was not on allowed list: ${server}`, {
artists,
track
})
return false;
}
}
return true;
}
handle = async (playObj, allClients) => {
if (!this.isValidEvent(playObj)) {
return;
}
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
try {
await allClients.scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier});
// only gets hit if we scrobbled ok
this.tracksDiscovered++;
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
}
}
}
+327
View File
@@ -0,0 +1,327 @@
import {createLabelledLogger, isValidConfigStructure, readJson} from "../utils.js";
import SpotifySource from "./SpotifySource.js";
import PlexSource from "./PlexSource.js";
import TautulliSource from "./TautulliSource.js";
import {SubsonicSource} from "./SubsonicSource.js";
import JellyfinSource from "./JellyfinSource.js";
import LastfmSource from "./LastfmSource.js";
export default class ScrobbleSources {
sources = [];
logger;
configDir;
localUrl;
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm'];
constructor(localUrl, configDir = process.cwd()) {
this.configDir = configDir;
this.localUrl = localUrl;
this.logger = createLabelledLogger('sources', 'Sources');
}
getByName = (name) => {
return this.sources.find(x => x.name === name);
}
getByType = (type) => {
return this.sources.filter(x => x.type === type);
}
getByNameAndType = (name, type) => {
return this.sources.find(x => x.name === name && x.type === type);
}
buildSourcesFromConfig = async (additionalConfigs = []) => {
let configs = additionalConfigs;
let configFile;
try {
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
throw new Error('config.json could not be parsed');
}
let sourceDefaults = {};
if (configFile !== undefined) {
const {
sources: mainConfigSourcesConfigs = [],
sourceDefaults: sd = {},
} = configFile;
sourceDefaults = sd;
const validMainConfigs = mainConfigSourcesConfigs.reduce((acc, curr, i) => {
if(curr === null) {
this.logger.error(`The source config entry at index ${i} in config.json is null but should be an object, will not parse`);
return acc;
}
if(typeof curr !== 'object') {
this.logger.error(`The source config entry at index ${i} in config.json should be an object, will not parse`);
return acc;
}
return acc.concat(curr);
}, []);
for (const c of validMainConfigs) {
const {name = 'unnamed'} = c;
configs.push({...c,
name,
source: 'config.json',
configureAs: 'source' // override user value
});
}
}
for (let sourceType of this.sourceTypes) {
let defaultConfigureAs = 'source';
// env builder for single user mode
switch (sourceType) {
case 'spotify':
const s = {
accessToken: process.env.SPOTIFY_ACCESS_TOKEN,
clientId: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
redirectUri: process.env.SPOTIFY_REDIRECT_URI,
refreshToken: process.env.SPOTIFY_REFRESH_TOKEN,
};
if (!Object.values(s).every(x => x === undefined)) {
configs.push({
type: 'spotify',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: s
})
}
break;
case 'tautulli':
const t = {
// support this for now
user: process.env.TAUTULLI_USER || process.env.PLEX_USER
};
if (!Object.values(t).every(x => x === undefined)) {
configs.push({
type: 'tautulli',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: t
})
}
break;
case 'plex':
const p = {
user: process.env.PLEX_USER
};
if (!Object.values(p).every(x => x === undefined)) {
configs.push({
type: 'plex',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: p
})
}
break;
case 'subsonic':
const sub = {
user: process.env.SUBSONIC_USER,
password: process.env.SUBSONIC_PASSWORD,
url: process.env.SUBSONIC_URL,
};
if (!Object.values(sub).every(x => x === undefined)) {
configs.push({
type: 'subsonic',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: sub
})
}
break;
case 'jellyfin':
const j = {
user: process.env.JELLYFIN_USER,
server: process.env.JELLYFIN_SERVER,
};
if (!Object.values(j).every(x => x === undefined)) {
configs.push({
type: 'jellyfin',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: j
})
}
break;
case 'lastfm':
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
defaultConfigureAs = 'client';
break;
default:
break;
}
let rawSourceConfigs;
try {
rawSourceConfigs = await readJson(`${this.configDir}/${sourceType}.json`, {throwOnNotFound: false});
} catch (e) {
this.logger.error(`${sourceType}.json config file could not be parsed`);
continue;
}
if (rawSourceConfigs !== undefined) {
let sourceConfigs = [];
if (Array.isArray(rawSourceConfigs)) {
sourceConfigs = rawSourceConfigs;
} else if (rawSourceConfigs === null) {
this.logger.error(`${sourceType}.json contained no data`);
continue;
} else if (typeof rawSourceConfigs === 'object') {
// backwards compatibility, assuming its single-user mode
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${sourceType}.json) must be in an array.`);
if (rawSourceConfigs.data === undefined) {
sourceConfigs = [{data: rawSourceConfigs, mode: 'single', name: 'unnamed'}];
} else {
sourceConfigs = [rawSourceConfigs];
}
} else {
this.logger.error(`All top level data from ${sourceType}.json must be an array of objects, will not parse configs from file`);
continue;
}
for (const [i,m] of sourceConfigs.entries()) {
if(m === null) {
this.logger.error(`The config entry at index ${i} from ${sourceType}.json is null`);
continue;
}
if (typeof m !== 'object') {
this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not an object, skipping`, m);
continue;
}
const {configureAs = defaultConfigureAs} = m;
if(configureAs === 'source') {
m.source = `${sourceType}.json`;
m.type = sourceType;
configs.push(m);
}
}
}
}
// we have all possible configurations so we'll check they are minimally valid
const validConfigs = configs.reduce((acc, c) => {
const isValid = isValidConfigStructure(c, {type: true, data: true});
if (isValid !== true) {
this.logger.error(`Source config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`);
return acc;
}
return acc.concat(c);
}, []);
// finally! all configs are valid, structurally, and can now be passed to addClient
// do a last check that names (within each type) are unique and warn if not, but add anyways
const typeGroupedConfigs = validConfigs.reduce((acc, curr) => {
const {type} = curr;
const {[type]: t = []} = acc;
return {...acc, [type]: [...t, curr]};
}, {});
// only need to warn if dup names PER TYPE
for (const [type, typedConfigs] of Object.entries(typeGroupedConfigs)) {
const nameGroupedConfigs = typedConfigs.reduce((acc, curr) => {
const {name = 'unnamed'} = curr;
const {[name]: n = []} = acc;
return {...acc, [name]: [...n, curr]};
}, {});
for (const [name, namedConfigs] of Object.entries(nameGroupedConfigs)) {
let tempNamedConfigs = namedConfigs;
const hasDups = namedConfigs.length > 1;
if (hasDups) {
const sources = namedConfigs.map(c => `Config object from ${c.source} of type [${c.type}]`);
this.logger.warn(`Source configs have naming conflicts -- the following configs have the same name "${name}":\n\n${sources.join('\n')}\n`);
if (name === 'unnamed') {
this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
}
}
tempNamedConfigs = tempNamedConfigs.map(({name = 'unnamed', ...x}, i) => ({
...x,
name: hasDups ? `${name}${i + 1}` : name
}));
for (const c of tempNamedConfigs) {
try {
await this.addSource(c, sourceDefaults);
} catch(e) {
this.logger.error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`);
this.logger.error(e);
}
}
}
}
}
addSource = async (clientConfig, defaults = {}) => {
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}
const {type, name, clients = [], data: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
this.logger.debug(`(${name}) Constructing ${type} source`);
let newSource;
switch (type) {
case 'spotify':
newSource = new SpotifySource(name, {
...data,
localUrl: this.localUrl,
configDir: this.configDir
}, clients);
break;
case 'plex':
newSource = await new PlexSource(name, data, clients);
break;
case 'tautulli':
newSource = await new TautulliSource(name, data, clients);
break;
case 'subsonic':
newSource = new SubsonicSource(name, data, clients);
break;
case 'jellyfin':
newSource = await new JellyfinSource(name, data, clients);
break;
case 'lastfm':
newSource = await new LastfmSource(name, {...data, configDir: this.configDir}, clients);
break;
default:
break;
}
if(newSource === undefined) {
// really shouldn't get here!
throw new Error(`Source of type ${type} was not recognized??`);
}
if(newSource.initialized === false) {
this.logger.debug(`(${name}) Attempting ${type} initialization...`);
if (await newSource.initialize() === false) {
this.logger.error(`(${name}) ${type} source failed to initialize. Source needs to be successfully initialized before activity capture can begin.`);
return;
} else {
this.logger.info(`(${name}) ${type} source initialized`);
}
} else {
this.logger.info(`(${name}) ${type} source initialized`);
}
if(newSource.requiresAuth && !newSource.authed) {
this.logger.debug(`(${name}) Checking ${type} source auth...`);
let success;
try {
success = await newSource.testAuth();
} catch (e) {
success = false;
}
if(!success) {
this.logger.warn(`(${name}) ${type} source auth failed.`);
} else {
this.logger.info(`(${name}) ${type} source auth OK`);
}
}
this.sources.push(newSource);
}
}
+254
View File
@@ -0,0 +1,254 @@
import dayjs from "dayjs";
import {
readJson,
writeFile,
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
} from "../utils.js";
import SpotifyWebApi from "spotify-web-api-node";
import AbstractSource from "./AbstractSource.js";
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
const state = 'random';
export default class SpotifySource extends AbstractSource {
spotifyApi;
localUrl;
workingCredsPath;
configDir;
requiresAuth = true;
requiresAuthInteraction = true;
constructor(name, config = {}, clients = []) {
super('spotify', name, config, clients);
const {
localUrl,
configDir,
interval = 60,
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
this.config.interval = interval;
this.configDir = configDir;
this.workingCredsPath = `${configDir}/currentCreds-${name}.json`;
this.localUrl = localUrl;
this.canPoll = true;
}
static formatPlayObj(obj, newFromSource = false) {
const {
track: {
artists = [],
name,
id,
duration_ms,
album: {
name: albumName,
} = {},
external_urls: {
spotify,
} = {}
} = {},
played_at
} = obj;
//let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
return {
data: {
artists: artists.map(x => x.name),
album: albumName,
track: name,
duration: duration_ms / 1000,
playDate: dayjs(played_at),
},
meta: {
trackLength: duration_ms / 1000,
source: 'Spotify',
sourceId: id,
newFromSource,
url: {
web: spotify
}
}
}
}
buildSpotifyApi = async () => {
let spotifyCreds = {};
try {
spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
} catch (e) {
this.logger.warn('Current spotify credentials file exists but could not be parsed', { path: this.workingCredsPath });
}
const {
accessToken,
clientId,
clientSecret,
redirectUri,
refreshToken,
} = this.config || {};
const rdUri = redirectUri || `${this.localUrl}/callback`;
const {token = accessToken, refreshToken: rt = refreshToken} = spotifyCreds || {};
const apiConfig = {
clientId,
clientSecret,
accessToken: token,
refreshToken: rt,
}
if (Object.values(apiConfig).every(x => x === undefined)) {
this.logger.info('No values found for Spotify configuration, skipping initialization');
return;
}
apiConfig.redirectUri = rdUri;
const validationErrors = [];
if (token === undefined) {
if (clientId === undefined) {
validationErrors.push('clientId must be defined when access token is not present');
}
if (clientSecret === undefined) {
validationErrors.push('clientSecret must be defined when access token is not present');
}
if (rdUri === undefined) {
validationErrors.push('redirectUri must be defined when access token is not present');
}
if (validationErrors.length !== 0) {
validationErrors.unshift('no access token is defined');
}
} else if (rt === undefined && (
clientId === undefined ||
clientSecret === undefined ||
rdUri === undefined
)) {
this.logger.warn('Access token is present but no refresh token is defined and remaining configuration is not sufficient to re-authorize. Without a refresh token API calls will fail after current token is expired.');
}
if (validationErrors.length !== 0) {
this.logger.warn(`Configuration was not valid:\*${validationErrors.join('\n')}`);
throw new Error('Failed to initialize a Spotify source');
}
this.spotifyApi = new SpotifyWebApi(apiConfig);
}
initialize = async () => {
if(this.spotifyApi === undefined) {
await this.buildSpotifyApi();
}
this.initialized = true;
return this.initialized;
}
testAuth = async () => {
try {
await this.callApi((api => api.getMe()));
this.authed = true;
} catch (e) {
this.logger.error('Could not successfully communicate with Spotify API');
this.authed = false;
}
return this.authed;
}
createAuthUrl = () => {
return this.spotifyApi.createAuthorizeURL(scopes, this.name);
}
handleAuthCodeCallback = async ({error, code}) => {
if (error === undefined) {
const tokenResponse = await this.spotifyApi.authorizationCodeGrant(code);
this.spotifyApi.setAccessToken(tokenResponse.body['access_token']);
this.spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
await writeFile(this.workingCredsPath, JSON.stringify({
token: tokenResponse.body['access_token'],
refreshToken: tokenResponse.body['refresh_token']
}));
this.logger.info('Got token from code grant authorization!');
return true;
} else {
this.logger.warn('Callback contained an error! User may have denied access?')
this.logger.error(error);
return error;
}
}
getRecentlyPlayed = async (options = {}) => {
const {limit = 20, formatted = false} = options;
const func = api => api.getMyRecentlyPlayedTracks({
limit
});
const result = await this.callApi(func);
if (formatted === true) {
return result.body.items.map(x => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate);
}
return result;
}
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 2,
} = this.config;
try {
return await func(this.spotifyApi);
} catch (e) {
if (e.statusCode === 401) {
if (this.spotifyApi.getRefreshToken() === undefined) {
throw new Error('Access token was not valid and no refresh token was present')
}
this.logger.debug('Access token was not valid, attempting to refresh');
const tokenResponse = await this.spotifyApi.refreshAccessToken();
const {
body: {
access_token,
// spotify may return a new refresh token
// if it doesn't then continue to use the last refresh token we received
refresh_token = this.spotifyApi.getRefreshToken(),
} = {}
} = tokenResponse;
this.spotifyApi.setAccessToken(access_token);
await writeFile(this.workingCredsPath, JSON.stringify({
token: access_token,
refreshToken: refresh_token,
}));
try {
return await func(this.spotifyApi);
} catch (ee) {
this.logger.error('Refreshing access token encountered an error');
this.logger.error(ee, {label: 'Spotify'});
throw ee;
}
} else if(maxRequestRetries > retries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return this.callApi(func, retries + 1);
} else {
this.logger.error(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`);
this.logger.error(e, {label: 'Spotify'});
throw e;
}
}
}
poll = async (allClients) => {
if (this.spotifyApi === undefined) {
this.logger.warn('Cannot poll spotify without valid credentials configuration')
return;
}
await this.startPolling(allClients);
}
}
+171
View File
@@ -0,0 +1,171 @@
import AbstractSource from "./AbstractSource.js";
import request from 'superagent';
import crypto from 'crypto';
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
import MemorySource from "./MemorySource.js";
dayjs.extend(isSameOrAfter);
export class SubsonicSource extends MemorySource {
requiresAuth = true;
constructor(name, config = {}, clients = []) {
// default to quick interval so we can get a decently accurate nowPlaying
const subsonicConfig = {interval: 10, maxSleep: 30, ...config};
super('subsonic', name, subsonicConfig, clients);
const {user, password, url} = this.config;
if (user === undefined) {
throw new Error(`Cannot setup Subsonic source, 'user' is not defined`);
}
if (password === undefined) {
throw new Error(`Cannot setup Subsonic source, 'password' is not defined`);
}
if (url === undefined) {
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
}
this.canPoll = true;
}
static formatPlayObj(obj, newFromSource = false) {
const {
id,
title,
album,
artist,
duration, // seconds
minutesAgo,
} = obj;
return {
data: {
artists: [artist],
album,
track: title,
duration,
// subsonic doesn't return an exact datetime, only how many whole minutes ago it was played
// so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off
playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'),
},
meta: {
trackLength: duration,
source: 'Subsonic',
sourceId: id,
newFromSource,
}
}
}
callApi = async (req, retries = 0) => {
const {
user,
password,
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
const salt = await crypto.randomBytes(10).toString('hex');
const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex')
req.query({
u: user,
t: hash,
s: salt,
v: '1.15.0',
c: `multi-scrobbler - ${this.name}`,
f: 'json'
});
try {
const resp = await req;
const {
body: {
"subsonic-response": {
status,
},
"subsonic-response": ssResp = {}
} = {}
} = resp;
if (status === 'failed') {
const err = new Error('Subsonic API returned an error');
err.response = resp;
throw err;
}
return ssResp;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response: {
status,
body: {
"subsonic-response": {
status: ssStatus,
error: {
code,
message: ssMessage,
} = {},
} = {},
"subsonic-response": ssResp
} = {},
text,
} = {},
response,
} = e;
let msg = response !== undefined ? `API Call failed: Server Response => ${ssMessage}` : `API Call failed: ${message}`;
const responseMeta = ssResp ?? text;
this.logger.error(msg, {status, response: responseMeta});
throw e;
}
}
initialize = async () => {
const {url} = this.config;
try {
await request.get(`${url}/`);
this.logger.info('Subsonic Connection: ok');
this.initialized = true;
} catch (e) {
if(e.status !== undefined && e.status !== 404) {
this.logger.info('Subsonic Connection: ok');
// we at least got a response!
this.initialized = true;
}
}
return this.initialized;
}
testAuth= async () => {
const {url} = this.config;
try {
await this.callApi(request.get(`${url}/rest/ping`));
this.authed = true;
this.logger.info('Subsonic API Status: ok');
} catch (e) {
this.authed = false;
}
return this.authed;
}
getRecentlyPlayed = async (options = {}) => {
const {formatted = false} = options;
const {url} = this.config;
const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`));
const {
nowPlaying: {
entry = []
} = {}
} = resp;
this.processRecentPlays(entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x));
return this.statefulRecentlyPlayed;
}
}
+47
View File
@@ -0,0 +1,47 @@
import dayjs from "dayjs";
import PlexSource from "./PlexSource.js";
export default class TautulliSource extends PlexSource {
constructor(name, config, clients) {
super(name, config, clients, 'tautulli');
}
static formatPlayObj(obj, newFromSource = false) {
const {
artist_name,
track_name,
track_artist,
album_name,
media_type,
title,
library_name,
server,
duration,
username,
} = obj;
let artists = [artist_name];
if (track_artist !== undefined && track_artist !== artist_name) {
artists.push(track_artist);
}
return {
data: {
artists,
album: album_name,
track: track_name,
duration,
playDate: dayjs(),
},
meta: {
title,
library: library_name,
server,
mediaType: media_type,
user: username,
trackLength: duration,
source: 'Tautulli',
newFromSource,
}
}
}
}
+289 -26
View File
@@ -1,18 +1,38 @@
import fs, {promises, constants} from "fs";
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import winston from "winston";
import jsonStringify from 'safe-stable-stringify';
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
import { Response } from 'superagent';
export async function readJson(path) {
await promises.access(path, constants.R_OK);
const data = await promises.readFile(path);
return JSON.parse(data);
const {format} = winston;
const {combine, printf, timestamp, label, splat, errors} = format;
// return new Promise((resolve, reject) => {
// fs.readFile(path, 'utf8', function (err, data) {
// if (err) {
// reject(err);
// }
// resolve(JSON.parse(data));
// });
// });
dayjs.extend(utc);
export async function readJson(path, {logErrors = true, throwOnNotFound = true} = {}) {
try {
await promises.access(path, constants.R_OK);
const data = await promises.readFile(path);
return JSON.parse(data);
} catch (e) {
const {code} = e;
if (code === 'ENOENT') {
if (throwOnNotFound) {
if (logErrors) {
this.logger.warn('No file found at given path', {filePath: path});
}
throw e;
} else {
return;
}
} else if (logErrors) {
this.logger.warn(`Encountered error while parsing file`, {filePath: path});
this.logger.error(e);
}
throw e;
}
}
export async function readText(path) {
@@ -49,18 +69,261 @@ export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const buildTrackString = (obj) => {
const {
track: {
artists = [],
name,
id,
external_urls: {
spotify,
} = {}
} = {},
played_at
} = obj;
let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(' / ');
return `${artistString} - ${name}, played at ${played_at}`
export const longestString = strings => strings.reduce((acc, curr) => curr.length > acc ? curr.length : acc, 0);
export const truncateStringArrToLength = (length, truncStr = '...') => {
const truncater = truncateStringToLength(length, truncStr);
return strings => strings.map(truncater);
}
export const truncateStringToLength = (length, truncStr = '...') => str => str.length > length ? `${str.slice(0, length)}${truncStr}` : str;
const defaultTransformer = input => input;
export const buildTrackString = (playObj, options = {}) => {
const {
include = ['time', 'artist', 'track'],
transformers: {
artists: artistsFunc = a => a.join(' / '),
track: trackFunc = defaultTransformer,
time: timeFunc = t => t.local().format(),
timeFromNow = t => t.local().fromNow(),
} = {}
} = options;
const {
data: {
artists,
album,
track,
playDate
} = {}
} = playObj;
let str = '';
if(include.includes('artist')) {
str = `${artistsFunc(artists)}`;
}
if(include.includes('track')) {
if(str !== '') {
str = `${str} - ${trackFunc(track)}`;
} else {
str = `${trackFunc(track)}`;
}
}
if (include.includes('time')) {
str = `${str} @ ${timeFunc(playDate)}`
}
if (include.includes('timeFromNow')) {
str = `${str} (${timeFromNow(playDate)})`
}
return str;
}
// sorts playObj formatted objects by playDate in ascending (oldest first) order
export const sortByPlayDate = (a, b) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1;
const s = splat();
const SPLAT = Symbol.for('splat')
const errorsFormat = errors({stack: true});
const CWD = process.cwd();
let longestLabel = 3;
export const defaultFormat = printf(({level, message, label = 'App', timestamp, [SPLAT]: splatObj, stack, ...rest}) => {
let stringifyValue = splatObj !== undefined ? jsonStringify(splatObj) : '';
if (label.length > longestLabel) {
longestLabel = label.length;
}
let msg = message;
let stackMsg = '';
if (stack !== undefined) {
const stackArr = stack.split('\n');
msg = stackArr[0];
const cleanedStack = stackArr
.slice(1) // don't need actual error message since we are showing it as msg
.map(x => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
.join('\n'); // rejoin with newline to preserve formatting
stackMsg = `\n${cleanedStack}`;
}
return `${timestamp} ${level.padEnd(7)}: [${label.padEnd(longestLabel)}] ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
});
export const labelledFormat = (labelName = 'App') => {
const l = label({label: labelName, message: false});
return combine(
timestamp(
{
format: () => dayjs().local().format(),
}
),
l,
s,
errorsFormat,
defaultFormat,
);
}
export const createLabelledLogger = (name = 'default', label = 'App') => {
if (winston.loggers.has(name)) {
return winston.loggers.get(name);
}
const def = winston.loggers.get('default');
winston.loggers.add(name, {
transports: def.transports,
level: def.level,
format: labelledFormat(label)
});
return winston.loggers.get(name);
}
export const setIntersection = (setA, setB) => {
let _intersection = new Set()
for (let elem of setB) {
if (setA.has(elem)) {
_intersection.add(elem)
}
}
return _intersection
}
export const isValidConfigStructure = (obj, required = {}) => {
const {name = false, type = false, data = true} = required;
const errs = [];
if (obj.type === undefined && type) {
errs.push("'type' must be defined");
}
if (obj.name === undefined && name) {
errs.push("'name' must be defined")
}
if (obj.data === undefined && data) {
errs.push("'data' must be defined");
}
if (errs.length > 0) {
return errs;
}
return true;
}
export const returnDuplicateStrings = (arr) => {
const alreadySeen = [];
const dupes = [];
arr.forEach(str => alreadySeen[str] ? dupes.push(str) : alreadySeen[str] = true);
return dupes;
}
export const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export const playObjDataMatch = (a, b) => {
const {
data: {
artists: aArtists = [],
album: aAlbum,
track: aTrack,
} = {},
meta: {
source: aSource,
sourceId: aSourceId,
} = {},
} = a;
const {
data: {
artists: bArtists = [],
album: bAlbum,
track: bTrack,
} = {},
meta: {
source: bSource,
sourceId: bSourceId,
} = {},
} = b;
// if sources are the same and both plays have source ids then we can just compare by id
if(aSource === bSource && aSourceId !== undefined && bSourceId !== undefined) {
if(aSourceId !== bSourceId) {
return false;
}
}
if (aTrack !== bTrack) {
return false;
}
if (aAlbum !== bAlbum) {
return false;
}
if (aArtists.length !== bArtists.length) {
return false;
}
// check if every artist from either playObj matches (one way or another) with the artists from the other play obj
if (!aArtists.every(x => bArtists.includes(x)) && bArtists.every(x => aArtists.includes(x))) {
return false
}
return true;
}
export const parseRetryAfterSecsFromObj = (err) => {
let raVal;
if (err instanceof TimeoutError) {
return undefined;
}
if (err instanceof WebapiError || err instanceof Response) {
const {headers = {}} = err;
raVal = headers['retry-after']
}
// if (err instanceof Response) {
// const {headers = {}} = err;
// raVal = headers['retry-after']
// }
const {
response: {
headers, // returned in superagent error
} = {},
retryAfter: ra // possible custom property we have set
} = err;
if (ra !== undefined) {
raVal = ra;
} else if (headers !== null && typeof headers === 'object') {
raVal = headers['retry-after'];
}
if (raVal === undefined || raVal === null) {
return raVal;
}
// first try to parse as float
let retryAfter = Number.parseFloat(raVal);
if (!isNaN(retryAfter)) {
return retryAfter; // got a number!
}
// try to parse as date
retryAfter = dayjs(retryAfter);
if (!dayjs.isDayjs(retryAfter)) {
return undefined; // could not parse string if not in ISO 8601 format
}
// otherwise we got a date! now get the difference the specified retry-after date and now in seconds
const diff = retryAfter.diff(dayjs(), 'second');
if (diff <= 0) {
// if diff is in the past returned undefined as its irrelevant now
return undefined;
}
return diff;
}
export const spreadDelay = (retries, multiplier) => {
if(retries === 0) {
return [];
}
let r;
let s = [];
for(r = 0; r < retries; r++) {
s.push(((r+1) * multiplier) * 1000);
}
return s;
}
+65
View File
@@ -0,0 +1,65 @@
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
<style>
a {
text-decoration: underline;
}
</style>
<title>Multi Scrobbler - Status</title>
</head>
<body>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
<div class="container mx-auto">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center flex-grow no-underline">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
<span class="px-4 break-normal">
Multi Scrobbler
</span>
</a>
<div>
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
</div>
</div>
</div>
</div>
<div class="container mx-auto">
<div class="grid">
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3>Recently Played (<%= name %>)</h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<% if (sourceType === 'spotify') { %>
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
<% } %>
<ul>
<% plays.forEach(function (play){ %>
<li><%-play%></li>
<% }) %>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
let toggleSwitch = document.querySelector('#toggle-switch');
toggleSwitch.addEventListener('change', (event) => {
document.body.classList.toggle('dark')
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
});
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (localStorage.getItem('ms-dark') === 'yes') {
document.body.classList.add('dark')
toggleSwitch.checked = true
localStorage.setItem('ms-dark', 'yes')
}
}
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
<style>
a {
text-decoration: underline;
}
</style>
<title>Multi Scrobbler</title>
</head>
<body class="">
<script>localStorage.getItem('ms-dark') === 'yes' ? document.body.classList.add('dark') : document.body.classList.remove('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
<div class="container mx-auto">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center flex-grow no-underline">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
<span class="px-4 break-normal">
Multi Scrobbler
</span>
</a>
<div>
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
</div>
</div>
</div>
</div>
<div class="container mx-auto">
<div class="grid md:grid-cols-3 gap-3 ">
<% sources.forEach(function (source){ %>
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3>(Source) <%= source.display %> - <%= source.name %></h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<div><b>Status: <%= source.status %></b></div>
<div>Tracks Discovered (since app started): <%= source.tracksDiscovered %></div>
<% if (source.canPoll === true) { %>
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="recent?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>See recently played tracks returned by API</a></div>
<% if (source.hasAuth) { %>
<div><a target="_blank" href="source/auth?name=<%= source.name %>&type=<%= source.type %>">(Re)authenticate and (re)start polling</a></div>
<% } %>
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="poll?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>Restart polling</a></div>
<% } %>
</div>
</div>
<% }) %>
</div>
<div class="grid md:grid-cols-3 gap-3">
<% clients.forEach(function (client){ %>
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3>(Client) <%= client.display %> - <%= client.name %></h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<ul>
<div><b>Status: <%= client.status %></b></div>
<div>Tracks Scrobbled (since app started): <%= client.tracksDiscovered %></div>
<% if (client.hasAuth) { %>
<div>Click <a target="_blank" href="client/auth?name=<%= client.name %>&type=<%= client.type %>">to (re)authenticate or initialize</a></div>
<% } %>
</ul>
</div>
</div>
<% }) %>
</div>
<div class="grid ">
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h2>Log (Most Recent)</h2>
</div>
<div class="p-6 md:px-10 md:py-6">
<div>Level : <%- logs.level %> </div>
<div>Sort : <%- logs.sort %></div>
<div>Limit : <%- logs.limit %></div>
<br />
<% logs.output.forEach(function (logEntry){ %>
<%-logEntry%>
<% }) %>
</div>
<div class="w-full flex-auto flex min-h-0 overflow-auto">
<div class="w-full relative flex-auto">
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let toggleSwitch = document.querySelector('#toggle-switch');
toggleSwitch.addEventListener('change', (event) => {
document.body.classList.toggle('dark')
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
});
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (localStorage.getItem('ms-dark') === 'yes') {
document.body.classList.add('dark')
toggleSwitch.checked = true
localStorage.setItem('ms-dark', 'yes')
}
}
</script>
</body>
</html>