Compare commits

...
Author SHA1 Message Date
FoxxMD 22cf95e58c feat(listenbrainz): Use upstream for backlogged 2026-03-25 01:37:54 +00:00
FoxxMD 358355c612 refactor: Move stagger config responsibility to transformer 2026-03-25 00:41:10 +00:00
FoxxMD c5f9600047 docs: Add duplicate scrobble detection explanation 2026-03-24 23:54:55 +00:00
FoxxMD 0dfa4c7aad fix(test): Fix expected plays for YTM interim plays 2026-03-24 20:23:47 +00:00
FoxxMD 68e473ec02 fix: Add stagger options as injected dependency to make tests faster 2026-03-24 20:21:14 +00:00
FoxxMD a8d20734aa fix: Fix wrong operation for maxRandomStagger 2026-03-24 20:20:37 +00:00
FoxxMD ebdb76cf6c fix: Fix type-erased meta property for json plays 2026-03-24 20:19:27 +00:00
FoxxMD e62e1ac2dd feat: Add seenAt to source/client play meta 2026-03-24 19:38:05 +00:00
FoxxMD e68b46f647 feat(ui): Implement play activity sortby seen/played 2026-03-24 19:15:54 +00:00
FoxxMD 7605cd3f1d virtualization is messy and plain accordian works better 2026-03-24 18:56:07 +00:00
FoxxMD 2735c283ce virtualized activity log 2026-03-24 17:47:51 +00:00
FoxxMD 0daaec81ca feat(ui): Add more info to play data 2026-03-24 01:22:39 +00:00
FoxxMD 2085df4406 feat(ui): Dynamic expand/collapse all 2026-03-24 00:16:57 +00:00
FoxxMD 9b8664d3a3 feat(ui): Condense timeline items into title using collapsible 2026-03-24 00:02:42 +00:00
FoxxMD e027a33e8d feat(ui): Play Data rows for better use of wide viewports 2026-03-23 18:56:50 +00:00
FoxxMD f04a19d34d feat(ui): Add flow and step state to timeline 2026-03-23 18:32:28 +00:00
FoxxMD 4b27671a91 feat: Add step and flow result to lifecycle 2026-03-23 18:32:12 +00:00
FoxxMD 5f4943e025 fix(ui): Fix keys 2026-03-20 20:47:38 +00:00
FoxxMD 66cdb94d57 feat(ui): Add lifecycle step story and fix diff rendering 2026-03-20 20:39:35 +00:00
FoxxMD 5ebb0e6a9c refactor: Separating functions so they are usuable by frontend storybook 2026-03-20 19:33:09 +00:00
FoxxMD dfb7f43c00 feat: Implement play with lifecycle fixture 2026-03-20 18:24:04 +00:00
FoxxMD ed85d35117 feat: Implement random object generator 2026-03-20 16:31:19 +00:00
FoxxMD 7d69e77ac6 Implement proper play conversion for frontend/backend testing
Convert to json-equivalent object, and back, without needing json stringify/parse
2026-03-20 15:22:53 +00:00
FoxxMD 03973d215b refactor: Fix JsonPlayObject to properly type dates 2026-03-20 14:00:17 +00:00
FoxxMD 3d5c2c7450 fix: Remove schema-plugin types hint from docusaurus config 2026-03-20 13:31:51 +00:00
FoxxMD d6264dd994 chore: ignore storybook folders for docker image 2026-03-20 13:31:33 +00:00
FoxxMD 4c18123207 refactor: Move some utils into core folder 2026-03-20 13:29:23 +00:00
FoxxMD 71e7f41ea5 more ui improvements 2026-03-20 01:02:25 +00:00
FoxxMD 60cf5d1019 feat(ui): Hide text-heavy elements for small mobile 2026-03-19 21:11:11 +00:00
FoxxMD 3977057dfa more tweaks 2026-03-19 20:36:48 +00:00
FoxxMD 943bdc3e88 feat(ui): Improve play data 2026-03-19 20:23:15 +00:00
FoxxMD 44710048f4 feat(ui): Integrate timeline into activity detail 2026-03-19 19:48:22 +00:00
FoxxMD 9f9f5655b1 feat(ui): Implement scrobble action 2026-03-19 18:38:39 +00:00
FoxxMD e621811364 feat(ui): Implement duplicate match check 2026-03-19 18:10:26 +00:00
FoxxMD 685bc846e8 feat(ui): Implement transform steps 2026-03-19 17:36:02 +00:00
FoxxMD 3a9d58fae9 feat(ui): Implement error stack and more things 2026-03-19 02:07:09 +00:00
FoxxMD 33639c6e4c more improvements 2026-03-18 20:59:53 +00:00
FoxxMD 7c235e3179 feat(ui): Add PlayInfo to list 2026-03-18 20:29:39 +00:00
FoxxMD 559d749207 feat(ui): More activity log improvements 2026-03-18 20:12:01 +00:00
FoxxMD 53feae0f8c more imrovements 2026-03-18 19:04:51 +00:00
FoxxMD 0941ccc0ac improved list layout 2026-03-18 18:33:16 +00:00
FoxxMD 82ecd96ba4 Containerized playinfo 2026-03-18 18:19:38 +00:00
FoxxMD e118dc5db1 Better args default for stories 2026-03-18 18:19:27 +00:00
FoxxMD 1a23491ac2 feat(ui): Set max height for code block with scrolling 2026-03-18 17:56:38 +00:00
FoxxMD d916f167a5 feat(ui): Implement code block toggle for play info 2026-03-18 17:35:43 +00:00
FoxxMD 0102249549 feat(ui): Implement PlayInfo component 2026-03-18 16:44:17 +00:00
FoxxMD 2ca96dab42 Init storybook with chakra-ui 2026-03-18 10:25:20 -04:00
FoxxMD 345e60b7a3 chore(release): Bump version for release 2026-03-16 13:33:06 +00:00
Matt Foxx b9fb6c9d35 Merge pull request #487 from FoxxMD/jsonView
feat(docs): Implement rich config exploration and interaction
2026-03-13 15:00:29 -04:00
FoxxMD 5d0ccd8867 docs: re-nable AIO config display as read-only on mobile 2026-03-13 14:52:29 -04:00
FoxxMD b36f484f3b chore: Add netlify-specific build command 2026-03-13 14:52:29 -04:00
FoxxMD d2f7f986c7 Add gitkeep for schema folder 2026-03-13 14:52:29 -04:00
FoxxMD 9137d03b9f use genericonfiguration component for remaining client/sources 2026-03-13 14:52:29 -04:00
FoxxMD f6c0d13a8b feat(docs): Implement config example/structure guidance 2026-03-13 14:52:17 -04:00
FoxxMD c472b4e33a refactor: reorganized and consolidate source/client types 2026-03-13 14:52:17 -04:00
FoxxMD f6c3fc6b50 refactor(docs): replace docusaurus json-schema-plugin
* Use cf-json-schema-viz for schema viewer
* Use modern-react-json-editor for editing
2026-03-13 14:50:39 -04:00
FoxxMD 8ba146495e feat(jellyin): Add external URL guidance
#486
2026-03-12 14:00:59 +00:00
Matt Foxx 68c46fefb2 Merge pull request #471 from FoxxMD/componentPagination
feat: Implement pagination fetching for component listen data
2026-03-12 08:46:36 -04:00
Matt Foxx eddda41559 Merge pull request #473 from owendaprile/push-ovmumqzwvmqx
fix(plex): Also read MBIDs from `guid` field
2026-03-11 14:08:36 -04:00
FoxxMD 5945fdc2ef docs(spotify): Add premium account guidance
#485
2026-03-11 17:59:27 +00:00
FoxxMD d110dcbdcf fix(sources): Add a confidence signal to plays generated from authoratiative sources to avoid fuzzy matching repeats
https://github.com/FoxxMD/multi-scrobbler/issues/430#issuecomment-4035670777
2026-03-11 13:54:24 +00:00
Owen D'Aprile ebc0aa8a4e fix(plex): Also read MBIDs from guid field
Some items on Plex store the MBID in the `guid` field in the `Metadata`
response, not in the `Guid` field. This change checks the `guid` field
as a fallback if the `Guid` field is not set.
2026-03-10 20:24:25 -04:00
FoxxMD d4113b464a feat(scrobbler): Improved dupe detection
* Check queue before adding so that previously cached or re-discovered plays from a source are not added multiple times to the queue (should only be unique plays)
* Add timestamp heuristic to dupe matching -- if score is *close* to matching and timestamp is *exact* then nudge it into a match
2026-03-10 20:37:14 +00:00
FoxxMD 753dfe1fb3 feat: Implement better request retry logic
* Use p-retry to standard retry behavior
* Simplify/remove other own-rolled retry logic
* Fix superagent request instantiation
  * Requests are mutable, cannot be retried once executed. Need to make a new request for each retry
2026-03-10 19:47:42 +00:00
FoxxMD 013cccaac5 add more logging for discord now playing update logic 2026-03-10 16:43:49 +00:00
FoxxMD 504f8ffbeb refactor(source): Reduce logging noise for activity checks
Move most activity checks to TRACE log level to reduce noise at DEBUG level
2026-03-10 16:06:55 +00:00
FoxxMD 81d1eb2553 feat(ui): Default logs to trace 2026-03-10 15:33:54 +00:00
FoxxMD c9327b0e92 fix(lastfm): Default to not including now playing in history response
Scrobbler getScrobblesForTimeRange function isn't aware of the context and we only want now playing for the Source impl anyway.

Fixes not scrobbling play because it is also now playing which mathced existing scrobble logic.

https://github.com/FoxxMD/multi-scrobbler/issues/430#issuecomment-4031615361
2026-03-10 15:00:13 +00:00
FoxxMD 36aa236d82 use TRACE for cache file loading chunk logging 2026-03-09 19:44:58 +00:00
FoxxMD 3c37aa4449 fix(lastfm): Fix not returning nowPlaying scrobble 2026-03-09 18:39:33 +00:00
FoxxMD f1bf3cbe64 fix(nowPlaying): Scrobbler should report now playing if player state is known nowPlayingMode source
#477#issuecomment-4022857648
2026-03-09 18:39:11 +00:00
FoxxMD 17a9356040 fix: Handle historical scrobble fetch failure
* Don't cache error
* Handle error with logging and put scrobble into dead queue (or increment retries on dead)
2026-03-09 16:47:11 +00:00
FoxxMD 97c799bf2c refactor: Re-implement retry logic for LZ with fixed request init 2026-03-09 16:46:10 +00:00
FoxxMD 99c02daec7 fix(koito): Assign cursor to page param 2026-03-09 16:45:30 +00:00
FoxxMD 4cb792641d fix(pagination): Assign cursor on initial request and properly catch errors to add request parameter logging 2026-03-09 16:45:13 +00:00
FoxxMD fbc2950854 fix upstream scrobble tests 2026-03-06 20:47:04 +00:00
FoxxMD 08e0bdb437 feat(scrobbler): re-implement scrobble preload
Use a limit/maxFetch upper limit to return a bulk of scrobbles by count, rather than timerange, so that backlogged scrobbles are less likely to cause many fetches on first startup
2026-03-06 20:28:22 +00:00
FoxxMD 73dcab4de1 fix(scrobbler): Fix ttl for timerange cahe and missing awaits 2026-03-06 19:42:15 +00:00
FoxxMD 00594a14ed feat: Implement timerange consolidation 2026-03-06 19:41:54 +00:00
FoxxMD 52d5bab0f5 Implement noop logger 2026-03-06 19:40:50 +00:00
FoxxMD a41f4c56f7 feat(scrobbler): Implement play-dependent, arbitrary timerange fetching for duplicate checks
* Remove design bias towards "recent" scrobbles with limited timerange and complex shouldRefresh logic
* Replace with logic to fetch scrobbles from timeranges that are relevant to queued/dead scrobbles
  * timeranges are grouped by closely timestamped queued scrobbles
  * timeranges are cached based on staleAfter
2026-03-06 16:57:09 +00:00
Matt Foxx 8ea83962d6 Merge pull request #482 from FoxxMD/namedTransformFix
fix(transform): Fixes for user transform and transform selection
2026-03-06 08:15:34 -05:00
FoxxMD 9d97cc6e4c fix(transform): Fix transform for stage not using name for selection #481 2026-03-05 15:49:51 +00:00
FoxxMD 023579de9a refactor(atproto): Replace naive TID impl with @ewanc26/tid 2026-03-04 21:00:35 +00:00
FoxxMD 214d99f1c3 fix(lastfm): Check for singular scrobble response 2026-03-04 21:00:35 +00:00
FoxxMD d6fc6b9b10 debug for lastfm list type 2026-03-04 21:00:35 +00:00
FoxxMD cbdab93f92 refactor(tealfm): Replace TID package to avoid node-gyp dependency 2026-03-04 21:00:35 +00:00
FoxxMD abf37f0687 refactor(spotify): use listen timerange fetcher 2026-03-04 21:00:35 +00:00
FoxxMD e993a43399 refactor: Implement generic cursor iteration 2026-03-04 21:00:35 +00:00
FoxxMD bc79969fd0 refactor(maloja): use listen timerange fetcher 2026-03-04 21:00:35 +00:00
FoxxMD 801f9ed403 refactor(koito): Use listen timerange fetcher 2026-03-04 21:00:34 +00:00
FoxxMD 3d9f6f8f58 refactor(lastfm): Use listen timerange fetcher 2026-03-04 21:00:34 +00:00
FoxxMD c2676fb620 refactor(listenbrainz): Use listen timerange fetcher 2026-03-04 21:00:34 +00:00
FoxxMD cdb4ab23e9 refactor(listenbrainz)!: Remove Koito support
Koito functionality has diverged enough that it doesn't make sense to provide partial support via LZ components.

This also makes timerange pagination easier.
2026-03-04 21:00:33 +00:00
FoxxMD b3217f2d9c mostly implemented time range fetch utility 2026-03-04 20:59:52 +00:00
FoxxMD 7b61385ad5 implement time range scrobbles for teal 2026-03-04 20:59:52 +00:00
FoxxMD b5c86e74ad more pagination implementation 2026-03-04 20:59:51 +00:00
FoxxMD a3031a7eb9 Somewhat working
Stole pagination from FoxxMD/multi-scrobbler#390
2026-03-04 20:59:32 +00:00
FoxxMD 7c49a0535f fix typings 2026-03-04 20:36:35 +00:00
FoxxMD 797e5b0794 feat(scrobbler): Add dupe match debug information to lifecycle 2026-03-04 20:30:03 +00:00
FoxxMD bc6cd78c86 refactor: Implement alreadyScrobbled in base class
None of the child classes use a different implementation. DRY
2026-03-04 18:40:27 +00:00
FoxxMD 98a5b40be5 refactor: Use trace logging for defaults
* All logger use trace as minimum level
* Force app log stream to trace minimum so we always show all possible logs in dashboard
* Add trace level to dashboard
* Make dashboard start in debug logs for readability
2026-03-04 18:22:41 +00:00
FoxxMD 87c174fe0f chore: exclude schema folder from search 2026-03-04 15:52:49 +00:00
Matt Foxx 0cbb45e4ad Merge pull request #478 from FoxxMD/discordHangingStatus
fix(discord): Add hidden activity type
2026-03-04 10:41:11 -05:00
FoxxMD b11226af94 fix(discord): Don't throw on unknown activity type
This isn't used for filtering anymore so its not necessary to validate to a known activity
2026-03-04 15:40:14 +00:00
Matt Foxx 27dc5b5e19 Merge pull request #479 from FoxxMD/userTransformDefaults
fix(transform): Fix missing defaults assignment for stage config parsing
2026-03-04 08:12:58 -05:00
FoxxMD d892b83110 fix(transform): Fix user transform default value 2026-03-03 22:00:42 +00:00
FoxxMD c28984524a fix(transform): Fix missing defaults assignment for stage config parsing 2026-03-03 21:04:40 +00:00
FoxxMD 5d4fb63baa fix(discord): Add hidden activity type
Fixes #477
2026-03-03 13:50:14 +00:00
FoxxMD ca2574d1e5 Add comment pointing to JF source on MB ids 2026-03-02 15:45:43 +00:00
FoxxMD 758038e278 fix: Missed dependency bump 2026-02-27 16:57:24 +00:00
FoxxMD cb803b1a24 feat: Use lower minimum log level to prepare for logging cleanup 2026-02-27 16:51:30 +00:00
FoxxMD c985af5722 docs: Add discord to readme 2026-02-27 14:19:41 +00:00
FoxxMD 1ede54acbb docs: Fix broken links and anchors 2026-02-27 14:18:50 +00:00
FoxxMD a73ca6a95d feat(ui): Add service status page link to dashboard 2026-02-27 02:45:12 +00:00
FoxxMD 25f4095276 chore(release): Bump version 2026-02-26 20:37:22 +00:00
FoxxMD d242c133dd docs(faq): Add connection/dns guidance 2026-02-26 19:39:51 +00:00
FoxxMD da3e8044b7 docs: Add status page to monitoring section 2026-02-26 19:23:58 +00:00
FoxxMD b6d5939fbf chore: Add kasm service as extenable compose file for dev 2026-02-26 19:12:21 +00:00
FoxxMD d23ef88647 docs(discord): Clearer wording on user token usage 2026-02-26 17:21:48 +00:00
FoxxMD f374f130de fix(discord): Kasm improvements
* Fix permissions in compose example
* Better wording in advantages/disadvantages
2026-02-26 17:21:30 +00:00
FoxxMD b0c2c8611b docs(discord): Use named volume for kasmcord 2026-02-26 02:16:43 +00:00
FoxxMD 6f6f56ce64 docs(discord): Reorganize discord app container instructions and improve art examples 2026-02-26 02:06:21 +00:00
FoxxMD 4b28ec4b3a fix(discord): wrong protocol comparison 2026-02-25 21:14:12 +00:00
FoxxMD d189084c15 feat(discord): Implement retry for cover art api 2026-02-25 20:56:19 +00:00
FoxxMD 975d9540bc docs(discord): Update kasmcord with persistent config volume 2026-02-25 20:18:41 +00:00
FoxxMD 212bfba950 docs(discord): Add discord container instructions 2026-02-25 19:19:31 +00:00
FoxxMD 619e32cd83 feat(discord): Add more logging for cover art acquistion 2026-02-25 19:17:09 +00:00
FoxxMD ce9ff53ae1 feat(discord): Add debug log when no cover art found 2026-02-25 19:07:39 +00:00
FoxxMD d13fb33884 docs(discord): Improved discord app docs 2026-02-25 16:31:55 +00:00
FoxxMD 253e91250f fix(discord): Patch IPC client to catch socket error 2026-02-25 02:54:18 +00:00
FoxxMD b23668372b feat(discord): Correct position timestamp based on player last update time #468 2026-02-24 20:07:31 +00:00
FoxxMD 5b847c136e fix(discord): Don't queue scrobbles 2026-02-24 19:03:25 +00:00
FoxxMD 0d304a793d fix(scrobblers): Don't queue now playing data if scrobbler is not ready 2026-02-24 18:46:02 +00:00
FoxxMD 906e1a1a19 fix(discord): Stop scrobbler and set as auth failure on invalid client from IPC 2026-02-24 18:45:43 +00:00
FoxxMD 87f46f1e02 Improve failure mode handling for IPC discord
* Set auth failure on 4000 from server (invalid client id)
* Better logging for connection issues
* Don't fail on connect so we can keep retrying on MS activity
2026-02-24 18:31:46 +00:00
FoxxMD 56b4be1fe1 QoL features for discord-rpc library
* Add keepAlive to so close events actually happen
* DEBUG log human readable payload rather than hex
2026-02-24 18:28:37 +00:00
FoxxMD a08dcf05f1 refactor(discord): Remove activity filtering
https://github.com/FoxxMD/multi-scrobbler/issues/468#issuecomment-3952116123
2026-02-24 15:13:28 +00:00
FoxxMD 460882f6c1 fix(discord): wrong eqaulity for token check 2026-02-24 13:31:41 +00:00
FoxxMD 71fc94ba63 Missing category 2026-02-23 22:32:24 +00:00
FoxxMD b587d834bc docs: Add discord local/headless docs 2026-02-23 22:31:33 +00:00
FoxxMD 9b63e022ed Don't require media proxy for IPC 2026-02-23 21:15:37 +00:00
FoxxMD 324cbd76ee refator(discord): Move client init to build stage with more logging 2026-02-23 21:03:58 +00:00
FoxxMD ceab13f672 refactor(discord): Consolidate shared functionality 2026-02-23 20:47:22 +00:00
FoxxMD 10e17ed239 Initial IPC implementation 2026-02-23 20:15:21 +00:00
FoxxMD 4f84479513 fix: Fix labels for MS heartbeat task 2026-02-23 16:42:18 +00:00
FoxxMD 216ad4ed32 fix(discord): Stabalize heartbeat interval and logging
* Refactor sequence and gateway receive logging as labels
* Use timeout instead of interval for heartbeat to try to fix weird speedup
2026-02-23 16:42:18 +00:00
FoxxMD 85e4555322 fix(discord): Even more reconnect handling improvements
* Fix reconnect logic and ready state clearing
* Clearer and less noisy logging
* Delay clearing activities for convience on quick reconnect
2026-02-20 16:59:03 +00:00
FoxxMD a90e443a88 faster heartbeat for safety 2026-02-19 17:25:24 +00:00
FoxxMD a9c54b5f91 refactor(discord): More connection logic improvements
* Handle connect and auth separately so it can be restarted by scrobble heartbeat
* make ms-initiated reconnect use manual close/open since isosockets won't
* better error handling for auth/connection init stages
* send heartbeats even if not authed
2026-02-19 17:23:38 +00:00
FoxxMD 6d21435aab fix(discord): Use iso-websockets reconnect and workaround initial connection issues 2026-02-19 16:32:59 +00:00
FoxxMD 9c3bb11a66 more discord ws logging 2026-02-19 14:28:16 +00:00
FoxxMD d983080584 fix(discord): Fix config logging for artwork 2026-02-18 19:40:46 +00:00
FoxxMD 852b96009c docs cleanup 2026-02-18 18:13:54 +00:00
FoxxMD c82617d857 docs(discord): Update artwork guidance for cover art archive 2026-02-18 17:19:08 +00:00
FoxxMD 6f19256187 feat(discord): Use release mbid to get coverart if fallback would otherwise be used 2026-02-18 16:16:46 +00:00
FoxxMD 479ede334b fix(transform): Use temporally-invariant hash comparison
Fixes not using transform hashes due to user listening data on discover or for playing now data
2026-02-18 16:16:05 +00:00
FoxxMD e1349255de feat(scrobblers): Run preCompare transform hook on playing now data 2026-02-18 16:15:10 +00:00
FoxxMD 4a600dc01d Make art its own type 2026-02-18 15:27:09 +00:00
FoxxMD 315256ce96 feat(musicbrainz): Implement CoverArtArchive API 2026-02-18 15:25:14 +00:00
Matt Foxx bbead20723 Merge pull request #472 from FoxxMD/discordPresence
feat: Discord rich presence
2026-02-17 16:35:30 -05:00
FoxxMD bb77c1c0bb feat(discord): Use artwork url if its from a known domain 2026-02-17 21:28:53 +00:00
FoxxMD 2a2fb9ad55 refactor(discord): Update links and art
* Use pocike icon for placeholder art
* Use MS logo for small art with link to docs
* Use web link as track url, if available
* Use MB link as track or artist url
2026-02-17 21:05:26 +00:00
FoxxMD ea5b66cdae fix(discord): Fix ajv not liking defaultUrl string | false 2026-02-17 20:16:50 +00:00
FoxxMD 1a978c7ee3 fix: Fix config validation error source 2026-02-17 20:13:11 +00:00
FoxxMD cfd458f463 docs(discord): Improve artwork url guidance and application id 2026-02-17 20:12:49 +00:00
FoxxMD 61b8277822 feat(discord): Add actionable links for public URLs and musicbrainz
Add links to album art and/or small image if application id enabled
2026-02-17 19:05:17 +00:00
FoxxMD 53c8036e34 feat(discord): Add more presence configuration options 2026-02-16 21:08:23 +00:00
FoxxMD f5c8ce5109 more now playing fixes and improvements 2026-02-16 21:08:06 +00:00
FoxxMD 17b11f19a3 feat(nowplaying): Update now playing based on play presence AND player status 2026-02-16 17:26:34 +00:00
FoxxMD 2993279d78 feat(discord): Add platform specific now playing should update check 2026-02-15 18:36:27 +00:00
FoxxMD b95b7e3781 fix(discord): Add missing applicationId env and docs 2026-02-14 00:12:16 +00:00
FoxxMD c57200132e use discord.js deps in production build 2026-02-13 23:04:38 +00:00
FoxxMD f6d9921581 type fixes 2026-02-13 22:08:46 +00:00
FoxxMD 3aada673d5 feat(discord): Add album art functionality 2026-02-13 22:00:40 +00:00
FoxxMD 1456070044 fix(discord): Better error handling and reconnect logic 2026-02-13 20:42:23 +00:00
FoxxMD 883797a51d docs(discord): Add discord docs 2026-02-13 19:47:18 +00:00
FoxxMD 1b93512b0a feat(discord): artwork fallback and ENV config 2026-02-13 19:47:05 +00:00
FoxxMD 7c99262a79 feat(discord): Initial implementation #468 2026-02-13 18:39:33 +00:00
FoxxMD d10ecb88bb chore(release): Bump version 2026-02-12 13:31:36 +00:00
FoxxMD bb0f8e1a9a fix(spotify): Destructure external_ids from default object #470 2026-02-12 13:18:49 +00:00
FoxxMD 8be1ace9a1 chore(release): Bump version 2026-02-11 15:26:20 +00:00
FoxxMD 80d6879d2a chore: Update git-cliff workflow
* Add as dev dependency with npm script so it can be run anywhere project is installed
* Update config with newer template based on scopesorted
  * Add more scopes and give them pretty emojis
2026-02-11 15:24:54 +00:00
Matt Foxx 0b8dfaa5b5 Merge pull request #466 from FoxxMD/deezerImprovements
feat: Deezer family account and other improvements
2026-02-11 08:17:55 -05:00
Matt Foxx 3e25be1e9d Merge pull request #467 from FoxxMD/jellyfinUnknownLax
feat(jellyfin): More granular media type filtering
2026-02-11 08:17:35 -05:00
FoxxMD 4e550d14af fix(jellyfin): Fix options being required
The old jellyfin config had optional options which kept the new jf options required from *always* being required. Removing the old config revealed this bug.

Fixes #469
2026-02-10 20:06:06 +00:00
FoxxMD eab33559ca fix: allow musicvideo media type and use allowedMediaTypes for NowPlayingItem check 2026-02-10 19:39:35 +00:00
FoxxMD 83076a0770 docs: Update screenshot 2026-02-05 17:21:47 +00:00
FoxxMD 850f9f081a fix(jellyfin): Allow all media types when allowUnknown is true
Fixes #445
2026-02-03 18:51:28 +00:00
FoxxMD 1110b6b047 docs(deezer): Use config tabs 2026-02-03 17:41:18 +00:00
FoxxMD 3c99adc01c feat(deezer): Add family account monitoring #344 2026-02-03 17:26:05 +00:00
FoxxMD 55cec9a16f fix(deezer): Fix error list access #344 2026-02-03 16:04:30 +00:00
270 changed files with 27027 additions and 2626 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
version: '3.7'
services:
# Update this to the name of the service you want to work with in your docker-compose.yml file
app:
multi-scrobbler:
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
+3 -1
View File
@@ -5,9 +5,11 @@
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"dockerComposeFile": [
"compose.yml"
// uncomment to add kasm/discord service
//"../deploy/docker-compose.discord.yml"
],
"service": "app",
"service": "multi-scrobbler",
"workspaceFolder": "/workspaces",
+4 -1
View File
@@ -27,4 +27,7 @@ build
.vscode
**/act/*
tmp-*
.env
.env
*.env
.storybook
.stories
+5
View File
@@ -73,6 +73,7 @@ web_modules/
# dotenv environment variables file
.env
.env.test
*.env
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -133,6 +134,7 @@ src/backend/common/schema/*.json
!src/backend/common/schema/aio.json
!src/backend/common/schema/client.json
!src/backend/common/schema/source.json
docsite/static/schemas/*.json
*.bak
@@ -146,3 +148,6 @@ build
**/generated-sources.*
.github/act/out
tmp-*
*storybook.log
storybook-static
+45
View File
@@ -0,0 +1,45 @@
import { defineMain } from "@storybook/react-vite/node";
import { mergeConfig } from "vite";
export default defineMain({
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
features: {
// https://github.com/storybookjs/storybook/discussions/33279
sidebarOnboardingChecklist: false,
},
"framework": "@storybook/react-vite",
viteFinal: async (config) => {
return mergeConfig(config, {
optimizeDeps: {
include: ["@storybook/addon-docs"],
},
plugins: [
{
name: "fix-mdx-react-shim",
enforce: "pre",
resolveId(source: any) {
if (
source.startsWith("file://") &&
source.includes("mdx-react-shim.js")
) {
// Convert file:///... path to normal filesystem path for Vite
return new URL(source).pathname;
}
return null;
},
},
],
});
},
});
+6
View File
@@ -0,0 +1,6 @@
import { addons } from 'storybook/manager-api';
import { themes } from 'storybook/theming';
addons.setConfig({
theme: themes.dark,
});
+28
View File
@@ -0,0 +1,28 @@
import addonDocs from "@storybook/addon-docs";
import addonA11y from "@storybook/addon-a11y";
import { definePreview } from "@storybook/react-vite";
import { themes } from 'storybook/theming';
export default definePreview({
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
},
docs: {
theme: themes.dark,
},
},
addons: [addonA11y(), addonDocs()]
});
+2 -1
View File
@@ -4,7 +4,8 @@
"mochaExplorer.exit": true,
"search.exclude": {
"**/.docusaurus": true,
"**/build": true
"**/build": true,
"**/schema": true
},
"tailwindCSS.files.exclude": [
"docsite/**"
+2 -1
View File
@@ -41,6 +41,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/yamaha-musiccast)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients)
* [Discord](https://foxxmd.github.io/multi-scrobbler/configuration/clients/discord) (Now Playing)
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/clients/koito)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/lastfm)
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/librefm)
@@ -60,7 +61,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler//quickstart)
<img src="/assets/status-ui.png" width="800">
<img src="/docsite/static/img/status-ui.png" width="800">
**Why should I use this over a browser extension and/or mobile app scrobbler?**
+1
View File
@@ -0,0 +1 @@
* Attribution for [`default-artwork.png`](/assets/default-artwork.png): https://www.flaticon.com/free-icon/music_15795471
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 KiB

+42 -29
View File
@@ -1,18 +1,20 @@
# configuration file for git-cliff (0.1.0)
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# changelog header
# A Tera template to be rendered as the changelog's header.
# See https://keats.github.io/tera/docs/#introduction
header = """
# Changelog
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
# A Tera template to be rendered for each release in the changelog.
# See https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
@@ -24,6 +26,7 @@ body = """
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{% raw %}\n{% endraw %}\
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
@@ -33,35 +36,45 @@ body = """
{% endif -%}
{% endif -%}
{% endfor -%}
{% endfor %}
{% raw %}\n{% endraw %}\
{% endfor %}\n
"""
# remove the leading and trailing whitespaces from the template
trim = true
# changelog footer
# A Tera template to be rendered as the changelog's footer.
# See https://keats.github.io/tera/docs/#introduction
footer = """
<!-- generated by git-cliff -->
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
[git]
# allow only conventional commits
# https://www.conventionalcommits.org
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = true
# regex for parsing and grouping commits
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = true
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
{ message = "^feat", group = "⛰️ Features" },
{ message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^doc", group = "📚 Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "🚜 Refactor" },
{ message = "^style", group = "💅 Styling" },
{ message = "^test", group = "🧪 Testing" },
{ message = "^ci", group = "🧑‍🍳 Continuous Integration" },
{ message = "^build", group = "🐋 Build" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "🛡️ Security" },
]
# filter out the commits that are not matched by commit parsers
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# Order releases topologically instead of chronologically.
topo_order = false
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "MS",
"enable": true,
"data": {
"applicationId": "8190211179716453570",
"ipcLocations": ["192.168.0.105:6655"]
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570"
}
}
]
+2 -1
View File
@@ -16,7 +16,8 @@
"librariesAllow": ["GoodMusic"],
"librariesBlock": ["BadMusic"],
"additionalAllowedLibraryTypes": ["musicvideos"],
"allowUnknown": false,
"allowMediaTypes": ["audio"],
"frontendUrlOverride": "https://myJellyfinExternal.tld"
},
"options": {
"logPayload": true,
+26
View File
@@ -0,0 +1,26 @@
services:
multi-scrobbler:
volumes:
- kasm_run:/run
kasmcord:
container_name: kasmcord
image: kasmweb/discord:1.14.0
ports:
# VNC port to use in web browser
- 6901:6901
shm_size: 512m
environment:
# set a password for VNC access
VNC_PW: ${KASM_PW:-hunter2}
XDG_RUNTIME_DIR: /run/user/1000
volumes:
- kasm_run:/run/user/1000
- kasm_config:/home/kasm-user/.config
user: "0"
entrypoint: sh -c "chmod 700 /run/user/1000 && chown -R kasm-user:kasm-user /run/user/1000 && chown -R kasm-user:kasm-user /home/kasm-user/.config && su kasm-user -c '/dockerstartup/kasm_default_profile.sh /dockerstartup/vnc_startup.sh /dockerstartup/kasm_startup.sh'"
volumes:
kasm_run:
name: kasm_run
kasm_config:
name: kasm_config
+17 -2
View File
@@ -60,6 +60,21 @@ If multi-scrobbler is not running on the same machine your browser is on then th
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
### I get errors with `ECONNRESET` `ETIMEDOUT` `ENOTFOUND` `EAI_AGAIN`
These are errors indicating a network issue is occuring between you and the Source/Client.
* `ECONNRESET` means the upstream service unexpectedly closed the connection
* `ETIMEDOUT` means the upstream service did not respond to a request MS
* `ENOTFOUND` means that MS could not find a DNS record for the domain of the upstream service
* `EAI_AGAIN` means that a DNS server did not respond to MS's request to lookup the record for a domain
`ECONNRESET` and timeout issues can occur when you have a poor connection or if the upstream service is overloaded or is generally not behaving well. This is a common occurrence for Listenbrainz/Musicbrainz related requests.
Please check the [Upstream Services Status Page](https://status.multi-scrobbler.app) to independently verify if the service is misbehaving. If the service is up and has good uptime then the issue may be in your network.
`ENOTFOUND` and `EAI_AGAIN` and generally caused by DNS caching (stale records) or misbehaving DNS servers. Though this can happen at the upstream service's end, it is *usually* an issue in your network. If you run a Pihole, AdGaurd, or other DNS solution try flushing the cache for the mishaving domain and check that your DNS application is working correctly.
### Youtube Music fails after some time
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
@@ -70,7 +85,7 @@ Error: Could not send the specified request to browse. Status code: 401
then YTM has invalidated your authentication.
First, ensure you are NOT using [YoutubeTV authentication.](/configuration/sources/youtube-music?ytmAuth=ytt#authentication) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
First, ensure you are NOT using [YoutubeTV authentication.](/configuration/sources/youtube-music?ytmAuth=ytt#ytm-auth) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
#### When using OAuth Client Authentication
@@ -80,7 +95,7 @@ Refresh your authentication by using the **(Re)authenticate** link from MS's web
The library MS uses relies on scraping the YTM site by using cookies from your actual browser to pretend it is a browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control. You can help limit the chance of your session being invalidated by [getting the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) and then immediately closing the browser afterwards.
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](/configuration/sources/youtube-music?ytmAuth=cookie#authentication) and then restart MS to potentially resolve the problem.
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](/configuration/sources/youtube-music?ytmAuth=cookie#ytm-auth) and then restart MS to potentially resolve the problem.
## Configuration Issues
@@ -12,6 +12,7 @@ A **Client** is an application that stores the historical information about what
| Name | Now Playing |
| :-------------------------------------------------- | :---------- |
| [Discord](/configuration/clients/discord) | ✅ |
| [Last.fm](/configuration/clients/lastfm) | ✅ |
| [Libre.fm](/configuration/clients/librefm) | ✅ |
| [Listenbrainz](/configuration/clients/listenbrainz) | ✅ |
@@ -22,6 +23,71 @@ A **Client** is an application that stores the historical information about what
## Features
### Duplicate Detection
Before a Play is scrobbled to a client MS checks existing scrobbles from the Client's API to see if the Play has already been scrobbled.
For each Play, MS fetches (cached) scrobbles from the Client in a time range inclusive of the Play's listening timestamp and then scores existing scrobbles against the Play based on:
* Similarity of Title, Artists, and Album
* Temporal closeness of the Play's timestamp to the existing scrobble's timestamp
* Whether MS detected the Play as a repeat (applicable to Sources that report realtime Player data)
Detailed scoring breakdowns against each existing scrobble are logged at the `TRACE` level.
<details>
<summary>Detailed Explanation</summary>
A **candidate** (to be scrobbled) Play is first transformed using the configured [`compare.candidate` Hook](/configuration/transforms/#hook), if any exists.
Next, MS checks (up to) the last 100 scrobbles *in-memory* scrobbles that it has made. These are *not* from the Client but the actual scrobbles MS made while it has been running. The data from these scrobbles is much richer than what is usually parsed from the Client which makes it easier to detect duplicates from.
If no in-memory scrobble matches then MS starts comparing the candidate against historical scrobbles fetched from an inclusive time range of the candidate's timestamp.
<h3>Matching Title/Artist/Album</h3>
For these string-based values MS uses an [token-order-invariant](https://github.com/FoxxMD/multi-scrobbler/blob/0dfa4c7aad6df98e13aee6d395827665c2414adb/src/backend/utils/StringUtils.ts#L299) method that scores string similarity based on a (token count) weighted average of two [similarity](https://foxxmd.github.io/string-sameness/#md:strategies) algorithms, [Levenshtien Distance](https://en.wikipedia.org/wiki/Levenshtein_distance) and [Dice's Coefficient](https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient). The weighted average ensures that very long strings (Track titles) are scored with a confidence proportional to their length.
<h3>Matching Timestamp</h3>
Timestamps are scored on how temporally close they are. There are four possible scores with decreasing value:
* **Exact** - Timestamps are within 1 second of each other
* **Close** - Timestamps are within `threshold` seconds of each other
* This is determined by the smallest update interval of Source
* Some Sources (subsonic) only update every 60 seconds so this is the smallest "close" value possible. Most are 10 seconds. Signified by `(Needed <10s)` in the breakdown example below.
* **Fuzzy** - One timestamp is within `threshold` seconds of the *end* of the other timestamp
* Sources can set the scrobble timestamp at different times. Some do it when the track is started listening to, some when it ends, some when the *player* stops.
* Where possible, MS knows and keeps track of when this timestamp *should* be, for each Source. If it's not possible then Fuzzy may be allowed.
* **None** - There is no correlation between timestamps
<h3>Scoring and Breakdows</h3>
A candidate Play must score >= 1 to be detected as a duplicate of an existing scrobble.
Each score and a breakdown of the scores for its individual components can be see at the `TRACE` logging level or in the [debug data](/help#copy-play-debug-data) for a scrobble. An example:
```
* Artist: 0.06 * 0.3 = 0.02
* Title: 0.04 * 0.4 = 0.02
* Time: (Exact) 1 * 0.5 = 0.50
* Existing: 19:13:33-04:00 - Candidate: 19:13:33-04:00
* Temporal Sameness: Exact
* Play Diff: 0s (Needed <10s)
* Range Comparison N/A
Score 0.54 => No Match
```
In each component equation, the first number is the similarity (or temporal closeness).
* 0 = no correlation
* 1 = exactly the same
The second number is the *weight* of that component in the final score.
</details>
### Dead Scrobbles
If multi-scrobbler is unable to submit a scrobble to a Client then it places the scrobble into a queue which is retried every 5 minutes for a number of times before it gives up.
@@ -0,0 +1,7 @@
{
"label": "Discord",
"link": {
"type": "doc",
"id": "configuration/clients/discord/discord"
}
}
@@ -0,0 +1,566 @@
---
title: Discord (App)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import TcpWarning from "@site/src/components/snippets/_discord-port-warn.mdx";
import JsonConfig from '!!raw-loader!@site/../config/discord-local.json.example';
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
This integration uses an **existing Discord application** to set your Rich Presence.
:::tip
Check the [main Discord docs page](/configuration/clients/discord) for a comparison between **App** and **Headless**.
:::
## Required Setup
### Discord Application
You must create a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) in order to use this integration. Creating an App is free and simple.
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
* Click on "New Application" and give it a name
* Copy the **Application Id** (Client Id) shown after creation
* Add this Id to your Multi-Scrobbler config
* Env Config => `DISCORD_APPLICATION_ID=12345678`
* File Config => `"applicationId": "12345678"`
### Discord Connection
This integration uses an existing, running Discord application to communicate with Discord and set Rich Presence. Multi-scrobbler must be able to communicate with the Discord application, either by being on the same machine or over a network.
<Tabs groupId="discordType" queryString>
<TabItem value="container" label="Containerized Discord">
[Kasm Workspaces](https://kasm.com) provides a self-hostable [Discord container image](https://hub.docker.com/r/kasmweb/discord). This image provides a desktop-like experience, through browser-based VNC, to use the official Discord application.
<img src={require('/img/discord_kasm.png').default} height="400"/>
This containerized approach can be used instead of configuring MS to use a [Native Discord](./?discordType=native#discord-connection) application on a specific machine.
**Advantages**
* Setup is much simpler
* Discord runs in the same stack as multi-scrobbler
* **Configuration is copy-paste.** Skip all of the [Discord Connection](#discord-connection) instructions.
* Get a [Headless](/configuration/clients/discord/discord-headless)-like experience without needing to break Discord TOS
**Disadvantages**
* Image only available for x86 arch
* Image and container are large (1GB+)
* Discord does not notify *other* clients of notifications in a channel if *any* client is viewing that channel.
* To avoid this, make sure your container discord instance is not viewing any server/channel/dm.
* Sometimes, Discord does not deliver "notifications you missed" to mobile because this depends on *all* clients being offline.
<h2>Setup</h2>
Setup is basically the same as using the instructions for [Discord on Linux](./?osType=linux&discordType=native#find-ipc-connection) with [MS on Same Host (Docker)](./?osType=linux&discordType=native#pass-ipc-to-multi-scrobbler).
<h3>1. Add Discord Service to Multi-Scrobbler Docker Compose Stack</h3>
<details>
<summary>Detailed Explanation</summary>
* Add the `kasmweb/discord` service to your compose stack
* Mount the directory containing Discord's unix socket to a named volume
* Configure MS to use Discord's unix socket
* Mount the unix socket volume into MS's container
* Add ENV `DISCORD_IPC_LOCATIONS` to point to the mounted volume
</details>
Use this example `docker-compose.yaml` to modify your existing multi-scrobbler `docker-compose.yaml`:
<details>
<summary>Example</summary>
```yaml title="docker-compose.yaml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler:latest
environment:
# ...
- DISCORD_APPLICATION_ID=12345678
# add below line
// highlight-start
- DISCORD_IPC_LOCATIONS=/run/discord-ipc-0
// highlight-end
volumes:
# ...
# add below line
// highlight-start
- kasm_run:/run
// highlight-end
# ...
# .. add discord service below
// highlight-start
kasmcord:
container_name: kasmcord
image: kasmweb/discord:1.14.0
ports:
# VNC port to use in web browser
- 6901:6901
shm_size: 512m
environment:
# set a password for VNC access
VNC_PW: hunter2
XDG_RUNTIME_DIR: /run/user/1000
volumes:
- kasm_run:/run/user/1000
- kasm_config:/home/kasm-user/.config
user: "0"
entrypoint: sh -c "chmod 700 /run/user/1000 && chown -R kasm-user:kasm-user /run/user/1000 && chown -R kasm-user:kasm-user /home/kasm-user/.config && su kasm-user -c '/dockerstartup/kasm_default_profile.sh /dockerstartup/vnc_startup.sh /dockerstartup/kasm_startup.sh'"
volumes:
kasm_run:
name: kasm_run
kasm_config:
name: kasm_config
// highlight-end
```
</details>
Then re-deploy your stack.
<h3>2. Login to Discord</h3>
Navigate in your browser to the IP/host Multi-Scrobbler/Discord are running on. Use the `port` set in the `kasmcord` service:
```
# basic auth login
# user: kasm_user
# pass is what was set in env VNC_PW
https://192.168.MY.HOST:6901
```
Login to Discord normally. After login is complete MS should connect automatically when a Now Playing event happens. If it does not or reports authentication failure then restart MS (but not Discord).
If MS works then you are done and can move on to [Optional Setup](#optional-setup).
</TabItem>
<TabItem value="native" label="Native Discord">
Use this method if you want to connect MS to Discord running on a physical desktop.
<DetailsAdmo type="tip" summary="Local MS Installation Requires No Config">
If MS was [installed locally](/installation#local-installation) (not Docker) on the same machine as the Discord application then MS should [automatically detect the correct method](#find-ipc-connection) and [use it](#pass-ipc-to-multi-scrobbler) **without** any configuration from you. This will work for **any OS**.
If MS cannot determine this automatically, follow the steps for your OS below.
</DetailsAdmo>
Follow the sections below to configure MS for your specific environment.
#### Find IPC Connection
Discord uses OS-specific methods to communicate with other applications on the same host, called [IPC](https://en.wikipedia.org/wiki/Inter-process_communication). You may need to determine some details about the IPC method in order for MS to communicate with it correctly.
<Tabs groupId="osType" queryString>
<TabItem value="linux" label="Discord on Linux">
On Linux, Discord uses a [Unix Socket](https://en.wikipedia.org/wiki/Unix_domain_socket) for IPC.
With Discord running, run this command:
```
$ sudo netstat -apeen | grep discord
unix 2 [ ACC ] STREAM LISTENING 11026149 1641787/Discord /home/foxx/.var/app/com.discordapp.Discord/cache/scoped_dirnnxNIn/SingletonSocket
unix 2 [ ACC ] STREAM LISTENING 11036791 1642030/exe /run/user/1000/discord-ipc-0
```
We see that the path we need is:
```
/run/user/1000/discord-ipc-0
```
</TabItem>
<TabItem value="macos" label="Discord on MacOS">
On MacOS, Discord uses a [Unix Socket](https://en.wikipedia.org/wiki/Unix_domain_socket) for IPC.
With Discord running, run this command:
```
$ sudo netstat -apeen | grep discord
unix 2 [ ACC ] STREAM LISTENING 11026149 1641787/Discord /home/foxx/.var/app/com.discordapp.Discord/cache/scoped_dirnnxNIn/SingletonSocket
unix 2 [ ACC ] STREAM LISTENING 11036791 1642030/exe /run/user/1000/discord-ipc-0
```
We see that the path we need is:
```
/run/user/1000/discord-ipc-0
```
</TabItem>
<TabItem value="windows" label="Discord on Windows">
Windows uses [**Named Pipes**](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes) for IPC. These aren't easily iteratable without external software, like [Pipetap](https://github.com/sensepost/pipetap).
The syntax for a named pipe is like this:
```
\\.\pipe\app_named_pipe_foo
```
and Discord's names follow this pattern:
```
\\.\pipe\discord-ipc-0
```
Where `0` increments for each different instance of Discord (official, canary, nightly, etc...).
The default Discord pipe name is: `discord-ipc-0`
</TabItem>
</Tabs>
#### Pass IPC to Multi-Scrobbler
After [finding your IPC connection path](#find-ipc-connection) you will need to make it accessible to Multi-Scrobbler.
<Tabs groupId="osType" queryString>
<TabItem value="linux" label="Discord on Linux">
<Tabs groupId="msConnection">
<TabItem value="remote" label="MS on Different Host">
<TcpWarning/>
Use [`socat`](https://linux.die.net/man/1/socat) to bidirectionally relay communication from Discord's [unix socket](#find-ipc-connection) to a listening TCP port on the same host. Likely, you will want to setup a service to start this command on login.
```shell
$ socat -v TCP-LISTEN:6655,reuseaddr,fork UNIX-CONNECT:/run/user/1000/discord-ipc-0
```
In your MS docker compose file add the `host:port` of the host running Discord/`socat` to `DISCORD_IPC_LOCATIONS` `environment:`
```yaml title="docker-compose.yaml"
environment:
# ...
DISCORD_IPC_LOCATIONS=192.168.DISCORD_HOST.IP:6655
```
</TabItem>
<TabItem value="local-docker" label="MS on Same Host (Docker)">
In your MS docker compose file, pass the path of Discord's [unix socket](#find-ipc-connection) as a volume and add this path to `DISCORD_IPC_LOCATIONS` in `environment:`
```yaml title="docker-compose.yaml"
environment:
# ...
DISCORD_IPC_LOCATIONS=/run/discord-ipc-0
volumes:
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
```
</TabItem>
<TabItem value="local" label="MS on Same Host (Non-Docker)">
No configuration is required. If MS does not automatically work for you then try the **MS on Same Host (Docker)** method using the `DISCORD_IPC_LOCATIONS` environment variable.
</TabItem>
</Tabs>
</TabItem>
<TabItem value="macos" label="Discord on MacOS">
<Tabs groupId="msConnection">
<TabItem value="remote" label="MS on Different Host">
<TcpWarning/>
Use one of the methods below to setup `socat` on your macOS machine.
<details>
<summary>`socat` installed natively</summary>
Install [`socat`](https://formulae.brew.sh/formula/socat) using homebrew to use it as a native command on your host.
Then, use [`socat`](https://linux.die.net/man/1/socat) to bidirectionally relay communication from Discord's [unix socket](#find-ipc-connection) to a listening TCP port on the same host. Likely, you will want to setup a service to start this command on login.
```shell
$ socat -v TCP-LISTEN:6655,reuseaddr,fork UNIX-CONNECT:/run/user/1000/discord-ipc-0
```
</details>
<details>
<summary>`socat` in Docker</summary>
Use the [`socat` container](https://hub.docker.com/r/alpine/socat) in combination with the Discord [unix socket](#find-ipc-connection) found earlier:
```yaml title="docker-compose.yaml"
services:
socat:
image: alpine/socat
ports:
- "6655:6655"
volumes:
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
command:
- '-v'
- 'TCP-LISTEN:6655,reuseaddr,fork'
- 'UNIX-CONNECT:/run/discord-ipc-0'
```
</details>
In your MS docker compose file add the `host:port` of the host running Discord/`socat` to `DISCORD_IPC_LOCATIONS` `environment:`
```yaml title="docker-compose.yaml"
environment:
# ...
DISCORD_IPC_LOCATIONS=192.168.DISCORD_HOST.IP:6655
```
</TabItem>
<TabItem value="local-docker" label="MS on Same Host (Docker)">
In your MS docker compose file, pass the path of Discord's [unix socket](#find-ipc-connection) as a volume and add this path to `DISCORD_IPC_LOCATIONS` in `environment:`
```yaml title="docker-compose.yaml"
environment:
# ...
DISCORD_IPC_LOCATIONS=/discord-ipc-0
volumes:
- /run/user/1000/discord-ipc-0:/run/discord-ipc-0
```
</TabItem>
<TabItem value="local" label="MS on Same Host (Non-Docker)">
No configuration is required. If MS does not automatically work for you then try the **MS on Same Host (Docker)** method using the `DISCORD_IPC_LOCATIONS` environment variable.
</TabItem>
</Tabs>
</TabItem>
<TabItem value="windows" label="Discord on Windows">
<Tabs groupId="msConnection">
<TabItem value="remote" label="MS in Docker">
:::note
While it is possible to use [named pipes directly in Docker](https://forums.docker.com/t/volume-mount-path-error/143626/3) this has not been tested and [may only work for windows containers](https://github.com/docker/for-win/issues/6813#issuecomment-632859857). If you are adventurous, try this out and if you can get it working please [report your findings in an issue.](https://github.com/FoxxMD/multi-scrobbler/issues)
:::
:::warning
This method is **experimental** and may be buggy and prone to crashes. If you are a Windows or Rust developer please consider helping improve the [pipe proxy implementation](https://github.com/FoxxMD/named-pipe-to-tcp-proxy).
:::
<TcpWarning/>
Use [**pipeProxy**](https://github.com/FoxxMD/named-pipe-to-tcp-proxy) to connect to the Discord named pipe and expose it on a TCP port.
Download the latest [`pipeProxy.exe`](https://github.com/FoxxMD/named-pipe-to-tcp-proxy/releases) from the releases page.
The host/ip should be the IP of the machine running discord.
Run pipeProxy from the windows command line:
```
pipeProxy.exe --pipe "\\.\pipe\discord-ipc-0" --tcp "192.168.0.100:6655"
```
In your MS docker compose file add the `host:port` of the host running `pipeProxy` to `DISCORD_IPC_LOCATIONS` `environment:`
```yaml title="docker-compose.yaml"
environment:
# ...
DISCORD_IPC_LOCATIONS=192.168.0.100:6655
```
</TabItem>
<TabItem value="local" label="MS on Same Host (Non-Docker)">
No configuration is required. If MS does not automatically work for you then try the **MS in Docker** method using the `DISCORD_IPC_LOCATIONS` environment variable.
</TabItem>
</Tabs>
</TabItem>
</Tabs>
</TabItem>
</Tabs>
## Optional Setup
:::note
Due to the limitations of the Discord local API the other listening activity detection and online status customizations found in [Headless](/configuration/clients/discord/discord-headless#optional-setup) are not available for the Local integration.
:::
### Artwork
Multi-scrobbler can display Album Art in your Discord status. The image that is used differs based on if the image URL is publically accessible.
The order in which multi-scrobbler determines what image to use is the same as the order of the tabs shown below.
#### Artwork URL Types
<Tabs groupId="artworkUrl" queryString>
<TabItem label="External URLs" value="external">
MS can use external URLs for artwork.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host images, it only links to them and the URL is visible to other users.
<details>
<summary>Why Should I Care?</summary>
There are some scenarios where you may not want your artwork URLs to be visible, for example:
* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`)
* A Source may be internet-facing but you do not want to expose this information to discord/other users
* A Source may be internet-facing but requires authentication to view the image
</details>
MS will use your artwork URLs only if:
* artwork url is from a known music service/[Cover Art Archive](./?artworkUrl=caa#artwork-url-types) or
* artwork url starts with `https` and
* `artwork` (`DISCORD_ARTWORK`) is
* `true` => always uses the url
* a list of custom domains keywords an external artwork url should contain EX
* ENV => `DISCORD_ARTWORK=mycdn,jellyfin`
* File Config => `"artwork": ["mycdn","jellyfin"]`
<details>
<summary>Examples</summary>
None of the below URLs will **ever** be used because they are not `https`
```
http://foobar.com/cool.jpg <-- NOT allowed, not http
http://192.168.0.112/fun.png <-- NOT allowed, not http
```
When `DISCORD_ARTWORK` is not set:
```
https://archive.org/download/dir/myimage.jpg <-- allowed, is https and known service
https://spotify.com/assets/cover.png?size=1000 <-- allowed, is https and known service
https://example.com/cool.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
https://anything.com/neat.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
```
Setting `DISCORD_ARTWORK=true`
```
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
http://foobar.com/cool.jpg <-- NOT allowed, not http
https://example.com/cool.jpg <-- allowed, is https
https://anything.com/neat.jpg <-- allowed, is https
```
Setting `DISCORD_ARTWORK=mycdn,jellyfin`
```
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
http://foobar.com/cool.jpg <-- NOT allowed, not http
https://example.com/cool.jpg <-- NOT allowed, is https but does not contain "mycdn" or "jellyfin"
https://jellyfin.mydomain.com/neat.jpg <-- allowed, is https and contains "jellyfin"
```
</details>
</TabItem>
<TabItem label="Musicbrainz/Cover Art Achive" value="caa">
MS can try to use [Cover Art Archive](http://coverartarchive.org/) to get album art. The URL it retrieves is public which means it acts like an External URL that is always available for you.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
MS will try to use Cover Art Archive if:
* The scrobble data does not contain an album art url (no art in dashboard) or the existing url has failed an [External URL](./?artworkUrl=external#artwork-url-types) condition
* and **the scrobble data contains a [Musicbrainz](/configuration/transforms/musicbrainz) Release (Album) MBID**
Basically, if MS would normally use the [MS Default](./?artworkUrl=ms-default#artwork-url-types) art, it will first try to use Cover Art Archive if the scrobble has a Release MBID.
Some Source, like [Jellyfin](/configuration/sources/jellyfin) and [Plex](/configuration/sources/plex), can automatically provide this ID if the music is "matched" in your library.
For all others, you can [**configure MS to use the Musicbrainz Stage**](/configuration/transforms/musicbrainz#use-metadata-for-discord-album-art) for Discord to try to get this MBID.
</TabItem>
<TabItem label="MS Default" value="ms-default">
When these conditions are true:
* Cannot use an [External URL](./?artworkUrl=external#artwork-url-types) and
* Cannot get album art from [Cover Art Archive](./?artworkUrl=caa#artwork-url-types) and
* Play does not contain artwork information (no artwork shown in dashboard)
The status artwork will use a default URL instead of your artwork URL.
Without any other configuration, the default URL is an icon from the [multi-scrobbler repository](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/default-artwork.png):
<img src={require('/img/discord_presence_ms_default.png').default} height="127"/>
This default image can be customized:
* ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png`
* File Config => `"artworkDefaultUrl": "https://cooldomain.com/art.png"`
Remember, this URL need to be accessible on the public internet.
</TabItem>
</Tabs>
## Configuration
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
| Environmental Variable | Required? | Default | Description |
| :---------------------------- | --------- | ------- | :--------------------------------------------------------------------------------------------------------------- |
| `DISCORD_APPLICATION_ID` | Yes | | Application ID |
| `DISCORD_IPC_LOCATIONS` | No | | A commera-separated list of `host:port` network locations or file paths to unix socket for Discord |
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
</Config>
@@ -0,0 +1,334 @@
---
title: Discord (Headless)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import JsonConfig from '!!raw-loader!@site/../config/discord.json.example';
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
This integration is **headless**, it does not depend on any specific Discord application to be running.
:::tip
Check the [main Discord docs page](/configuration/clients/discord) for a comparison between **App** and **Headless**.
:::
<DetailsAdmo type="note" summary="How MS Uses Discord API">
Multi-scrobbler does its upmost to implement, to-spec, [Gateway API communication](https://docs.discord.com/developers/events/gateway) and respect usage. It requires no [permissions](https://docs.discord.com/developers/events/gateway#gateway-intents) and reads no data other than monitoring what other user sessions are active.
Aside from the on-paper violation of using a user token programmatically (on your behalf), there is no misuse of the API to achieve rich presence.
Additionally, MS uses the state of non-MS sessions to conservatively update presence. [By default it will not conflict](#configure-when-presence-is-used) with any other official presence activities and is only used when you are actually active on a device.
</DetailsAdmo>
<DetailsAdmo type="note" summary="REQUIRED Interaction after Starting Multi-Scrobbler">
Every time multi-scrobbler is started a discord session update needs to be triggered so that [MS can capture the signals it uses to determine if presence can be updated.](#configure-when-presence-is-used) This only needs to be *once*, after starting multi-scrobbler. But it does need to be *every time* multi-scrobbler is (re)started. Without these signals MS will not update presence.
This session update can happen automatically after *some* time so you may not need to do anything. If you want to force the update then do one of the following:
* Open discord on a device it was not recently active on
* Exit discord on an active device
* Change your status (online, idle, etc...)
* Set a custom status
* Cause a presence update with a different app (listening to..., playing..., etc.)
</DetailsAdmo>
## Required Setup
### User Token
You must provide a **Discord User Token** for your account in order to authenticate with Discord.
<DetailsAdmo type="warning" summary="User Token usage violates Discord TOS">
This functionality requires you to use your own **Discord User Token** in a way that is **against Discord's policies/terms of service.** If you use this scrobbler you do so **at your risk**.
There is no precedent for Discord punishing users who use their own token for non-abuse/spam reasons but there is also no guarantee this lack of enforcement will continue.
Refer to the **How MS Uses Discord API** section above to gauge whether this risk is acceptable to you.
</DetailsAdmo>
<DetailsAdmo type="warning" summary="User Token is a Sensitive Credential">
Treat your User Token like a login credential. Anyone who has your token can access Discord as you.
When finding instructions for obtaining this token only run commands, or follow steps, that you can understand and trust. Do not run commands you do not understand. Never give your token to someone else.
</DetailsAdmo>
No instructions will be provided for obtaining a User Token because it is against Discord's policies to use it. Instead, you can search online for "how to obtain discord user token".
## Optional Setup
### Configure When Presence Is Used
MS uses several signals from your "real" Discord sessions to determine if it should update presence. The default configuration is conservative to ensure that MS does not conflict with other apps using presence. Additionally, it only updates if a "real" user device is active on discord.
:::tip[TLDR]
Without any additional configuration, MS will only update presence if:
* you have discord open on a real device
* your status is either: online, idle, or dnd (not invisible)
* no other apps are broadcasting listening activities (listening to spotify, etc...)
:::
#### Online Status
Configure if MS is allowed to update presence based on your online status. If this setting is not defined MS will only update if your status is **online**, **idle**, or **dnd**.
Allowed Values: `online` `idle` `dnd` `invisible`
<details>
<summary>Example</summary>
Only allow presence updating when all of your "real" sessions are either online or idle.
In File/AIO:
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"statusOverrideAllow": ["online", "idle"]
}
}
]
```
In ENV:
```
DISCORD_STATUS_OVERRIDE_ALLOW=online,idle
```
</details>
#### Other Listening Activities
Without any additional configuration, MS will not set Rich Presence if **other** apps are broadcasting **listening** activities EX "Listening to Spotify".
<details>
<summary>What about other activity types?</summary>
Discord uses an undocumented activity type priority to determine what activity is shown "first" for your profile. The Listening activity type has the *lowest priority*.
This means that if you start any other type of activity (streaming, watching, competing, etc...) it will take precedence over MS's listening activity.
Your listening activity will still be visible by clicking into your full profile.
</details>
Configure the names of **other** listening activities that MS **is allowed** to broadcast at the same time as.
<details>
<summary>Example</summary>
You want to allow MS to update presence even if Spotify is also broadcasting a listening activity.
In File/AIO:
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"listeningActivityAllow": ["spotify"]
}
}
]
```
In ENV:
```
DISCORD_LISTENING_ACTIVITY_ALLOW=spotify
```
</details>
### Discord Application
Creating a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) enables you to use custom album cover art and secondary status images for more links. Creating an App is free and simple, it is recommended you do this to get the most functionality from Rich Presence.
<details>
<summary>Create An Application</summary>
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
* Click on "New Application" and give it a name
* Copy the **Application Id** (Client Id) shown after creation
* Add this Id to your Multi-Scrobbler config
* Env Config => `DISCORD_APPLICATION_ID=12345678`
* File Config => `"applicationId": "12345678"`
</details>
### Artwork
Multi-scrobbler can display Album Art in your Discord status. The image that is used differs based on if the image URL is publically accessible.
:::note
If no [**Application Id**](#discord-application) is configured the status art will **always** be the [Discord Default](./?artworkUrl=discord-default#artwork-url-types).
:::
The order in which multi-scrobbler determines what image to use is the same as the order of the tabs shown below.
#### Artwork URL Types
<Tabs groupId="artworkUrl" queryString>
<TabItem label="External URLs" value="external">
When [**Application Id**](#discord-application) **is** configured MS can use external URLs for artwork.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host images, it only links to them and the URL is visible to other users.
<details>
<summary>Why Should I Care?</summary>
There are some scenarios where you may not want your artwork URLs to be visible, for example:
* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`)
* A Source may be internet-facing but you do not want to expose this information to discord/other users
* A Source may be internet-facing but requires authentication to view the image
</details>
MS will use your artwork URLs only if:
* artwork url is from a known music service/[Cover Art Archive](./?artworkUrl=caa#artwork-url-types) or
* artwork url starts with `https` and
* `artwork` (`DISCORD_ARTWORK`) is
* `true` => always uses the url
* a list of custom domains keywords an external artwork url should contain EX
* ENV => `DISCORD_ARTWORK=mycdn,jellyfin`
* File Config => `"artwork": ["mycdn","jellyfin"]`
<details>
<summary>Examples</summary>
None of the below URLs will **ever** be used because they are not `https`
```
http://foobar.com/cool.jpg <-- NOT allowed, not http
http://192.168.0.112/fun.png <-- NOT allowed, not http
```
When `DISCORD_ARTWORK` is not set:
```
https://archive.org/download/dir/myimage.jpg <-- allowed, is https and known service
https://spotify.com/assets/cover.png?size=1000 <-- allowed, is https and known service
https://example.com/cool.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
https://anything.com/neat.jpg <-- NOT allowed, is https but not a known service and DISCORD_ARTWORK is not set
```
Setting `DISCORD_ARTWORK=true`
```
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
http://foobar.com/cool.jpg <-- NOT allowed, not http
https://example.com/cool.jpg <-- allowed, is https
https://anything.com/neat.jpg <-- allowed, is https
```
Setting `DISCORD_ARTWORK=mycdn,jellyfin`
```
https://archive.org/my/dir/myimage.jpg <-- allowed, is https and known service
http://foobar.com/cool.jpg <-- NOT allowed, not http
https://example.com/cool.jpg <-- NOT allowed, is https but does not contain "mycdn" or "jellyfin"
https://jellyfin.mydomain.com/neat.jpg <-- allowed, is https and contains "jellyfin"
```
</details>
</TabItem>
<TabItem label="Musicbrainz/Cover Art Achive" value="caa">
When [**Application Id**](#discord-application) **is** configured MS can try to use [Cover Art Archive](http://coverartarchive.org/) to get album art. The URL it retrieves is public which means it acts like an External URL that is always available for you.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
MS will try to use Cover Art Archive if:
* The scrobble data does not contain an album art url (no art in dashboard) or the existing url has failed an [External URL](./?artworkUrl=external#artwork-url-types) condition
* and **the scrobble data contains a [Musicbrainz](/configuration/transforms/musicbrainz) Release (Album) MBID**
Basically, if MS would normally use the [MS Default](./?artworkUrl=ms-default#artwork-url-types) art, it will first try to use Cover Art Archive if the scrobble has a Release MBID.
Some Source, like [Jellyfin](/configuration/sources/jellyfin) and [Plex](/configuration/sources/plex), can automatically provide this ID if the music is "matched" in your library.
For all others, you can [**configure MS to use the Musicbrainz Stage**](/configuration/transforms/musicbrainz#use-metadata-for-discord-album-art) for Discord to try to get this MBID.
</TabItem>
<TabItem label="MS Default" value="ms-default">
When [**Application Id**](#discord-application) **is** configured and these conditions are true:
* Cannot use an [External URL](./?artworkUrl=external#artwork-url-types) and
* Cannot get album art from [Cover Art Archive](./?artworkUrl=caa#artwork-url-types) and
* Play does not contain artwork information (no artwork shown in dashboard)
The status artwork will use a default URL instead of your artwork URL.
Without any other configuration, the default URL is an icon from the [multi-scrobbler repository](https://github.com/FoxxMD/multi-scrobbler/blob/master/assets/default-artwork.png):
<img src={require('/img/discord_presence_ms_default.png').default} height="127"/>
This default image can be customized:
* ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png`
* File Config => `"artworkDefaultUrl": "https://cooldomain.com/art.png"`
Remember, this URL need to be accessible on the public internet.
</TabItem>
<TabItem label="Discord Default" value="discord-default">
When [**Application Id**](#discord-application) is **not** configured Discord will show its own image. No custom artwork can be used in this configuration.
This is also the image Discord will display if the artwork URL cannot be fetched.
<img src={require('/img/discord_presence_discord_default.png').default} height="127"/>
</TabItem>
</Tabs>
## Configuration
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
| Environmental Variable | Required? | Default | Description |
| :--------------------------------- | --------- | ----------------- | :---------------------------------------------------------------------------------------------------------------- |
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
| `DISCORD_LISTENING_ACTIVITY_ALLOW` | No | | A commera-seperated list of listening activity names MS **is allowed** to broadcast presence at the same time as. |
</Config>
@@ -0,0 +1,37 @@
---
sidebar_position: 2
title: Discord
toc_max_heading_level: 4
---
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
There are two types of Discord integrations to choose from:
## App
The **App** integration uses an **existing** Discord application on a desktop computer to set Rich Presence.
* Uses "permitted" access to Discord, no TOS issues
* More restricted customization compared to Headless
* Only active when your existing Discord application is open
* For most scenarios it requires additional, OS-specific steps to setup correctly
* May require an additional program on your machine to relay the Discord connection
* This is potentially insecure, or less secure, then Headless
* **Can use [discord in a container](/configuration/clients/discord/discord-app#discord-connection) as an easy alternative**
* This is a Headless-like experience
[**App Docs**](/configuration/clients/discord/discord-app)
## Headless
The **Headless** integration uses Discord cloud Gateway/API, directly, to set Rich Presence.
* Does not require an existing Discord application (uses Discord cloud API)
* Using this **Violates Discord TOS** because it requires a user token
* More customization than App integration
* Active if **any** Discord application is online
[**Headless Docs**](/configuration/clients/discord/discord-headless)
@@ -20,7 +20,7 @@ You will need a [Last.fm](https://www.last.fm) account to use the Last.fm API.
After creating your account, [register for an API account here.](https://www.last.fm/api/account/create)
The callback URL in the API account form is not used, but to keep it consistent use the same value you set using the [Callback/Redirect URL](#callback-redirect-url) section.
The callback URL in the API account form is not used, but to keep it consistent use the same value you set using the [Callback/Redirect URL](#callbackredirect-url) section.
**Take note of your API Key and Secret after account creation.**
@@ -22,7 +22,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
## Configuration
<Config config="ListenbrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
<Config config="ListenBrainzClientConfig" fileContent={JsonConfig} client name="listenbrainz">
| Environmental Variable | Required? | Default | Description |
| ---------------------- | --------- | ----------------------------- | ------------------------------- |
| `LZ_TOKEN` | Yes | | User token from your LZ profile |
@@ -64,7 +64,7 @@ The handle used with multi-scrobbler should be your **full** ATProto handle, inc
If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
<Config config="RockskyClientConfig" fileContent={JsonConfig} client name="rocksky">
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
| `ROCKSKY_HANDLE` | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
+16 -4
View File
@@ -168,7 +168,7 @@ Each entry for a Source/Client includes a **Configuration** section that describ
## Secrets Interpolation
When using [File](./?configType=file#configuration-types) or [AIO](./?configType=aio#configuration-types) Configuration, Multi-Scrobbler can interpolate Environmental Variables into your json files. This can be used, for example, to keep sensitive data (like [Last.fm Client/Secret](/configurations/clients/lastfm#configuration)) out of your configuration files so that they can be committed to git.
When using [File](./?configType=file#configuration-types) or [AIO](./?configType=aio#configuration-types) Configuration, Multi-Scrobbler can interpolate Environmental Variables into your json files. This can be used, for example, to keep sensitive data (like [Last.fm Client/Secret](/configuration/clients/lastfm#configuration)) out of your configuration files so that they can be committed to git.
Multi-scrobbler will look for patterns in text fields within _all_ of your json files:
@@ -179,7 +179,7 @@ Multi-scrobbler will look for patterns in text fields within _all_ of your json
<summary>Example</summary>
Given this [Last.fm](/configurations/clients/lastfm#configuration) [File](./?configType=file#configuration-types) config:
Given this [Last.fm](/configuration/clients/lastfm#configuration) [File](./?configType=file#configuration-types) config:
```json title="lastfm.json"
[
@@ -555,7 +555,7 @@ If you do not need the dashboard and/or ingress sources, or have security concer
:::warning
Any **[ingress-based sources](/configuration/sources#ingress) will be unusable** (Webscrobbler, etc...) if this is disabled.
Any **[ingress-based sources](/configuration/sources?sourceComm=ingress#by-communication-method) will be unusable** (Webscrobbler, etc...) if this is disabled.
:::
@@ -566,7 +566,19 @@ Disable using either:
## Monitoring
Multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
### Upstream Services Status
A status page for monitoring the reachability of public services used by some Sources/Clients is available at
**https://status.multi-scrobbler.app**
This monitor runs on a VPS and checks the uptime of *actual* APIs for these services, not just landing pages. On a ~minute interval it checks:
* Listenbrainz API
* Musicbrainz API
* CoverArtArchive API
* Last.fm API
* A few MS-hosted support services
### Webhook Configurations
+2 -2
View File
@@ -11,13 +11,13 @@ Scenario:
* Each person has their own [Maloja](/configuration/clients/maloja) server
* Each person has their own Spotify account
* You have your own Airsonic ([subsonic](/configuration/sources/subsonic)) server you to scrobble from
* You have your own [Youtube Music](/configuration/soures/youtube-mysic) account you want to scrobble from
* You have your own [Youtube Music](/configuration/sources/youtube-music) account you want to scrobble from
* Mary has her own [Last.fm](/configuration/clients/lastfm) account she also wants to scrobble to
* Fred has his own [Spotify](/configuration/sources/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](/configuration/sources/plex) called `party` that you only play when you are hanging out. You want plays from that library to be scrobbled to everyone's servers.
* Fred also has his own [Jellyfin server](/configurion/sources/jellyfin) and wants to scrobble everything he plays
* Fred also has his own [Jellyfin server](/configuration/sources/jellyfin) and wants to scrobble everything he plays
* You have an android music app that can scrobble to a [custom listenbrainz server](/configuration/sources/listenbrainz-endpoint)
### All-in-one Config
@@ -49,28 +49,11 @@ If neither is configured automatic scrobble defaults to **off** and you should e
## Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
<Config config="AzuracastSourceConfig" fileContent={AzuracastConfig} name="azuracast">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
| `AZ_STATION` | Yes | | The station name shown on the public page |
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
</TabItem>
<TabItem value="file" label="File">
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<AIOExample data={AzuracastConfig} name="azuracast"/>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
</Tabs>
</Config>
+70 -22
View File
@@ -44,28 +44,6 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat
</TabItem>
</Tabs>
## Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ------------------------------------------------ |
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
</TabItem>
<TabItem value="file" label="File">
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<AIOExample data={DeezerConfig} name="deezer"/>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
</Tabs>
### Duplicate detection
Third party integrations with Deezer (Sonos) may cause Deezer to [report the same track many times in listening history.](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2922374738) If you experience this issue modify your Deezer Source config (file-based only) to include the option `"fuzzyDiscoveryIgnore": "aggressive"`
@@ -91,6 +69,76 @@ import DeezerDeprecatedConfig from '!!raw-loader!@site/../config/deezer-deprecat
This option comes with some trade-offs: MS will aggressively detect repeated tracks within a window of time that should eliminate all duplicates. However, this will also prevent *intentionally* repeated tracks from being scrobbled. See [this thread](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2970417070) for more information on how this works.
### Family account
Multi-scrobbler can monitor listening history for accounts linked to a [Deezer Family Account](https://www.deezer.com/en/offers/family).
<details>
<summary>Instructions</summary>
Start multi-scrobbler using the [ARL](#retrieve-arl) for the **main account**.
In the logs look for **Linked Accounts** associated with the Deezer Source. It will look something like this:
```
VERBOSE: [App] [Sources] [Deezer - MyDeezer] Linked Accounts:
Name: Joe | ID: 166276334 | Private?: Yes
Name: Mary | ID: 48475231 | Private?: Yes
Name: FoxxMD | ID: 896225281 | Private?: No
Name: Cool Guy | ID: 128522478 | Private?: Yes
```
The Account **ID** you want to monitor should be set:
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
Set in your docker compose `environment` section:
```yaml
- DEEZER_ACCOUNT_ID=896225281
```
</TabItem>
<TabItem value="file" label="File or AIO">
Add an `accountId` property to the `data` section
```json
[
{
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"arl": "UOsRPjT3U5Dhaaup3xQ30D...",
"accountId": "896225281"
}
}
]
```
</TabItem>
</Tabs>
Restart multi-scrobbler to start monitoring that account.
:::warning[Restrictions]
Deezer may not return the correct listening history under these circumstances:
* The ARL used is **not** for the main account
* If you can get an ARL specifically for the linked account (login as the linked account), use that **instead** of family `accountId`
* The ARL account is private
* The linked account to be monitored ( using `accountId`) is private
:::
</details>
## Configuration
<Config config="DeezerCompatConfig" fileContent={DeezerConfig} name="deezer">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ------------------------------------------------ |
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
</Config>
</TabItem>
<TabItem value="official" label="Official API">
@@ -144,8 +144,7 @@ To diagnose bad/incomplete track information or strange MS player behavior pleas
## Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
<Config config="ChromecastSourceConfig" fileContent={ChromecastConfig} name="chromecast">
:::note
[Manually configuring cast device connections](#connecting-devices) is only available through file-based config.
:::
@@ -157,28 +156,4 @@ To diagnose bad/incomplete track information or strange MS player behavior pleas
| CC_BLACKLIST_DEVICES | No | | Do not scrobble from these Cast devices. Comma-delimited list |
| CC_WHITELIST_APPS | No | | Only scrobble from these casted Apps. Comma-delimited list. EX spotify, pandora |
| CC_BLACKLIST_APPS | No | | Do not scrobble from these casted Apps. Comma-delimited list |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/chromecast.json" language="json5">{ChromecastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="ChromecastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={ChromecastConfig} name="chromecast"/>
</details>
or <SchemaLink lower objectName="ChromecastSourceConfig"/>
</TabItem>
</Tabs>
</Config>
+2 -26
View File
@@ -62,32 +62,8 @@ If no `sources` are configured for multi-scrobbler it will automatically try to
## Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
<Config config="IcecastSourceConfig" fileContent={IcecastConfig} name="icecast">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ----------- |
| `ICECAST_URL` | Yes | | Icecast URL |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={IcecastConfig} name="icecast"/>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
</Tabs>
</Config>
+35 -33
View File
@@ -27,41 +27,43 @@ By default...
* Allowed Users (`usersAllow` or `JELLYFIN_USERS_ALLOW`) are only necessary if you want to scrobble for additional users.
* multi-scrobbler will **only** scrobble media found in Jellyfin libraries that were labelled as **Music.**
* `librariesAllow` or `JELLYFIN_LIBRARIES_ALLOW` will override this
* OR use `additionalAllowedLibraryTypes` to allow more types (like `mixed` or `book` for audiobooks)
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio.**
* To force multi-scrobbler to scrobble when media is detected as **Unknown** use `"allowUnknown": true` in file/aio configuration.
* OR use `additionalAllowedLibraryTypes` to allow more *library* types (like those labelled as `mixed` or `book` for audiobooks)
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio**.
* To allow other media types to be scrobbled set `allowMediaTypes` or `JELLYFIN_MEDIATYPES_ALLOW` with a list of [official types](https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22).
* One or more of: `Unknown`, `Video`, `Audio`, `Photo`, `Book`
* If you use this setting **you must explicitly specify `Audio`**, it will no longer be provided as a default
</DetailsAdmo>
<DetailsAdmo type="tip" summary="External Asset URL">
If you use MS and Jellyfin on an internal or otherwise inaccessible network but still want album art assets used in the UI or for [Discord](/config/clients/discord) to be accessible you can specify a URL to replace your internal url.
Use (file) `frontendUrlOverride` or (env) `JELLYFIN_FRONTEND_URL_OVERRIDE` to replace `url`/`JELLYFIN_URL`, respectively, when rendering links for the dashboard UI or other resources.
Example:
Your `JELLYFIN_URL` is `http://jellyfin:8096` but your Jellyfin instance is externally accessible (by MS or in general) at `https://jellyfin.mydomain.com`.
Use `JELLYFIN_FRONTEND_URL_OVERRIDE=https://jellyfin.mydomain.com` in order for the MS dashboard to use `https://jellyfin.mydomain.com/art/1234.jpg` when linking to album art.
</DetailsAdmo>
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| -------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
| `JELLYFIN_URL` | **Yes** | | The URL of the Jellyfin server IE `http://localhost:8096` |
| `JELLYFIN_USER` | **Yes** | | The user to authenticate with the API |
| `JELLYFIN_APIKEY` | No | | The API Key to use for authentication **(Must provide either apikey or password)** |
| `JELLYFIN_PASSWORD` | No | | The password of the user to authenticate for. **(Must provide either apikey or password)** |
| `JELLYFIN_USERS_ALLOW` | No | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_USERS_BLOCK` | No | | Comma-separated list of usernames (from Jellyfin) to disallow scrobble for |
| `JELLYFIN_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
| `JELLYFIN_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
| `JELLYFIN_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
| `JELLYFIN_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
</TabItem>
<TabItem value="file" label="File">
<CodeBlock title="CONFIG_DIR/jellyfin.json" language="json5">{JellyfinConfig}</CodeBlock>
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<AIOExample data={JellyfinConfig} name="jellyfin"/>
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
</TabItem>
</Tabs>
<Config config="JellyApiSourceConfig" fileContent={JellyfinConfig} name="jellyfin">
| Environmental Variable | Required? | Default | Description |
| -------------------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JELLYFIN_URL` | **Yes** | | The URL of the Jellyfin server IE `http://localhost:8096` |
| `JELLYFIN_USER` | **Yes** | | The user to authenticate with the API |
| `JELLYFIN_APIKEY` | No | | The API Key to use for authentication **(Must provide either apikey or password)** |
| `JELLYFIN_PASSWORD` | No | | The password of the user to authenticate for. **(Must provide either apikey or password)** |
| `JELLYFIN_USERS_ALLOW` | No | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_USERS_BLOCK` | No | | Comma-separated list of usernames (from Jellyfin) to disallow scrobble for |
| `JELLYFIN_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
| `JELLYFIN_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
| `JELLYFIN_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
| `JELLYFIN_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
| `JELLYFIN_MEDIATYPES_ALLOW` | No | | Comma-separated list of [media types](https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22) to allow scrobbling. Defaults to `audio` |
| `JELLYFIN_FRONTEND_URL_OVERRIDE` | No | | The external URL to use for album art assets instead of `JELLYFIN_URL` |
</Config>
+2 -20
View File
@@ -49,28 +49,10 @@ If any part of this URL is missing multi-scrobbler will use a default value. Thi
## Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
<EnvType>
<Config config="JRiverSourceConfig" fileContent={JriverConfig} name="jriver">
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------------------------------|------------------------------------------------|
| JRIVER_URL | Yes | http://localhost:52199/MCWS/v1/ | The URL of the JRiver server |
| JRIVER_USERNAME | No | | If authentication is enabled, the username set |
| JRIVER_PASSWORD | No | | If authenticated is enabled, the password set |
</EnvType>
</TabItem>
<TabItem value="file" label="File">
<FileType>
<CodeBlock title="CONFIG_DIR/jriver.json" language="json5">{JriverConfig}</CodeBlock>
or <SchemaLink lower objectName="JRiverSourceConfig"/>
</FileType>
</TabItem>
<TabItem value="aio" label="AIO">
<AIOType>
<AIOExample data={JriverConfig} name="jriver"/>
or <SchemaLink lower objectName="JRiverSourceConfig"/>
</AIOType>
</TabItem>
</Tabs>
</Config>
@@ -31,7 +31,7 @@ See the [Rocksky (Client)](/configuration/clients/rocksky#setup) configuration f
* [File/AIO config](/configuration?configType=file#configuration-types) must include `"configureAs": "source"`
<Config config="RockskyClientConfig" fileContent={JsonConfig} name="rocksky">
<Config config="RockskySourceConfig" fileContent={JsonConfig} name="rocksky">
| Environmental Variable | Required? | Default | Description |
| :---------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
| `SOURCE_ROCKSKY_HANDLE` | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
@@ -16,6 +16,23 @@ If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-
#### Authenticate Spotify with Multi-Scrobbler
<DetailsAdmo type="warning" summary="Premium Account Required">
Starting [March 9th 2026](https://developer.spotify.com/blog/2026-02-06-update-on-developer-access-and-platform-security) all applications on the Spotify Developer platform will only continue to work if they are **owned** by a **Spotify Premium** account.
If your App is not created by a Premium account you will likely recieve a 403 error when trying to use MS with Spotify.
If you do not have a Spotify Premium account *you can still use Spotify with MS* but it will require you to coordinate with a (friend?) user who *does* have Spotify Premium:
* Follow the steps below **logged in as the Spotify Premium account user**
* After completing App creation (step 4 or 5), navigate to the **User Management** page for the created App
* Add **your** (non-premium) account here using your account email/name
* Complete the rest of the authentication/configuration process normally
Assure your friend: **Creating a Spotify Developer app does not give anyone access to the owner's account.** The authentication process within MS is where authorization is given: it requires clicking the link in MS's dashboard and following a separate auth flow. This auth flow only authorizes access to the specific account used in the flow, unrelated to the owner of the developer app.
</DetailsAdmo>
To access your Spotify history you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
Client ID/Secret.
@@ -30,7 +30,7 @@ Use the optional `usersAllow` property with **File** or **AIO** configuration to
## Configuration
<Config config="SubsonicSourceConfig" fileContent={SubsonicConfig} name="subsonic">
<Config config="SubSonicSourceConfig" fileContent={SubsonicConfig} name="subsonic">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------|
| `SUBSONIC_USER` | Yes | | |
@@ -86,7 +86,7 @@ This affects **only** MS's behavior and does not affect the general connector be
## Configuration
<Config config="WebscrobblerSourceConfig" fileContent={JsonConfig} name="webscrobbler">
<Config config="WebScrobblerSourceConfig" fileContent={JsonConfig} name="webscrobbler">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------------------------------------------|
| WS_ENABLE | No | | Set to 'true' to enable WS without needing to define other ENVs |
@@ -50,7 +50,7 @@ Set up [Valkey Caching](/configuration?cachedThings=metadata#caching) to cache M
### API Setup
To avoid rate limiting, MusicBrainz requires API users to identify themself using a [User-Agent that contains an email or URL.](https://wiki.musicbrainz.org/MusicBrainz_API/Rate_Limiting#How_can_I_be_a_good_citizen_and_be_smart_about_using_the_Web_Service?) You must set this in the `data` for your [stage configuration](http://localhost:3000/docs/configuration/transforms/#configuring-stages) in your [AIO Config](/configuration?configType=aio#configuration-types):
To avoid rate limiting, MusicBrainz requires API users to identify themself using a [User-Agent that contains an email or URL.](https://wiki.musicbrainz.org/MusicBrainz_API/Rate_Limiting#How_can_I_be_a_good_citizen_and_be_smart_about_using_the_Web_Service?) You must set this in the `data` for your [stage configuration](/configuration/transforms/#configuring-stages) in your [AIO Config](/configuration?configType=aio#configuration-types):
```json5 title="config.json"
{
@@ -160,7 +160,7 @@ Example:
]
```
Each [**Stage Rule**](/configuration/transforms#stage-rules) should be either a boolean, specifying if the transformed data should be used for this field, or a [`when` condition](/configuration/transforms#conditional-moditication):
Each [**Stage Rule**](/configuration/transforms#stage-rules) should be either a boolean, specifying if the transformed data should be used for this field, or a [`when` condition](/configuration/transforms#conditional-modification):
<details>
@@ -247,11 +247,11 @@ To configure [stage defaults](#stage-configuration) use `MB_PRESETS` with a comm
* `fields` - Applies [Field Scoring](#field-scoring) to match sorting so match text is baised towards similarity to original scrobble
* `sensible` - Applies the [Sensible Default](#sensible-default) configuration for [sorting releases](#sorting-releases)
Finally, use ENV `*_TRANSFORMS=musicbrainz` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preTransform` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules) enabled.
Finally, use ENV `*_TRANSFORMS=musicbrainz` on each Source/Client you wish to apply this stage to. This applies the stage in the [`preTransform` Hook](/configuration/transforms#lifecycle-hooks) with all [Rules](#rules-and-hooks) enabled.
The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this. Example:
* All [Subsonic Source](http://localhost:3000/docs/configuration/sources/subsonic/) ENVs look like `SUBSONIC_USER=myuser` etc...
* All [Subsonic Source](/configuration/sources/subsonic) ENVs look like `SUBSONIC_USER=myuser` etc...
* Use `SUBSONIC_TRANSFORMS=musicbrainz`
<details>
@@ -706,7 +706,7 @@ Sorting can be used instead of filters, or in conjunction with filters, it's you
#### Release Attributes {#sorting-releases}
Each [Release Attribute](#release-attribute) has one additional property that can be used to **rank** releases based on the order of the values you give it. This is the `priority` property.
Each [Release Attribute](#release-attributes) has one additional property that can be used to **rank** releases based on the order of the values you give it. This is the `priority` property.
In configuration:
@@ -818,7 +818,7 @@ Consider adding [Artist Extraction (`artist`)](#search-methods) in **Native Mode
How complete, and accurate, your scrobble data is from a certain Source should dictate what [Search Methods](#search-methods) you should use for matching.
Musicbrainz agressively normalizes **artist, album, and title** fields in its database which means that if your Scrobble data contains major innaccuracies or phrases fields in a non-normal way, it's possible Musicbrainz won't find any matches. This is a more common occurrence when trying to match Scrobble data from Sources that don't support multiple artist data, like [Subsonic](/configuration/sources/subsonic) and [Last.fm](/configuration/sources/lastfm).
Musicbrainz agressively normalizes **artist, album, and title** fields in its database which means that if your Scrobble data contains major innaccuracies or phrases fields in a non-normal way, it's possible Musicbrainz won't find any matches. This is a more common occurrence when trying to match Scrobble data from Sources that don't support multiple artist data, like [Subsonic](/configuration/sources/subsonic) and [Last.fm](/configuration/sources/lastfm-source).
<details>
@@ -869,7 +869,7 @@ When using your own filters consider:
### Using Partial Match
Use [Rules](#rules) to apply MusicBrainz match data selectively.
Use [Rules](#rules-and-hooks) to apply MusicBrainz match data selectively.
If you know that your music collection is well organized and you do not want to change the artists/title/album etc... sent to a [Client](/configuration/clients), you can still benefit from matches by only applying MBIDs using `meta` so that any Client that supports Musicbrainz data ([Koito](/configuration/clients/koito), [Tealfm](/configuration/clients/tealfm), [Listenbrainz](/configuration/clients/listenbrainz), [Rocksky](/configuration/clients/rocksky)) can still get that data.
@@ -1086,7 +1086,7 @@ Your [AIO Config](/configuration?configType=aio#configuration-types):
}
```
In a [Jellyfin](/configuration/clients/jellyfin) [File Config](/configuration?configType=file#configuration-types):
In a [Jellyfin](/configuration/sources/jellyfin) [File Config](/configuration?configType=file#configuration-types):
```json5 title="jellyfin.json"
[
@@ -1119,7 +1119,7 @@ In a [Jellyfin](/configuration/clients/jellyfin) [File Config](/configuration?co
* [Sensible Defaults](#sensible-default)
* Fallback search with [Artist Extraction](#search-methods)
* [Default Native Stage](/configuration/transforms/native/#default-stage)
* Uses [Flow Control](/configurations/transforms#flow-control) to run Native only if there are no Musicbrainz matches
* Uses [Flow Control](/configuration/transforms#flow-control) to run Native only if there are no Musicbrainz matches
<details>
@@ -1153,7 +1153,7 @@ Your [AIO Config](/configuration?configType=aio#configuration-types):
}
```
In a [Jellyfin](/configuration/clients/jellyfin) [File Config](/configuration?configType=file#configuration-types):
In a [Jellyfin](/configuration/sources/jellyfin) [File Config](/configuration?configType=file#configuration-types):
```json5 title="jellyfin.json"
[
@@ -1266,4 +1266,122 @@ Stage Configuration
]
```
</details>
### Use Metadata for Discord Album Art
The [Discord](/configuration/clients/discord) Client can fetch album art images from [Cover Art Archive](http://coverartarchive.org/) if the Scrobble data contains a Release MBID. Use the Musicbrainz Stage on the `preCompare` hook for your Discord client so that missing Musicbrainz IDs are populated for this functionality.
<details>
<summary>Example</summary>
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
Using the [ENV Configuration](#env-configuration) from above, add these fields to your docker compose `environment:`
```yaml
- MB_CONTACT=contact@mydomain.com
- MB_PRESETS=id,native,sensible
- DISCORD_TRANSFORMS=musicbrainz
```
</TabItem>
<TabItem value="file" label="File">
Your [AIO Config](/configuration?configType=aio#configuration-types):
```json5 title="config.json"
{
// ...
"transformers": [
{
"type": "musicbrainz",
"name": "MyMB",
"data": {
"apis": [
{
"contact": "contact@mydomain.com"
}
]
},
"defaults": {
// sensible defaults
"releaseStatusPriority": ["official"],
"releaseGroupPrimaryTypePriority": ["album", "single", "ep"],
"releaseCountryPriority": ["XW"],
"searchArtistMethod": "native",
"searchOrder": ["isrc","basic", "artist"]
}
}
]
}
```
In your [Discord](/configuration/clients/discord) [File Config](/configuration?configType=file#configuration-types):
```json5 title="discord.json"
[
{
"name": "MS",
"data": { /* ... */},
"options": {
"playTransform": {
"preCompare": [
{
"type": "musicbrainz",
}
]
}
}
}
]
```
</TabItem>
<TabItem value="aio" label="AIO">
Your [AIO Config](/configuration?configType=aio#configuration-types):
```json5 title="config.json"
{
// ...
"transformers": [
{
"type": "musicbrainz",
"name": "MyMB",
"data": {
"apis": [
{
"contact": "contact@mydomain.com"
}
]
},
"defaults": {
// sensible defaults
"releaseStatusPriority": ["official"],
"releaseGroupPrimaryTypePriority": ["album", "single", "ep"],
"releaseCountryPriority": ["XW"],
"searchArtistMethod": "native",
"searchOrder": ["isrc","basic", "artist"]
}
}
],
"sources": [
{
"name": "MS",
"type": "discord",
"data": { /* ... */},
"options": {
"playTransform": {
"preCompare": [
{
"type": "musicbrainz",
}
]
}
}
}
]
}
```
</TabItem>
</Tabs>
</details>
@@ -75,7 +75,7 @@ Config => extracted artists
### Rules
Each [Rule](/configuration/transforms#stage-rules) should be either a boolean, specifying if the transformed data should be used for this field, or a [`when` condition.](/configuration/transforms#conditional-moditication):
Each [Rule](/configuration/transforms#stage-rules) should be either a boolean, specifying if the transformed data should be used for this field, or a [`when` condition.](/configuration/transforms#conditional-modification):
```json5
{
@@ -96,7 +96,7 @@ If a rule is not present then multi-scrobbler defaults it to `true`.
For your convenience, if you do not define any Native [Stage Configurations](/configuration/transforms#configuring-stages) then multi-scrobbler automatically builds one using the defaults shown in [configuration](#configuration).
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#modification-stage).
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#stage).
<details>
@@ -132,7 +132,7 @@ Use ENV `*_TRANSFORMS=native` on each Source/Client you wish to apply this stage
The `*` stands for the prefix used for each Source/Client's ENV keys. Refer to the individual Source/Client Configuration sections to find this. Example
* All [Subsonic Source](http://localhost:3000/docs/configuration/sources/subsonic/) ENVs look like `SUBSONIC_USER=myuser` etc...
* All [Subsonic Source](/configuration/sources/subsonic/) ENVs look like `SUBSONIC_USER=myuser` etc...
* Use `SUBSONIC_TRANSFORMS=native`
<details>
@@ -36,7 +36,7 @@ Scrobble **Clients** listen for discovered Plays from Sources, then...
* The Client **compares** this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
* The Client **scrobbles** the Play downstream to the scrobble service and adds it as a Scrobble it has seen already
## Lifecyle Hooks
## Lifecycle Hooks
You'll notice there is a pattern above that looks like this:
@@ -376,7 +376,7 @@ Specifying these Rules is **not** the same as [configuring the Stage](#configuri
Stages may be optionally configured to **continue** to the next Stage or **stop** (end early) all subsequent Stages, based on the outcome of the currently running Stage.
These three properties can be added to the [Modification Stage](#modification-stage) data, alongside [Rules](#rules-for-play-data):
These three properties can be added to the [Modification Stage](#stage) data, alongside [Rules](#stage-rules):
* `onSuccess` (default `continue`) - If the Stage successfully finishes processing
* `onFailure` (default `stop`) - If the Stage encounters an error while processing, or otherwise fails to achieve the transformation result
@@ -16,7 +16,7 @@ The user [Stage `type`](/configuration/transforms#stage) is `user`.
## Configuration
All [Stage Configuration](/configuration/transforms#configuring-stage) is done using [**search-and-replace** expressions](#search-and-replace-expression) inside individual [rules](#rules). Default configuration for all Rules can still be done using Stage `defaults`.
All [Stage Configuration](/configuration/transforms#configuring-stages) is done using [**search-and-replace** expressions](#search-and-replace-expression) inside individual [rules](#rules). Default configuration for all Rules can still be done using Stage `defaults`.
<details>
@@ -46,9 +46,9 @@ All [Stage Configuration](/configuration/transforms#configuring-stage) is done u
#### Default Stage
For your convenience, if you do not define any User [Stage Configurations](/configuration/transforms#configuration) then multi-scrobbler automatically builds an empty one for you.
For your convenience, if you do not define any User [Stage Configurations](/configuration/transforms#configuring-stages) then multi-scrobbler automatically builds an empty one for you.
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#modification-stage).
This can be used by omitting the `name` in your [modification Stage](/configuration/transforms#stage).
<details>
@@ -126,7 +126,7 @@ The `replace` property uses javascript's [`replace()` function and so can use an
### Rules
Each [Rule](/configuration/transforms#stage-rules) must be an array of [search-and-replace expressions](#search-and-replace-expressions):
Each [Rule](/configuration/transforms#stage-rules) must be an array of [search-and-replace expressions](#search-and-replace-expression):
```json5 title="lastfm.json"
[
+1 -1
View File
@@ -54,7 +54,7 @@ Thank you for your time.
### FAQ
Check the [FAQ](/faq) to see if your issue is a known issue or a commonly-experienced issue with Multi-Scrobbler. The FAQ also includes fixes or ways to help troubleshoot these common issues.
Check the [FAQ](/FAQ) to see if your issue is a known issue or a commonly-experienced issue with Multi-Scrobbler. The FAQ also includes fixes or ways to help troubleshoot these common issues.
### Existing Issues
+1
View File
@@ -42,6 +42,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
* [Youtube Music](/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](/configuration/clients)
* [Discord](/configuration/clients/discord) (Now Playing)
* [Koito](/configuration/clients/koito)
* [Last.fm](/configuration/clients/lastfm)
* [Libre.fm](/configuration/clients/librefm)
+2 -2
View File
@@ -10,7 +10,7 @@ This guide will get you up and running with multi-scrobbler using [Docker](https
* the dashboard served on port `9078` of a host machine which has an IP of `192.168.0.100`
* data saved to the same directory as the `docker-compose.yml` file
* multi-scrobbler monitoring [Spotify](/configuration/sources/spotify), [Jellyfin](/configuration/sources/jellyfin), Navidrome, [Plex](/configuration/sources/plex), and/or [Last.fm](/configurations/sources/lastfm) for listening activity
* multi-scrobbler monitoring [Spotify](/configuration/sources/spotify), [Jellyfin](/configuration/sources/jellyfin), Navidrome, [Plex](/configuration/sources/plex), and/or [Last.fm](/configuration/sources/lastfm-source) for listening activity
* multi-scrobbler scrobbling to [Lastfm](/configuration/clients/lastfm), [Maloja](/configuration/clients/maloja), and/or [Koito](/configuration/clients/koito)
:::note
@@ -166,7 +166,7 @@ Add these value to the `environment` section in the [`docker-compose.yml` you cr
- MB_PRESETS=default,sensible,native
```
**Presets** are found [here](http://localhost:3000/docs/configuration/transforms/musicbrainz/#env-configuration).
**Presets** are found [here](/configuration/transforms/musicbrainz/#env-configuration).
<h3>Enable Musicbrainz For A Source</h3>
+3 -2
View File
@@ -71,7 +71,7 @@ const config: Config = {
],
],
themes: [
"docusaurus-json-schema-plugin",
//"docusaurus-json-schema-plugin",
[
"@easyops-cn/docusaurus-search-local",
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
@@ -90,6 +90,7 @@ const config: Config = {
'docusaurus-theme-github-codeblock'
],
plugins: [
'docusaurus-plugin-sass',
[
'@docusaurus/plugin-client-redirects',
{
@@ -160,7 +161,7 @@ const config: Config = {
],
},
footer: {
style: 'dark',
links: [
{
title: 'Docs',
+1422 -102
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -11,7 +11,8 @@
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
"typecheck": "tsc",
"postinstall": "patch-package"
},
"dependencies": {
"@docusaurus/core": "3.8.1",
@@ -20,15 +21,22 @@
"@docusaurus/preset-classic": "3.8.1",
"@easyops-cn/docusaurus-search-local": "0.51.1",
"@mdx-js/react": "^3.0.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"cf-json-schema-viz": "^0.3.0",
"clsx": "^2.0.0",
"docusaurus-json-schema-plugin": "^1.14.0",
"docusaurus-plugin-sass": "^0.2.6",
"docusaurus-theme-github-codeblock": "^2.0.2",
"json5": "^2.2.3",
"micromark-extension-directive": "^3.0.1",
"modern-react-json-editor": "^1.0.2",
"patch-package": "^8.0.1",
"prism-react-renderer": "^2.3.0",
"raw-loader": "^4.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"sass": "^1.97.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
import React, { ReactNode, CSSProperties } from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
// Define the Button type to control the props that can be passed to the Button component.
export type ButtonProps = {
// The size prop can be one of the following values: 'sm', 'lg', 'small', 'medium', 'large', or null.
// We'll convert 'small' to 'sm' and 'large' to 'lg' in the component. 'medium' will be considered null.
size?: 'sm' | 'lg' | 'small' | 'medium' | 'large' | null;
// The outline prop is a boolean that determines if the button should be an outline button.
outline?: boolean;
// The variant prop is a string that determines the color of the button.
// It can be one of the following values: 'primary', 'secondary', 'danger', 'warning', 'success', 'info', 'link', or any other string.
// The default value is 'primary'.
variant: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'link' | string;
// The block prop is a boolean that determines if the button should be a block-level button.
block?: boolean;
// The disabled prop is a boolean that determines if the button should be disabled.
disabled?: boolean;
// The className prop is a string that allows you to add custom classes to the button.
className?: string;
// The style prop is an object that allows you to add custom styles to the button.
style?: CSSProperties;
// The link prop is a string that determines the URL the button should link to.
link: string;
// The label prop is a string that determines the text of the button.
label: string;
onClick?: () => any
}
// Button component that accepts the specified props.
export default function Button ({
size = null,
outline = false,
variant = 'primary',
block = false,
disabled = false,
className,
style = {},
link,
label,
onClick = () => null,
...rest
}: ButtonProps) {
// Map the size prop values to corresponding CSS classes.
const sizeMap = {
sm: 'sm',
small: 'sm',
lg: 'lg',
large: 'lg',
medium: null,
};
const buttonSize = size ? sizeMap[size] : '';
const sizeClass = buttonSize ? `button--${buttonSize}` : '';
const outlineClass = outline ? 'button--outline' : '';
const variantClass = variant ? `button--${variant}` : '';
const blockClass = block ? 'button--block' : '';
const disabledClass = disabled ? 'disabled' : '';
// If the button is disabled, set the destination to null.
const destination = disabled ? null : link;
return (
<Link to={destination}>
<button
className={clsx(
'button',
sizeClass,
outlineClass,
variantClass,
blockClass,
disabledClass,
className
)}
style={style}
role='button'
aria-disabled={disabled}
onClick={(e) => {
e.preventDefault();
onClick()
}}
{...rest}
>
{label}
</button>
</Link>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React, { ReactNode, CSSProperties } from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Button, { ButtonProps } from './Button';
const leftButton = {
borderRadius: 'var(--ifm-button-border-radius) 0 0 var(--ifm-button-border-radius)'
}
const rightButton = {
borderRadius: '0 var(--ifm-button-border-radius) var(--ifm-button-border-radius) 0'
}
const middleButton = {
borderRadius: '0'
}
// Define the Button type to control the props that can be passed to the Button component.
type ButtonGroup = {
options: [string, string][]
value?: string
defaultValue?: string
// The size prop can be one of the following values: 'sm', 'lg', 'small', 'medium', 'large', or null.
// We'll convert 'small' to 'sm' and 'large' to 'lg' in the component. 'medium' will be considered null.
size?: 'sm' | 'lg' | 'small' | 'medium' | 'large' | null;
variant: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'link' | string;
// The block prop is a boolean that determines if the button should be a block-level button.
block?: boolean;
// The disabled prop is a boolean that determines if the button should be disabled.
disabled?: boolean;
// The className prop is a string that allows you to add custom classes to the button.
className?: string;
// The style prop is an object that allows you to add custom styles to the button.
style?: CSSProperties;
onChange?: (val: string) => any
}
// Button component that accepts the specified props.
export default function ButtonGroup ({
size = null,
variant = 'primary',
block = false,
disabled = false,
className,
style,
options,
value,
defaultValue,
onChange = () => null
}: ButtonGroup) {
if(options === undefined || options.length === 0) {
return null;
}
const buttons = options.map((opt, index) => {
const props: ButtonProps = {
style: middleButton,
outline: true,
size,
variant,
label: opt[1],
link: '#',
onClick: () => onChange(opt[0])
};
if(index === 0) {
props.style = leftButton;
} else if(index === options.length - 1) {
props.style = rightButton;
}
if((value ?? defaultValue) === opt[0]) {
props.outline = false;
}
return <Button key={index} {...props}/>
});
return <span>{buttons}</span>;
}
@@ -0,0 +1,74 @@
import React, { Fragment, useMemo } from "react"
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
import FileExample, { FileProps } from "./FileExample";
export interface FileConfigProps extends FileProps {
data: string
name: string
configureAs?: 'client' | 'source'
aio?: boolean
filePathName?: string
}
const FileConfigExample = (props: FileConfigProps) => {
const {
data,
configureAs = 'source',
aio = false,
name,
...rest
} = props;
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
<p>Example component crashed because of error!</p>
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
}
const transformed = useMemo(() => {
let content: object;
if(typeof data === 'string') {
content = json5.parse(data);
} else {
content = data;
}
if(configureAs !== undefined && Array.isArray(content)) {
const filtered = content.filter(x => x.configureAs === undefined || x.configureAs === configureAs);
if(aio) {
const configType = configureAs === 'client' ? 'clients' : 'sources';
content = {[configType]: filtered.map(x => ({...x, type: name}))}
} else {
content = filtered;
}
}
return content;
},[data, configureAs, aio, name])
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === configureAs);
return <FileExample data={transformed} {...rest}/>
}
const WrappedFileConfigExample = (props: FileConfigProps) => {
return <ErrorBoundary
fallback={({error}) => (
<div>
<p>Example component crashed because of error: {error.message}.</p>
</div>
)}
><FileConfigExample {...props} /></ErrorBoundary>
}
export default WrappedFileConfigExample;
+66 -20
View File
@@ -1,44 +1,90 @@
import React, { Fragment } from "react"
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
import React, { Fragment, useMemo } from "react"
import CodeBlock, { Props as CodeBlockProps } from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
import { useTypedLocalStorage } from "./useLocalStorage";
import ButtonGroup from "./ButtonGroup";
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
import {useWindowSize} from '@docusaurus/theme-common';
export interface FileProps extends Omit<CodeBlockProps, 'children'> {
data: string
client?: boolean
data: string | object
filePathName?: string
schemaName?: string
interactive?: boolean
defaultDisplay?: 'plain' | 'interactive'
readOnlyAtWidth?: number
}
const FileExample = (props: FileProps) => {
const {
data,
client = false,
interactive = true,
defaultDisplay = 'interactive',
schemaName,
filePathName,
readOnlyAtWidth = 1,
...rest
} = props;
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
<p>Example component crashed because of error!</p>
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
const [fileExampleShow, setFileExampleShow] = useTypedLocalStorage('docusaurus.tab.fileExampleShow', defaultDisplay, false);
const windowSize = useWindowSize({desktopBreakpoint: readOnlyAtWidth});
let displaySwitch = null;
const isInteractive = interactive && windowSize === 'desktop'
if (isInteractive) {
displaySwitch = (
<ButtonGroup
options={[['plain', 'Plain'], ['interactive', 'Interactive']]}
defaultValue={defaultDisplay}
value={fileExampleShow}
variant="primary"
size="medium"
onChange={(val) => setFileExampleShow(val as 'interactive' | 'plain')}
/>
);
}
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
let fileNameContent = null;
if(filePathName !== undefined && (isInteractive && fileExampleShow === 'interactive')) {
fileNameContent = <code style={{float: 'right', paddingRight: '0.5em', paddingLeft: '0.5em'}}>{filePathName}</code>
}
return <CodeBlock {...rest} language="json5">{JSON.stringify(configObj, null, 2)}</CodeBlock>
let header = null;
if(displaySwitch !== null || fileNameContent !== null) {
header = (
<div style={{marginBottom: '1em'}}>
{displaySwitch}{fileNameContent}
</div>
);
}
const codeBlockContent = useMemo(() =>{
if(isInteractive && fileExampleShow === 'interactive') {
return null;
}
if(typeof data !== 'string') {
return JSON.stringify(data, null, 2);
}
return data;
}, [interactive, fileExampleShow, data]);
return (<Fragment>
{header}
{!isInteractive || fileExampleShow === 'plain' ? <CodeBlock title={filePathName} language="json5">{codeBlockContent}</CodeBlock> : null}
{isInteractive && fileExampleShow === 'interactive' ? <EditorFetch configContent={data} schemaName={schemaName}/> : null}
</Fragment>
);
}
const WrappedFileExample = (props: FileProps) => {
return <ErrorBoundary
fallback={({error}) => (
fallback={({ error }) => (
<div>
<p>Example component crashed because of error: {error.message}.</p>
</div>
@@ -1,6 +1,11 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import ExplorerFetch from '@site/src/components/Schema/ExplorerFetch';
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
import './GenericConfiguration.scss';
import FileConfigExample from './FileConfigExample';
import {useWindowSize} from '@docusaurus/theme-common';
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
@@ -10,19 +15,96 @@ import CodeBlock from '@theme/CodeBlock';
</TabItem>
<TabItem value="file" label="File">
<FileType>
<div
className="schemaWrapper"
style={{
display: "flex"
}}
>
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
<DetailsAdmo type="note" summary="Config Structure">
This displays the **structure** of the [File Configuration](/configuration?configType=aio#configuration-types) for a <strong children={props.name}/> type <strong children={props.client ? 'Client' : 'Source'}/> with all possible properties, their shape, and descriptions/types. Use this to understand how to write a valid config.
</DetailsAdmo>
<ExplorerFetch name={`schemas/${props.config}`} />
</div>
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
<div className="fileEditor">
<DetailsAdmo type="note" summary="Config Example">
This displays an **example config file** of a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> [File Configuration](/configuration?configType=aio#configuration-types) that adheres to the shown **Config Structure**.
<FileExample title={`CONFIG/${props.name}.json`} client={props.client ?? false} data={props.fileContent}/>
* **text** mode lets you edit the JSON directly.
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
or <SchemaLink lower objectName={props.config}/>
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
After you finish editing, switch to **text** and then copy all text to get a completed config.
</DetailsAdmo>
</div>
<div className="fileEditorMobile">
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
This displays an **example config file** of a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> [File Configuration](/configuration?configType=aio#configuration-types) that adheres to the shown **Config Structure**.
The example config file editor is meant for a larger screen experience so **only the read-only example is shown.** Please use this site on a tablet/laptop/desktop to enable file editor features.
</DetailsAdmo>
</div>
<FileConfigExample
filePathName={`CONFIG/${props.name}.json`}
configureAs={props.client ? 'client' : 'source'}
data={props.fileContent}
schemaName={props.config}
readOnlyAtWidth={500}
/>
</div>
</div>
</FileType>
</TabItem>
<TabItem value="aio" label="AIO">
<AIOType>
<div
className="schemaWrapper"
style={{
display: "flex"
}}
>
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
<DetailsAdmo type="note" summary="Config Structure">
This displays the **structure** of the [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) with all possible properties, their shape, and descriptions/types. Use this to understand how to write a valid config.
<AIOExample data={props.fileContent} client={props.client ?? false} name={props.name}/>
Select the <strong children={props.name}/> type from the respective <code children={props.client ? 'clients' : 'sources'}/> node below, then expand it.
</DetailsAdmo>
<ExplorerFetch name={`schemas/aio`} />
</div>
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
<div className="fileEditor">
<DetailsAdmo type="note" summary="Config Example">
This displays an **example config file** of a [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) that includes a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> that adheres to the shown **Config Structure**.
* **text** mode lets you edit the JSON directly.
* **tree** mode gives you a guided point-and-click editing experience that always keeps the JSON *syntax* valid.
or <SchemaLink lower objectName={props.config}/>
Both modes **validate** that the configuraion is correct. Any errors show up as squiggly lines.
After you finish editing, switch to **text** and then copy all text to get a completed config.
</DetailsAdmo>
</div>
<div className="fileEditorMobile">
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
This displays an **example config file** of a [all-in-one (`config.json`) configuration](/configuration?configType=aio#configuration-types) that includes a <strong children={props.name}/> <span children={props.client ? 'Client' : 'Source'}/> that adheres to the shown **Config Structure**.
The example config file editor is meant for a larger screen experience so **only the read-only example is shown.** Please use this site on a tablet/laptop/desktop to enable file editor features.
</DetailsAdmo>
</div>
<FileConfigExample
filePathName={`CONFIG/config.json`}
configureAs={props.client ? 'client' : 'source'}
data={props.fileContent}
schemaName="aio"
name={props.name}
readOnlyAtWidth={500}
aio
/>
</div>
</div>
</AIOType>
</TabItem>
</Tabs>
</Tabs>
@@ -0,0 +1,26 @@
.schemaWrapper {
margin-top: 1em;
display: flex;
flex-direction: row;
row-gap: 1em;
column-gap: 1em;
}
.fileEditorMobile {
display: none;
}
@media screen and (max-width: 1378px) {
.schemaWrapper {
flex-direction: column;
}
}
@media screen and (max-width: 500px) {
.fileEditor {
display: none;
}
.fileEditorMobile {
display: block
}
}
-44
View File
@@ -1,44 +0,0 @@
import React from "react"
import BrowserOnly from "@docusaurus/BrowserOnly"
import { useColorMode } from "@docusaurus/theme-common"
import JSONSchemaEditor from "@theme/JSONSchemaEditor"
// Context
import { usePlaygroundContext } from "@site/src/contexts/PlaygroundContext"
// Common stringify of the JSON
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
function JSONSchemaDataInner(): JSX.Element {
const {
state: { userSchema, editorRef, data: value },
updateState,
} = usePlaygroundContext()
const { colorMode } = useColorMode()
return (
<div style={{ boxSizing: "border-box", width: "50%" }}>
<JSONSchemaEditor
value={value}
schema={userSchema}
theme={colorMode === "dark" ? "vs-dark" : "vs"}
editorDidMount={(editor) => {
updateState({ editorRef: editor })
}}
height={"70vh"}
key={STRINGIFY_JSON(userSchema)}
/>
</div>
)
}
export default function JSONSchemaDataComponent(): JSX.Element {
return (
<BrowserOnly fallback={<div>Loading...</div>}>
{() => {
return <JSONSchemaDataInner />
}}
</BrowserOnly>
)
}
+26
View File
@@ -0,0 +1,26 @@
.schemaWrapper2 {
margin: 1em;
display: flex;
flex-direction: row;
row-gap: 1em;
column-gap: 1em;
}
.fileEditorMobile2 {
display: none;
}
@media screen and (max-width: 1280px) {
.schemaWrapper2 {
flex-direction: column;
}
}
@media screen and (max-width: 500px) {
.fileEditor2 {
display: none;
}
.fileEditorMobile2 {
display: block
}
}
+90 -35
View File
@@ -1,50 +1,105 @@
import React from "react"
import React, {useEffect, useState} from "react"
import BrowserOnly from "@docusaurus/BrowserOnly"
import {useColorMode, useWindowSize} from '@docusaurus/theme-common';
import CodeBlock from '@theme/CodeBlock';
import Schema from "@site/static/aio.json";
import Schema from "@site/static/schemas/aio.json";
import ConfigExample from "@site/static/configExample.json";
import JSONSchemaViewer from "@theme/JSONSchemaViewer"
import JSONSchemaEditor from "@theme/JSONSchemaEditor";
import {useColorMode} from "@docusaurus/theme-common";
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t")
import './Playground.scss';
import { JsonSchemaViewer } from 'cf-json-schema-viz';
import "./json-schema-viewer-styles.css";
import "./modern-json-react-styles.css";
import { ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import f from "ajv-formats"
import DetailsAdmo from "./AdmonitionDetails";
const validator = createAjvValidator({
schema: Schema,
ajvOptions: {
strict: "log",
strictSchema: "log",
allowUnionTypes: true,
},
onCreateAjv: (ajv) => {
ajv.addKeyword('deprecationMessage');
f.default(ajv);
return ajv;
}
});
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
function PlaygroundInner(): JSX.Element {
const { colorMode } = useColorMode()
const {
colorMode, // the "effective" color mode, never null
// colorModeChoice, // the color mode chosen by the user, can be null
// setColorMode, // set the color mode chosen by the user
} = useColorMode();
const windowSize = useWindowSize({desktopBreakpoint: 500});
const [data, setData] = useState<Content>({json: ConfigExample });
return (
<div
style={{
display: "flex",
flexDirection: "column",
overflowY: "hidden",
overflowX: "hidden",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div style={{width: '50%'}}>
<JSONSchemaViewer
schema={Schema}
showExamples={true}
<div className="schemaWrapper2">
<div style={{ padding: '0 1em 0 1em', flexBasis: 'min-content', flexGrow: 2}}>
<DetailsAdmo type="note" summary="Config Structure">
<p>This displays the <strong>structure</strong> of the <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> with all possible properties, their shape, and descriptions/types.</p>
<p>Use this to understand how to write a valid config.</p>
</DetailsAdmo>
<JsonSchemaViewer
name="Todos Model"
schema={Schema}
expanded={false}
hideTopBar={false}
renderRootTreeLines={true}
emptyText="No schema defined"
defaultExpandedDepth={0}
markup
/>
</div>
<div style={{ boxSizing: "border-box", width: "50%" }}>
<JSONSchemaEditor
value={STRINGIFY_JSON(ConfigExample)}
theme={colorMode === "dark" ? "vs-dark" : "vs"}
schema={Schema}
height={"70vh"}
/>
<div style={{ flexBasis: 'auto', flexGrow: 3 }}>
<div className="fileEditor2">
<DetailsAdmo type="note" summary="Config Example">
<p>
This displays an <strong>example config file</strong> of a <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> that adheres to the shown <strong>Config Structure.</strong>
</p>
<ul>
<li>
<strong>text</strong> mode lets you edit the JSON directly.
</li>
<li>
<strong>tree</strong> mode gives you a guided point-and-click editing experience that always keeps the JSON <i>syntax</i> valid.
</li>
</ul>
<p>Both modes <strong>validate</strong> that the configuraion is correct. Any errors show up as squiggly lines.</p>
<p>After you finish editing, switch to <strong>text</strong> and then copy all text to get a completed config.</p>
</DetailsAdmo>
</div>
<div className="fileEditorMobile2">
<DetailsAdmo type="warning" summary="Config Example Mobile Experience">
<p>
This displays an <strong>example config file</strong> of a <a href="/configuration?configType=aio#configuration-types">all-in-one (<code>config.json</code>) configuration</a> that adheres to the shown <strong>Config Structure.</strong>
</p>
<p>
The example config file editor is meant for a larger screen experience so <strong>only the read-only example is shown.</strong> Please use this site on a tablet/laptop/desktop to enable file editor features.
</p>
</DetailsAdmo>
</div>
{windowSize === 'mobile' ? (<CodeBlock title={`CONFIG/config.json`} language="json5">{JSON.stringify(data, null, 2)}</CodeBlock>) : (
<ReactJsonEditor
validator={validator}
theme={colorMode}
content={data}
mode="text"
style={{height: 'initial'}}
/>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,38 @@
import React, {useEffect, useState} from "react"
import BrowserOnly from "@docusaurus/BrowserOnly"
//import './Explorer.scss';
import { JsonSchemaViewer } from 'cf-json-schema-viz';
import '../json-schema-viewer-styles.css'
export interface ExplorerProps {
schema: object
}
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
export default function Explorer(props: ExplorerProps): JSX.Element {
//injectStyles();
return (
<div>
<JsonSchemaViewer
schema={props.schema}
renderRootTreeLines={true}
emptyText="No schema defined"
defaultExpandedDepth={0}
markup
/>
</div>
)
}
// export default function PlaygroundComponent(): JSX.Element {
// return (
// <BrowserOnly fallback={<div>Loading...</div>}>
// {() => {
// return <PlaygroundInner />
// }}
// </BrowserOnly>
// )
// }
@@ -0,0 +1,48 @@
import React, {useEffect, useState, Fragment} from "react"
//import BrowserOnly from "@docusaurus/BrowserOnly"
import Explorer from './Explorer';
import useBaseUrl from '@docusaurus/useBaseUrl';
export interface ExplorerFetchProps {
name: string
}
export default function ExplorerFetchFunc(props: ExplorerFetchProps): JSX.Element {
const [schema, setSchema] = useState(undefined as undefined | Error | object);
const url = useBaseUrl(`/${props.name}.json`);
useEffect( () => {
fetch(
url,
{
headers: {
'Accept': 'application/json',
}
}
)
.then((response) => response.json())
.then((data) => setSchema(data))
.catch( (err) => setSchema(err) )
}, [setSchema, url])
return (
<Fragment>
{schema === undefined && <div>Loading ...</div>}
{schema !== undefined && schema instanceof Error && <div>Houston we have a problem : {schema.message}</div>}
{schema !== undefined && !(schema instanceof Error) && <Explorer schema={schema}/>}
</Fragment>
)
}
// export default function ExplorerFetch(props: ExplorerFetchProps): JSX.Element {
// return (
// <BrowserOnly fallback={<div>Loading...</div>}>
// {() => {
// return <ExplorerFetchFunc {...props} />
// }}
// </BrowserOnly>
// )
// }
@@ -0,0 +1,7 @@
import Schema from "@site/static/aio.json";
import Explorer from './Explorer';
export default function ExplorerSSR(): JSX.Element {
return <Explorer schema={Schema}/>
}
@@ -0,0 +1,76 @@
import React, { useEffect, useState, useMemo } from "react"
import BrowserOnly from "@docusaurus/BrowserOnly"
import { useColorMode } from '@docusaurus/theme-common';
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
import "../modern-json-react-styles.css";
import { JSONSchema, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import f from "ajv-formats"
import json5 from 'json5';
export interface SchemaEditorProps {
config: object | string
schema?: object
height?: string
}
// based on https://github.com/jy95/docusaurus-json-schema-plugin/blob/main/testsite/src/components/PlaygroundInner.tsx
function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
const validator: Validator | undefined = useMemo(() => {
if(props.schema === undefined) {
return undefined;
}
return createAjvValidator({
schema: props.schema as JSONSchema,
ajvOptions: {
strict: "log",
strictSchema: "log",
allowUnionTypes: true,
},
onCreateAjv: (ajv) => {
ajv.addKeyword('deprecationMessage');
f.default(ajv);
return ajv;
}
});
}, [props.schema]);
const initialData = useMemo(() => {
let content: object;
if(typeof props.config === 'string') {
content = json5.parse(props.config);
} else {
content = props.config;
}
return {json: content};
},[props.config])
// const [data, setData] = useState({ json: props.config });
const {
colorMode, // the "effective" color mode, never null
} = useColorMode();
//const val = typeof props.config === 'object' ? STRINGIFY_JSON(props.config) : props.config;
return (
<ReactJsonEditor
validator={validator}
theme={colorMode}
mode="text"
content={initialData}
style={{height: 'initial'}}
/>
)
}
export default function SchemaEditor(props: SchemaEditorProps): JSX.Element {
return (
<BrowserOnly fallback={<div>Loading...</div>}>
{() => {
return <SchemaEditorInner {...props} />
}}
</BrowserOnly>
)
}
@@ -0,0 +1,74 @@
import React, {useEffect, useState, Fragment} from "react"
import useBaseUrl from '@docusaurus/useBaseUrl';
import SchemaEditor from "./SchemaEditor";
import json5 from 'json5';
export interface SchemaEditorFetchProps {
configContent: string | object
schemaName?: string
}
export default function ExplorerFetchFunc(props: SchemaEditorFetchProps): JSX.Element {
const {
schemaName
} = props;
const [schema, setSchema] = useState(undefined as undefined | Error | object | false);
const [config, setConfig] = useState(undefined as undefined | Error | object);
const schemaUrl = schemaName === undefined ? undefined : useBaseUrl(`schemas/${props.schemaName}.json`);
//const configUrl = props.configName !== undefined ? useBaseUrl(`/${props.configName}.json`) : undefined;
useEffect( () => {
if(schemaUrl === undefined) {
setSchema(false);
} else {
fetch(
schemaUrl,
{
headers: {
'Accept': 'application/json',
}
}
)
.then((response) => response.json())
.then((data) => setSchema(data))
.catch( (err) => setSchema(err) )
}
}, [setSchema, schemaUrl])
// useEffect( () => {
// if(configUrl !== undefined) {
// fetch(
// configUrl,
// {
// headers: {
// 'Accept': 'application/json',
// }
// }
// )
// .then((response) => response.json())
// .then((data) => setConfig(data))
// .catch( (err) => setConfig(err) )
// } else if(props.configContent !== undefined) {
// if(typeof props.configContent === 'string') {
// setConfig(json5.parse(props.configContent));
// } else {
// setConfig(props.configContent);
// }
// }
// }, [setConfig, configUrl, props.configContent])
return (
<Fragment>
{schema === undefined && <div>Loading ...</div>}
{schema !== undefined && schema instanceof Error && <div>Houston we have a problem : {schema.message}</div>}
{schema !== undefined && !(schema instanceof Error) &&
<SchemaEditor
schema={schema === false ? undefined : schema}
config={props.configContent}
/>}
</Fragment>
)
}
@@ -0,0 +1,480 @@
/* JSON Schema Viewer - Pure CSS with CSS Custom Properties */
/* ========================================
Theme Variables - Light Mode (default)
Define at :root so portaled elements inherit them
======================================== */
:root {
--jsv-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--jsv-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--jsv-font-size: 0.875rem;
/* Colors - Light Mode defaults */
--jsv-color-body: #1a1a1a;
--jsv-color-muted: #6b7280;
--jsv-color-border: #e5e7eb;
--jsv-color-canvas: #f4f4f5;
--jsv-color-canvas-tint: #f4f4f5;
--jsv-color-bg: #ffffff;
/* Semantic colors - Cloudflare orange as primary */
--jsv-color-primary: #f6821f;
--jsv-color-danger: #dc2626;
--jsv-color-warning: #f97316;
--jsv-color-success: #16a34a;
}
/* ========================================
Theme Variables - Dark Mode
Cloudflare docs dark theme colors
======================================== */
:root[data-theme="dark"],
[data-theme="dark"] {
--jsv-color-body: #e4e4e7;
--jsv-color-muted: #a1a1aa;
--jsv-color-border: #3f3f46;
--jsv-color-canvas: #27272a;
--jsv-color-canvas-tint: #3f3f46;
--jsv-color-bg: #18181b;
--jsv-color-primary: #f6821f;
--jsv-color-danger: #f87171;
--jsv-color-warning: #fb923c;
--jsv-color-success: #4ade80;
}
/* ========================================
Base Styles
======================================== */
.jsv-root {
font-size: var(--jsv-font-size);
font-family: var(--jsv-font-sans);
color: var(--jsv-color-body);
line-height: 1.5;
position: relative;
}
.jsv-content {
padding-left: 2rem;
padding-right: 1rem;
padding-bottom: 0.5rem;
padding-top: 0.375rem;
}
/* ========================================
Schema Row
======================================== */
.jsv-row {
position: relative;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
display: flex;
align-items: flex-start;
max-width: 100%;
}
.jsv-row-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
max-width: 100%;
flex: 1;
min-width: 0;
}
.jsv-row-header {
display: flex;
align-items: center;
max-width: 100%;
gap: 0.25rem;
}
/* Nubbin - horizontal connector from left border to property */
.jsv-nubbin {
width: 0.75rem;
height: 0;
border-top: 1px solid var(--jsv-color-border);
flex-shrink: 0;
margin-top: 0.5rem;
margin-right: 0.5rem;
margin-left: -1rem;
}
.jsv-nubbin-short {
width: 0.25rem;
}
/* ========================================
Child Stack (nested content with left border)
======================================== */
.jsv-child-stack {
font-size: var(--jsv-font-size);
}
.jsv-child-stack-nested {
border-left: 1px solid var(--jsv-color-border);
margin-left: 0.5rem;
padding-left: 1rem;
margin-top: 0.25rem;
}
/* ========================================
Property Name
======================================== */
.jsv-property-name {
font-family: var(--jsv-font-mono);
font-weight: 600;
margin-right: 0.5rem;
flex-shrink: 0;
}
/* ========================================
Type Display
======================================== */
.jsv-type {
color: var(--jsv-color-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jsv-types-container {
display: flex;
align-items: baseline;
font-size: var(--jsv-font-size);
min-width: 0;
}
/* ========================================
Badges (required, deprecated, read-only)
======================================== */
.jsv-badges {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.jsv-badge {
font-size: 0.75rem;
}
.jsv-badge-required {
color: var(--jsv-color-warning);
}
.jsv-badge-deprecated {
color: var(--jsv-color-warning);
}
.jsv-badge-readonly {
color: var(--jsv-color-muted);
}
/* ========================================
Divider (horizontal line on hover)
======================================== */
.jsv-divider {
height: 1px;
flex: 1;
margin-left: 0.75rem;
margin-right: 0.75rem;
min-width: 1rem;
background-color: transparent;
transition: background-color 0.15s ease;
}
.jsv-divider-visible {
background-color: var(--jsv-color-border);
}
/* ========================================
Description
======================================== */
.jsv-description {
font-size: var(--jsv-font-size);
color: var(--jsv-color-muted);
margin-top: 0.0625rem;
line-height: 1.4;
}
/* ========================================
Validations
======================================== */
.jsv-validations {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
margin-top: 0.125rem;
}
.jsv-validations-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
}
.jsv-validation {
display: flex;
align-items: baseline;
gap: 0.375rem;
color: var(--jsv-color-muted);
font-size: 0.75rem;
}
.jsv-validation-label {
flex-shrink: 0;
}
.jsv-validation-values {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
flex: 1;
}
.jsv-validation-value {
padding: 0.0625rem 0.375rem;
background-color: var(--jsv-color-canvas-tint);
color: var(--jsv-color-muted);
border: 1px solid var(--jsv-color-border);
border-radius: 0.25rem;
font-family: var(--jsv-font-mono);
font-size: 0.75rem;
word-break: break-all;
}
/* ========================================
Caret (expand/collapse icon)
======================================== */
.jsv-caret {
width: 1.25rem;
height: 1.25rem;
margin-left: -1.5rem;
margin-right: 0.25rem;
color: var(--jsv-color-muted);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.jsv-caret:hover {
color: var(--jsv-color-body);
}
.jsv-caret-expanded {
transform: rotate(90deg);
}
/* ========================================
Error Display
======================================== */
.jsv-error {
color: var(--jsv-color-danger);
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
}
/* ========================================
Change Annotations (added/removed/modified)
======================================== */
.jsv-annotation {
position: absolute;
width: 0.25rem;
height: 1rem;
border-radius: 0.125rem;
top: 50%;
transform: translateY(-50%);
}
.jsv-annotation-added {
background-color: var(--jsv-color-success);
}
.jsv-annotation-removed {
background-color: var(--jsv-color-danger);
}
.jsv-annotation-modified {
background-color: var(--jsv-color-warning);
}
/* ========================================
Path Crumbs (sticky breadcrumb header)
Wrapper is sticky with height:0 (no layout impact)
Inner crumbs div is absolute, overlays content
======================================== */
.jsv-crumbs {
height: 2rem;
padding-left: 2rem;
padding-right: 1rem;
font-family: var(--jsv-font-mono);
font-size: var(--jsv-font-size);
line-height: 1;
z-index: 20;
background-color: var(--sl-color-bg, var(--jsv-color-bg, #fff));
color: var(--jsv-color-muted);
display: flex;
align-items: center;
gap: 0.25rem;
border-bottom: 1px solid var(--jsv-color-border);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
box-sizing: border-box;
/* Hidden by default */
opacity: 0;
pointer-events: none;
}
[data-theme="dark"] .jsv-crumbs,
:root[data-theme="dark"] .jsv-crumbs {
border-bottom-color: var(--sl-color-gray-5, #52525b);
}
.jsv-crumbs[data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
.jsv-crumb-current {
color: var(--jsv-color-body);
font-weight: 600;
}
/* ========================================
Select / Menu Dropdown
======================================== */
.jsv-select-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: var(--jsv-font-size);
font-family: var(--jsv-font-sans);
background-color: transparent;
border: 1px solid var(--jsv-color-border);
border-radius: 0.25rem;
cursor: pointer;
color: var(--jsv-color-muted);
line-height: 1.4;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.jsv-select-trigger:hover {
background-color: var(--jsv-color-canvas);
border-color: var(--jsv-color-muted);
}
.jsv-select-trigger:focus {
outline: none;
box-shadow: 0 0 0 2px var(--jsv-color-primary);
}
.jsv-select-positioner {
z-index: 9999 !important;
position: relative;
}
.jsv-select-popup {
background-color: var(--jsv-color-bg);
border: 1px solid var(--jsv-color-border);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 0.25rem 0;
min-width: 10rem;
overflow: hidden;
font-family: var(--jsv-font-sans);
font-size: var(--jsv-font-size);
}
.jsv-select-item {
padding: 0.5rem 0.75rem;
padding-left: 2rem;
font-size: var(--jsv-font-size);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
outline: none;
color: var(--jsv-color-body);
background-color: var(--jsv-color-bg);
position: relative;
transition: background-color 0.1s ease;
}
.jsv-select-item:hover,
.jsv-select-item:focus,
.jsv-select-item[data-highlighted] {
background-color: var(--jsv-color-canvas);
}
.jsv-select-item[data-selected] {
font-weight: 600;
}
.jsv-select-item-indicator {
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
color: var(--jsv-color-primary);
}
/* ========================================
Top Level Styles
======================================== */
.jsv-top-label {
font-family: var(--jsv-font-mono);
font-weight: 600;
font-size: var(--jsv-font-size);
padding-bottom: 0.5rem;
}
.jsv-top-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
}
.jsv-combiner-label {
display: flex;
align-items: center;
color: var(--jsv-color-muted);
font-size: var(--jsv-font-size);
}
/* ========================================
Link Styles
======================================== */
.jsv-link {
color: var(--jsv-color-primary);
cursor: pointer;
text-decoration: none;
margin-left: 0.5rem;
}
.jsv-link:hover {
text-decoration: underline;
}
/* ========================================
Misc Helpers
======================================== */
.jsv-cursor-pointer {
cursor: pointer;
}
.jsv-ml-2 {
margin-left: 0.5rem;
}
@@ -0,0 +1 @@
.jse-theme-dark{--jse-theme: dark;--jse-theme-color: #2f6dd0;--jse-theme-color-highlight: #467cd2;--jse-background-color: #1e1e1e;--jse-text-color: #d4d4d4;--jse-text-color-inverse: #4d4d4d;--jse-main-border: 1px solid #4f4f4f;--jse-menu-color: #fff;--jse-modal-background: #2f2f2f;--jse-modal-overlay-background: rgba(0, 0, 0, .5);--jse-modal-code-background: #2f2f2f;--jse-tooltip-color: var(--jse-text-color);--jse-tooltip-background: #4b4b4b;--jse-tooltip-border: 1px solid #737373;--jse-tooltip-action-button-color: inherit;--jse-tooltip-action-button-background: #737373;--jse-panel-background: #333333;--jse-panel-background-border: 1px solid #464646;--jse-panel-color: var(--jse-text-color);--jse-panel-color-readonly: #737373;--jse-panel-border: 1px solid #3c3c3c;--jse-panel-button-color-highlight: #e5e5e5;--jse-panel-button-background-highlight: #464646;--jse-navigation-bar-background: #656565;--jse-navigation-bar-background-highlight: #7e7e7e;--jse-navigation-bar-dropdown-color: var(--jse-text-color);--jse-context-menu-background: #4b4b4b;--jse-context-menu-background-highlight: #595959;--jse-context-menu-separator-color: #595959;--jse-context-menu-color: var(--jse-text-color);--jse-context-menu-pointer-background: #737373;--jse-context-menu-pointer-background-highlight: #818181;--jse-context-menu-pointer-color: var(--jse-context-menu-color);--jse-key-color: #9cdcfe;--jse-value-color: var(--jse-text-color);--jse-value-color-number: #b5cea8;--jse-value-color-boolean: #569cd6;--jse-value-color-null: #569cd6;--jse-value-color-string: #ce9178;--jse-value-color-url: #ce9178;--jse-delimiter-color: #949494;--jse-edit-outline: 2px solid var(--jse-text-color);--jse-selection-background-color: #464646;--jse-selection-background-inactive-color: #333333;--jse-hover-background-color: #343434;--jse-active-line-background-color: rgba(255, 255, 255, .06);--jse-search-match-background-color: #343434;--jse-collapsed-items-background-color: #333333;--jse-collapsed-items-selected-background-color: #565656;--jse-collapsed-items-link-color: #b2b2b2;--jse-collapsed-items-link-color-highlight: #ec8477;--jse-search-match-color: #724c27;--jse-search-match-outline: 1px solid #966535;--jse-search-match-active-color: #9f6c39;--jse-search-match-active-outline: 1px solid #bb7f43;--jse-tag-background: #444444;--jse-tag-color: #bdbdbd;--jse-table-header-background: #333333;--jse-table-header-background-highlight: #424242;--jse-table-row-odd-background: rgba(255, 255, 255, .1);--jse-input-background: #3d3d3d;--jse-input-border: var(--jse-main-border);--jse-button-background: #808080;--jse-button-background-highlight: #7a7a7a;--jse-button-color: #e0e0e0;--jse-button-secondary-background: #494949;--jse-button-secondary-background-highlight: #5d5d5d;--jse-button-secondary-background-disabled: #9d9d9d;--jse-button-secondary-color: var(--jse-text-color);--jse-a-color: #55abff;--jse-a-color-highlight: #4387c9;--jse-svelte-select-background: #3d3d3d;--jse-svelte-select-border: 1px solid #4f4f4f;--list-background: #3d3d3d;--item-hover-bg: #505050;--multi-item-bg: #5b5b5b;--input-color: #d4d4d4;--multi-clear-bg: #8a8a8a;--multi-item-clear-icon-color: #d4d4d4;--multi-item-outline: 1px solid #696969;--list-shadow: 0 2px 8px 0 rgba(0, 0, 0, .4);--jse-color-picker-background: #656565;--jse-color-picker-border-box-shadow: #8c8c8c 0 0 0 1px}
@@ -1,2 +1 @@
[**AIO** Config Type](/configuration?configType=aio#configuration-types)
{props.children}
@@ -0,0 +1,10 @@
<DetailsAdmo type="warning" summary="Insecure Communication">
This method of exposing Discord IPC is **not secure**. There is no authentication used for the traffic relayed to/from the opened TCP port.
You should only use this method if you trust the network your machine is on. Additionally:
* **Do** use a firewall to limit source (IP) of incoming traffic to this TCP port
* **Do not** expose this port to the public internet
</DetailsAdmo>
@@ -1,3 +1,5 @@
[**ENV** Config Type](/configuration?configType=env#configuration-types)
:::note[Configuration Type]
This is configuration for the **ENV** [Config Type](/configuration?configType=env#configuration-types).
:::
{props.children}
@@ -1,3 +1 @@
[**File** Config Type](/configuration?configType=file#configuration-types)
{props.children}
+116
View File
@@ -0,0 +1,116 @@
/**
* Inspired by typings from https://github.com/michal-worwag/hooks-ts/blob/main/packages/hooks-ts/src/useLocalStorage/useLocalStorage.ts
* and global updates/dedup from https://github.com/kaansey/react-localstorage-hook/blob/master/src/useLocalStorage/useLocalStorage.ts
*/
import { useState, useEffect, useCallback } from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
const localTypedStorageHook = <T>(key: string, initialValue: T, json: boolean = true): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
const item = window.localStorage.getItem(key);
if (json) {
try {
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
} else {
try {
if (null === item) {
return initialValue;
}
if (item === 'true') {
return true as T;
}
if (item === 'false') {
return false as T;
}
if (isNumeric(item)) {
if (item.includes('.')) {
return parseFloat(item) as T;
}
return parseInt(item) as T;
}
return item as T;
} catch (error) {
console.error(error);
return initialValue;
}
}
});
const updateValue = useCallback(
newValue => {
setStoredValue(() => {
if(json) {
localStorage.setItem(key, JSON.stringify(newValue))
} else {
localStorage.setItem(key, newValue)
}
return newValue
})
},
[key]
);
useEffect(() => {
const onStorage = (event: any) => {
if (event.storageArea === localStorage && event.key === key) {
setStoredValue(
event.newValue === null ? initialValue : event.newValue
)
}
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
});
return [storedValue, updateValue];
}
const isNumeric = (num: any) => (typeof(num) === 'number' || typeof(num) === "string" && num.trim() !== '') && !isNaN(num as number);
const createTypedLocalStorageHook = <T>(key: string, defaultValue: T, json?: boolean) => {
const updates: Array<any> = []
return () => {
const [value, setValue] = localTypedStorageHook<T>(key, defaultValue, json)
const updateValue = useCallback((newValue: T) => {
for (const update of updates) {
update(newValue)
}
}, [])
useEffect(() => {
updates.push(setValue)
return () => {
updates.splice(updates.indexOf(setValue), 1)
}
}, [setValue])
return [value, updateValue]
}
}
const hookInstances: Record<string, any> = {}
export const useTypedLocalStorage = <T>(key: string, defaultValue: T, json?: boolean): [T, (value: T) => void] => {
if (key in hookInstances) {
return hookInstances[key]()
}
// prevents usage during server static rendering
if(!ExecutionEnvironment.canUseDOM) {
return [defaultValue, () => null];
}
hookInstances[key] = createTypedLocalStorageHook(key, defaultValue, json)
return hookInstances[key]()
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Playground(): JSX.Element {
return (
<Layout
title={`Playground`}
description="Playground of docusaurus-json-schema-plugin"
description="Config Playground"
>
<PlaygroundComponent />
</Layout>
+5 -1
View File
@@ -9,6 +9,8 @@ import EnvType from "@site/src/components/snippets/_env-config.mdx";
import FileType from "@site/src/components/snippets/_file-config.mdx";
import AIOType from "@site/src/components/snippets/_aio-config.mdx";
import Config from "@site/src/components/GenericConfiguration.mdx";
import Button from '../components/Button';
import ButtonGroup from '../components/ButtonGroup';
export default {
// Re-use the default mapping
@@ -20,5 +22,7 @@ export default {
FileType,
AIOType,
Config,
DetailsAdmo
DetailsAdmo,
Button,
ButtonGroup
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 KiB

After

Width:  |  Height:  |  Size: 550 KiB

View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -4,7 +4,6 @@
"compilerOptions": {
"baseUrl": ".",
"resolveJsonModule": true,
// Extending "@tsconfig/docusaurus/tsconfig.json".types with "docusaurus-json-schema-plugin"
"types": ["node", "@docusaurus/module-type-aliases", "@docusaurus/theme-classic", "docusaurus-json-schema-plugin"]
"types": ["node", "@docusaurus/module-type-aliases", "@docusaurus/theme-classic","docusaurus-plugin-sass"]
}
}
+4 -1
View File
@@ -1,3 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
// @ts-check
import eslint from '@eslint/js';
@@ -33,4 +36,4 @@ export default tsEslint.config({
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
}
});
}, storybook.configs["flat/recommended"]);
+8190 -358
View File
File diff suppressed because it is too large Load Diff
+39 -7
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.11.2",
"version": "0.12.0",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -19,8 +19,12 @@
"docs:install": "cd docsite && npm ci --no-audit",
"docs:start": "cd docsite && npm start",
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
"postinstall": "patch-package",
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\""
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
"cliff": "git-cliff --unreleased",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"exports": {
".": {
@@ -49,14 +53,17 @@
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@awaitjs/express": "^0.6.3",
"@chakra-ui/react": "^3.34.0",
"@curvenote/ansi-to-react": "^7.0.0",
"@donedeal0/superdiff": "^1.1.1",
"@emotion/react": "^11.14.0",
"@ewanc26/tid": "^1.0.2",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.5",
"@foxxmd/logging": "^0.2.6",
"@foxxmd/redact-string": "^0.1.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@foxxmd/string-sameness": "^0.4.0",
@@ -70,6 +77,7 @@
"@svrooij/sonos": "^2.5.0",
"@tailwindcss/vite": "^4.1.16",
"@types/express-serve-static-core": "^4.19.6",
"@xhayper/discord-rpc": "^1.3.0",
"address": "^1.2.2",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
@@ -87,6 +95,7 @@
"cors": "^2.8.5",
"dayjs": "^1.10.4",
"dbus-ts": "^0.0.7",
"discord.js": "^14.25.1",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-session": "^1.17.2",
@@ -104,6 +113,7 @@
"jest-diff": "^30.2.0",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"jsondiffpatch-react": "^1.0.8",
"keyv": "^5.5.0",
"kodi-api": "^0.2.1",
"lastfm-ts-api": "^2.6.0",
@@ -112,22 +122,28 @@
"mpd-api": "^1.1.2",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"next-themes": "^0.4.6",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.7.6",
"p-event": "^4.2.0",
"p-map": "^7.0.4",
"p-retry": "^7.1.1",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
"patch-package": "^8.0.0",
"prom-client": "^15.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"react-virtuoso": "^4.18.3",
"round-robin-js": "^3.0.10",
"sass": "^1.93.3",
"serialize-error": "^13.0.1",
"shiki": "^4.0.2",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^4.1.16",
@@ -144,11 +160,17 @@
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@dbus-types/notifications": "^0.0.5",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.1",
"@gr2m/net-interceptor": "^1.0.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-docs": "10.1.11",
"@storybook/addon-onboarding": "10.1.11",
"@storybook/addon-vitest": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -165,28 +187,38 @@
"@types/jest": "^27.5.2",
"@types/jscodeshift": "^0.11.6",
"@types/mocha": "^9.1.0",
"@types/node": "^18.0.0",
"@types/node": "^20.19.2",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
"@types/react-window": "^1.8.5",
"@types/sinon": "^21.0.0",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-storybook": "10.1.11",
"git-cliff": "^2.12.0",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.1.2",
"msw": "^2.12.10",
"neotraverse": "^0.6.18",
"nodemon": "^3.0.3",
"ts-essentials": "^9.1.2",
"typescript": "5.5.4",
"playwright": "^1.58.2",
"sinon": "^21.0.2",
"storybook": "^10.2.0",
"ts-essentials": "^10.1.1",
"typescript": "^5.9.3",
"typescript-eslint": "^7.0.1",
"vite": "^5.2.12",
"vitest": "^4.1.0",
"with-local-tmp-dir": "^6.0.0"
},
"browserslist": {
+85
View File
@@ -0,0 +1,85 @@
diff --git a/node_modules/@xhayper/discord-rpc/dist/transport/IPC.d.ts b/node_modules/@xhayper/discord-rpc/dist/transport/IPC.d.ts
index 6da3fb1..796344d 100644
--- a/node_modules/@xhayper/discord-rpc/dist/transport/IPC.d.ts
+++ b/node_modules/@xhayper/discord-rpc/dist/transport/IPC.d.ts
@@ -6,7 +6,7 @@ export declare enum IPC_OPCODE {
PING = 3,
PONG = 4
}
-export type FormatFunction = (id: number) => string;
+export type FormatFunction = (id: number) => string | [number, string];
export type PathData = {
platform: NodeJS.Platform[];
format: FormatFunction;
diff --git a/node_modules/@xhayper/discord-rpc/dist/transport/IPC.js b/node_modules/@xhayper/discord-rpc/dist/transport/IPC.js
index 2a9a7b3..074f5bb 100644
--- a/node_modules/@xhayper/discord-rpc/dist/transport/IPC.js
+++ b/node_modules/@xhayper/discord-rpc/dist/transport/IPC.js
@@ -59,9 +59,12 @@ const createSocket = async (path) => {
socket.removeListener("error", onError);
resolve(socket);
};
- const socket = node_net_1.default.createConnection(path);
+ let socket;
+ if (typeof path === "string") socket = node_net_1.default.createConnection(path);
+ else socket = node_net_1.default.createConnection(path[0], path[1]);
socket.once("connect", onConnect);
socket.once("error", onError);
+ socket.setKeepAlive(true, 10000);
});
};
class IPCTransport extends Transport_1.Transport {
@@ -100,14 +103,19 @@ class IPCTransport extends Transport_1.Transport {
else
for (let i = 0; i < 10; i++)
pipeIdList.push(i);
- for (const pipeId of pipeIdList) {
- const socketPath = pat.format(pipeId);
- if (process.platform !== "win32" && !node_fs_1.default.existsSync(socketPath))
- continue;
- useablePath.push(socketPath);
+ const test = pat.format(pipeId);
+ if(Array.isArray(test)) {
+ useablePath.push(test);
+ } else {
+ for (const pipeId of pipeIdList) {
+ const socketPath = pat.format(pipeId);
+ if (process.platform !== "win32" && typeof socketPath === 'string' && !node_fs_1.default.existsSync(socketPath))
+ continue;
+ useablePath.push(socketPath);
+ }
}
}
- this.client.emit("debug", `CLIENT | Found ${useablePath.length} Discord client path;\n${useablePath.join("\n")}`);
+ this.client.emit("debug", `CLIENT | Found ${useablePath.length} Discord client path;\n${useablePath.map(x => Array.isArray(x) ? `${x[1]}:${x[0]}` : x).join("\n")}`);
if (useablePath.length < 0)
return reject(new RPCError_1.RPCError(Transport_1.CUSTOM_RPC_ERROR_CODE.COULD_NOT_FIND_CLIENT, "Unable to find any Discord client"));
for (const path of useablePath) {
@@ -134,11 +142,12 @@ class IPCTransport extends Transport_1.Transport {
const chunk = this.socket?.read();
if (!chunk)
break;
- this.client.emit("debug", `SERVER => CLIENT | ${chunk
- .toString("hex")
- .match(/.{1,2}/g)
- ?.join(" ")
- .toUpperCase()}`);
+ // this.client.emit("debug", `SERVER => CLIENT | ${chunk
+ // .toString("hex")
+ // .match(/.{1,2}/g)
+ // ?.join(" ")
+ // .toUpperCase()}`);
+ this.client.emit("debug", `SERVER => CLIENT | ${chunk.toString()}`);
data = Buffer.concat([data, chunk]);
} while (true);
if (data.length < 8) {
@@ -187,6 +196,9 @@ class IPCTransport extends Transport_1.Transport {
this.socket = undefined;
this.emit("close", "Closed by Discord");
});
+ this.socket.on("error", (e) => {
+ this.emit("error", e);
+ });
}
send(message, op = IPC_OPCODE.FRAME) {
this.client.emit("debug", `CLIENT => SERVER | OPCODE.${IPC_OPCODE[op]} |`, message);
+64 -49
View File
@@ -4,7 +4,7 @@ import {
} from "@foxxmd/regex-buddy-core";
import deepEqual from 'fast-deep-equal';
import { Simulate } from "react-dom/test-utils";
import { PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
@@ -27,11 +27,14 @@ import { findCauseByReference } from "../utils/ErrorUtils.js";
import { hashObject } from "../utils/StringUtils.js";
import { metaInvariantTransform, playContentInvariantTransform } from "../utils/PlayComparisonUtils.js";
import { MSCache } from "./Cache.js";
import { jdiff } from "../utils/DataUtils.js";
import { jdiff } from "../../core/DataUtils.js";
import ConsoleFormatter from "jsondiffpatch/formatters/console";
import clone from "clone";
const console = new ConsoleFormatter();
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
export default abstract class AbstractComponent extends AbstractInitializable {
declare config: CommonClientConfig | CommonSourceConfig;
@@ -41,9 +44,9 @@ export default abstract class AbstractComponent extends AbstractInitializable {
protected transformManager: TransformerManager;
protected cache: MSCache;
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
protected constructor(config: AbstractComponentConfig) {
super(config);
this.transformManager = getRoot().items.transformerManager;
this.transformManager = config.transformManager ?? getRoot().items.transformerManager;
this.cache = getRoot().items.cache();
}
@@ -196,7 +199,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(metaInvariantTransform(play))}`;
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
const cachedTransformPlay = await this.cache.cacheTransform.get<PlayObject>(transformHash);
if(cachedTransformPlay !== undefined) {
// if(shouldLog) {
@@ -209,6 +212,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
logger.debug(`Transform start for => ${buildTrackString(play)}`);
let transformedPlay: PlayObject = play;
let transformHistory: TransformResult[] = [];
let shouldBreak = false;
for(const hookItem of hook) {
const {
@@ -218,6 +222,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
failureReturnPartial = false
} = hookItem;
const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 ? transformedPlay.meta.lifecycle.steps[existingStepIndex] : {
name: hookType,
source: this.getIdentifier(),
}
let newTransformedPlay: PlayObject,
stageName: string = 'Unnamed',
err: Error,
@@ -233,35 +243,69 @@ export default abstract class AbstractComponent extends AbstractInitializable {
if(err !== undefined) {
const merged = mergeSimpleError(err);
step.error = merged;
const skipError = findCauseByReference(err, SkipTransformStageError);
if(skipError !== undefined) {
let skipMsg = `Stage '${stageName}' was skipped`;
step.flowResult = onSkip;
step.flowKnownState = 'skip';
if(onSkip === 'stop') {
skipMsg += ' and will stop transform due to onSkip: stop';
}
step.flowReason = skipMsg;
logger.debug(merged, skipMsg);
if(onSkip === 'stop') {
break;
shouldBreak = true;
}
} else if(onFailure === 'continue') {
logger.warn(merged, 'A transform encounte[red an error but continuing due to onFailure: continue');
logger.warn(merged, 'Transform encountered an error but continuing due to onFailure: continue');
step.flowReason = 'Transform encountered an error but continuing due to onFailure: continue';
step.flowResult = onFailure;
} else {
step.flowResult = onFailure;
const reqError = findCauseByReference(err, StagePrerequisiteError);
if(reqError !== undefined) {
logger.warn(merged, 'Transform could not be completed due to prerequisite failure');
const prereqMsg = 'Transform could not be completed due to prerequisite failure';
logger.warn(merged, prereqMsg);
step.flowKnownState = 'prereq';
step.flowReason = prereqMsg;
} else {
logger.error(merged, 'Transform encountered an error');
cacheOk = false;
step.flowReason = 'Transform encountered an error';
}
if(!failureReturnPartial) {
// rewind to original play so we don't return partial transform
transformedPlay = play;
transformHistory = [];
} else if(transformHistory.length > 0) {
step.flowReason += ' | Preserving play transformations up to this point due to failureReturnPartial=true'
}
break;
shouldBreak = true;
}
} else {
step.flowResult = onSuccess;
if(!deepEqual(play, newTransformedPlay)) {
const o = JSON.parse(JSON.stringify(play));
const t = JSON.parse(JSON.stringify(newTransformedPlay));
const patch = jdiff.diff(o,t);
step.patch = patch;
}
if(newTransformedPlay.meta.lifecycleInputs?.length > 0) {
step.inputs = clone(newTransformedPlay.meta.lifecycleInputs)
} else if(transformedPlay.meta.lifecycleInputs?.length > 0) {
step.inputs = clone(transformedPlay.meta.lifecycleInputs)
}
delete transformedPlay.meta.lifecycleInputs;
// for logging only
transformHistory.push({
type: hookItem.type,
name: stageName,
@@ -272,22 +316,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
if(err === undefined && onSuccess === 'stop') {
logger.debug(`Stopping transform due to onSuccess: stop`);
shouldBreak = true;
}
if(existingStepIndex !== -1) {
transformedPlay.meta.lifecycle.steps[existingStepIndex] = step;
} else {
transformedPlay.meta.lifecycle.steps.push(step);
}
if(shouldBreak) {
break;
}
}
const orig = play.data;
// if(transformedPlay.meta.transforms === undefined) {
// transformedPlay.meta.transforms = {
// original: play.data
// };
// }
const o = JSON.parse(JSON.stringify(play));
const t = JSON.parse(JSON.stringify(transformedPlay));
const patch = jdiff.diff(o,t);
if(shouldLog !== false) {
if(transformHistory.length === 0) {
logger.debug('Transform Diff: No Change');
@@ -322,34 +364,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier());
if(existingStepIndex !== -1) {
transformedPlay.meta.lifecycle.steps[existingStepIndex].patch = patch;
transformedPlay.meta.lifecycle.steps[existingStepIndex].inputs = transformedPlay.meta.lifecycleInputs;
} else {
transformedPlay.meta.lifecycle.steps.push({
name: hookType,
source: this.getIdentifier(),
patch: patch,
inputs: transformedPlay.meta.lifecycleInputs
});
}
delete transformedPlay.meta.lifecycleInputs;
// transformedPlay.meta.lifecycle = {
// ...(transformedPlay.meta.lifecycle ?? {
// original: play
// }),
// steps: [
// ...(transformedPlay.meta.lifecycle?.steps ?? []),
// {
// name: hookType,
// source: this.getIdentifier(),
// patch: patch
// }
// ]
// }
await this.cache.cacheTransform.set<PlayObject>(transformHash, transformedPlay, '5m');
await this.cache.cacheTransform.set<PlayObject>(transformHash, transformedPlay, '10m');
return transformedPlay;
} catch (e) {
logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
+7 -5
View File
@@ -13,11 +13,11 @@ import { childLogger, Logger } from '@foxxmd/logging';
import { projectDir } from './index.js';
import path from 'path';
import { cacheFunctions } from "@foxxmd/regex-buddy-core";
import { fileOrDirectoryIsWriteable } from '../utils.js';
import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js';
import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js';
import { Typeson } from 'typeson';
import { builtin } from 'typeson-registry';
import { MaybeLogger } from './logging.js';
import { loggerNoop } from './MaybeLogger.js';
import { ListenProgressPositional, ListenProgressTS } from '../sources/PlayerState/ListenProgress.js';
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
@@ -50,6 +50,7 @@ export class MSCache {
cacheAuth: Cacheable;
regexCache: ReturnType<typeof cacheFunctions>;
cacheTransform: Cacheable;
cacheClientScrobbles: Cacheable;
logger: Logger;
@@ -96,6 +97,7 @@ export class MSCache {
this.regexCache = cacheFunctions(this.config.regex);
this.cacheTransform = new Cacheable({primary: initMemoryCache({lruSize: 500})});
this.cacheClientScrobbles = new Cacheable({primary: initMemoryCache({lruSize: 100, ttl: '5m'})});
}
init = async () => {
@@ -213,7 +215,7 @@ export const flatCacheCreate = (opts: FlatCacheOptions) => {
});
}
export const flatCacheLoad = async (flatCache: FlatCache, logger: MaybeLogger): Promise<void> => {
export const flatCacheLoad = async (flatCache: FlatCache, logger: Logger = loggerNoop): Promise<void> => {
const cachePath = path.join(flatCache.cacheDir, flatCache.cacheId);
try {
@@ -224,7 +226,7 @@ export const flatCacheLoad = async (flatCache: FlatCache, logger: MaybeLogger):
const streamPromise = new Promise((resolve, reject) => {
flatCache.loadFileStream(cachePath, (progress: number, total: number) => {
logger.debug(`Loading ${progress}/${total} chunks...`);
logger.trace(`Loading ${progress}/${total} chunks...`);
}, () => {
resolve(true);
}, (err: Error) => {
@@ -260,7 +262,7 @@ export const flatCacheLoad = async (flatCache: FlatCache, logger: MaybeLogger):
}
}
export const initFileCache = async (opts: FlatCacheOptions = {}, logger: MaybeLogger = new MaybeLogger()): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => {
export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger = loggerNoop): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => {
const flatCache = flatCacheCreate(opts);
try {
await flatCacheLoad(flatCache, logger);
+67
View File
@@ -0,0 +1,67 @@
import { Logger } from "@foxxmd/logging";
export class MaybeLogger {
logger?: Logger;
constructor(logger?: Logger, label?: string) {
if (logger !== undefined && label !== undefined) {
this.logger = logger;//this.logger = childLogger(logger, label);
} else {
this.logger = logger;
}
}
public info(first: any, ...rest: any) {
if (this.logger) {
this.logger.info(first, ...rest);
}
}
public debug(first: any, ...rest: any) {
if (this.logger) {
this.logger.debug(first, ...rest);
}
}
public trace(first: any, ...rest: any) {
if (this.logger) {
this.logger.trace(first, ...rest);
}
}
public warn(first: any, ...rest: any) {
if (this.logger) {
this.logger.warn(first, ...rest);
}
}
public verbose(first: any, ...rest: any) {
if (this.logger) {
this.logger.verbose(first, ...rest);
}
}
public error(first: any, ...rest: any) {
if (this.logger) {
this.logger.error(first, ...rest);
}
}
}
export const noopLog = (_: any, ...rest: any) => undefined;
export const loggerNoop: Logger = {
trace: noopLog,
debug: noopLog,
log: noopLog,
info: noopLog,
verbose: noopLog,
warn: noopLog,
error: noopLog,
fatal: noopLog,
silent: noopLog,
level: 'silent',
child: (_: any, ...rest: any) => loggerNoop as Logger
} as unknown as Logger;
+1 -1
View File
@@ -12,7 +12,7 @@ export class UpstreamError extends Error {
constructor(message: string, options?: UpstreamErrorOptions | undefined) {
super(message, options);
const {showStopper = false, response, responseBody} = options;
const {showStopper = false, response, responseBody} = options ?? {};
this.showStopper = showStopper;
this.response = response;
this.responseBody = responseBody;
+118 -143
View File
@@ -1,140 +1,16 @@
import { Logger } from '@foxxmd/logging';
import { Dayjs } from "dayjs";
import { Dayjs, ManipulateType } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { isPlayObject, PlayMeta, PlayObject, PlayObjectLifecycleless } from "../../../core/Atomic.js";
import { isPlayObject, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
import { MusicBrainzApi } from 'musicbrainz-api';
export type SourceType =
'spotify'
| 'plex'
| 'subsonic'
| 'jellyfin'
| 'lastfm'
| 'librefm'
| 'deezer'
| 'endpointlz'
| 'endpointlfm'
| 'ytmusic'
| 'mpris'
| 'mopidy'
| 'musiccast'
| 'listenbrainz'
| 'jriver'
| 'kodi'
| 'webscrobbler'
| 'chromecast'
| 'maloja'
| 'musikcube'
| 'mpd'
| 'vlc'
| 'icecast'
| 'azuracast'
| 'koito'
| 'tealfm'
| 'rocksky'
| 'sonos';
export const sourceTypes: SourceType[] = [
'spotify',
'plex',
'subsonic',
'jellyfin',
'lastfm',
'librefm',
'deezer',
'endpointlz',
'endpointlfm',
'ytmusic',
'mpris',
'mopidy',
'musiccast',
'listenbrainz',
'jriver',
'kodi',
'webscrobbler',
'chromecast',
'maloja',
'musikcube',
'mpd',
'vlc',
'icecast',
'azuracast',
'koito',
'tealfm',
'rocksky',
'sonos'
];
export const sourceInterfaces = [
'AIOSourceRelaxedConfig',
'SpotifySourceConfig',
'PlexCompatConfig',
'DeezerCompatConfig',
'ListenbrainzEndpointSourceConfig',
'LastFMEndpointSourceConfig',
'IcecastSourceConfig',
'SubSonicSourceConfig',
'JellyfinCompatConfig',
'LastfmSourceConfig',
'LibrefmSourceConfig',
'YTMusicSourceConfig',
'MalojaSourceConfig',
'MPRISSourceConfig',
'MopidySourceConfig',
'ListenBrainzSourceConfig',
'JRiverSourceConfig',
'KodiSourceConfig',
'ChromecastSourceConfig',
'WebScrobblerSourceConfig',
'MusikcubeSourceConfig',
'MusicCastSourceConfig',
'MPDSourceConfig',
'VLCSourceConfig',
'AzuracastSourceConfig',
'KoitoSourceConfig',
'TealSourceConfig',
'RockskySourceConfig',
'SonosSourceConfig'
]
export const isSourceType = (data: string): data is SourceType => {
return sourceTypes.includes(data as SourceType);
}
import { SourceType } from './config/source/sources.js';
import { ClientType, clientTypes } from './config/client/clients.js';
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export type ClientType =
'maloja'
| 'lastfm'
| 'librefm'
| 'listenbrainz'
| 'koito'
| 'tealfm'
| 'rocksky';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
'librefm',
'listenbrainz',
'koito',
'tealfm',
'rocksky'
];
export const clientInterfaces = [
'AIOClientRelaxedConfig',
'MalojaClientConfig',
'LastfmClientConfig',
'LibrefmClientConfig',
'ListenBrainzClientConfig',
'KoitoClientConfig',
'TealClientConfig',
'RockSkyClientConfig'
];
export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
}
@@ -222,14 +98,15 @@ export interface FormatPlayObjectOptions {
[key: string]: any
}
export interface ProgressAwarePlayObject extends PlayObject {
meta: PlayMeta & {
export interface ProgressAwarePlayObject extends PlayObjectLifecycleless {
meta: PlayMetaLifecycleless & {
initialTrackProgressPosition?: number
}
}
export type DeviceId = string;
export type PlayUserId = string;
export type PlayPlatformId = [DeviceId, PlayUserId];
export type PlayPlatformIdStr = string;
export type GroupedPlays = TupleMap<DeviceId,PlayUserId,ProgressAwarePlayObject[]>;
@@ -239,6 +116,7 @@ export const NO_DEVICE = 'NoDevice';
export const NO_USER = 'SingleUser';
export const SINGLE_USER_PLATFORM_ID: PlayPlatformId = [NO_DEVICE, NO_USER];
export const SINGLE_USER_PLATFORM_ID_STR = `${NO_DEVICE}-${NO_USER}`;
export interface ScrobbledPlayObject {
play: PlayObject
@@ -305,18 +183,6 @@ export type ExpressRequest = Request<ParamsDictionary, any, any, Query, Record<s
export type ExpressResponse = Response<any, Record<string, any>>;
export type ExpressHandler = (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>
export interface numberFormatOptions {
toFixed: number,
defaultVal?: any,
prefix?: string,
suffix?: string,
round?: {
type?: string,
enable: boolean,
indicate?: boolean,
}
}
export const DELIMITERS = [',','&','/','\\'];
export const DELIMITERS_NO_AMP = [',','/','\\'];
export const DELIMETERS_REGEX: RegExp = new RegExp(/[,&\/\\]/);
@@ -341,6 +207,10 @@ export interface Authenticatable {
testAuth: () => Promise<any>
}
export interface ScrobbleRangeFetchable {
getScrobblesForTimeRange: TimeRangeListensFetcher
}
export interface MdnsDeviceInfo {
name: string
type: string
@@ -399,4 +269,109 @@ export interface MusicbrainzApiConfigData {
export const MUSICBRAINZ_URL = 'https://musicbrainz.org';
export const MBID_VARIOUS_ARTISTS = "89ad4ac3-39f7-470e-963a-56509c546377";
export type MusicBrainzSingletonMap = Map<string,MusicBrainzApi>;
export type MusicBrainzSingletonMap = Map<string,MusicBrainzApi>;
/* https://websocket.org/reference/close-codes/ */
export const WEBSOCKET_CLOSE_CODE_REASONS = {
1000: 'Closed gracefully',
1001: 'Going Away',
1002: 'Protocol Error',
1003: 'Unsupported Data',
1005: 'No Status Received',
1006: 'Abnormal Closure (Connection lost unexpectedly)',
1008: 'Policy Violation',
1009: 'Message Too Big',
1011: 'Server-side exception',
1012: 'Service Restart',
1013: 'Try Again Later',
1014: 'Bad Gateway',
1015: 'TLD handshake fail'
}
export const WEBSOCKET_CLOSE_CODE = {
AbnormalClosure: 1006,
InternalError: 1011
}
export const WEBSOCKET_CLOSE_CODES_RETRY = [1006,1011,1001];export interface PaginatedLimit {
/** per page max number of results to return */
limit?: number
}
export interface PaginatedTimeRangeOptions {
/** Unix timestamp */
from: UnixTimestamp
/** Unix timestamp */
to: UnixTimestamp
/** maximum number of results to fetch before returning early */
fetchMax?: number
}
export type PaginatedTimeRangeCommonOptions = Partial<PaginatedTimeRangeOptions> & PaginatedLimit;
export type CursorType = number | string;
export interface PaginatedListensOptions<T extends CursorType> extends PaginatedLimit {
cursor: T
}
export interface PagelessListensTimeRangeOptions extends PaginatedTimeRangeCommonOptions {
}
export interface PaginatedListensTimeRangeOptions<T extends CursorType = CursorType> extends Partial<PaginatedTimeRangeOptions>, PaginatedListensOptions<T> {
}
export interface PaginatedResults<T extends CursorType = CursorType> {
total?: number
more?: boolean
cursorNext?: T
order?: 'desc' | 'asc'
}
export interface PaginatedListens {
getPaginatedListens(params: PaginatedListensOptions<CursorType>): Promise<{data: PlayObject[], meta: PaginatedListensOptions<CursorType> & PaginatedResults<CursorType>}>
}
export const hasPaginagedListens = (obj: Object): obj is PaginatedListens => {
return 'getPaginatedListens' in obj;
}
export interface PaginatedTimeRangeListensResult<T extends CursorType> {
data: PlayObject[];
meta: PaginatedListensTimeRangeOptions<T> & PaginatedResults<T>;
}
export interface PaginatedTimeRangeListens<T extends CursorType = CursorType> {
getPaginatedTimeRangeListens(params: PaginatedListensTimeRangeOptions<T>): Promise<PaginatedTimeRangeListensResult<T>>
getPaginatedUnitOfTime(): ManipulateType;
}
export const hasPaginatedTimeRangeListens = (obj: Object): obj is PaginatedTimeRangeListens => {
return 'getPaginatedTimeRangeListens' in obj;
}
export interface PagelessTimeRangeListensResult {
data: PlayObject[]
meta: PagelessListensTimeRangeOptions & PaginatedResults
}
export interface PagelessTimeRangeListens {
getPagelessTimeRangeListens(params: PagelessListensTimeRangeOptions): Promise<PagelessTimeRangeListensResult>
getPaginatedUnitOfTime(): ManipulateType;
}
export const hasPagelessTimeRangeListens = (obj: Object): obj is PagelessTimeRangeListens => {
return 'getPagelessTimeRangeListens' in obj;
}
export type PaginatedTimeRangeSource = PaginatedTimeRangeListens | PagelessTimeRangeListens;
export type PaginatedSource = PaginatedListens | PaginatedTimeRangeSource;
export type TimeRangeListensFetcher<T extends CursorType = CursorType> = (opts: PaginatedTimeRangeCommonOptions | PaginatedListensTimeRangeOptions<T>) => Promise<PlayObject[]>
export interface ScrobbleRangeResult {
plays: PlayObject[]
fetchedAt: Dayjs
}
export const REFRESH_STALE_DEFAULT = 60;
@@ -5,7 +5,7 @@ import { RequestRetryOptions } from "./common.js";
import { WebhookConfig } from "./health/webhooks.js";
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
import { SourceAIOConfig } from "./source/sources.js";
import { CacheConfigOptions, ClientType, SourceType } from "../Atomic.js";
import { CacheConfigOptions } from "../Atomic.js";
import { TransformerCommonConfig } from "../../../../core/Atomic.js";
@@ -5,7 +5,75 @@ import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
import { TealClientAIOConfig, TealClientConfig } from "./tealfm.js";
import { RockSkyClientAIOConfig, RockSkyClientConfig } from "./rocksky.js";
import { LibrefmClientConfig, LibrefmClientAIOConfig } from "./librefm.js";
import { DiscordClientAIOConfig, DiscordClientConfig } from "./discord.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | LibrefmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig;
export type ClientConfig =
MalojaClientConfig
| LastfmClientConfig
| LibrefmClientConfig
| ListenBrainzClientConfig
| KoitoClientConfig
| TealClientConfig
| RockSkyClientConfig
| DiscordClientConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | LibrefmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig;
export type ClientAIOConfig = MalojaClientAIOConfig
| LastfmClientAIOConfig
| LibrefmClientAIOConfig
| ListenBrainzClientAIOConfig
| KoitoClientAIOConfig
| TealClientAIOConfig
| RockSkyClientAIOConfig
| DiscordClientAIOConfig;
/** Used for docusaurus schemas
* We need to show "array of" for each type of config when looking at File Config
*
* This is defined in the AIO config and we *assume* arrays in individual files when parsing in builders
* But we don't have any actual definitions for this that we can pull for generating individual schema files
*/
export type MalojaClientConfigs = MalojaClientConfig[];
export type LastfmClientConfigs = LastfmClientConfig[];
export type LibrefmClientConfigs = LibrefmClientConfig[];
export type ListenBrainzClientConfigs = ListenBrainzClientConfig[];
export type KoitoClientConfigs = KoitoClientConfig[];
export type TealClientConfigs = TealClientConfig[];
export type RockSkyClientConfigs = RockSkyClientConfig[];
export type DiscordClientConfigs = DiscordClientConfig[];
export const atomicClientInterfaces = [
'MalojaClientConfig',
'LastfmClientConfig',
'LibrefmClientConfig',
'ListenBrainzClientConfig',
'KoitoClientConfig',
'TealClientConfig',
'RockSkyClientConfig',
'DiscordClientConfig'
];
export const clientInterfaces = [
'AIOClientRelaxedConfig',
...atomicClientInterfaces
];
export type ClientType =
'maloja'
| 'lastfm'
| 'librefm'
| 'listenbrainz'
| 'koito'
| 'tealfm'
| 'rocksky'
| 'discord';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
'librefm',
'listenbrainz',
'koito',
'tealfm',
'rocksky',
'discord'
];
@@ -0,0 +1,125 @@
import { ActivityType, StatusDisplayType } from "discord.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
export interface DiscordData {
token?: string
applicationId?: string
artwork?: boolean | string | string[]
artworkDefaultUrl?: string
statusOverrideAllow?: string | StatusType[]
listeningActivityAllow?: string | string[]
ipcLocations?: string | (string | [number, string])[]
}
export interface DiscordClientData extends DiscordData, CommonClientData {}
export interface DiscordClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Koito as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: DiscordClientData
}
export interface DiscordClientAIOConfig extends DiscordClientConfig {
type: 'discord'
}
export type ActivityTypeString = 'playing' | 'streaming' | 'listening' | 'watching' | 'custom' | 'competing' | 'hanging';
export const ActivityTypes: ActivityTypeString[] = ['playing','streaming','listening','watching','custom','competing', 'hanging'];
export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible';
export interface DiscordStrongData extends DiscordData {
artwork?: boolean | string[]
statusOverrideAllow?: StatusType[]
listeningActivityAllow?: string[]
ipcLocations?: (string | [number, string])[]
}
export interface DiscordWSData extends DiscordStrongData {
token: string
}
export interface DiscordIPCData extends DiscordStrongData {
applicationId: string
//ipcLocations: (string | [number, string])[]
}
export interface ActivityAssets {
largeImage?: string
largeText?: string
largeUrl?: string
smallImage?: string
smallText?: string
smallUrl?: string
}
export interface ActivityTimestamps {
start?: number
end?: number
}
export enum ActivityTypeOthers {
Hanging = 6
}
export declare enum ActivityTypeReal {
/**
* Playing \{game\}
*/
Playing = 0,
/**
* Streaming \{details\}
*/
Streaming = 1,
/**
* Listening to \{name\}
*/
Listening = 2,
/**
* Watching \{details\}
*/
Watching = 3,
/**
* \{emoji\} \{state\}
*/
Custom = 4,
/**
* Competing in \{name\}
*/
Competing = 5,
Hanging = 6
}
export interface ActivityData {
name: string
details?: string
detailsUrl?: string
state?: string
stateUrl?: string
activityType?: ActivityType
statusDisplayType?: StatusDisplayType
assets?: ActivityAssets
timestamps?: ActivityTimestamps
createdAt: number
}
export const ACTIVITY_TYPE = {
Playing: 0,
Streaming: 1,
Listening: 2,
Watching: 3,
Custom: 4,
Competing: 5,
Hanging: 6
} as const satisfies Record<string, ActivityTypeReal>
export const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png';
export const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png';
@@ -1,3 +1,4 @@
import { UnixTimestamp } from "../../../../../core/Atomic.js"
import { RequestRetryOptions } from "../common.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
@@ -15,6 +16,18 @@ export interface ListenObjectResponse {
track: TrackResponse
}
export interface GetListensOptions {
limit?: number
page?: number
week?: number
month?: number
year?: number
// new in 0.1.0
to?: UnixTimestamp
from?: UnixTimestamp
}
export interface TrackResponse {
id: number
title: string
@@ -38,3 +38,11 @@ export interface ListenBrainzClientConfig extends CommonClientConfig {
export interface ListenBrainzClientAIOConfig extends ListenBrainzClientConfig {
type: 'listenbrainz'
}
/** https://github.com/metabrainz/listenbrainz-server/pull/2572
* https://github.com/metabrainz/listenbrainz-server/blob/master/listenbrainz/webserver/views/api_tools.py#L48
*/
export const MAX_ITEMS_PER_GET_LZ = 1000;
export const DEFAULT_ITEMS_PER_GET_LZ = 25;
export const DEFAULT_MS_ITEMS_PER_GET_LZ = 100;
@@ -39,6 +39,9 @@ export interface DeezerInternalData extends CommonSourceData, PollingOptions {
* @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0"
*/
userAgent?: string
/** The ID (USER_ID) of the linked account to monitor. If not set, monitors the main ARL account */
accountId?: string
}
export interface DeezerInternalSourceConfig extends CommonSourceConfig {
@@ -1,7 +1,9 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import {
// @ts-expect-error weird typings?
CollectionType
CollectionType,
// @ts-expect-error weird typings?
MediaType,
} from "@jellyfin/sdk/lib/generated-client/index.js";
export interface JellyApiData extends CommonSourceData {
@@ -66,9 +68,22 @@ export interface JellyApiData extends CommonSourceData {
* Force media with a type of "Unknown" to be counted as Audio
*
* @default false
* @deprecated use allowMediaTypes instead
*/
allowUnknown?: boolean
/**
* Allow these media types to be scrobbled.
*
* If not defined or empty, uses 'Audio' as default. If non-empty then *only* uses these types (make sure you include Audio!)
*
* Values are case-insensitive.
*
* See https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22 for possible types
*
*/
allowMediaTypes?: MediaType[] | string
/**
* HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from)
* If unspecified it will use the normal server HOST and PORT from the `url`
@@ -84,7 +99,7 @@ export interface JellyApiOptions extends CommonSourceOptions {
export interface JellyApiSourceConfig extends CommonSourceConfig {
data: JellyApiData
options: JellyApiOptions
options?: JellyApiOptions
}
export interface JellyApiSourceAIOConfig extends JellyApiSourceConfig {

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