Compare commits

...
181 Commits
Author SHA1 Message Date
FoxxMD 4fdd346c00 Merge branch 'refs/heads/develop' 2024-08-20 09:18:01 -04:00
FoxxMD 4f26b63ab1 chore: Bump version for release 2024-08-20 09:17:28 -04:00
FoxxMD 0fed91302c chore(ci): Update untagged package schedule to run once a week
Doesn't need to run every day.
2024-08-19 11:55:47 -04:00
FoxxMD 831cefd9a7 docs(deezer): Deprecate due to discontinued API support
Deprecate Source with warning message and update docs. #175
2024-08-19 11:25:11 -04:00
FoxxMD 114a3dda8b feat(ui): Add timestamp to recent pages #167 2024-07-26 10:07:25 -04:00
FoxxMD 55ddd2c5a0 docs: Mention data modification in readme 2024-07-26 09:38:18 -04:00
FoxxMD 1d276186f8 feat: Implement removing field if field is an empty string after transforming 2024-07-26 09:30:58 -04:00
FoxxMD fb3886b981 docs: Modification improvements 2024-07-26 09:19:11 -04:00
FoxxMD 2bed7be654 docs: Fix compare example 2024-07-25 17:47:57 -04:00
FoxxMD 5c460ad747 docs: Add Play Transform docs 2024-07-25 17:28:49 -04:00
FoxxMD 32220d7509 feat: Implement play transforms in source/client logic 2024-07-25 16:16:58 -04:00
FoxxMD b0265001f8 feat: Improve logging for play transforms
* Use a proper child logger and error messages on catch
* Log full play string before => after instead of just parts
2024-07-25 15:12:39 -04:00
FoxxMD 9ede4634cb test: Use faker 9.0RC for more plausible artist/album names in generated data 2024-07-25 15:04:37 -04:00
FoxxMD 0e008ecd5c feat: Add album option to play string building function 2024-07-25 15:03:39 -04:00
FoxxMD 2ae82fdd9e feat: Implement common play transform functionality 2024-07-25 13:43:34 -04:00
Matt Foxx 3b430b95ed Merge pull request #174 from FoxxMD/issue-173-scrobbler-improvement
Enable more agressive scrobble checking behavior
2024-07-25 09:18:07 -04:00
FoxxMD e907892c4a refactor(scrobbler): Change refresh force to user-configurable staleness time
Use refreshStaleAfter to force refresh to user can configure the time.

Allows more nuanced refreshing behavior as well as always refresh (0 seconds)
2024-07-24 12:06:20 -04:00
FoxxMD d9cd3764b9 refactor(scrobbler): Consolidate refresh logic and add more logging
* Move recentScrobbles and generic refresh logic into abstract class
* New abstract function for scrobbler children to implement to get scrobbler-specific play lists on refresh
* Implement shouldRefereshScrobble with logging for better insight into why or why not a scrobbler refreshed upstream scrobbles
2024-07-24 11:20:09 -04:00
FoxxMD 9c2bf05a24 refactor(listenbrainz): Simplify scrobble api calls and logging 2024-07-24 10:31:37 -04:00
FoxxMD 91e52c4c5d feat(musikcube): Provide more error logging and example config
* Update iso-websockets to fix typing exports and get node network error from WS error
* Add url example to config
2024-07-23 12:11:19 -04:00
FoxxMD b41d7f539f feat(scrobble): Enable forcing existing scrobbles refresh on every scrobble #173 2024-07-23 10:48:35 -04:00
FoxxMD 9735b980d7 fix(tests): Do not re-use scrobbler between tests 2024-07-19 14:44:39 -04:00
FoxxMD 706483ed06 feat: On polling error retry check source is initialized and reinitialize if not 2024-07-19 14:36:50 -04:00
FoxxMD 7760776a5f feat(mpd): Use idle event to wake up from polling early 2024-07-19 14:36:27 -04:00
FoxxMD 8b9a7893d3 feat(mpd): Implement MPD Source MVP 2024-07-19 12:10:51 -04:00
FoxxMD 729250a2cc Merge branch 'refs/heads/develop' 2024-07-18 14:18:39 -04:00
FoxxMD f14203f85e docs: More improvements to configuration docs 2024-07-18 11:14:40 -04:00
FoxxMD 60e880f7f2 Merge branch 'refs/heads/develop'
# Conflicts:
#	docsite/docs/configuration/configuration.md
2024-07-17 12:47:23 -04:00
FoxxMD 16cef0ae65 chore: Bump versions for release 2024-07-17 12:46:18 -04:00
FoxxMD 12ff1f421e docs: Use relative paths for volume mounting 2024-07-17 11:46:00 -04:00
FoxxMD 61c33da2b0 docs: Fix quickstart link 2024-07-17 11:39:41 -04:00
FoxxMD 8b0c7205b7 docs: Add quick start guide 2024-07-17 11:33:56 -04:00
FoxxMD b02c96ada8 docs: Parse and render config examples from repository 2024-07-17 09:51:44 -04:00
FoxxMD 52b20e4c8e docs: Update kitchensink code block titles 2024-07-16 15:37:09 -04:00
FoxxMD a94ac8284f docs: refactor installation for docusaurus 2024-07-16 15:28:09 -04:00
FoxxMD ee399136a9 docs: Lean 100% into docusaurus documentation
Since docs are now generated alongside builds and hosted on the app server there is no longer a barrier to using docusaurus docs as the main documentation presentation. Any user looking for docs can use the GH Pages hosted site or a "versioned" docs site hosted on their MS app. Due to this we can fully commit to docusaurus and take advantage of MDX layouts to simplify docs.

* Replace env/file configs headings in configuration with MDX tabs to make reading easier
* Remove superfluous TOCs
* Replace internal links in repo readme with links to docsite
2024-07-16 14:11:14 -04:00
FoxxMD ab54422acf docs: Replace manual notes with admonitions 2024-07-16 11:58:09 -04:00
FoxxMD d56c5720eb docs(flatpak): Fix typo in docs website link 2024-07-16 11:46:13 -04:00
FoxxMD 040de4b000 docs(flatpak): Implement bash setup script and move flatpak instructions to docsite
* setup.sh takes care of building generated sources for project/docsite and building flatpak app
* Modify flatpak manifest to include two generated sources files and build docsite files
* Update flatpak docs with script usage and generated sources changes, move to docusauraus doc site
2024-07-16 11:34:15 -04:00
FoxxMD 815a774e3d docs(fix): Fix broken links/anchors 2024-07-15 14:24:00 -04:00
FoxxMD b2e7f58e14 docs(fix): Add missing docsite build steps to alpine docker variant 2024-07-15 13:50:37 -04:00
FoxxMD 8d124a20ac docs(fix): Update package scripts to use npx to invoke docusarus 2024-07-15 13:44:07 -04:00
FoxxMD d1a4a54d2a docs: Implement self-hosted docs
* Add npm scripts for installing/building docusaurus from main project
* Set base url based on ENV
* Build docsite in docker image
* Add link to doc in dashboard and link to github
* Update nodejs install instructions to include doc build command
* Update GH pages workflow to use correct base url
* Add hint page to main app when docs are not built
2024-07-15 13:26:57 -04:00
FoxxMD 049bb06762 docs: Make docs site more prominent in readme 2024-07-12 16:55:48 -04:00
FoxxMD e5dd473981 Merge branch 'refs/heads/develop' 2024-07-12 14:39:53 -04:00
FoxxMD ca5cc3bb30 docs(feat): Add TOC to dev docs and links in readme 2024-07-12 14:37:52 -04:00
FoxxMD 444d75c803 docs(feat): Add architecture overview, common dev structures, and dev guidance for Sources 2024-07-12 14:31:11 -04:00
FoxxMD ccda3a6098 Merge branch 'refs/heads/develop' 2024-07-09 16:06:05 -04:00
FoxxMD ce1c70a4e1 ci: Ignore flatpak and docsite for image deploy 2024-07-09 16:05:15 -04:00
FoxxMD 5fcfa979c5 docs: Improve TOC for docusaurus and github
* Use lower headers so docusaurs sidebar TOC is more accurate
* Specify header levels for config/FAQ to make sidebar more concise
* Wrap inline TOC in details block -- so docusaurs users don't have to see it twice but its still available to github viewers
2024-07-09 15:56:44 -04:00
FoxxMD ec4d609ba1 docs: Update docusaurs playground schema for MS to latest 2024-07-09 15:55:07 -04:00
FoxxMD b42278a372 docs: Update docusaurus to v3 2024-07-09 15:54:45 -04:00
FoxxMD 1f96035ba9 Merge branch 'refs/heads/develop' 2024-07-09 10:25:29 -04:00
FoxxMD 332edb5dca chore: Bump flatpak release history 2024-07-09 10:25:17 -04:00
FoxxMD c5681d31eb Merge branch 'refs/heads/develop' 2024-07-09 09:53:03 -04:00
FoxxMD 685d70c639 chore: Bump version for release 2024-07-09 09:51:43 -04:00
FoxxMD 848878bc5e docs: Update screenshot 2024-07-09 09:51:33 -04:00
FoxxMD 088d063d38 fix: use correct ordering for version priority 2024-07-05 11:26:23 -04:00
FoxxMD 4e493db987 refactor: Use @foxxmd/get-version to render app version 2024-07-05 10:42:41 -04:00
FoxxMD 6d017d4b27 chore(ci): Update setup-node to v4 to squash node deprecation warnings 2024-07-03 10:11:52 -04:00
FoxxMD a34066d650 fix(docker): Typo in Dockerfile name 2024-07-03 10:09:24 -04:00
FoxxMD 0e34d63c84 feat(docker)!: Use debian image as default
Sporadic DNS issues with alpine have caused a number of issues #160 #126 #134

Use debian as default since it has been proven to be stable. The image size increase is worth squashing these issues.
2024-07-03 10:07:35 -04:00
FoxxMD 9dca8ebfa0 fix(chromecast): Add missing avahi-utils package for debian image 2024-07-03 09:55:58 -04:00
FoxxMD c908aea1ae ci: Better package cleanup and manual triggers 2024-06-26 16:39:42 -04:00
FoxxMD c7a58bbce6 ci: Add package cleanup 2024-06-26 16:26:30 -04:00
FoxxMD b26f303479 ci: remove test workflow 2024-06-26 15:52:52 -04:00
FoxxMD 13d64d2ce2 ci: Implement gated automated PR building
Only checkout/build PR branches if labelled first
2024-06-26 15:44:12 -04:00
FoxxMD ddb7fb3d6e ci: Use checked out repo to get git info rather than GH variables 2024-06-26 14:05:33 -04:00
FoxxMD 457967bfb2 Pass default APP_VERSION env as build arg 2024-06-26 13:23:45 -04:00
FoxxMD 734742ecbe Add APP_VERSION parsing to github actions 2024-06-26 12:37:09 -04:00
FoxxMD 26dab3870a Simplify version info on frontend by using api endpoint 2024-06-26 12:37:08 -04:00
FoxxMD ca9acbb089 Add git versioning check on backend 2024-06-26 12:37:08 -04:00
FoxxMD f78d6fec1d fix(youtube): Catch auth issues that occur during polling #158 2024-06-26 12:37:08 -04:00
FoxxMD bafb852545 feat(youtube): Use full-fat list diffing to watch for new plays #156
* Use superdiff to diff PlayObject lists and detect changes as well as append/prepend scenarios
* Replace YTM recently played logic with list diffing, only accept prepend-validated lists
  * On non-prepend scenarios replace existing recently played and log human readable diff
2024-06-26 12:37:08 -04:00
FoxxMD a755db45b9 fix: Fix ntfy imports 2024-06-26 12:37:08 -04:00
FoxxMD b9715f2159 chore: update dependencies 2024-06-26 12:37:07 -04:00
FoxxMD e90dbaf691 chore: npm audit fixes 2024-06-26 12:37:07 -04:00
FoxxMD 08ac55f718 fix(webscrobbler): Fix formatting of slug url in logs 2024-06-26 12:37:07 -04:00
FoxxMD 5bf831e236 feat(youtube): Sync cookies with those returned from YTM responses #158
* Patch youtube-music-ts-api to use updated cookies from response and provide a callback on update
* Implement currentCreds/build init data and read from MS-updated creds if available
* Write to currentCreds when ytm-ts-api invokes auth update callback and optionally log what parts changed based on config options
2024-06-26 12:37:07 -04:00
FoxxMD 1905804f2a fix(webscrobbler): Add preflight route #157 2024-06-26 12:37:07 -04:00
FoxxMD afc931ac8c docs: Use vars instead of secrets 2024-06-26 12:37:07 -04:00
FoxxMD 9fb032e030 docs: Add scripting on deploy for plausible analytics 2024-06-26 12:37:07 -04:00
FoxxMD 09b0c96263 feat(youtube): Surface library history playlist and raw recently played for debugging #156 2024-06-26 12:37:06 -04:00
FoxxMD 7c66816023 Implement musikcube source 2024-06-26 12:35:13 -04:00
FoxxMD 3e7e5df5de fix: Missed package-lock.json commit 2024-06-05 11:01:12 -04:00
FoxxMD 1c84a74034 fix(vite): Use default base string if no other path detected
* Update Vite dep
* Use inlineViteConfig for vite-express middleware and detect correct base string
* Refactor vite.config.ts to detect correct base string
2024-06-05 10:58:52 -04:00
FoxxMD 09969dd9d7 fix(spotify): Convert URL to string for redirectUri 2024-06-05 10:21:18 -04:00
FoxxMD 63b57e1113 docs: Clarify BASE_URL usage and add more examples #155 2024-06-04 15:51:30 -04:00
FoxxMD 585677e515 fix: Refactor Base URL normalization to cover more cases #155
* Move code into testable function
* Fix port erasure when url uses standard port but it is explicitly specified
* Simplify building default url and fix using undefined mainPort variable
* Fix port erasure when https protocol is explicitly specified
* Refactor localUrl usage as URL instead of string
  * Implement pathname joining on URL to make handling trailing/leading slashes take place in nodejs lib
* Implements tests for URL building/manipulation
2024-06-04 15:44:28 -04:00
FoxxMD 57f2b5f7e5 feat: Update normalize-url to prevent port erasure #155 2024-06-04 15:39:47 -04:00
FoxxMD 3597735556 feat(client): Implement initial refresh count and config control #153
* Refresh recent scrobbles with a higher limit after initialization (maximum supported by api call)
* Implement controlling count in client config
2024-05-16 15:22:24 -04:00
FoxxMD 2442cf2286 refactor: Move all init logic into common abstract base class 2024-05-16 15:01:07 -04:00
FoxxMD 97438e13d2 refactor(client): Use same init logic as source 2024-05-16 14:50:26 -04:00
FoxxMD 57dff9d122 feat!(source): Increase backlog limit defaults and add config control #153
* Increase default backlog limits to maximum pagination supported by each source
* Add scrobbleBacklogCount config property so user can explicitly set # of tracks to backlog
2024-05-16 13:43:44 -04:00
FoxxMD 963fc792a0 fix: Fix clientDefaults interface 2024-05-10 12:53:04 -04:00
FoxxMD 35a6418f38 docs!: Update schema 2024-05-10 12:48:08 -04:00
FoxxMD 3c0b951132 refactor(config)!: Move common options out of client config and into new option property 2024-05-10 12:44:16 -04:00
FoxxMD 3dc9e7d8b4 refactor(config)!: Move remaining common options out of source data and into options
* Move logPayload and logFilterFailure to options
* Disallow use of "options" property in common data
2024-05-10 12:38:09 -04:00
FoxxMD ab372b8357 refactor(config)!: Move source retry config to options 2024-05-10 12:07:39 -04:00
FoxxMD c79f04581f refactor(config)!: Move scrobble thresholds to options 2024-05-10 12:01:45 -04:00
FoxxMD 0dcd049453 Merge branch 'refs/heads/develop' 2024-04-11 09:39:08 -04:00
FoxxMD 7170b09586 chore: Bump version for release 2024-04-11 09:38:55 -04:00
FoxxMD 3d893b126f refactor(logging): Balance debug/verbose level logging pt.1
* Move some debug => verbose for non-repeating logs that are relevant to state, notifications, and scrobbling
* Move some verbose => debug that were repeating and making verbose level noisy
2024-04-10 11:25:27 -04:00
FoxxMD fb73b8b106 feat(mpris): Less noisy error handling
* Convert DBus non-standard thrown error data to regular Errors
* Only warn about position errors if error message is unexpected
2024-04-10 10:57:02 -04:00
FoxxMD cede77e3fa refactor: Remove pony-cause dependency
Use built-in error cause and adapt helper functions from pony-cause so we can remove a dependency
2024-04-10 10:56:04 -04:00
FoxxMD 0572d6a308 refactor(logging): Reduce noise for polling interval logging 2024-04-10 10:19:58 -04:00
FoxxMD 5967e3df46 feat: Implement notifications via Apprise 2024-04-05 12:07:41 -04:00
FoxxMD 6b1ee83654 docs: Add config section and config example for disabling web server #150 2024-04-04 09:35:27 -04:00
FoxxMD 0af1496a0f chore: Update proxies 2024-04-04 09:30:42 -04:00
FoxxMD 52b08efd88 feat: Implement option for disabling web server #150
If a user does not need the dashboard or has security concerns about ingress and cannot control their hosting environment this enables reducing attack surface.

Closes #150
2024-04-04 09:30:33 -04:00
FoxxMD 7a3e4e56e2 chore: Remove some unused packages 2024-04-02 10:15:41 -04:00
FoxxMD abe6638f66 refactor: Use updated logger to reduce log file path logic complexity 2024-03-28 12:28:56 -04:00
FoxxMD eb62c2cea9 refactor: optimize imports
* Remove unused imports
* Normalize spaces in curly braces
* Sort by module (npm packages, then relative imports)
2024-03-28 09:20:46 -04:00
FoxxMD 7e6eddce3e refactor: Reduce usage of ErrorWithCause
Error Cause (https://github.com/tc39/proposal-error-cause) was finalized and introduced in ES2022 so we don't need to ponyfill this anymore. Still using the helpful helper functions thought.
2024-03-28 09:20:45 -04:00
FoxxMD bc517e0cb0 Merge branch 'develop' 2024-03-26 09:37:03 -04:00
FoxxMD 41900b0048 chore: Bump version for release 2024-03-26 09:36:37 -04:00
FoxxMD 9ea71b931c fix(flatpak): Fix missing package.json needed to run as esm module 2024-03-26 09:04:31 -04:00
FoxxMD 64f222b5b7 fix: Add binary alias(?) to pass appstream guidelines
flathub/io.github.foxxmd.multiscrobbler#14
2024-03-25 09:49:01 -04:00
FoxxMD 6c27406803 fix: Fix colored output being applied to file logging 2024-03-21 17:08:14 -04:00
FoxxMD 2f51e7ad99 feat: Enable colorizing docker log output 2024-03-21 16:29:33 -04:00
FoxxMD d86dd63236 fix: Fix log name to match existing logs names 2024-03-21 15:50:43 -04:00
FoxxMD f794133915 fix: Fix missing parent logger 2024-03-21 15:33:06 -04:00
FoxxMD c7d2e7acc2 feat(ui): Use ansi-to-react for log coloring
Vastly simplifies log formatting on the dashboard
2024-03-21 15:33:06 -04:00
FoxxMD 2dde3840c6 refactor(logging): Replace winston with @foxxmd/logging 2024-03-21 15:33:06 -04:00
FoxxMD 4675847fec fix(flatpak): Reduce summary length 2024-03-11 16:14:45 -04:00
FoxxMD 830d1a2243 fix(flatpak): Add missing developer name
https://github.com/flathub/io.github.foxxmd.multiscrobbler/pull/13#issuecomment-1949888692
2024-03-06 10:10:13 -05:00
FoxxMD 8a43f0ded5 test arm64 building for debian docker image using updated node version #126
Using latest LTS (20) seems to have fixed TLS timeout/connection reset issue seen when trying to `npm ci` on LTS 18
2024-02-19 15:32:24 -05:00
FoxxMD af24e0ffbe chore(docker): Remove unused dependencies 2024-02-19 15:09:55 -05:00
FoxxMD d3a70bf2c1 fix: Add comment for future typed linting 2024-02-19 13:24:53 -05:00
FoxxMD a62fe705f9 fix: Fix lint errors 2024-02-19 13:02:28 -05:00
FoxxMD 1c6407238c feat: Add initial linting config 2024-02-19 12:27:34 -05:00
FoxxMD ec897b185c fix: Fix logger used for tests to always be 'noop'
Cleans up logging output during tests
2024-02-19 10:50:10 -05:00
FoxxMD 69e15b3797 fix: Refactor static arrow function class fields to normal functions #143
Something about using arrow function form for static class methods causes node debugger to crash.

Closes #143
2024-02-19 10:38:59 -05:00
FoxxMD dae4af070f chore: Bump mocha version 2024-02-15 13:01:22 -05:00
FoxxMD beaf0f3bfd chore(!): Upgrade Node to LTS for --import support
So that we can use tsx with official --import rather than requiring (old-style) which doesn't work with string-sameness esm for some reason

https://github.com/mochajs/mocha/issues/5002
https://github.com/mochajs/mocha-examples/pull/76
2024-02-15 12:40:02 -05:00
FoxxMD 8057982184 chore: Upgrade string-sameness
* Removes dependency on unmaintained dice coeffecient library
* New version is esm-only library
2024-02-15 12:27:59 -05:00
FoxxMD 9a5a808780 fix: Remove alt single quote when normalizing strings 2024-02-15 12:05:50 -05:00
FoxxMD d2d5f4a705 chore: Remove more unused packages 2024-02-15 10:10:01 -05:00
FoxxMD 4aa875660f chore: Remove unused es6-error package 2024-02-15 09:25:32 -05:00
FoxxMD 5ab8f00aeb Fix microsecond conversion 2024-02-15 09:23:26 -05:00
FoxxMD 1e1b5f56ce refactor: Replace dbus-next with dbus-ts #142 2024-02-14 16:32:38 -05:00
FoxxMD 49b9ee96f3 fix: Fix ts schema generation import issue
Changing tsconfig resolution caused generation to generate new (bad?) import lines. Replacing with regex from https://github.com/YousefED/typescript-json-schema/issues/582 returned ref/definitions to normal
2024-02-14 09:47:00 -05:00
FoxxMD aef7716634 fix(webscrobbler): Fix missing wildcard to match slug route #137
Closes #137
2024-02-14 09:45:14 -05:00
FoxxMD 57f2e1d500 feat: Log more URL when route is unknown 2024-02-14 09:44:47 -05:00
FoxxMD 41c696992a feat: Add legacy fallback options for subsonic communication #136
* Add config options for using legacy authentication and ignoring TLS cert errors
* Fallback to legacy auth if subsonic error code 41 is detected
2024-02-13 15:59:17 -05:00
FoxxMD f231d4e1e4 ci: Implement test checks for PRs and image publishing 2024-02-08 13:07:13 -05:00
FoxxMD 0efbe41b7e test: Use tsx through mocha config 2024-02-08 12:14:11 -05:00
FoxxMD be9675bb10 fix: Handle when no existing scrobble match with score greater than 0 #130
https://github.com/FoxxMD/multi-scrobbler/issues/130#issuecomment-1934452275
2024-02-08 11:51:00 -05:00
FoxxMD 4e45d35925 test: Add test that handles no existing scrobble match with score greater than 0 #130
Detects track string error thrown reported via https://github.com/FoxxMD/multi-scrobbler/issues/130#issuecomment-1934452275
2024-02-08 11:47:58 -05:00
FoxxMD a5e293554c Merge branch 'develop' 2024-02-06 09:28:35 -05:00
FoxxMD c76d4822fe docs: Fix which host debian image is available for 2024-02-06 09:28:22 -05:00
FoxxMD 0f8fd5cc0e chore: Forgot to bump version in lock file 2024-02-06 09:28:02 -05:00
FoxxMD 8b27890254 Merge branch 'develop' 2024-02-05 11:53:18 -05:00
FoxxMD 8747c8375e chore: Bump version 2024-02-05 11:52:47 -05:00
FoxxMD 50f27c110c Merge branch 'develop' 2024-02-05 11:47:40 -05:00
FoxxMD c1f8997c01 feat: Implement remaining recently played endpoints 2024-02-05 11:40:06 -05:00
FoxxMD 12ed767961 ci: Bump action versions to fix node deprecations 2024-02-05 10:47:58 -05:00
FoxxMD 335808217b docs: Add debian docker image network workaround to docs #126 #134 2024-02-05 10:38:15 -05:00
FoxxMD b5bb7024fc docs: Add workarounds for rollup issues and flatpak disclaimer 2024-02-05 10:28:40 -05:00
FoxxMD ac2806dc66 fix(frontend): Fix scss division deprecation warnings 2024-02-05 10:14:11 -05:00
FoxxMD 9d149dab49 Try to use direct node binary distribution #126 2024-02-02 12:55:58 -05:00
FoxxMD 451404c0f9 Disable arm64 debian builds and document issues in dockerfile #126 2024-02-02 11:05:09 -05:00
FoxxMD a3f1f077ce Revert fancy default negation
Doesn't work
2024-02-01 12:18:30 -05:00
FoxxMD dcd13833ae Ignore secrets files 2024-02-01 12:16:03 -05:00
FoxxMD 78b732b75c Update metadata-action version 2024-02-01 11:55:57 -05:00
FoxxMD 3152933937 chore: Add matrix for building debian flavor tag 2024-02-01 11:54:40 -05:00
FoxxMD 6c6126bb27 chore: make container image build tags agnostic to default branch name 2024-02-01 11:48:15 -05:00
FoxxMD ad13b7687d feat: Implement debian-based image #126 2024-02-01 11:39:05 -05:00
FoxxMD 633e366e82 feat: Improved logging for last.fm api network errors #115 2024-01-31 15:52:25 -05:00
FoxxMD 69b1786859 fix: Add patch-package as postinstall script so packages actually get patched 2024-01-31 14:09:31 -05:00
FoxxMD 41839a961a chore: Ignore apple cert extensions 2024-01-31 14:09:06 -05:00
FoxxMD ccb50c64bb fix(lastfm): Patch lfm library to actually implement a request timeout #134 2024-01-31 13:15:20 -05:00
FoxxMD d88ac1b424 feat(source): Start implementing recent from API 2024-01-31 12:36:35 -05:00
FoxxMD c807611e55 fix: Revert debug statement 2024-01-31 11:51:20 -05:00
FoxxMD 155ec1403e feat(sources): Improve Source of Truth usage and presentation to user #134
* Refactor SOT to be a type (may have more in the future)
* Set player stale/orphan interval based on SOT type
* Add note in log when initializing MemorySource which does not use player as SOT
* Add tooltip to UI player when it is not SOT
2024-01-31 11:46:22 -05:00
FoxxMD ecde657c3e feat(lastfm): Implement nowPlaying lfm api call 2024-01-31 10:01:12 -05:00
FoxxMD 6aa8374f25 feat: Add debug logging for user api requests 2024-01-31 09:30:58 -05:00
FoxxMD c2f01473fd fix(scrobbler): Fix erasing non-english characters #121 2024-01-30 14:47:19 -05:00
FoxxMD 41e607c995 refactor(scrobbler): Use simpler method for handling queued scrobbles #130 2024-01-30 13:40:52 -05:00
FoxxMD fee754f15f feat(chrome): Used improved library 2024-01-30 13:24:09 -05:00
FoxxMD d62d18232d chore: Remove some unused packages 2024-01-30 10:30:33 -05:00
205 changed files with 22792 additions and 15410 deletions
+4 -1
View File
@@ -2,6 +2,7 @@
node_modules
npm-debug.log
Dockerfile
*.Dockerfile
.dockerignore
.gitignore
.github
@@ -10,13 +11,15 @@ Dockerfile
.idea
config/*.json
config/*.bak
config/*.p8
/docs
/logs
.flatpak-builder
flatpak/generated-sources.json
**/generated-sources.*
flatpak/.flatpak-builder
docsite/build
docsite/node_modules
docsite/.docusaurus
docsite/.cache-loader
build
*.secrets
+14
View File
@@ -0,0 +1,14 @@
Testing GH Actions with [ACT]
### Test Branch Push
```shell
act -W '.github/act/actTest.yml' -e '.github/act/actBranchEvent.json'
```
### Test Tag Push
```shell
act -W '.github/act/actTest.yml' -e '.github/act/actTagEvent.json'
```
+4
View File
@@ -0,0 +1,4 @@
{
"ref": "refs/heads/master",
"sha": "0a1a94d9ad4efa373f8d34aace5e7f0a3fff42ad"
}
+5
View File
@@ -0,0 +1,5 @@
{
"ref": "refs/tags/0.8.0",
"ref_name": "0.8.0",
"sha": "0a1a94d9ad4efa373f8d34aace5e7f0a3fff42ad"
}
+104
View File
@@ -0,0 +1,104 @@
name: Publish Docker image to Dockerhub
on:
push:
branches:
- 'master'
- 'develop'
tags:
- '*.*.*'
# don't trigger if just updating docs
paths-ignore:
- '**.md'
# use release instead of tags once version is correctly parsed
# https://github.com/docker/metadata-action/issues/422
# https://github.com/docker/metadata-action/issues/240
# release:
# types: [ published ]
jobs:
test:
name: Build and push container images
runs-on: ubuntu-latest
# strategy:
# fail-fast: false
# matrix:
# include:
# - dockerfile: ./Dockerfile
# suffix: ''
# platforms: 'linux/amd64'
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Debug
run: echo $JSON
env:
JSON: ${{ toJSON(github) }}
- name: Set git state to ENV
id: vars
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
# short sha available under env.COMMIT_SHORT_SHA
run: |
calculatedSha=$(git rev-parse --short HEAD)
branchName=$(git rev-parse --abbrev-ref HEAD)
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
# - name: Set push event short ref
# run: |
# shortEventRef=${github.event.push.ref:10}
# echo "SHORT_REF=$shortEventRef" >> $GITHUB_ENV
- name: Check App Version
env:
# use release instead of tags once version is correctly parsed
#APP_VERSION: ${{ github.event.release.tag_name }}
# https://github.com/actions/runner/issues/409#issuecomment-752775072
# https://stackoverflow.com/a/69919067/1469797
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
run: |
echo $APP_VERSION
# - name: Extract metadata (tags, labels) for Docker
# id: meta
# uses: docker/metadata-action@v5
# with:
# # generate Docker tags based on the following events/attributes
# # https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
# tags: |
# type=raw,value=latest,enable={{endsWith(github.ref, 'master')}},suffix=${{ matrix.suffix }}
# type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
# type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
# flavor: |
# latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
env:
# use release instead of tags once version is correctly parsed
#APP_VERSION: ${{ github.event.release.tag_name }}
# https://github.com/actions/runner/issues/409#issuecomment-752775072
# https://stackoverflow.com/a/69919067/1469797
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
uses: docker/build-push-action@v5
with:
context: .
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
build-args: |
APP_BUILD_VERSION=${{env.APP_VERSION}}
file: ${{ matrix.dockerfile }}
push: false
tags: mstest:latest
#tags: ${{ steps.meta.outputs.tags }}
#labels: ${{ steps.meta.outputs.labels }}
#platforms: ${{ matrix.platforms }}
+9 -3
View File
@@ -1,9 +1,11 @@
name: Deploy to GitHub Pages
on:
workflow_dispatch:
push:
branches:
- master
- developDocs
# Review gh actions docs if you want to further define triggers, paths, etc
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
@@ -12,16 +14,20 @@ jobs:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
working-directory: ./docsite
- name: Build website
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
working-directory: ./docsite
@@ -0,0 +1,21 @@
name: Delete Untagged Packages
on:
schedule:
- cron: '30 1 * * 0'
workflow_run:
workflows: ["Publish Docker image to Dockerhub"]
types:
- completed
workflow_dispatch:
jobs:
delete-untagged:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# maybe set up for PR close later
# https://github.com/dataaxiom/ghcr-cleanup-action?tab=readme-ov-file#delete-image-when-pull-request-is-closed
+122
View File
@@ -0,0 +1,122 @@
name: PR Workflow
on:
pull_request_target:
types:
- labeled
- synchronize
- reopened
- opened
branches:
- 'develop'
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Build Backend
run: 'npm run build:backend'
- name: Test Backend
run: npm run test
release-snapshot:
name: Release snapshot
runs-on: ubuntu-latest
needs: test
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
permissions:
packages: write
contents: read
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./Dockerfile
suffix: ''
platforms: 'linux/amd64,linux/arm64'
- dockerfile: ./alpine.Dockerfile
suffix: '-alpine'
platforms: 'linux/amd64,linux/arm64'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
foxxmd/multi-scrobbler
ghcr.io/foxxmd/multi-scrobbler
tags: |
type=ref,event=pr,suffix=${{ matrix.suffix }}
flavor: |
latest=false
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
env:
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, github.event.pull_request.head.sha ) }}
with:
context: .
build-args: |
APP_BUILD_VERSION=${{env.APP_VERSION}}
file: ${{ matrix.dockerfile }}
push: ${{ !env.ACT}}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms }}
combine-and-comment:
name: Leave comment
runs-on: ubuntu-latest
needs: release-snapshot
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
steps:
- name: Create comment
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
header: "pr-release"
message: |
#### :package: A new release has been made for this pull request.
To play around with this PR, pull an image:
* `foxxmd/multi-scrobbler:pr-${{ github.event.number }}`
* `foxxmd/multi-scrobbler:pr-${{ github.event.number }}-alpine`.
Images are available for x86_64 and ARM64.
> Latest commit: ${{ github.event.pull_request.head.sha }}
+76 -17
View File
@@ -1,6 +1,7 @@
name: Publish Docker image to Dockerhub
on:
workflow_dispatch:
push:
branches:
- 'master'
@@ -9,30 +10,76 @@ on:
- '*.*.*'
# don't trigger if just updating docs
paths-ignore:
- '**.md'
- 'README.md'
- '.github/**'
- 'flatpak/**'
# use release instead of tags once version is correctly parsed
# https://github.com/docker/metadata-action/issues/422
# https://github.com/docker/metadata-action/issues/240
# release:
# types: [ published ]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
test:
name: Run Tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Build Backend
run: 'npm run build:backend'
- name: Test Backend
run: npm run test
push_to_registry:
name: Build and push container images
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: test
strategy:
fail-fast: false
matrix:
include:
- dockerfile: ./Dockerfile
suffix: ''
platforms: 'linux/amd64,linux/arm64'
- dockerfile: ./alpine.Dockerfile
suffix: '-alpine'
platforms: 'linux/amd64,linux/arm64'
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set short git commit SHA
id: vars
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
# short sha available under env.COMMIT_SHORT_SHA
run: |
calculatedSha=$(git rev-parse --short HEAD)
branchName=$(git rev-parse --abbrev-ref HEAD)
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -40,30 +87,42 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: |
foxxmd/multi-scrobbler
ghcr.io/foxxmd/multi-scrobbler
# generate Docker tags based on the following events/attributes
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}},suffix=${{ matrix.suffix }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }},suffix=${{ matrix.suffix }}
type=semver,pattern={{version}},suffix=${{ matrix.suffix }}
flavor: |
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v4
env:
# use release instead of tags once version is correctly parsed
#APP_VERSION: ${{ github.event.release.tag_name }}
# https://github.com/actions/runner/issues/409#issuecomment-752775072
# https://stackoverflow.com/a/69919067/1469797
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' && !env.ACT}}
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
build-args: |
APP_BUILD_VERSION=${{env.APP_VERSION}}
file: ${{ matrix.dockerfile }}
push: ${{ !env.ACT}}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platforms }}
+3
View File
@@ -125,8 +125,11 @@ src/**/**.js
src/**/**.js.map
*.bak
*.p8
.flatpak-builder
flatpak/generated-sources.json
*.secrets
build
!setupProxy.js
**/generated-sources.*
+3 -1
View File
@@ -1,3 +1,5 @@
{
"reporter": "dot"
"reporter": "dot",
"extension": "ts",
"import": "tsx/esm"
}
+1 -1
View File
@@ -1 +1 @@
v18.16.0
v18.19.1
+68 -39
View File
@@ -1,22 +1,51 @@
FROM lsiobase/alpine:3.18 as base
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
ENV TZ=Etc/GMT
ENV NODE_VERSION 20.11.1
# borrowing openssl header removal trick from offical docker-node
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
RUN \
ARCH= OPENSSL_ARCH= && dpkgArch="$(dpkg --print-architecture)" \
&& case "${dpkgArch##*-}" in \
amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \
ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \
s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \
arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \
armhf) ARCH='armv7l' OPENSSL_ARCH='linux-armv4';; \
i386) ARCH='x86' OPENSSL_ARCH='linux-elf';; \
*) echo "unsupported architecture"; exit 1 ;; \
esac && \
set -ex && \
echo "**** install build packages ****" && \
apk add --no-cache \
alpine-base \
avahi \
avahi-tools \
git \
nodejs \
npm \
#yarn \
openssh && \
apt-get update && \
apt-get install --no-install-recommends -y \
#ca-certificates \
xz-utils \
avahi-utils \
curl && \
echo "**** Fetch and install node****" && \
# get node/npm directly from nodejs dist \
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L41
curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr --strip-components=1 --no-same-owner && \
rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
ln -s /usr/bin/node /usr/bin/nodejs && \
npm update -g npm && \
echo "**** cleanup ****" && \
rm -rf \
/root/.cache \
/tmp/*
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L49
# Remove unused OpenSSL headers to save ~34MB
# (does not affect arm64 issue below)
find /usr/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && \
apt-get purge --auto-remove -y perl xz-utils && \
apt-get autoclean && \
apt-get autoremove && \
rm -rf \
/config/.cache \
/root/cache \
/var/lib/apt/lists/* \
/var/tmp/* \
/tmp/*
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
@@ -24,61 +53,61 @@ ARG data_dir=/config
VOLUME $data_dir
ENV CONFIG_DIR=$data_dir
COPY docker/root/ /
RUN npm install -g patch-package \
&& chown -R root:root /usr/local/lib/node_modules/patch-package
COPY docker/root /
WORKDIR /app
FROM base as build
# would very much like to use YARN for dependency management but its got show-stopping bad design for prod dependencies
# https://github.com/yarnpkg/yarn/issues/6323 -- always downloads devDependencies -- and im not about to migrate to v2 since alpine doesn't support it yet
# copy dep/TS config and install dev dependencies
#COPY --chown=abc:abc package.json tsconfig.json yarn.lock ./
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
# for debugging, so the build fails faster when timing out (arm64)
#RUN npm config set fetch-retries 1 && \
# npm config set fetch-retry-mintimeout 5000 && \
# npm config set fetch-retry-maxtimeout 5000
RUN npm install \
# https://www.npmjs.com/package/tls-test
# used to test that the OS supports downloading packages over HTTPS with TLS 1.2 enforced
# -- this always succeeds but a good sanity check
#RUN npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
RUN npm ci \
--verbose \
# --no-audit \
&& chown -R root:root node_modules
#RUN yarn install
COPY --chown=abc:abc . /app
# need to set before build so server/client build is optimized and has constants (if needed)
ENV NODE_ENV=production
RUN npm run build && rm -rf node_modules
#RUN yarn run build && rm -rf node_modules
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
FROM base as app
#COPY --chown=abc:abc package.json yarn.lock ./
COPY --chown=abc:abc package*.json ./
COPY --chown=abc:abc patches ./patches
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
COPY --from=base /usr/local/bin /usr/local/bin
COPY --from=base /usr/local/lib /usr/local/lib
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
COPY --from=base /usr/bin /usr/bin
COPY --from=base /usr/lib /usr/lib
ENV NODE_ENV=production
ENV IS_DOCKER=true
#
#RUN yarn global add patch-package \
# && yarn install --production=true \
# && yarn global remove patch-package \
# && yarn cache clean --mirror \
# && chown abc:abc node_modules \
# && rm -rf node_modules/ts-node \
# && rm -rf node_modules/typescript
RUN npm install --omit=dev \
# https://stackoverflow.com/a/63640896/1469797
ARG APP_BUILD_VERSION
ENV APP_VERSION=$APP_BUILD_VERSION
RUN npm ci --omit=dev \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/@types
&& rm -rf node_modules/@types \
/root/.cache
ARG webPort=9078
ENV PORT=$webPort
+40 -25
View File
@@ -3,6 +3,8 @@
[![Latest Release](https://img.shields.io/github/v/release/foxxmd/multi-scrobbler)](https://github.com/FoxxMD/multi-scrobbler/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
[![Docs](https://img.shields.io/badge/Read%20The%20Docs-1082c2)](https://foxxmd.github.io/multi-scrobbler/)
<img src="/assets/icon.png" align="right"
alt="multi-scrobbler logo" width="180" height="180">
@@ -10,31 +12,38 @@ alt="multi-scrobbler logo" width="180" height="180">
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
* Supports scrobbling from many **Sources**
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
* [WebScrobbler](/docsite/docs/configuration/configuration.md#webscrobbler)
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz--source-)
* [Deezer](/docsite/docs/configuration/configuration.md#deezer)
* [MPRIS (Linux Desktop)](/docsite/docs/configuration/configuration.md#mpris)
* [Mopidy](/docsite/docs/configuration/configuration.md#mopidy)
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
* [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-)
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [Tautulli](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
* Supports scrobbling to many **Clients**
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz)
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Install using [Docker images for x86/ARM](/docsite/docs/installation/installation.md#docker), [flatpak](/docsite/docs/installation/installation.md#flatpak), or [locally with NodeJS](/docsite/docs/installation/installation.md#nodejs)
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/transforms)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installationr#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
<img src="/assets/status-ui.png" width="800">
**Why should I use this over a browser extension and/or mobile app scrobbler?**
@@ -45,9 +54,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src="/assets/status-ui.png" width="800">
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
## How Does multi-scrobbler (MS) Work?
@@ -72,13 +79,17 @@ Client configurations consist of:
* A friendly name.
* Any data needed to communicate or authenticate with the Client.
## Quick Start
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
## Installation
[See the **Installation** documentation](/docsite/docs/installation/installation.md)
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/docs/installation)
## Configuration
[See the **Configuration** documentation](/docsite/docs/configuration/configuration.md)
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration)
## Usage
@@ -93,7 +104,11 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
## Help/FAQ
Having issues with connections or configuration? Check the [FAQ](/docsite/docs/FAQ.md) before creating an issue!
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ) before creating an issue!
## Development
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/docs/development/dev-common)
## License
+71
View File
@@ -0,0 +1,71 @@
FROM lsiobase/alpine:3.18 as base
ENV TZ=Etc/GMT
RUN \
echo "**** install build packages ****" && \
apk add --no-cache \
avahi \
avahi-tools \
nodejs \
npm \
&& \
echo "**** cleanup ****" && \
rm -rf \
/root/.cache \
/tmp/*
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ARG data_dir=/config
VOLUME $data_dir
ENV CONFIG_DIR=$data_dir
COPY docker/root/ /
WORKDIR /app
FROM base as build
# copy dep/TS config and install dev dependencies
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
RUN npm ci \
&& chown -R root:root node_modules
#RUN yarn install
COPY --chown=abc:abc . /app
# need to set before build so server/client build is optimized and has constants (if needed)
ENV NODE_ENV=production
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
FROM base as app
#COPY --chown=abc:abc package.json yarn.lock ./
COPY --chown=abc:abc package*.json ./
COPY --chown=abc:abc patches ./patches
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
COPY --from=base /usr/local/bin /usr/local/bin
COPY --from=base /usr/local/lib /usr/local/lib
ENV NODE_ENV=production
ENV IS_DOCKER=true
ENV COLORED_STD=true
# https://stackoverflow.com/a/63640896/1469797
ARG APP_BUILD_VERSION
ENV APP_VERSION=$APP_BUILD_VERSION
RUN npm ci --omit=dev \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/@types
ARG webPort=9078
ENV PORT=$webPort
EXPOSE $PORT
Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 699 KiB

-217
View File
@@ -1,217 +0,0 @@
import {API, Collection, FileInfo, Options} from 'jscodeshift';
import path from 'path';
import fs from 'fs';
export type TransformFrom = 'none' | 'any' | string;
export type TransformTo = 'none' | 'any' | string;
export type ImportTypes = 'any' | 'relative' | 'alias';
const base = process.cwd();
export default function transformer(
file: FileInfo,
{ jscodeshift: j }: API,
options: Options,
) {
const tf = options.transformFrom as string | undefined;
if(tf === undefined) {
throw new Error(`arg '--transformFrom' must be defined.`);
}
const transformFrom = tf.split(',').map(x => x.toLocaleLowerCase()) as TransformFrom[];
const tfIsAny = transformFrom.includes('any');
const tt = options.transformTo as string | undefined;
if(tt === undefined) {
throw new Error(`arg '--transformTo' must be defined.`);
}
const transformTo = tt.split(',').map(x => x.toLocaleLowerCase()) as TransformTo[];
if(transformTo.length > 1 && transformFrom.length !== transformTo.length) {
throw new Error('When more than one Transform To is specified then number of Transform From arguments must match');
}
const itypes = options.importTypes as string | undefined;
if(itypes === undefined) {
throw new Error(`arg '--importTypes' must be defined.`);
}
const importTypes = itypes.split(',').map(x => x.toLocaleLowerCase()) as ImportTypes[];
const importsIsAny = importTypes.includes('any');
const fileDir = path.dirname(path.join(base, file.path));
const processingFilePath = file.path;
console.log(`Processing => ${processingFilePath}`);
let source: Collection<any>;
try {
source = j(file.source);
} catch (e) {
console.error(`Failed to parse ${processingFilePath}, will skip`);
console.error(e);
}
const imports = source.find(j.ImportDeclaration)
imports.forEach((x) => {
const importPath = x.value.source.value as string;
const importPathPrefix = `${importPath.padEnd(60, ' ')} =>`;
const pathInfo = path.parse(importPath);
const hasNoExt = pathInfo.ext === '';
let filenameFromDir: string;
if(tfIsAny || (hasNoExt && transformFrom.includes('none')) || (transformFrom.includes(pathInfo.ext.replace('.', '').toLocaleLowerCase()))) {
const pathFull = path.join(fileDir, importPath);
const dir = path.dirname(pathFull);
const normalExt = pathInfo.ext.replace('.', '');
let isRelative: boolean | undefined;
let dirExists: boolean | undefined;
let fileExists: boolean | undefined;
try {
// is dir path real?
fs.realpathSync(dir);
dirExists = true;
} catch (e) {
isRelative = false;
dirExists = false;
}
if(isRelative === undefined) {
// if extension is none we need to check for any file in dir with this name
if(hasNoExt) {
const dirFiles = fs.readdirSync(dir)
filenameFromDir = dirFiles.find(x => path.parse(x).name === pathInfo.name);
if(filenameFromDir === undefined) {
fileExists = false;
isRelative = false;
} else {
fileExists = true;
isRelative = true;
}
} else {
try {
// does a file exist?
fs.realpathSync(pathFull);
isRelative = true;
} catch (e) {
isRelative = false;
fileExists = false;
}
}
}
if(!importsIsAny) {
if(!isRelative && !importTypes.includes('alias')) {
console.log(`${importPathPrefix} Import looks like an alias ${dirExists ? '(dir exists, file does not)' : '(dir does not exist)'} but import types does specify alias`);
return;
} else if(isRelative && !importTypes.includes('relative')) {
console.log(`${pathFull} => Import is relative but import types does not specify relative`);
return;
}
}
// determine transformTo
// if there is only one TO then use it
let derivedTT: undefined | string = transformTo.length === 1 ? transformTo[0] : undefined;
if(derivedTT === undefined) {
// otherwise we find the TO by using the same index as the matching FROM extension type
const tfIndex = transformFrom.findIndex((x) => {
if(x === 'any') {
return true;
}
if(x === 'none' && hasNoExt) {
return true;
}
if(x === normalExt) {
return true;
}
});
if(tfIndex === -1) {
console.warn(`${importPathPrefix} did not match a Transform From type. Will not transform`);
return;
}
derivedTT = transformTo[tfIndex];
}
let transformPrefix = ` --> ${hasNoExt ? '(None)' : normalExt} TO ${derivedTT} <--`;
let transformedImport: string;
switch(derivedTT) {
case 'none':
if(hasNoExt) {
console.log(`${importPathPrefix} ${transformPrefix} => Import already has no extension, nothing to do`);
return;
}
transformedImport = combineDirFile(pathInfo.dir, pathInfo.name); // path.join(pathInfo.root, pathInfo.dir, pathInfo.name);
break;
case 'any':
if(hasNoExt && filenameFromDir) {
transformedImport = filenameFromDir;
} else {
const otherFilename = fs.readdirSync(dir).find(x => {
const pinfo = path.parse(x);
return pinfo.name === pathInfo.name && pinfo.ext !== pathInfo.ext;
});
if(otherFilename === undefined) {
console.warn(`${importPathPrefix} ${transformPrefix} => Could not find another file in directory that had same name but different extension. Will not transform.`);
return;
}
transformedImport = combineDirFile(pathInfo.dir, otherFilename); // path.join(pathInfo.root, dir, otherFilename);
transformPrefix = `${transformPrefix} (${path.parse(otherFilename).ext})`
}
break;
default:
transformedImport = combineDirFile(pathInfo.dir, `${path.parse(filenameFromDir).name}.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
break;
}
console.log(`${importPathPrefix} ${transformPrefix} => Replacing with ${transformedImport}`);
j(x).replaceWith(
j.importDeclaration(
x.node.specifiers,
j.stringLiteral(transformedImport)
)
);
} else {
console.log(`${importPathPrefix} Import did not match transformFrom ('${tf}')`);
return;
}
});
/**
* Early exit condition
* -----
* It is often good practice to exit early and return the original source file
* if it does not contain code relevant to the codemod.
* See this page for more information:
* https://codeshiftcommunity.github.io/CodeshiftCommunity/docs/your-first-codemod#output
*/
// if (/* Some condition here */ true) {
// return file.source;
// }
/**
* Codemod logic goes here 👇
* -----
* This is where the core logic for your codemod will go,
* consider grouping specific actions into 'motions' and running them in sequence
*
* See this page for more information:
* https://codeshiftcommunity.github.io/CodeshiftCommunity/docs/authoring#motions
*/
//source.findVariableDeclarators('foo').renameTo('bar');
/**
* Return your modified AST here 👇
* -----
* This is where your modified AST will be transformed back into a string
* and written back to the file.
*/
return source.toSource(options.printOptions);
}
const combineDirFile = (dir: string, file: string) => {
if(dir === '') {
return file;
}
return `${dir}${path.sep}${file}`;
}
+1 -1
View File
@@ -8,5 +8,5 @@ These are **NOT** exhaustive examples. You should consult the **configuration**
Documentation at
* [internal docs](../docsite/docs/configuration/configuration.md)
* [internal docs](../docsite/docs/configuration/configuration.mdx)
* External Link: https://foxxmd.github.io/multi-scrobbler/docs/configuration
+7 -8
View File
@@ -1,18 +1,17 @@
{
"debugMode": false,
"disableWeb": false,
"sourceDefaults": {
"maxPollRetries": 1,
"maxRequestRetries": 1,
"retryMultiplier": 1.5,
"logPayload": false,
"logFilterFailure": "warn",
"logPlayerState": false,
"scrobbleThresholds": {
"duration": 30,
"percent": 50
},
"options": {
"logPayload": false,
"logFilterFailure": "warn",
"logPlayerState": false
}
"maxPollRetries": 1,
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
"clientDefaults": {
"maxRequestRetries": 1,
+13
View File
@@ -0,0 +1,13 @@
[
{
"enable": true,
"name": "MyMPD",
"data": {
"url": "192.168.0.100:6600",
"password": "MY_PASSWORD"
},
"options": {
"disableDiscovery": false
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"type": "musikcube",
"enable": true,
"name": "musikcube",
"data": {
"url": "ws://localhost:7905",
"password": "MY_PASSWORD"
}
}
]
+45 -47
View File
@@ -1,25 +1,15 @@
<!-- TOC -->
* [Connection Issues](#connection-issues)
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
* [Configuration Issues](#configuration-issues)
* [Config could not be parsed](#config-could-not-be-parsed)
* [Scrobbling Issues](#scrobbling-issues)
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
* [Google Cast track information is missing/incorrect or MS player has weird times](#google-cast-track-information-is-missingincorrect-or-ms-player-has-weird-times)
* [Google Cast device does not track media](#google-cast-device-does-not-track-media)
<!-- TOC -->
---
toc_min_heading_level: 2
toc_max_heading_level: 5
---
# Connection Issues
## Connection Issues
## Plex/Tautulli/Jellyfin don't connect
### Plex/Tautulli/Jellyfin/Webscrobbler don't connect
These three [sources](/#source) are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server to contact multi-scrobbler, as opposed to multi-scrobbler contacting the server.
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
multi-scrobbler will log information about any server that connects to it for these three services. In the logs it looks something like this:
multi-scrobbler will log information about any server that connects to it for these services. In the logs it looks something like this:
```
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
@@ -30,15 +20,15 @@ It also logs if a server tries to connect to a URL that it does not recognize:
```
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
```
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin is not able to connect to your multi-scrobbler instance at all.**
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin/Webscrobbler is not able to connect to your multi-scrobbler instance at all.**
This is not something multi-scrobbler can fix and means you have an issue in your network.
### Troubleshooting
#### Troubleshooting
Check or try all these steps before submitting an issue:
#### Turn on Debug Logging
##### Turn on Debug Logging
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
@@ -47,36 +37,40 @@ First, turn on **debug** logging for multi-scrobbler by setting the environmenta
Check the output for any additional information.
#### Check Host name and URL
##### Check Host name and URL
The URLs examples in the [configuration](configuration/configuration.md) documentation assume you are running Plex/Tautulli/Jellyfin on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Plex/Tautulli/Jellyfin/Webscrobbler on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
#### Check Firewall and Port Forwarding
##### Check Firewall and Port Forwarding
If the machine multi-scrobbler is running on has a firewall ensure that port **9078** is open. Or if it is in another network entirely make sure your router is forwarding this port and it is open to the correct machine.
#### Check Source Service Logs
##### Check Source Service Logs
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
##### Plex
###### Plex
Settings -> Manage -> Console
##### Tautulli
###### Tautulli
Check the command-line output of the application or docker logs.
##### Jellyfin
###### Jellyfin
Administration -> Dashboard -> Advanced -> Logs
## Jellyfin has warnings about undefined or missing data
###### Webscrobbler
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
### Jellyfin has warnings about undefined or missing data
Make sure you have
* [Configured the webhook plugin correctly](configuration/configuration.md#jellyfin)
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
@@ -89,10 +83,10 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
"name": "MyJellyfin",
"clients": [],
"data": {
"users": ["FoxxMD"],
"options": {
"logPayload": true
}
"users": ["FoxxMD"]
},
"options": {
"logPayload": true
}
}
]
@@ -100,7 +94,7 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
## Jellyfin has warnings about missing headers
### Jellyfin has warnings about missing headers
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
@@ -111,13 +105,13 @@ If you experience issues trying to scrobble with Jellyfin and find this in your
A workaround that may fix this:
* In Webhook settings:
* [In the webhook you have already configured...](configuration/configuration.md#jellyfin)
* [In the webhook you have already configured...](configuration/configuration.mdx#jellyfin)
* Add Request Header...
* **Key:** `Content-Type`
* **Value:** `application/json`
* Then Save
## Spotify/Deezer/LastFM won't authenticate
### Spotify/Deezer/LastFM won't authenticate
Ensure any **client id** or **secrets** are correct in your configuration.
@@ -130,9 +124,13 @@ 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`
# Configuration Issues
### Deezer is not working
## Config could not be parsed
Deezer has discontinued support for their API and the Deezer Source is now [**deprecated.**](configuration/configuration.mdx#deezer) See [this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
## Configuration Issues
### Config could not be parsed
If you see something like this in your logs:
@@ -144,25 +142,25 @@ If you see something like this in your logs:
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
# Scrobbling Issues
## Scrobbling Issues
## Last.fm does not scrobble tracks with multiple artists correctly
### Last.fm does not scrobble tracks with multiple artists correctly
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
## Jellyfin does not scrobble tracks with multiple artists correctly
### Jellyfin does not scrobble tracks with multiple artists correctly
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
## Google Cast track information is missing/incorrect or MS player has weird times
### Google Cast track information is missing/incorrect or MS player has weird times
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
To diagnose these issues you [**must enable payload logging**](configuration/configuration.md#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
To diagnose these issues you [**must enable payload logging**](configuration/configuration.mdx#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
## Google Cast device does not track media
### Google Cast device does not track media
It is likely the app playing on the cast device is incorrectly reporting the media type as **not music**.
@@ -172,10 +170,10 @@ MS logs will tell you what type the media is reported as with lines like:
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
```
Refer to [Allow Unknown Media Type](configuration/configuration.md#allow-unknown-media-type) section to fix this
Refer to [Allow Unknown Media Type](configuration/configuration.mdx#allow-unknown-media-type) section to fix this
```
My Artist - Example Track has 'movie' media type so will not track
```
Refer to [Force Media Tracking](configuration/configuration.md#forcing-media-tracking) section to fix this
Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tracking) section to fix this
+1 -1
View File
@@ -1,6 +1,6 @@
{
"label": "Configuration",
"position": 2,
"position": 3,
"link": {
"type": "generated-index",
"description": "Configuring Multi-Scrobbler and examples"
-986
View File
@@ -1,986 +0,0 @@
---
sidebar_position: 2
title: Overview
---
# Configuration
<!-- TOC -->
* [Overview](#overview)
* [ENV-Based Configuration](#env-based-configuration)
* [File-Based Configuration](#file-based-configuration)
* [All-in-One File Configuration](#all-in-one-file-configuration)
* [Specific File Configuration](#specific-file-configuration)
* [Application Options](#application-options)
* [Base URL](#base-url)
* [Source Configurations](#source-configurations)
* [Spotify](#spotify)
* [Plex](#plex)
* [Tautulli](#tautulli)
* [Subsonic](#subsonic)
* [Jellyfin](#jellyfin)
* [Last.fm (Source)](#lastfm--source-)
* [Listenbrainz (Source)](#listenbrainz--source-)
* [Deezer](#deezer)
* [Youtube Music](#youtube-music)
* [MPRIS](#mpris)
* [Mopidy](#mopidy)
* [JRiver](#jriver)
* [Kodi](#kodi)
* [WebScrobbler](#webscrobbler)
* [Multiple Users](#multiple-users)
* [Google Cast (Chromecast)](#google-cast--chromecast-)
* [Connecting Devices](#connecting-devices)
* [What Media Does MS Scrobble?](#what-media-does-ms-scrobble)
* [Cast Troubleshooting](#cast-troubleshooting)
* [Client Configurations](#client-configurations)
* [Maloja](#maloja)
* [Last.fm](#lastfm)
* [Listenbrainz](#listenbrainz)
* [Monitoring](#monitoring)
* [Webhook Configurations](#webhook-configurations)
* [Gotify](#gotify)
* [Ntfy](#ntfy)
* [Health Endpoint](#health-endpoint)
<!-- TOC -->
# Overview
[**Sources** and **Clients**](/#how-does-multi-scrobbler-ms-work) are configured using environmental (ENV) variables and/or json files.
**MS will parse configuration from both configuration types.** You can mix and match configurations but it is generally better to stick to one or the other.
TIP: Check the [**FAQ**](../FAQ.md) if you have any issues after configuration!
## ENV-Based Configuration
This is done by passing environmental variables and so does not require any files to run MS.
* Using a docker container EX `docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" ...`
* Using a local installations by exporting variables before running MS EX `SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret node index.js`
Use ENV-based configuration if:
* You are the only person for whom MS is scrobbling for
* You have a very simple setup for MS such as one scrobble [Client](/#client) and one [Source](/#source) IE Plex -> Maloja
## File-Based Configuration
MS will parse configuration files located in the directory specified by the `CONFIG_DIR` environmental variable. This variable defaults to:
* Local installation -> `PROJECT_DIR/config`
* Docker -> `/config` (in the container) -- see the [install docs](../installation/installation.md#docker) for how to configure this correctly
Use File-based configuration if:
* You have many [Sources](/#source)
* You have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
Funkwhale...
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers
* You want only some **Sources** to scrobble to some **Clients** IE Fred's Spotify account scrobbles to Fred's Maloja
server, but not Mary's Maloja server
File-based configurations located in the `CONFIG_DIR` directory can be parsed from
* an **all-in-one** config file named `config.json` that contains information for all Sources and Clients and/or
* many **specific** files named based on the client/source to configure IE `plex.json` `spotify.json`
There are **example configurations** for all Source/Client types and AIO config located in the [/config](https://github.com/FoxxMD/multi-scrobbler/tree/master/config) directory of this project. These can be used as-is by renaming them to `.json`.
For docker installations these examples are copied to your configuration directory on first-time use.
There is also a [**kitchensink example**](kitchensink.md) that provides examples of using all sources/clients in a complex configuration.
### All-in-One File Configuration
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
Example directory structure:
```
/CONFIG_DIR
config.json
```
<details>
<summary>Config Example</summary>
```json5 title="config.json"
{
//...
"sources": [
{
"name": "myConfig",
"type": "spotify",
"clients": [
"myMalojaClient"
],
"data": {
"clientId": "anExample"
//...
}
}
],
"clients": [
{
"name": "myFirstMalojaClient",
"type": "maloja",
"data": {
"url": "http://myMalojaServer.example",
// ...
}
}
]
}
```
</details>
`config.json` can also be used to set default behavior for all sources/clients using `sourceDefaults` and `clientDefaults` properties.
See [config.json.example](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/config.json.example) for an annotated example or check out [the kitchen sink example](kitchensink.md).
### Specific File Configuration
Each file is named by the **type** of the Client/Source found in below sections. Each file as an **array** of that type of Client/Source.
Example directory structure:
```
/CONFIG_DIR
plex.json
spotify.json
maloja.json
```
<details>
<summary>Config Example</summary>
```json5
// in maloja.json
[
{
"name": "myFirstMalojaClient",
"data": {
"url": "http://myMalojaServer.example",
"apiKey": "myKey"
}
},
{
"name": "mySecondMalojaClient",
"data": {
"url": "http://my2ndMalojaServer.example",
"apiKey": "myKey"
}
}
]
```
</details>
See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
# Application Options
These options affect multi-scrobbler's behavior and are not specific to any source/client.
#### Base URL
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
* Default => `http://localhost`
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
# Source Configurations
## [Spotify](https://www.spotify.com)
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a
Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
**NOTE:** If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-is-Automix/ta-p/5257278) enabled and Spotify uses it for your playlist/queue then MS cannot accurately determine when a track will end. This is because the track is "mixed" in your queue with a shorter play time than its actual length and [Spotify does not report this modified play time in its API.](https://community.spotify.com/t5/Spotify-for-Developers/Wrong-duration-ms-of-track-with-Automix/m-p/5429147) This **does not affect MS's ability to scrobble** from Spotify but it will affect the accuracy of the duration MS reports was played.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
| `SPOTIFY_CLIENT_ID` | Yes | | |
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:9078/callback` | URI must end in `callback` |
### File-Based
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Plex](https://plex.tv)
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------|-------------------------------------------------|
| `PLEX_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
### File-Based
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Tautulli](https://tautulli.com)
Check the [instructions](plex.md) on how to setup a notification agent.
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------|-------------------------------------------------|
| `TAUTULLI_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
### File-Based
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Subsonic](http://www.subsonic.org/)
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
**Known Issues:**
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
* "played X minutes ago" sometimes is also not reported correctly
* Multiple artists are reported as one value and cannot be separated
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
| `SUBSONIC_USER` | Yes | | |
| `SUBSONIC_PASSWORD` | Yes | | |
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
### File-Based
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Jellyfin](https://jellyfin.org/)
Must be using Jellyfin 10.7 or greater
* In the Jellyfin desktop web UI Navigate to -> Administration -> Dashboard -> Plugins -> Catalog
* Under Notifications -> **Webhook** -> Install, then restart your server
* Navigate back to -> Administration -> Dashboard -> Plugins -> My Plugins -> Webhook
* Click "..." -> Settings
* In Webhook settings:
* `Add Generic Destination`
* In the new `Generic` dropdown:
* Webhook Url: `http://localhost:9078/jellyfin`
* Notification Type: `Playback Progress`
* Item Type: `Songs`
* Check `Send All Properties`
* Save
If you see errors in the MS logs regarding `missing headers` when using Jellyfin [see this workaround.](../FAQ.md#jellyfin-has-warnings-about-missing-headers)
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|-------------------------------------------------------------------|
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
### File-Based
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Last.fm (Source)](https://www.last.fm)
See the [Last.fm (Client)](#lastfm) setup for registration instructions. You may need to disable "Hide recent listening information" on your [privacy page](https://www.last.fm/settings/privacy) for this to work.
### ENV-Based
No support for ENV based for Last.fm as a client (only source)
### File-Based
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Listenbrainz (Source)](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
**NOTE:** You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
### File-Based
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
**Change `configureAs` to `source`**
## [Deezer](https://deezer.com/)
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
* Redirect URL must end in `deezer/callback`
* Default would be `http://localhost:9078/deezer/callback`
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
* **Application ID**
* **Secret Key**
* **Redirect URL** (if not the default)
**If no access token is provided...**
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|-----------------------------------------|----------------------------------------------------|
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
### File-Based
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Youtube Music](https://music.youtube.com)
Credentials for YT Music are obtained from a browser request to https://music.youtube.com **once you are logged in.** [Specific requirements are here and summarized below:](https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate)
* Open a new tab
* Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
* Go to https://music.youtube.com and ensure you are logged in
Then...
1. Find and select an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you dont see the request, try scrolling down a bit or clicking on the library button in the top bar.
2. **Make sure **Headers** pane is selected and open
3. In the **Request Headers** section find and copy the **entire value** found after `Cookie:` and use this as the `cookie` value in your multi-scrobbler config
4. If present, in the **Request Headers** section find and copy the number found in `X-google-AuthUser` and use this as the value for `authUser` in your multi-scrobbler config
![Google Headers](google-header.jpg)
NOTES:
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps above to get new credentials.
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
### File-Based
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
MPRIS is a standard interface for communicating with Music Players on **linux operating systems.**
If you run Linux and have a notification tray that shows what media you are listening to, you likely have access to MPRIS.
![Notification Tray](mpris.jpg)
multi-scrobbler can listen to this interface and scrobble tracks played by **any media player** that communicates to the operating system with MPRIS.
**NOTE:** multi-scrobbler needs to be running as a [**Local Installation**](../installation/installation.md#local) in order to use MPRIS. This cannot be used from docker.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|----------------------------------------------------------------------------------|
| MPRIS_ENABLE | No | | Use MPRIS as a Source (useful when you don't need any other options) |
| MPRIS_BLACKLIST | No | | Comma-delimited list of player names not to scrobble from |
| MPRIS_WHITELIST | No | | Comma-delimited list of players names to ONLY scrobble from. Overrides blacklist |
### File-Based
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Mopidy](https://mopidy.com/)
Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/)
multi-scrobbler can scrobble tracks played from any Mopidy backend source, regardless of where you listen to them.
### File-Based
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
Configuration Options:
##### `url`
The URL used to connect to the Mopidy server. You MUST have [Mopidy-HTTP extension](https://mopidy.com/ext/http) enabled.
If no `url` is provided a default is used which assumes Mopidy is installed on the same server as multi-scrobbler: `ws://localhost:6680/mopidy/ws/`
Make sure the hostname and port number match what is found in the Mopidy configuration file `mopidy.conf`:
```
...
[http]
hostname = localhost
port = 6680
...
```
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `ws://`
* Hostname => `localhost`
* Port => `6680`
* Path => `/mopidy/ws/`
<details>
<summary>URL Transform Examples</summary>
```json
{
"url": "mopidy.mydomain.com"
}
```
MS transforms this to: `ws://mopidy.mydomain.com:6680/mopidy/ws/`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/`
```json
{
"url": "mopidy.mydomain.com:80/MOPWS"
}
```
MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS`
</details>
#### URI Blacklist/Whitelist
If you wish to disallow or only allow scrobbling from some sources played through Mopidy you can specify these using `uriBlacklist` or `uriWhitelist` in your config. multi-scrobbler will check the list to see if any string matches the START of the `uri` on a track. If whitelist is used then blacklist is ignored. All strings are case-insensitive.
EX:
```json
{
"uriBlacklist": ["soundcloud"]
}
```
Will prevent multi-scrobbler from scrobbling any Mopidy track that start with a `uri` like `soundcloud:song:MySong-1234`
#### Album Blacklist
For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake Album name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. All strings are case-insensitive.
EX:
```json
{
"albumBlacklist": ["SoundCloud", "Mixcloud"]
}
```
If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist`
then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist`
## [JRiver](https://jriver.com/)
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
* Tools -> Options -> Media Network
* Check `Use Media Network to share this library...`
* If you have `Authentication` checked you will need to provide the **Username** and **Password** in the ENV/File configuration below.
#### URL
If you do not provide a URL then a default is used which assumes JRiver is installed on the same server as multi-scrobbler: `http://localhost:52199/MCWS/v1/`
* Make sure the port number matches what is found in `Advanced` section in the [Media Network](#jriver) options.
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `http://`
* Hostname => `localhost`
* Port => `52199`
* Path => `/MCWS/v1/`
<details>
<summary>URL Transform Examples</summary>
```json
{
"url": "jriver.mydomain.com"
}
```
MS transforms this to: `http://jriver.mydomain.com:52199/MCWS/v1/`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `http://192.168.0.101:3456/MCWS/v1/`
```json
{
"url": "mydomain.com:80/jriverReverse/MCWS/v1/"
}
```
MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
</details>
### ENV-Based
| 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 |
### File-Based
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Kodi](https://kodi.tv/)
In order for multi-scrobbler to communicate with Kodi you must have the [Web Interface](https://kodi.wiki/view/Web_interface) enabled. This can can be in the Kodi GUI:
* Settings -> Services -> Control
* Check `Allow remote control via HTTP`
* Ensure you have a **Username** and **Password** set, you will need to provide them in the ENV/File configuration below.
#### URL
If you do not provide a URL then a default is used which assumes Kodi is installed on the same server as multi-scrobbler: `http://localhost:8080/jsonrpc`
* Make sure the port number matches what is found in **Port** in the [Control](#kodi) section mentioned above.
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `http://`
* Hostname => `localhost`
* Port => `8080`
* Path => `/jsonrpc`
<details>
<summary>URL Transform Examples</summary>
```json
{
"url": "kodi.mydomain.com"
}
```
MS transforms this to: `http://kodi.mydomain.com:8080/jsonrpc`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `http://192.168.0.101:3456/jsonprc`
```json
{
"url": "mydomain.com:80/kodiReverse/jsonrpc"
}
```
MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
</details>
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|-------------------------------|----------------------------|
| KODI_URL | Yes | http://localhost:8080/jsonrpc | The URL of the Kodi server |
| KODI_USERNAME | No | | The username set |
| KODI_PASSWORD | No | | The password set |
### File-Based
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [WebScrobbler](https://web-scrobbler.com/)
After installing the extension open the preferences/settings for it:
* Under **Accounts**
* **Add Webhook**
* API URL: `http://localhost:9078/api/webscrobbler`
* Application name: `(whatever you want)`
Reload the extension after adding the webhook.
* **On Firefox** - Only FQNs (domain.tld), `localhost`, and `127.0.0.1` are supported for API URL due to [firefox requiring https](https://github.com/web-scrobbler/web-scrobbler/issues/4183#issuecomment-1749222006)
* **On Chromium-based Browsers** - Any domain will work for API URL
* All Other browsers are untested
#### Multiple Users
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](#file-based-configuration)
Example:
In `webscrobbler.json`
```json
[
{
"name": "aUserWS",
"clients": [
"client1Maloja"
],
"data": {
"slug": "usera"
}
},
{
"name": "bUserWS",
"clients": [
"client2Maloja"
],
"data": {
"slug": "userb"
}
}
]
```
* To use `aUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/usera`
* To use `bUserWS` source set **API URL** to `http://localhost:9078/api/webscrobbler/userb`
Note: `http://localhost:9078/api/webscrobbler` is matched with the first source that _that does not have a slug defined._
##### Connectors Black/Whitelist
MS can be configured to only scrobble, or NOT scrobble, from some WS connectors. Use the name of the website from the [supported websites](https://web-scrobbler.com/) or from the **Connectors** tab in the extension. Note that this **only** affects MS's behavior and does not affect the general connector behavior you have configured within the WebScrobbler extension.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------------------------------------------|
| WS_ENABLE | No | | Set to 'true' to enable WS without needing to define other ENVs |
| WS_WHITELIST | No | | Only scrobble from these WebScrobbler Connectors. Comma-delimited list |
| WS_BLACKLIST | No | | Do not scrobble from these WebScrobbler Connectors. Comma-delimited list |
### File-Based
See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/webscrobbler.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FWebScrobblerSourceConfig/%23%2Fdefinitions%2FWebScrobblerData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
## [Google Cast (Chromecast)](https://www.google.com/chromecast/built-in/)
**NOTE:** Google Cast support is **experimental**. You may experience crashes and errors while using this Source. Please open an issue if you experience problems and include all information detailed in the issue template to help debug your issue.
If your media device can be **Cast** to using this button ![Chromecast Icon](https://upload.wikimedia.org/wikipedia/commons/2/26/Chromecast_cast_button_icon.svg) on your phone/computer then multi-scrobbler can monitor it in order to scrobble music you play.
**Note:** This source relies on common, **basic** music data provided by the cast device which will always be less exhaustive than data parsed from full source integrations. If there is an existing [Source](#source-configurations) it is recommended to configure for it and blacklist the app on Google Cast, rather than relying solely on Google Cast for scrobbling.
### Connecting Devices
Cast devices can be manually configured using [File-based configuration](#file-based-14) OR automatically discovered using **mDNS.**
##### mDNS Discovery
The host machine running multi-scrobbler must be configured to allow [mDNS traffic on port 5353/UDP](https://book.hacktricks.xyz/network-services-pentesting/5353-udp-multicast-dns-mdns).
##### Linux
**Docker**
The host machine must have [avahi-daemon](https://avahi.org/) running to circumvent limitations with DNS resolution due to musl in Alpine. Most major linux distributions package avahi and many have it built-in. Once avahi is running you must pass D-Bus and the avahi daemon socket to your container like so:
```
docker run ... -v /var/run/dbus:/var/run/dbus -v /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket ... foxxmd/multi-scrobbler
```
**Flatpak/Nodejs**
No additional steps are required.
##### Windows
**Docker**
Unsupported at this time.
**Nodejs**
No additional steps are required.
### What Media Does MS Scrobble?
Cast devices report what type of media the current activity is [(see `metadata` property here)](https://developers.google.com/cast/docs/media/messages#MediaInformation). The reported type is dependent on the application playing the media to correctly report it, the cast device does not magically know what the media is. If an application does not report a type it is always classified as `unknown`.
**By default, MS will only track media that is reported as `MusicTrack`.**
#### Allow Unknown Media Type
Media with an Unknown (`Generic`) media type can be explicitly allowed by setting `"allowUnknownMedia": true` in the [file-based configuration.](#file-based-14) This can also be configured to only allow unknown media types for specific applications by using a list of application names like:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
// only allow unknown if app name contains any of these phrases
"allowUnknownMedia": ["smarttube", "default media receiver"]
},
}
]
```
#### Forcing Media Tracking
MS can be forced to track media from an application regardless of media type. This is useful if an application incorrectly reports a media type you are sure should be music. Set `"forceMediaRecognitionOn"` in the [file-based configuration.](#file-based-14) to a list of application names that should always be tracked like:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
// media from applications that contains these phrases will always be tracked, regardless of media type reported
"forceMediaRecognitionOn": ["smarttube", "default media receiver"]
},
}
]
```
### Cast Troubleshooting
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue:
```json5
// in chromecast.json or config.json sources
[
{
"name": "MyCast",
"type": "chromecast",
"data": {
//...
},
"options": {
"logPayload": true
}
}
]
```
### ENV-Based
Note: [Manually configuring cast device connections](#connecting-devices) is only available through [File-based config.](#file-based-14)
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|--------------------------------------------------------------------------------------|
| CC_ENABLE | No | | Set to 'true' to enable Cast monitoring without needing to define other ENVs |
| CC_WHITELIST_DEVICES | No | | Only scrobble from these Cast devices. Comma-delimited list. EX mini-home, family-tv |
| CC_BLACKLIST_DEVICES | No | | Do not scrobble from these Cast devices. Comma-delimited list |
| CC_WHITELIST_APPS | No | | Only scrobble from these casted Apps. Comma-delimited list. EX spotify, pandora |
| CC_BLACKLIST_APPS | No | | Do not scrobble from these casted Apps. Comma-delimited list |
### File-Based
See [`chromecast.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FChromecastSourceConfig/%23%2Fdefinitions%2FChromecastData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
# Client Configurations
## [Maloja](https://github.com/krateng/maloja)
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|---------|-------------------------------|
| `MALOJA_URL` | Yes | | Base URL of your installation |
| `MALOJA_API_KEY` | Yes | | Api Key |
### File-Based
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
## [Last.fm](https://www.last.fm)
[Register for an API account here.](https://www.last.fm/api/account/create)
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
```
http://localhost:9078/lastfm/callback
```
or replace `localhost:9078` with your own base URL
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|-----------------------------------------|-------------------------------|
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
### File-Based
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
## [Listenbrainz](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-------------------------------|---------------------------------|
| LZ_TOKEN | Yes | | User token from your LZ profile |
| LZ_USER | Yes | | Your LZ username |
| LZ_URL | No | https://api.listenbrainz.org/ | The base URL for the LZ server |
### File-Based
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
# Monitoring
multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
## Webhook Configurations
Webhooks will **push** a notification to your configured servers on these events:
* Source polling started
* Source polling retry
* Source polling stopped on error
* Scrobble client scrobble failure
Webhooks are configured in the main [config.json](#all-in-one-file-configuration) file under the `webhook` top-level property. Multiple webhooks may be configured for each webhook type. EX:
```json5
{
"sources": [
//...
],
"clients": [
//...
],
"webhooks": [
{
"name": "FirstGotifyServer",
"type": "gotify",
"url": "http://192.168.0.100:8070",
"token": "abcd"
},
{
"name": "SecondGotifyServer",
"type": "gotify",
//...
},
{
"name": "NtfyServerOne",
"type": "ntfy",
//...
},
//...
]
}
```
### [Gotify](https://gotify.net/)
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
EX
```json
{
"type": "gotify",
"name": "MyGotifyFriendlyNameForLogs",
"url": "http://192.168.0.100:8070",
"token": "AQZI58fA.rfSZbm",
"priorities": {
"info": 5,
"warn": 7,
"error": 10
}
}
```
### [Ntfy](https://ntfy.sh/)
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
EX
```json
{
"type": "ntfy",
"name": "MyNtfyFriendlyNameForLogs",
"url": "http://192.168.0.100:9991",
"topic": "RvOwKJ1XtIVMXGLR",
"username": "Optional",
"password": "Optional",
"priorities": {
"info": 3,
"warn": 4,
"error": 5
}
}
```
## Health Endpoint
An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health`
* Returns `200 OK` when **everything** is working or `500 Internal Server Error` if **anything** is not
* The plain url (`/health`) aggregates status of **all clients/sources** -- so any failing client/source will make status return 500
* Use query params `type` or `name` to restrict client/sources aggregated IE `/health?type=spotify` or `/health?name=MyMaloja`
* On 500 the response returns a JSON payload with `messages` array that describes any issues
* For any clients/sources that require authentication `/health` will return 500 if they are **not authenticated**
* For sources that poll (spotify, yt music, subsonic) `/health` will 500 if they are **not polling**
File diff suppressed because it is too large Load Diff
+9 -21
View File
@@ -21,9 +21,7 @@ Scenario:
### All-in-one Config
Using just one config file located at `CONFIG_DIR/config.json`:
```json5
```json5 title="CONFIG_DIR/config.json"
{
"sourceDefaults": {
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
@@ -42,6 +40,8 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
},
"options": {
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
}
},
@@ -158,9 +158,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
### Separate JSON files
In `CONFIG_DIR/spotify.json`:
```json5
```json5 title="CONFIG_DIR/spotify.json"
[
{
// may omit 'type' property since app knows this is file is for spotify configs
@@ -191,9 +189,7 @@ In `CONFIG_DIR/spotify.json`:
]
```
In `CONFIG_DIR/plex.json`
```json5
```json5 title="CONFIG_DIR/plex.json"
[
{
"name": "fredPlex",
@@ -219,9 +215,7 @@ In `CONFIG_DIR/plex.json`
]
```
In `CONFIG_DIR/jellyfin.json`
```json5
```json5 title="CONFIG_DIR/jellyfin.json"
[
{
"name": "FredJelly",
@@ -232,9 +226,7 @@ In `CONFIG_DIR/jellyfin.json`
]
```
In `CONFIG_DIR/ytmusic.json`
```json5
```json5 title="CONFIG_DIR/ytmusic.json"
[
{
"type": "ytmusic",
@@ -248,9 +240,7 @@ In `CONFIG_DIR/ytmusic.json`
]
```
In `CONFIG_DIR/maloja.json`:
```json5
```json5 title="CONFIG_DIR/maloja.json"
[
{
"name": "foxxMaloja",
@@ -276,9 +266,7 @@ In `CONFIG_DIR/maloja.json`:
]
```
In `CONFIG_DIR/lastfm.json`:
```json5
```json5 title="CONFIG_DIR/lastfm.json"
[
{
"name": "maryLFM",
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+320
View File
@@ -0,0 +1,320 @@
---
sidebar_position: 4
title: Scrobble Modification
toc_max_heading_level: 4
---
Multi-scrobbler configs support the ability to modify scrobble data in an automated fashion by matching and replacing strings in **title, artists, and album** at many different times in multi-scrobbler's lifecycle.
### Why?
You may need to "clean up" data from a Source or before sending to a scrobble Client due to any number of reasons:
* ID3 tags in your music collection are dirty or have repeating garbage IE `[YourMusicSource.com] My Artist - My Title`
* A Source's service often incorrectly adds data to some field IE `My Artist - My Title (Album Version)` when the title should just be `My Title`
* An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles
In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.
## Overview
### Journey of a Scrobble
First, let's recap the lifecycle of a scrobble in multi-scrobbler:
**Sources** are the beginning of the journey for a **Play** (song you've listened to long enough to be scrobblable)
* A Source finds a new valid **Play**
* The Source **compares** this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
* The Source **discovers** the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients
Scrobble **Clients** listen for discovered Plays from Sources, then...
* A Client receives a **Play** from a Source
* 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
You'll notice there is a pattern above that looks like this:
* **Before** data is compared
* Data is **compared**
* **After** data is compared
These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.
#### TLDR
In more concrete terms this is the structure of hooks within a configuration (can be used in any **Source** or **Client**):
```json5 title="lastfm.json" {10-14}
[
{
"name": "myLastFm",
"enable": true,
"configureAs": "source",
"data": {
// ...
},
"options": {
"playTransform": {
"preCompare": {/* ... */},
"compare": {/* ... */},
"postCompare": {/* ... */}
}
}
}
]
```
##### Hook
For **Sources**:
* `preCompare` - modify Play data immediately when received
* `compare` - temporarily modify Play data when it is being compared to see if Play was already discovered
* `postCompare` - modify Play data before sending to scrobble **Clients**
For **Clients**:
* `preCompare` - modify Play data immediately when received
* `compare` - temporarily modify Play data when it is being compared to see if it was already scrobbled
* `postCompare` - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
:::tip
Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle.
For example, to modify the track so it's the same anywhere it is processed in multi-scrobbler you only need to modify it in the **Source's** `preCompare` hook because all later processes will receive the data with the modified track.
:::
### Modification Parts
Each [**hook**](#hook) (`preCompare` etc...) is an object that specifies what part of the **Play** to modify:
```json5
{
"title": [/* ... */],
"artists": [/* ... */],
"album": [/* ... */]
}
```
##### Expression
and then a **list** what pattern/replacements (expressions) to use for the modification by using either simple strings or `search-replace` objects:
```json5
[
"badTerm", // remove all instances of 'badTerm'
{
"search": "anotherBadTerm", // and also match all instances of 'anotherBadTerm'
"replace": "goodTerm" // replace with the string 'goodTerm'
}
]
```
Putting it all together:
```json5 title="lastfm.json"
[
{
"name": "myLastFm",
"enable": true,
"configureAs": "source",
"data": {
// ...
},
"options": {
"playTransform": {
"preCompare": {
"title": [
[
"badTerm",
{
"search": "badTerm",
"replace": "goodTerm"
}
]
]
},
}
}
}
]
```
:::note
If the value of the field (title, an artist, album) is an empty string after transforming then the field is **removed.**
:::
:::tip
Modifications can also be applied to **all Sources** or **all Clients** when using the [AIO Config](./configuration.mdx?configType=aio#configuration-types) `config.json` by setting `playTransform` in `sourceDefaults` or `clientDefaults`:
<details>
<summary>Example</summary>
```json5 title="config.json"
{
"sourceDefaults": { // will apply playTransform to all sources
"playTransform": {
"preCompare": {
"title": [
"(Album Version)"
]
}
}
},
"sources": [/* ... */],
"clients": [/* ... */]
}
```
</details>
:::
#### Compare Hook
The `compare` [hook](#hook) is slightly different than `preCompare` and `postCompare`. It consists of an object where you define which side(s) of the comparison should be modified. It also **does not modify downstream data!** Instead, the modifications are made only for use in the comparison.
```json5 title="lastfm.json"
[
{
"name": "myLastFm",
// ...
"options": {
"playTransform": {
"compare": {
"candidate": {/* ... */}, // modify the "new" Play being compared
"existing": {/* ... */}, // modify all "existing" Play/Scrobbles the new Play is being compared against
},
}
}
}
]
```
#### Regular Expressions
In addition to plain strings [expressions](#expression) that are matched and removed you can also use Regular Expressions. Write your regex like you normally would, but as a string, and it'll automatically be parsed:
```json5
[
"/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)' and removes it
{
"search": "/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)'
"replace": "[MySite.com]" // replace with the string '[MySite.com]'
}
]
```
The `replace` property uses javascript's [`replace()` function and so can use any special string characters.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement)
## Examples
### Remove phrase from Title in all new Plays
Removes the phrase `(Album Version)` from the Title of a Play
<details>
<summary>Example</summary>
```json5 title="config.json"
{
"sourceDefaults": {
"playTransform": {
"preCompare": {
"title": [
"(Album Version)"
]
}
}
}
}
```
</details>
### Remove all parenthesized content from the end of a title
<details>
<summary>Example</summary>
```json5 title="lastfm.json"
[
{
"name": "myLastFm",
// ...
"options": {
"playTransform": {
"compare": {
"candidate": {
"title": [
"/(\(.+\))\s*$/"
]
},
"existing": {
"title": [
"/(\(.+\))\s*$/"
]
},
},
}
}
}
]
```
</details>
### Rename misspelled artist in all new Plays
<details>
<summary>Example</summary>
```json5 title="config.json"
{
"sourceDefaults": {
"playTransform": {
"preCompare": {
"artists": [
{
"search": "Boz Skaggs",
"replace": "Boz Scaggs"
}
]
}
}
}
}
```
</details>
### Remove "Various Artists" albums in all new Plays
<details>
<summary>Example</summary>
```json5 title="config.json"
{
"sourceDefaults": {
"playTransform": {
"preCompare": {
"album": [
{
"search": "Various Artists",
"replace": ""
}
]
}
}
}
}
```
</details>
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Development",
"position": 4,
"link": {
"type": "generated-index",
"description": "Developing for Multi-Scrobbler and tutorials"
}
}
+8
View File
@@ -0,0 +1,8 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 3
title: Client Development/Tutorial
---
To do...
+168
View File
@@ -0,0 +1,168 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 1
title: Common Development
description: Start here for MS development
---
# Development
## Architecture
Multi-scrobbler is written entirely in [Typescript](https://www.typescriptlang.org/). It consists of a backend and frontend. The backend handles all Source/Client logic, mounts web server endpoints that listen for Auth callbacks and Source ingress using [expressjs](https://expressjs.com/), and serves the frontend. The frontend is a standalone [Vitejs](https://vitejs.dev/) app that communicates via API to the backend in order to render the dashboard.
## Project Setup
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system. It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
git checkout --track origin/develop
nvm use # optional, to set correct Node version
npm install
npm run start
```
Use the [`develop`](https://github.com/FoxxMD/multi-scrobbler/tree/develop) branch as the target for any Pull Requests. The `master` branch is reserved for releases and minor documentation updates only.
## Common Development
:::info
In this document, when referring to aspects of Sources and Clients that are shared between both, the Source/Client will be referred to as a **Component.**
:::
A Component is composed of two parts:
* Typescript interfaces describing structure of configuration for that Component
* A concrete class inheriting from a common "startup" abstract class that enforces how the Component is built and operates
In both parts Source/Clients share some common properties/behavior before diverging in how they operate.
### Config
The configuration for a Component should always have this minimum shape, enforced respectively by the interfaces [CommonSourceConfig](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/infrastructure/config/source/index.ts#L105) and [CommonClientConfig](https://github.com/FoxxMD/multi-scrobbler/blob/ce1c70a4e1e87fb5bea7cca960eaafbd15881a1f/src/backend/common/infrastructure/config/client/index.ts#L68):
```ts
interface MyConfig {
name: string
data?: object
options?: object
}
```
* `data` contains data that is required for a Component to operate such as credentials, callback urls, api keys, endpoints, etc...
* `options` are **optional** settings that can be used to fine-tune the usage of the Component but are not required or do not majorly affect behavior. EX additional logging toggles
### Concrete Class
Components inherit from an abstract base class, [`AbstractComponent`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts), that defines different "stages" of how a Component is built and initialized when MS first starts as well as when restarting the Component in the event it stops due to an error/network failure/etc...
#### Stages
Stages below are invoked in the order listed. All stages are asynchronous to allow fetching network requests or reading files.
The stage function (described in each stage below) should return a value or throw:
* return `null` if the stage is not required
* return `true` if the stage succeeded
* return a `string` if the stage succeeded and you wish to append a result to the log output for this stage
* throw an `Exception` if the stage failed for any reason and the Component should not continue to run/start up
##### Stage: Build Data
This stage should be used to validate user configuration, parse any additional data from async sources (file, network), and finalize the shape of any configuration/data needed for the Component to operate.
:::info
Implement [`doBuildInitData`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L71) in your child class to invoke this stage.
::::
<details>
<summary>Examples</summary>
* Parse a full URL like `http://SOME_IP:7000/subfolder/api` from user config containing a base url like `data.baseUrl: 'SOME_IP'` and then store this in the class config
* Validate that config `data` contains required properties `user` `password` `salt`
* Read stored credentials from `${this.configDir}/currentCreds-MySource-${name}.json`;
</details>
##### Stage: Check Connection
This stage is used to validate that MS can communicate with the service the Component is interacting with. This stage is invoked on MS startup as well as any time the Component tries to restart after a failure.
If the Component depends on **ingress** (like Jellyfin/Plex webhook) this stage is not necessary.
:::info
Implement [`doCheckConnection`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L103) in your child class to invoke this stage.
::::
<details>
<summary>Examples</summary>
* Make a [`request`](https://nodejs.org/docs/latest-v18.x/api/http.html#httprequesturl-options-callback) to the service's server to ensure it is accessible
* Open a websocket connection and check for a ping-pong
</details>
##### Stage: Test Auth
MS determines if Auth is required for a Component based on two class properties. You should set these properties during `constructor` initialization for your Component class:
* `requiresAuth` - (default `false`) Set to `true` if MS should check/test Auth for this Component
* `requiresAuthInteraction` - (default `false`) Set to `true` if user interaction is required to complete auth IE user needs to visit a callback URL
If the Component requires authentication in order to communicate with a service then any required data should be built in this stage and a request made to the service to ensure the authentication data is valid.
This stage should return:
* `true` if auth succeeded
* `false` if auth failed without unexpected errors
* IE the authentication data is not valid and requires user interaction to resolve the failure
* throw an exception if network failure or unexpected error occurred
You _should_ attempt to re-authenticate, if possible. Only throw an exception or return `false` if there is no way to recover from an authentication failure.
:::info
Implement [`doAuthentication`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L111) in your child class to invoke this stage.
::::
<details>
<summary>Examples</summary>
* Generate a Bearer Token for Basic Auth from user/password given in config and store in class properties
* Make a request to a known endpoint with Authorization token from read credentials file to see if succeeds or returns 403
* Catch a 403 and attempt to reauthenticate at an auth endpoint with user/password given in config
</details>
### Play Object
The **PlayObject** is the standard data structure MS uses to store listen (track) information and data required for scrobbling. It consists of:
* Track Data -- a standard format for storing track, artists, album, track duration, the date the track was played at, etc...
* Listen Metadata -- Optional but useful data related to the specific play or specifics about the Source/Client context for this play such as
* Platform specific ID, web URL to track, device/user ID that played this track, etc...
Both Sources and Clients use the **PlayObject** interface. When a Component receives track info from its corresponding service it must transform this data into a PlayObject before it can be interacted with.
For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/core/Atomic.ts#L141) in your project
## Creating Clients and Sources
* [Source Development and Tutorial](dev-source.md)
* [Client Development and Tutorial](dev-client.md)
+552
View File
@@ -0,0 +1,552 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 2
title: Source Development/Tutorial
---
This document will provide a step-by-step guide for creating a (trivial) new Source in MS alongside describing what aspects of the Source need to be implemented based on the service you use. Before using this document you should review [Common Development](dev-common.md#common-development).
## Scenario
You are the developer of a fancy, new self-hosted web-based media player called **Cool Player.** Cool Player has a slick interface and many bells and whistles, but most importantly it has an API. The API:
* Has an unauthenticated health endpoint at `/api/health` that returns `200` if the service is running properly
* Has authenticated endpoints that require a user-generated token in the header `Authorization MY_TOKEN`
* Has a `/api/recent` endpoint that lists recently played tracks with a timestamp
* Has a `/api/now-playing` endpoint that returns information about the state of the player like current track, player position in the track, etc...
* Cool Player is by default accessed on port `6969`
* Your personal instance of Cool Player is hosted at `http://192.168.0.100:6969` and the api is accessed at `http://192.168.0.100:6969/api`
Because there is an API that MS can actively read this will be a **polling** Source where MS sends requests to Cool Player to get scrobble information -- as opposed to an **ingress** Source like Jellyfin/Plex that uses webhooks from the service to send data to MS.
## Minimal Implementation
### Define and Implement Config
We will create a new config interface for Cool Player using the [Common Config](dev-common.md#config) and tell MS it is a valid config that can be used.
Create a new file for your config:
```ts title="/src/backend/common/infrastructure/config/source/coolplayer.ts"
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
// all of the required data for the Build Data and Test Auth stages (from Common Development docs)
// should go here
export interface CoolPlayerSourceData extends CommonSourceData, PollingOptions {
// remember to annotation your properties!
/**
* The user-generated token for Cool Player auth created in Cool Player -> Settings -> User -> Tokens
*
* @example f243331e-cf5b-49d7-846b-0845bdc965b4
* */
token: string
/**
* The host and port where Cool Player is hosted
*
* @example http://192.168.0.100:6969
* */
baseUrl: string
}
export interface CoolPlayerSourceConfig extends CommonSourceConfig {
data: CoolPlayerSourceData
}
export interface CoolPlayerSourceAIOConfig extends CoolPlayerSourceConfig {
// when using the all-in-one 'config.json' this is how users will identify this source
type: 'coolplayer'
}
```
Add the new interface to the list of valid interfaces for sources:
```ts title="src/backend/common/infrastructure/config/source/sources.ts"
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
// ...
// highlight-next-line
import { CoolPlayerSourceAIOConfig, CoolPlayerSourceConfig } from "./coolplayer.js";
export type SourceConfig =
SpotifySourceConfig
// ...
// highlight-next-line
| CoolPlayerSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
// ...
// highlight-next-line
| CoolPlayerSourceAIOConfig;
```
Finally, add the source type identifier to the list of valid identifiers
```ts title="src/backend/common/infrastructure/Atomic.ts"
export type SourceType =
'spotify'
// ...
// highlight-next-line
| 'coolplayer';
export const sourceTypes: SourceType[] = [
'spotify',
// ...
// highlight-next-line
'coolplayer'
];
```
Now we will create a new Source inheriting from [`AbstractComponent`](dev-common.md#concrete-class) that:
* accepts our config interface
* implements a function to transform CoolPlayer's track data into a [**PlayObject**](dev-common.md#play-object)
* implements required [stages](dev-common.md#stages)
* implements required methods to current player state and/or now playing track
### Create CoolPlayer Source
First we create a new Source called `CoolPlayerSource` and setup our constructor to accept the config and [specify Auth behavior.](dev-common.md#stage-test-auth)
```ts title="src/backend/sources/SpotifySource.ts"
import { CoolPlayerSourceConfig } from "../common/infrastructure/config/source/coolplayer.js";
import MemorySource from "./MemorySource.js";
import {
InternalConfig,
} from "../common/infrastructure/Atomic.js";
// MemorySource is the base class used for polling-type Sources
export default class CoolPlayerSource extends MemorySource {
// type hints for TS to know what the base class config looks like
declare config: CoolPlayerSourceConfig;
constructor(name: any, config: CoolPlayerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('coolplayer', name, config, internal, emitter);
// Cool Player required authentication
this.requiresAuth = true;
// but does not require user interaction for auth to work
this.requiresAuthInteraction = false;
// tells MS this is a Source that can be activity monitored through API
this.canPoll = true;
}
}
```
### Initialize Source from Config
When MS starts it reads all configs and determines which Source to build based on the configs found. We need to tell it to build a `CoolPlayerSource` when a `coolplayer` config type is found.
We modify `ScrobbleSources.ts` to add `CoolPlayerSource` as an option:
```ts title="src/backend/sources/ScrobbleSources.ts"
// ...
import { CoolPlayerSource, CoolPlayerData } from "./CoolPlayerSource.js";
export default class ScrobbleSources {
// ...
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => {
// ...
// if CoolPlayerSource should be able to be built from ENVs only
// then add it as a case statement here
for (const sourceType of sourceTypes) {
switch (sourceType) {
// ...
case 'musikcube':
// ...
break;
// highlight-start
case 'coolplayer':
const cp = {
baseUrl: process.env.COOL_URL,
token: process.env.COOL_TOKEN
}
if (!Object.values(cp).every(x => x === undefined)) {
configs.push({
type: 'coolplayer',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: cp as CoolPlayerData
});
}
break;
// highlight-end
default:
break;
}
}
}
// ...
// (required) create new CoolPlayerSource if source config type is 'coolplayer'
addSource = async (clientConfig: ParsedConfig, defaults: SourceDefaults = {}) => {
// ...
let newSource: AbstractSource;
switch (type) {
// ...
case 'musikcube':
// ...
break;
// highlight-start
case 'coolplayer':
newSource = await new CoolPlayerSource(name, compositeConfig as CoolPlayerSourceConfig, internal, this.emitter);
break;
// highlight-end
default:
break;
}
}
}
```
### Implement Play Object Transform
Now we will create a static function that is used to take the track data returned from Cool Player's API and return a standard [`PlayObject`.](dev-common.md#play-object)
```ts title="src/backend/sources/CoolPlayerSource.ts"
import dayjs from "dayjs";
import {
FormatPlayObjectOptions,
} from "../common/infrastructure/Atomic.js";
import { PlayObject } from "../../core/Atomic.js";
export default class CoolPlayerSource extends MemorySource {
// ...
// 'obj' should ideally be a real TS interface
// if CoolPlayer has a ts/js client we would use that otherwise
// we should build our own interfaces to represent track data from Cool Player's API
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
const {
trackName,
artistName,
albumName,
duration,
playedAt,
} = obj;
return {
data: {
artists: [artistName],
album: albumName,
track: trackName,
// assuming seconds
duration,
// assuming playedAt is an ISO8601 timestamp
playDate: dayjs(playedAt)
},
meta: {
source: 'CoolPlayer'
}
}
}
}
```
### Implement Stages
Next we will implement the [Stages](dev-common.md#stages) required to get CoolPlayerSource running.
#### Build Data
First we implement the [Build Data Stage](dev-common.md#stage-build-data). We will check that the `token` and `baseUrl` properties have been provided by the user. Additionally, we will parse the baseUrl and add default ports/prefix.
```ts title="src/backend/sources/CoolPlayerSource.ts"
import { URL } from "url";
// ...
export default class CoolPlayerSource extends MemorySource {
baseUrl!: URL;
// ...
static parseConnectionUrl(val: string) {
const normal = normalizeUrl(val, {removeTrailingSlash: false, normalizeProtocol: true})
const url = new URL(normal);
if (url.port === null || url.port === '') {
url.port = '6969';
}
if (url.pathname === '/') {
url.pathname = '/api/';
}
return url;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
token,
baseUrl
} = this.config;
if (token === null || token === undefined || (typeof token === 'string' && token.trim() === '')) {
throw new Error(`'token' must be defined`);
}
if (baseUrl === null || baseUrl === undefined || (typeof baseUrl === 'string' && baseUrl.trim() === '')) {
throw new Error(`'baseUrl' must be defined`);
}
try {
this.baseUrl = CoolPlayerSource.parseConnectionUrl(baseUrl);
} catch (e) {
throw new Error(`Could not parse baseUrl: ${baseUrl}`, {cause: e});
}
this.logger.verbose(`Config URL: ${baseUrl} => Normalized: '${this.url.toString()}'`);
return true;
}
}
```
#### Check Connection
Second we will implement the [Check Connection Stage](dev-common.md#stage-check-connection):
```ts title="src/backend/sources/CoolPlayerSource.ts"
import request from 'superagent';
import { UpstreamError } from "../common/errors/UpstreamError.js";
// ...
export default class CoolPlayerSource extends MemorySource {
// ...
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
const resp = await request.get(`${this.baseUrl}/health`);
return true;
// if /health returned version info we could instead read response and return a string with version info for visibility to the user
// return `Cool Player Version: ${resp.body.version}`;
} catch (e) {
throw e;
}
}
}
```
#### Test Auth
Finally, we will implement [Auth Test Stage](dev-common.md#stage-test-auth):
```ts title="src/backend/sources/CoolPlayerSource.ts"
import request from 'superagent';
import { UpstreamError } from "../common/errors/UpstreamError.js";
// ...
export default class CoolPlayerSource extends MemorySource {
// ...
doAuthentication = async () => {
try {
const resp = await request
.get(`${this.baseUrl}/recent`)
.set('Authorization', `Token ${this.config.token}`);
return true;
} catch (e) {
// if Cool Player returned an error as json we could parse it from error body and throw new Error with the message
throw e;
}
}
}
```
### Implement Polling
The majority of Sources MS monitors primarily operate as a source of truth for a **music player** rather than a **played music history.** Only Listenbrainz and Last.fm operate as a source of music history.
To this end, MS implements a [state machine](https://www.freecodecamp.org/news/state-machines-basics-of-computer-science-d42855debc66/) that emulates the behavior of a music player in order to keep track of when a song you are listening to should be scrobbled. It does this by monitoring the "currently playing" track reported by a Source's service, with varying degrees of accuracy depending on what information is returned from the service. The state machine is implemented in `MemorySource` which our `CoolPlayerSource` inherits from.
For a polling Source to work properly we need to implement a function, [`getRecentlyPlayed`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L92), that returns PlayObjects that are "newly" played. These are then checked against previously "discovered" plays and their timestamp to determine if they should be surfaced to Clients to scrobble.
To take advantage of the `MemorySource` state machine we will additionally use [`processRecentPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/MemorySource.ts#L113) from `MemorySource` inside `getRecentlyPlayed`. We pass track and/or player state returned from the Source service to `processRecentPlayers`. It then takes care of deriving Source player state based on how this data changes over time. The advantage to using `processRecentPlays` is that our Source service does not necessarily need to pass any player information -- as long as the track info has a **duration** we can more-or-less determine if it has been played long enough to scrobble.
```ts title="src/backend/sources/CoolPlayerSource.ts"
import request from 'superagent';
import {
SourceData,
PlayerStateData,
SINGLE_USER_PLATFORM_ID
} from "../common/infrastructure/Atomic.js";
// ...
export default class CoolPlayerSource extends MemorySource {
// ...
protected async getRecentlyPlayed(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> {
const plays: SourceData[] = [];
try {
// currently playing tracks/player state data
const resp = await request
.get(`${this.baseUrl}/now-playing`)
.set('Authorization', `Token ${this.config.token}`);
const {
body: {
playerState, // 'playing' or 'stopped' or 'paused'...
position, // number of seconds into the track IE at position 48 -> ( 0:48/3:56 in player UI )
play: { /* track data */}
}
} = resp;
// transform into standard player state data
const playerData: PlayerStateData = {
platformId: SINGLE_USER_PLATFORM_ID,
play: CoolPlayerSource.formatPlayObj(play),
position
};
// if Cool Player does return player state we can also push a regular PlayObject to this list
plays.push(playerData);
} catch (e) {
throw e;
}
// process player state through state machine
// if the track changes or player state changes
// and currently played track has been listened to long enough to be scrobbled it will return in newPlays
const newPlays = this.processRecentPlays(plays);
// finally, we return new plays and MS checks to see if they have been previously seen
// before signalling to Clients that they can be scrobbled
return newPlays;
}
}
```
Congratulations! Your `CoolPlayerSource` has been minimally implemented and can now be used in multi-scrobbler.
## Further Implementation
### Backlog
To have your Source try to scrobble "missed" tracks when MS starts up the Source's service must be able to provide:
* track information
* timestamp of when the track was played
In your Source implement [`getBackloggedPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L235) and set setting in constructor indicating it has backlogging capabilities:
```ts title="src/backend/sources/CoolPlayerSource.ts"
import request from 'superagent';
import {
PlayObject,
} from "../common/infrastructure/Atomic.js";
// ...
export default class CoolPlayerSource extends MemorySource {
constructor(/* ... */) {
super(/* ... */);
// ...
// tell MS it should try to get backlogged tracks on startup
this.canBacklog = true;
}
// ...
protected getBackloggedPlays = async (options: RecentlyPlayedOptions): Promise<PlayObject[]> => {
try {
const resp = await request
.get(`${this.baseUrl}/recent`)
.set('Authorization', `Token ${this.config.token}`);
// assuming list from body looks like track info returned in
// "Implement Play Object Transform" section
const {
body = []
} = resp;
return body.map(x => CoolPlayerSource.formatPlayObj(x));
} catch (e) {
throw new Error('Error occurred while getting recently played', {cause: e});
}
}
}
```
### Other Source Types
There are some scenarios where polling and/or state machine is not the right tool to handle determining if incoming data should be scrobbled:
* The Source service handles scrobble threshold internally, the data being received should always be scrobbled (WebScrobbler, Plex, Tautulli, Listenbrainz, Last.fm)
* You prefer to handle the scrobble determination yourself
#### Music History Source
If the Source is still polling but the track returned should always be scrobbled if not already seen IE the Source service is a **music history source** (Listenbrainz, Last.fm), rather than a music player, then simply indicate to MS the source of truth type by setting it in the constructor. The state machine will always return a track if it is new and not seen, regardless of how recently it was seen:
```ts title="src/backend/sources/CoolPlayerSource.ts"
import { SOURCE_SOT } from "../../core/Atomic.js";
// ...
export default class CoolPlayerSource extends MemorySource {
constructor(/* ... */) {
super(/* ... */);
// ...
// tell MS it should immediately scrobble any new, unseen tracks from the upstream service
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
}
}
```
#### Non-Polling Source
**Ingress** Sources (like Plex, Tautulli, Webscrobbler, Jellyfin) do not having a polling mechanism because the upstream service contacts MS when there is an event, rather than MS contacting the upstream service.
For these Sources you will need to implement endpoints in `src/service/api.ts` and corresponding files. See the existing Sources in the project as references for how to do this.
You may still wish to use the state machine `MemorySource` (like Jellyfin) if the events received are not "scrobble" events but instead of implementing `getRecentlyPlayed` you will implement your own function in your Source class, like `handle()`, that receives data and then uses `processRecentPlays`.
After new plays have been determined see the next section for how to scrobble...
#### Basic Source
At the core of a Source that implements `AbstractSource`'s functionality is the ability to **Discover** and **Scrobble** plays.
These functions are not seen in the MVP `CoolPlayerSource` because they are automatically done by the polling functionality after being returned from `getRecentlyPlayed`.
##### Discovery
A Source keeps track of all the "plays" that are determined to be valid for scrobbling. When a play is valid it is checked to see if it has already been "discovered" by comparing the track info and timestamp of the play against already discovered plays. This prevents duplicate scrobbling by using the Source's own data and simplifies scrobbling for Sources by allowing your implementation to "always" ingest track data without having to worry about whether its new or not -- `AbstractSource` and `discover()` will take care of that for you.
```ts title="src/backend/sources/MyBasicSource.ts"
export default class MyBasicSource extends AbstractSource {
handle(somePlay: PlayObject) {
// if the track is "new" and not seen before it is returned in the discovered list
// we then know it is OK to be sent to Clients for scrobbling
const discovered: PlayObject[] = this.discover([somePlay]);
}
}
```
This additionally will be surfaced to the user in the Dashboard in the "Tracks Discovered" page.
##### Scrobbling
After a play is verified to be discovered we can then scrobble it. This will emit the plays to the ScrobbleClients service which then disseminates the play to all Clients that were configured to listen in the Source's config.
```ts title="src/backend/sources/MyBasicSource.ts"
export default class MyBasicSourceSource extends AbstractSource {
handle(somePlay: PlayObject) {
const discovered: PlayObject[] = this.discover([somePlay]);
// emit plays that can be scrobbled by clients
this.scrobble(discovered);
}
}
```
If your service only emits an event when a play is scrobbled you can _technically_ skip using `discover()` but it is good practice to use it unless you have a very good reason not to.
:::note
Using `scrobble()` does not guarantee a track is actually scrobbled! The Scrobble Clients also check the play against their own "recently scrobbled" list to prevent duplicates.
:::
+104
View File
@@ -0,0 +1,104 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 4
title: Flatpak
description: Building Flatpak App locally
---
:::note
These steps are for building the flatpak from source. If you want to install the application normally then [get it through flathub](../installation/installation.mdx#flatpak)
:::
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
## Install Requirements
### Flatpak and flatpak-builder
Install [Flatpak](https://flatpak.org/setup/)
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
#### [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
## Update Project source
Set the `branch` `tag` or `commit` to use for MS in the `source` section of [`io.github.foxxmd.multiscrobbler.yml`](https://github.com/FoxxMD/multi-scrobbler/blob/master/flatpak/io.github.foxxmd.multiscrobbler.yml)
## Generate Sources and Build
### Use Setup Script
A convenience bash script is provided that automates generating offline sources and building the flatpak app for you. This is the recommend method to use.
Located in the project at [`flatpak/setup.sh`](https://github.com/FoxxMD/multi-scrobbler/blob/master/flatpak/setup.sh), run it from the `flatpak` directory with this syntax:
```shell
./setup.sh -o -b /path/to/flatpak/build/dir
```
```
Args:
-o => Delete and overwrite any existing generated sources
-b => The absolute path to the directory that should be used for flatpak build/artifacts. If not defined will use `CWD/build`
-y => Proceed without confirming settings
```
### Manual Setup
If you cannot use `setup.sh` follow the below to manually generate sources and build the flatpak app:
<details>
<summary>Instructions</summary>
#### Use `flatpak-node-generator` to generate sources
First, [make sure `node_modules` is deleted or empty](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442) for both the project and `docsite` directories.
Then, from MS project root:
```shell title="PROJECT_DIR"
flatpak-node-generator npm package-lock.json
```
Move `generated-sources.json` into `PROJECT_DIR/flatpak` and rename `generated-sources.0.json`
Then, generate `docsite` sources:
```shell title="PROJECT_DIR"
flatpak-node-generator npm docsite/package-lock.json
```
Move `generated-sources.json` into `PROJECT_DIR/flatpak` and rename `generated-sources.1.json`
#### Build flatpak
In the below command replace `/home/yourUser/multi-scrobbler-flatpak` with a directory to use for storing flatpak build/artifacts.
```shell title="PROJECT_DIR/flatpak"
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean
```
:::info
Append `--install --user` to the above command to have the app installed immediately.
::::
</details>
# Run App
If built with `--install --user` (default when using `setup.sh`) you can now run MS using the command
```shell
flatpak run -u io.github.foxxmd.multiscrobbler
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"label": "Installation",
"position": 1,
"position": 2,
"link": {
"type": "generated-index",
"description": "Way to install Multi-Scrobbler"
-151
View File
@@ -1,151 +0,0 @@
---
sidebar_position: 1
title: 'Overview'
---
# Installation
# Local
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
## Nodejs
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
npm run build
npm run start
```
### Usage Examples
* The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
```shell
npm run start
```
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
```shell
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
```
## Flatpak
You must have [Flatpak](https://flatpak.org/) installed on your system.
```shell
flatpak install flathub io.github.foxxmd.multiscrobbler
```
### Usage Examples
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
```shell
flatpak run io.github.foxxmd.multiscrobbler
```
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
```shell
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
```
## Docker
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
[Dockerhub](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
docker.io/foxxmd/multi-scrobbler:latest
```
[Github Packages](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
```
ghcr.io/foxxmd/multi-scrobbler:latest
```
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
#### Networking
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin).
The default container port is `9078`. To map container to host port:
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
### Linux Host
If you are
* using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_)
* running docker on MacOS or Windows
this **DOES NOT** apply to you.
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
## Docker Usage Examples
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
```
### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
```bash
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
```
See the [docker-compose.yml](../../../docker-compose.yml) file for how to use with docker-compose.
+344
View File
@@ -0,0 +1,344 @@
---
sidebar_position: 1
title: 'Overview'
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::tip
For the difference between **ENV** and **File** examples in this document see [Configuration Types](../configuration/configuration.mdx#configuration-types).
:::
## Local Installation
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
### Nodejs
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
npm run docs:install && npm run build
npm run start
```
#### Rollup build error
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
```json
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
}
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.9.6"
}
}
```
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```shell
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
```
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>`./config/config.json`</summary>
```json title="./config/config.json"
{
"sources": [
{
"type": "spotify",
"clients": ["myConfig"],
"name": "mySpotifySource",
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
}
}
],
"clients": [
{
"type": "maloja",
"name": "myConfig",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
}
}
],
}
```
</details>
```shell
npm run start
```
</TabItem>
</Tabs>
:::tip
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
:::
### Flatpak
You must have [Flatpak](https://flatpak.org/) installed on your system.
```shell
flatpak install flathub io.github.foxxmd.multiscrobbler
```
:::warning
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
* to use a [Docker](#docker) installation if possible or
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
::::
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
```shell
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
```
</TabItem>
<TabItem value="file" label="File">
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
```shell
flatpak run io.github.foxxmd.multiscrobbler
```
</TabItem>
</Tabs>
## Docker
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
:::info[Available Images]
<Tabs>
<TabItem value="docker" label="Dockerhub">
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
docker.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
<TabItem value="ghcr" label="Github Packages">
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
```
ghcr.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
</Tabs>
:::
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
```shell
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
volumes:
- "./config:/config"
```
</TabItem>
</Tabs>
</details>
#### Networking
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin). The default container port is `9078`.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
```shell
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
ports:
- "9078:9078"
```
</TabItem>
</Tabs>
</details>
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
```shell
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
environment:
- BASE_URL="http://hostMachineIP"
```
</TabItem>
</Tabs>
</details>
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
### Linux Host
::::info
:::note
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) or are running docker on MacOS/Windows this **DOES NOT** apply to you.
:::
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
::::
### Docker Usage Example
:::tip
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
:::
The example scenario:
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
* Serving app on port `9078`
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
* Config/data directory on host machine in a directory next to `docker-compose.yml`
* Linux uid/gid is `1000:1000`
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="file" label="File">
```bash
docker run -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
</Tabs>
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
See [`docker-compose.yml`](../../../docker-compose.yml) file for more options and annotations.
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- BASE_URL="http://192.168.0.100:9078"
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
- PUID=1000
- PGID=1000
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
</TabItem>
</Tabs>
+5 -5
View File
@@ -3,7 +3,7 @@ sidebar_position: 2
title: 'As a Service'
---
If you have multi-scrobbler installed [locally](installation.md#local) you can enable it to run as a background service when you login.
If you have multi-scrobbler installed [locally](installation.mdx#nodejs) you can enable it to run as a background service when you login.
Before running as a service you should run it at least once in the foreground to ensure it can start up correctly!
@@ -17,7 +17,7 @@ This setup will create a [user service](https://wiki.archlinux.org/title/systemd
Create a new service file for multi-scrobbler under your HOME config:
```console
```bash
mkdir -p ~/.config/systemd/user
touch ~/.config/systemd/user/multi-scrobbler.service
```
@@ -38,11 +38,11 @@ Restart=no
WantedBy=default.target
```
The above assumes you [installed multi-scrobbler using flatpak](installation.md#flatpak)
The above assumes you [installed multi-scrobbler using flatpak](installation.mdx#flatpak)
### Node.js Installs
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.md#nodejs) you should modify the `[Service]`:
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
```ini
[Service]
@@ -56,7 +56,7 @@ Restart=no
Save the file then run:
```console
```bash
systemctl daemon-reload
systemctl --user enable multi-scrobbler.service
systemctl --user start multi-scrobbler.service
+165
View File
@@ -0,0 +1,165 @@
---
title: 'Quickstart'
sidebar_position: 1
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
This guide will get you up and running with multi-scrobbler using [Docker](https://www.docker.com/) and [`docker compose`](https://docs.docker.com/compose/). At the end of the guide you will have:
* 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/configuration.mdx#spotify) and/or [Jellyfin](./configuration/configuration.mdx#jellyfin) for listening activity
* multi-scrobbler scrobbling to [Lastfm](./configuration/configuration.mdx#lastfm) and/or [Maloja](./configuration/configuration.mdx#maloja)
:::note
If the multi-scrobbler container is on the same machine you will be viewing the dashboard from (IE `localhost`) you can remove and ignore `BASE_URL` usage below. Additionally, replace usage of `192.168.0.100` with `localhost`.
:::
## Create Docker Compose File
Create a new folder for multi-scrobbler related data and then create `docker-compose.yml` with this content:
```yaml title="~/msData/docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- BASE_URL="http://192.168.0.100:9078"
# all Environmental Variables in below examples go here!
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
## Setup Sources
**Sources** are the services multi-scrobbler monitors to look for listening activity.
<Tabs>
<TabItem value="jellyfin" label="Jellyfin">
Follow the Jellyfin configuration [instructions for setting up a **webhook**.](./configuration/configuration.mdx#jellyfin)
After webhook is setup add **at least one** of these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- JELLYFIN_USER=myUserName # comma-separated list of users to monitor
- JELLYFIN_SERVER=myServerName # comma-separated list of servers that should be monitored
```
</TabItem>
<TabItem value="spotify" label="Spotify">
To access your Spotify activity you must [register a Spotify application](https://developer.spotify.com/dashboard) to get a
**Client ID/Secret**.
When creating the application add this to **Redirect URIs**
```
http://192.168.0.100:9078/callback
```
After the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- SPOTIFY_CLIENT_ID=yourClientId
- SPOTIFY_CLIENT_SECRET=yourClientSecret
```
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the Spotify card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
</TabItem>
</Tabs>
## Setup Clients
**Clients** are services that store scrobbles. Multi-scrobbler will scrobble all listening activity from the **Sources** you configured to all **Clients** you configure here.
<Tabs>
<TabItem value="maloja" label="Maloja">
Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
<details>
<summary>Maloja Setup Intructions</summary>
Using Maloja's example `docker-compose.yml`:
```yaml reference title="~/malojaData/docker-compose.yml"
https://github.com/krateng/maloja/blob/master/example-compose.yml
```
Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password
Start the container:
```shell title="~/malojaData"
docker compose up -d
```
</details>
* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://192.168.0.100:42010/admin_apikeys)
* Create a **New Key** and then copy the generated key value
Finally, add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- MALOJA_URL="http://192.168.0.100:42010"
- MALOJA_API_KEY=myApiKey
```
</TabItem>
<TabItem value="lastfm" label="Last.fm">
[Register for an API account at Last.fm.](https://www.last.fm/api/account/create)
Use the following for **Callback URL**:
```
http://192.168.0.100:9078/lastfm/callback
```
After account creation use the displayed information and add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- LASTFM_API_KEY=myApiKey
- LASTFM_SECRET=myApiSecret
```
</TabItem>
</Tabs>
## Start Multi-Scrobbler
:::tip
If you are running your multi-scrobbler container on a Linux host see [these instructions for setting proper file permissions.](./installation/installation.mdx#linux-host)
:::
From the same directory as the [`docker-compose.yml` you created earlier](#create-docker-compose-file) start the container:
```shell title="~/msData"
docker compose up -d
```
You're done! Multi-scrobbler is now running. It will monitor the sources you configured and scrobble to clients you set up.
Visit `http://192.168.0.100:9078` to see the dashboard where
* configured Sources/Clients
* show current status and authentication options
* display statistics about discovered/scrobbled tracks and Now Playing status
* a real-time log shows multi-scrobbler's activity
## Next Steps
* See more advanced docker options as well as other install methods in the [**Installation**](./installation/installation.mdx#docker) docs
* Review the [**Configuration**](./configuration/configuration.mdx) docs
* Learn about how to configure multi-scrobbler using files for more complicated Source/Client scenarios
* See all available Sources/Clients alongside configuration examples
* Learn how to set up [notification webhooks](./configuration/configuration.mdx#webhook-configurations)
* Check out the [kitchensink example](./configuration/kitchensink.md)
* Consult the [**FAQ**](./FAQ.md) for solutions to common problems
@@ -1,11 +1,9 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
import type * as Preset from '@docusaurus/preset-classic';
import type { Config } from '@docusaurus/types';
import * as themes from 'prism-react-renderer';
//import sidebars from './sidebars';
const lightCodeTheme = require('prism-react-renderer/themes/github');
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
/** @type {import('@docusaurus/types').Config} */
const config = {
const config: Config = {
title: 'Multi-Scrobbler',
tagline: 'Scrobble all the things',
favicon: 'img/favicon.ico',
@@ -14,7 +12,7 @@ const config = {
url: 'https://foxxmd.github.io',
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: '/multi-scrobbler',
baseUrl: process.env.DOCS_BASE !== undefined && process.env.DOCS_BASE !== '' ? process.env.DOCS_BASE : '/docs',
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
@@ -23,7 +21,7 @@ const config = {
trailingSlash: false,
onBrokenLinks: 'throw',
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'warn',
// Even if you don't use internalization, you can use this field to set useful
@@ -33,14 +31,14 @@ const config = {
defaultLocale: 'en',
locales: ['en'],
},
scripts: [
],
presets: [
[
'classic',
/** @type {import('@docusaurus/preset-classic').Options} */
({
{
docs: {
sidebarPath: require.resolve('./sidebars.js'),
sidebarPath: './sidebars.ts',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
@@ -56,23 +54,33 @@ const config = {
blog: false,
theme: {
customCss: require.resolve('./src/css/custom.css'),
customCss: './src/css/custom.css',
},
}),
} satisfies Preset.Options,
],
],
themes: ["docusaurus-json-schema-plugin"],
plugins: [
themes: [
"docusaurus-json-schema-plugin",
[
require.resolve("@cmfcmf/docusaurus-search-local"),
"@easyops-cn/docusaurus-search-local",
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
{
// ... Your options.
// `hashed` is recommended as long-term-cache of index file is possible.
hashed: true,
indexBlog: false,
// For Docs using Chinese, The `language` is recommended to set to:
// ```
// language: ["en", "zh"],
// ```
},
],
'docusaurus-theme-github-codeblock'
],
plugins: [
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
{
// Replace with your project's social card
image: 'img/docusaurus-social-card.jpg',
navbar: {
@@ -98,6 +106,11 @@ const config = {
label: 'GitHub',
position: 'right',
},
{
href: 'https://foxxmd.github.io/multi-scrobbler/',
label: 'Website',
position: 'right',
},
],
},
footer: {
@@ -112,31 +125,18 @@ const config = {
},
{
label: 'Installation',
to: '/docs/installation',
to: 'docs/installation',
},
{
label: 'Configuration',
to: '/docs/configuration',
to: 'docs/configuration',
},
{
label: 'Development',
to: 'docs/development/dev-common',
},
],
},
/* {
title: 'Community',
items: [
{
label: 'Stack Overflow',
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
},
{
label: 'Discord',
href: 'https://discordapp.com/invite/docusaurus',
},
{
label: 'Twitter',
href: 'https://twitter.com/docusaurus',
},
],
},*/
{
title: 'More',
items: [
@@ -144,16 +144,42 @@ const config = {
label: 'GitHub',
href: 'https://github.com/foxxmd/multi-scrobbler',
},
{
label: 'Website',
href: 'https://foxxmd.github.io/multi-scrobbler/',
},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} Multi-Scrobbler. Built with Docusaurus.`,
},
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
theme: themes.themes.github,
darkTheme: themes.themes.dracula,
additionalLanguages: ['json','json5','typescript', 'docker', 'bash', 'ini']
},
}),
colorMode: {
defaultMode: 'dark',
disableSwitch: false,
respectPrefersColorScheme: false,
},
codeblock: {
showGithubLink: true,
githubLinkLabel: 'View on GitHub',
showRunmeLink: false,
runmeLinkLabel: 'Checkout via Runme'
}
} satisfies Preset.ThemeConfig,
};
module.exports = config;
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
const script = {
src: process.env.ANALYTICS,
}
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
}
config.scripts.push(script)
}
export default config;
+6283 -4056
View File
File diff suppressed because it is too large Load Diff
+29 -25
View File
@@ -3,32 +3,36 @@
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"start": "npx docusaurus start",
"build": "npx docusaurus build",
"swizzle": "npx docusaurus swizzle",
"deploy": "npx docusaurus deploy",
"clear": "npx docusaurus clear",
"serve": "npx docusaurus serve",
"write-translations": "npx docusaurus write-translations",
"write-heading-ids": "npx docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@cmfcmf/docusaurus-search-local": "^1.1.0",
"@docusaurus/core": "2.4.1",
"@docusaurus/preset-classic": "2.4.1",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-json-schema-plugin": "^1.6.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"@docusaurus/core": "^3.4.0",
"@docusaurus/preset-classic": "^3.4.0",
"@easyops-cn/docusaurus-search-local": "^0.44.3",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-json-schema-plugin": "^1.12.1",
"docusaurus-theme-github-codeblock": "^2.0.2",
"micromark-extension-directive": "^3.0.1",
"prism-react-renderer": "^2.3.0",
"raw-loader": "^4.0.2",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.4.1",
"@tsconfig/docusaurus": "^1.0.5",
"typescript": "^4.7.4"
"@docusaurus/module-type-aliases": "^3.4.0",
"@docusaurus/tsconfig": "^3.4.0",
"@docusaurus/types": "^3.4.0",
"@types/react": "^18.2.29",
"typescript": "~5.2.2"
},
"browserslist": {
"production": [
@@ -37,12 +41,12 @@
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=16.14"
"node": ">=18.0"
}
}
+4 -5
View File
@@ -1,3 +1,5 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
@@ -9,10 +11,7 @@
Create as many sidebars as you want.
*/
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [
{type: 'autogenerated', dirName: '.'},
@@ -37,4 +36,4 @@ const sidebars = {
*/
};
module.exports = sidebars;
export default sidebars;
+50
View File
@@ -0,0 +1,50 @@
import React, { Fragment } from "react"
import CodeBlock 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;
export interface AIOProps {
data: string
client?: boolean
name: string
}
const AIOExample = (props: AIOProps) => {
const {
data,
name,
client = false
} = props;
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = JSON.parse(data);
} 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>
}
configObj[0].type = name;
const configType = client ? 'clients' : 'sources';
const aio = {[configType]: configObj};
return <CodeBlock title="CONFIG_DIR/config.json" language="json5">{JSON.stringify(aio, null, 2)}</CodeBlock>
}
const WrappedAIOExample = (props: AIOProps) => {
return <ErrorBoundary
fallback={({error}) => (
<div>
<p>Example component crashed because of error: {error.message}.</p>
</div>
)}
><AIOExample {...props} /></ErrorBoundary>
}
export default WrappedAIOExample;
+27
View File
@@ -0,0 +1,27 @@
import React, {PropsWithChildren, Fragment} from "react"
export interface SchemaLinkProps {
objectName: string
lower?: boolean
client?: boolean
}
const sourceURL = 'https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json';
const clientURL = 'https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json';
const SchemaLink = (props: PropsWithChildren<SchemaLinkProps>) => {
const {
children,
lower,
client = false
} = props;
let content = children;
if(content === undefined) {
content = <Fragment>{lower ? 'explore' : 'Explore'} the schema with an example and live editor/validator</Fragment>
}
const definition = `https://json-schema.app/view/%23/%23%2Fdefinitions%2F${props.objectName}`;
const url = client ? clientURL : sourceURL;
return <a target="_blank" href={`${definition}?url=${url}`}>{content}</a>
}
export default SchemaLink;
+20 -7
View File
@@ -20,25 +20,32 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [WebScrobbler](docs/configuration#webscrobbler)
* [Youtube Music](docs/configuration#youtube-music)
* [Last.fm](docs/configuration#lastfm-source)
* [ListenBrainz](docs/configuration#listenbrainz--source-)
* [Deezer](docs/configuration#deezer)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [~~Deezer~~](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
* [Kodi](docs/configuration#kodi)
* [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-)
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
* [Musikcube](docs/configuration#musikcube)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
* [ListenBrainz](docs/configuration#listenbrainz)
* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Modify data before scrobbling with [regular expression or search patterns](docs/transforms)
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
[**Quick Start Guide**](docs/quickstart)
<img src={require('/img/status-ui.png').default} width="800"/>
**Why should I use this over a browser extension and/or mobile app scrobbler?**
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
@@ -48,9 +55,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src={require('/img/status-ui.png').default} width="800"/>
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
## How Does multi-scrobbler (MS) Work?
@@ -75,6 +80,10 @@ Client configurations consist of:
* A friendly name.
* Any data needed to communicate or authenticate with the Client.
## Quick Start
[See the **Quick Start Guide**](docs/quickstart)
## Installation
[See the **Installation** documentation](docs/installation)
@@ -98,6 +107,10 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
Having issues with connections or configuration? Check the [FAQ](docs/FAQ) before creating an issue!
## Development
[Detailed architecture and development guides for Sources/Clients](docs/development/dev-common)
## License
MIT
+2
View File
@@ -17,7 +17,9 @@ function PlaygroundComponent(): JSX.Element {
>
<BrowserOnly fallback={<div>Loading...</div>}>
{() => {
// required to keep SSR from trying to render monaco/etc...
const PlaygroundInnerComponent =
// eslint-disable-next-line @typescript-eslint/no-var-requires
require("@site/src/components/PlaygroundInner").default
return <PlaygroundInnerComponent />
}}
+1208 -675
View File
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/LastfmClientConfig"
},
{
"$ref": "#/definitions/ListenBrainzClientConfig"
},
{
"$ref": "#/definitions/MalojaClientConfig"
}
],
"definitions": {
"CommonClientData": {
"title": "CommonClientData",
"type": "object"
},
"CommonClientOptions": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"refreshInitialCount": {
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
"title": "refreshInitialCount",
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "CommonClientOptions",
"type": "object"
},
"LastfmClientConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using LastFM as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"title": "configureAs",
"type": "string"
},
"data": {
"allOf": [
{
"$ref": "#/definitions/CommonClientData"
},
{
"$ref": "#/definitions/LastfmData"
}
],
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
"data",
"name"
],
"title": "LastfmClientConfig",
"type": "object"
},
"LastfmData": {
"properties": {
"apiKey": {
"description": "API Key generated from Last.fm account",
"examples": [
"787c921a2a2ab42320831aba0c8f2fc2"
],
"title": "apiKey",
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"redirectUri": {
"default": "http://localhost:9078/lastfm/callback",
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
"examples": [
"http://localhost:9078/lastfm/callback"
],
"title": "redirectUri",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
"ec42e09d5ae0ee0f0816ca151008412a"
],
"title": "secret",
"type": "string"
},
"session": {
"description": "Optional session id returned from a completed auth flow",
"title": "session",
"type": "string"
}
},
"required": [
"apiKey",
"secret"
],
"title": "LastfmData",
"type": "object"
},
"ListenBrainzClientConfig": {
"properties": {
"configureAs": {
"default": "client",
"description": "Should always be `client` when using Listenbrainz as a client",
"enum": [
"client",
"source"
],
"examples": [
"client"
],
"title": "configureAs",
"type": "string"
},
"data": {
"$ref": "#/definitions/ListenBrainzClientData",
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
"data",
"name"
],
"title": "ListenBrainzClientConfig",
"type": "object"
},
"ListenBrainzClientData": {
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
],
"title": "token",
"type": "string"
},
"url": {
"default": "https://api.listenbrainz.org/",
"description": "URL for the ListenBrainz server, if not using the default",
"examples": [
"https://api.listenbrainz.org/"
],
"title": "url",
"type": "string"
},
"username": {
"description": "Username of the user to scrobble for",
"title": "username",
"type": "string"
}
},
"required": [
"token",
"username"
],
"title": "ListenBrainzClientData",
"type": "object"
},
"MalojaClientConfig": {
"properties": {
"data": {
"$ref": "#/definitions/MalojaClientData",
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
"MyConfig"
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
"data",
"name"
],
"title": "MalojaClientConfig",
"type": "object"
},
"MalojaClientData": {
"properties": {
"apiKey": {
"description": "API Key for Maloja server",
"examples": [
"myApiKey"
],
"title": "apiKey",
"type": "string"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"url": {
"description": "URL for maloja server",
"examples": [
"http://localhost:42010"
],
"title": "url",
"type": "string"
}
},
"required": [
"apiKey",
"url"
],
"title": "MalojaClientData",
"type": "object"
},
"MatchLoggingOptions": {
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
"properties": {
"confidenceBreakdown": {
"default": false,
"description": "Include confidence breakdowns in track match logging, if applicable",
"examples": [
false
],
"title": "confidenceBreakdown",
"type": "boolean"
},
"onMatch": {
"default": false,
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
"examples": [
false
],
"title": "onMatch",
"type": "boolean"
},
"onNoMatch": {
"default": false,
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
"examples": [
false
],
"title": "onNoMatch",
"type": "boolean"
}
},
"title": "MatchLoggingOptions",
"type": "object"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 699 KiB

+739 -481
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@tsconfig/docusaurus/tsconfig.json",
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"resolveJsonModule": true,
+43
View File
@@ -0,0 +1,43 @@
// @ts-check
import eslint from '@eslint/js';
import tsEslint from 'typescript-eslint';
import arrow from 'eslint-plugin-prefer-arrow-functions';
export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.recommended,
// use to enable typed linting (way more errors) https://typescript-eslint.io/linting/typed-linting
/* ...tsEslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
project: true,
tsconfigDirName: import.meta.dirname,
},
},
},*/
{
files: ['src/backend/**/*.ts'],
ignores: ['eslint.config.js'],
plugins: {
"prefer-arrow-functions": arrow
},
rules: {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
"allowNamedFunctions": false,
"classPropertiesAllowed": false,
"disallowPrototype": false,
"returnStyle": "unchanged",
"singleReturnOnly": false
}
],
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
}
}
);
+1 -48
View File
@@ -1,48 +1 @@
NOTE: This steps are for building the flatpak entirely locally, from source. If you want to install the application normally then [get it through flathub](/docs/installation.md#flatpak)
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
# 1. Install Requirements
## Flatpak and flatpak-builder
Install [Flatpak](https://flatpak.org/setup/)
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
## [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
# 2. Update Project source
Set the `branch` `tag` or `commit` to use for MS in the `git` source in [`io.github.foxxmd.multiscrobbler.yml`](/flatpak/io.github.foxxmd.multiscrobbler.yml)
# 3. Use `flatpak-node-generator` to generate sources
First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442)
Then, from MS project root:
```shell
flatpak-node-generator npm package-lock.json
```
Move `generated-sources.json` into [`/flatpak`](/flatpak)
# 4. Build flatpak
From MS project root:
```shell
cd flatpak
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
```
Add `--install --user` to have the app installed immediately.
# 5. Run (Locally)
If built with `--install --user` you can now run MS using the command
```shell
flatpak run -u io.github.foxxmd.multiscrobbler
```
See flatpak docs on the [docs website](https://foxxmd.github.io/multi-scrobbler/docs/development/flatpak) or at [`/docsite/docs/development/flatpak.md`](../docsite/docs/development/flatpak.md)
@@ -3,14 +3,18 @@
<id>io.github.foxxmd.multiscrobbler</id>
<provides>
<id>io.github.foxxmd.multiscrobbler.desktop</id>
<binary>multiscrobbler</binary>
</provides>
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
<name>multi-scrobbler</name>
<summary>Scrobbles music from many sources to many clients</summary>
<summary>Scrobbles music from many sources</summary>
<url type="homepage">https://foxxmd.github.io/multi-scrobbler</url>
<url type="help">https://foxxmd.github.io/multi-scrobbler/docs/configuration</url>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<developer id="tld.vendor">
<name>FoxxMD</name>
</developer>
<description>
<p>
Track your music listening history from many different sources:
@@ -22,13 +26,13 @@
<li>Jellyfin</li>
<li>WebScrobbler</li>
<li>Mopidy</li>
<li>Deezer</li>
<li>JRiver</li>
<li>Kodi</li>
<li>Webscrobbler</li>
<li>Youtube Music</li>
<li>MPRIS (linux desktop)</li>
<li>Google Cast (Chromecast)</li>
<li>Musikcube</li>
</ul>
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
<ul>
@@ -45,6 +49,12 @@
</screenshot>
</screenshots>
<releases>
<release version="0.8.2" date="2024-08-20"/>
<release version="0.8.1" date="2024-07-17"/>
<release version="0.8.0" date="2024-07-08"/>
<release version="0.7.1" date="2024-04-11"/>
<release version="0.7.0" date="2024-03-26"/>
<release version="0.6.5" date="2024-02-05"/>
<release version="0.6.4" date="2024-01-29"/>
<release version="0.6.3" date="2024-01-10"/>
<release version="0.6.2" date="2023-11-29"/>
+18 -8
View File
@@ -21,7 +21,8 @@ modules:
npm_config_offline: 'true'
build-commands:
# install npm dependencies
- npm install --offline
- npm ci --offline
- cd docsite && npm ci --offline && cd ../
# build app
- NODE_ENV=production npm run build
@@ -30,17 +31,24 @@ modules:
- mkdir -p /app/bin /app/lib /app/lib/dist /app/lib/src /app/lib/assets
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
- rm -r /app/lib/node_modules/npm/{docs,man}
- rm -r /app/lib/node_modules/npm/{docs,man} # remove this when updating to newer runtime-version
# remove dev dependencies
- npm prune --production
# remove docsite dependencies since we've built static site
- rm -r docsite/node_modules
# copy node_modules needed to run app
- cp -r node_modules/. /app/lib/node_modules
# even if not using package.json for scripts it must be present
# so that tsx/node runs as esm (needs to see "type": "module" in file)
- cp package.json /app/lib/package.json
# copy app files to runtime dir
- cp -r dist/. /app/lib/dist
- cp -r src/. /app/lib/src
- cp -r docsite/. /app/lib/docsite
- cp -r config /app/lib/config
- cp -r assets /app/lib/assets
- cp -r public /app/lib/public
@@ -59,11 +67,12 @@ modules:
subdir: main
sources:
# use for official releases
- type: git
url: https://github.com/FoxxMD/multi-scrobbler
tag: 0.6.3
commit: 4bd996eb466137ffc5c3f48305afabde03720525
dest: main
# - type: git
# url: https://github.com/FoxxMD/multi-scrobbler
# tag: 0.6.3
# commit: 4bd996eb466137ffc5c3f48305afabde03720525
# dest: main
# use if developing locally
# - type: dir
# path: /home/yourUser/multi-scrobbler
@@ -84,4 +93,5 @@ modules:
fi
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME LOG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node_modules/.bin/tsx src/backend/index.ts
- generated-sources.json
- generated-sources.0.json
- generated-sources.1.json
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
OVERWRITE="0"
CONFIRM="1"
# https://stackoverflow.com/a/14203146
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
-b|--buildpath)
BUILDPATH="$2"
shift # past argument
shift # past value
;;
-o|--overwrite)
OVERWRITE="1"
shift # past argument
;;
-y|--yes)
CONFIRM="0"
shift # past argument
;;
-*|--*)
echo "Unknown option $1"
exit 1
;;
*)
POSITIONAL_ARGS+=("$1") # save positional arg
shift # past argument
;;
esac
done
set -- "${POSITIONAL_ARGS[@]}"
if [ ! -f "./io.github.foxxmd.multiscrobbler.yml" ]; then
echo "Run this script inside the 'flatpak' directory!"
exit 1
fi
if [ -z "${BUILDPATH}" ]; then
printf "\nNo build path set, using ./build\n"
BUILDPATH="${PWD##*/}/build"
fi
printf '\nBuild Path: %s' "${BUILDPATH}"
if [ "$OVERWRITE" = "1" ]; then echo 'Overwrite Sources: True'; else printf 'Overwrite Sources: False\n'; fi
if [ "$CONFIRM" = "1" ]; then
read -p "Continue? (Y/N): " confirm && [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]] || exit 1
fi
printf '\n'
cd ../
if [ -d ./node_modules ]; then
echo 'Project node_modules exists, deleting...'
rm -r node_modules
fi
if [ -d ./docsite/node_modules ]; then
echo 'Docsite node_modules exists, deleting...'
rm -r docsite/node_modules
fi
GENERATE_SOURCES="1"
if [ -f "flatpak/generated-sources.0.json" ] || [ -f "flatpak/generated-sources.1.json" ]; then
if [ "$OVERWRITE" = "0" ]; then
echo 'Generated sources exist, will not overwrite.';
GENERATE_SOURCES=0
else
echo 'Deleting existing sources...';
rm -f flatpak/generated-sources.0.json
rm -f flatpak/generated-sources.1.json
fi
fi
if [ "$GENERATE_SOURCES" = "1" ]; then
printf '\nGenerating project sources...\n'
rm -f generated-sources.json
flatpak-node-generator npm package-lock.json
mv generated-sources.json flatpak/generated-sources.0.json
printf '\nGenerating docsite sources...\n'
flatpak-node-generator npm docsite/package-lock.json
mv generated-sources.json flatpak/generated-sources.1.json
fi
cd flatpak || exit
mkdir -p "$BUILDPATH"
printf '\nBuilding flatpak app...\n'
set -x
flatpak-builder --repo="$BUILDPATH"/repo --state-dir="$BUILDPATH"/state "$BUILDPATH"/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
# https://stackoverflow.com/questions/2853803/how-to-echo-shell-commands-as-they-are-executed#comment135696350_13718771
{ set +x; } &> /dev/null
echo 'Done!'
+3260 -3809
View File
File diff suppressed because it is too large Load Diff
+38 -38
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.6.4",
"version": "0.8.2",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -10,14 +10,18 @@
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"typedoc": "typedoc",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "npm run -s test:backend",
"test:backend": "NODE_OPTIONS='--loader tsx' mocha --extension ts --reporter spec --recursive src/backend/tests/**/*.test.ts",
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend",
"dev": "APP_VERSION=$npm_package_version nodemon -w src/backend -x tsx src/backend/index.ts",
"start": "APP_VERSION=$npm_package_version NODE_ENV=production tsx src/backend/index.ts",
"build": "APP_VERSION=$npm_package_version vite build"
"test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts",
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
"start": "NODE_ENV=production tsx src/backend/index.ts",
"build:frontend": "vite build",
"build:backend": "tsc -p src/backend",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
"docs:install": "cd docsite && npm ci",
"docs:start": "cd docsite && npm start",
"docs:build": "cd docsite && npm run build",
"postinstall": "patch-package"
},
"exports": {
".": {
@@ -26,7 +30,7 @@
}
},
"engines": {
"node": ">=18.0.0",
"node": ">=18.19.1",
"npm": ">=9.1.0"
},
"repository": {
@@ -43,12 +47,16 @@
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
"@awaitjs/express": "^0.6.3",
"@curvenote/ansi-to-react": "^7.0.0",
"@donedeal0/superdiff": "^1.1.1",
"@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.1",
"@foxxmd/string-sameness": "^0.2.0",
"@foxxmd/winston": "3.3.31",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.1.14",
"@foxxmd/regex-buddy-core": "^0.1.0",
"@foxxmd/string-sameness": "^0.4.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
@@ -64,53 +72,49 @@
"common-tags": "^1.8.2",
"compare-versions": "^4.1.2",
"concat-stream": "^2.0.0",
"cors": "^2.8.5",
"dayjs": "^1.10.4",
"dbus-next": "0.10.2",
"dbus-ts": "^0.0.7",
"dotenv": "^10.0.0",
"es6-error": "^4.1.1",
"express": "^4.17.1",
"express-session": "^1.17.2",
"fixed-size-list": "^0.3.0",
"formidable": "^2.1",
"gotify": "^1.1.0",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
"mpd-api": "^1.1.2",
"nanoid": "^3.3.1",
"normalize-url": "^6.1.0",
"ntfy": "1.0.5",
"normalize-url": "^8.0.1",
"ntfy": "^1.5.4",
"object-hash": "^3.0.0",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
"pony-cause": "^1.1.1",
"patch-package": "^8.0.0",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"safe-stable-stringify": "^1.1.1",
"sass": "^1.70.0",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^3.3.3",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"triple-beam": "^1.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.13.0",
"wait-on": "^7.0.1",
"web-vitals": "^2.1.4",
"winston-daily-rotate-file": "^4.5.0",
"winston-duplex": "0.1.3",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"xml2js": "^0.4.23",
"vite-express": "^0.16.0",
"xml2js": "0.5.0",
"youtube-music-ts-api": "^1.7.0"
},
"devDependencies": {
"@faker-js/faker": "^8.1.0",
"@dbus-types/notifications": "^0.0.5",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.0-rc.1",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@@ -121,6 +125,7 @@
"@types/clone": "^2.1.2",
"@types/common-tags": "^1.8.1",
"@types/concat-stream": "^2.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/formidable": "^2.0.5",
@@ -135,26 +140,21 @@
"@types/react-window": "^1.8.5",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^4.1.16",
"@types/triple-beam": "^1.3.2",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"clone": "^2.1.2",
"concurrently": "^8.2.0",
"jscodeshift": "^0.15.0",
"mocha": "10.1.0",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"mocha": "^10.3.0",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"nyc": "^15.1.0",
"patch-package": "^8.0.0",
"ts-essentials": "^9.1.2",
"typedoc": "^0.25",
"typescript": "^5.3.3",
"typescript-eslint": "^7.0.1",
"typescript-json-schema": "~0.55",
"vite": "^5.0.11",
"vite-tsconfig-paths": "^4.3.1",
"wtfnode": "^0.9.1"
"vite": "^5.2.12"
},
"browserslist": {
"production": [
+38
View File
@@ -0,0 +1,38 @@
diff --git a/node_modules/lastfm-node-client/lib/ApiRequest.js b/node_modules/lastfm-node-client/lib/ApiRequest.js
index ecd39d0..c8e1cc5 100644
--- a/node_modules/lastfm-node-client/lib/ApiRequest.js
+++ b/node_modules/lastfm-node-client/lib/ApiRequest.js
@@ -91,7 +91,7 @@ class ApiRequest {
const paramsStr = querystring.stringify(paramsObj);
const options = {
hostname: "ws.audioscrobbler.com",
- path: "/2.0"
+ path: "/2.0",
};
if (method === "POST") {
@@ -113,7 +113,11 @@ class ApiRequest {
httpResponse.on("data", chunk => data += chunk);
httpResponse.on("end", () => resolve(data));
httpResponse.on("error", err => reject(err));
- });
+ })
+ // stop waiting for request if it takes longer than 3 seconds
+ .setTimeout(3000, () => {
+ httpRequest.destroy(new Error('ETIMEDOUT - socket hang up'));
+ });
httpRequest.on("error", err => reject(err));
diff --git a/node_modules/lastfm-node-client/lib/LastFm.js b/node_modules/lastfm-node-client/lib/LastFm.js
index 2393a4d..75bdf37 100644
--- a/node_modules/lastfm-node-client/lib/LastFm.js
+++ b/node_modules/lastfm-node-client/lib/LastFm.js
@@ -1045,7 +1045,6 @@ class LastFm {
* @param {callback} [callback]
* @returns {(Promise|LastFm)}
*/
-
trackUpdateNowPlaying(params, callback) {
const apiRequest = new ApiRequest()
.set(params)
@@ -0,0 +1,26 @@
diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
index 23cc6d1..221adc2 100644
--- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js
+++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
@@ -55,7 +55,7 @@ var crypto_1 = require("crypto");
var ts = require("typescript");
var path_equal_1 = require("path-equal");
var vm = require("vm");
-var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
+var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
var REGEX_TSCONFIG_NAME = /^.*\.json$/;
var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
diff --git a/node_modules/typescript-json-schema/typescript-json-schema.ts b/node_modules/typescript-json-schema/typescript-json-schema.ts
index 5908567..c188383 100644
--- a/node_modules/typescript-json-schema/typescript-json-schema.ts
+++ b/node_modules/typescript-json-schema/typescript-json-schema.ts
@@ -9,7 +9,7 @@ export { Program, CompilerOptions, Symbol } from "typescript";
const vm = require("vm");
-const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
+const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
const REGEX_TSCONFIG_NAME = /^.*\.json$/;
const REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
const REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
@@ -1,12 +0,0 @@
diff --git a/node_modules/winston-daily-rotate-file/index.js b/node_modules/winston-daily-rotate-file/index.js
index c818660..a8d3040 100644
--- a/node_modules/winston-daily-rotate-file/index.js
+++ b/node_modules/winston-daily-rotate-file/index.js
@@ -1,6 +1,6 @@
'use strict';
-var winston = require('winston');
+var winston = require('@foxxmd/winston');
var DailyRotateFile = require('./daily-rotate-file');
winston.transports.DailyRotateFile = DailyRotateFile;
-10
View File
@@ -1,10 +0,0 @@
diff --git a/node_modules/winston-null/index.js b/node_modules/winston-null/index.js
index 1a8a4ff..5088d6c 100644
--- a/node_modules/winston-null/index.js
+++ b/node_modules/winston-null/index.js
@@ -1,4 +1,4 @@
-const winston = require('winston');
+const winston = require('@foxxmd/winston');
const compat = require('winston-compat');
const semver = require('semver');
File diff suppressed because one or more lines are too long
+376
View File
@@ -0,0 +1,376 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { searchAndReplace, SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
import { compare } from "compare-versions";
import { ObjectPlayData, PlayData, PlayObject } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { configPartsToStrongParts, configValToSearchReplace } from "../utils.js";
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
import { hasUpstreamError } from "./errors/UpstreamError.js";
import { PlayTransformParts, PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "./infrastructure/Atomic.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
export default abstract class AbstractComponent {
requiresAuth: boolean = false;
requiresAuthInteraction: boolean = false;
authed: boolean = false;
authFailure?: boolean;
buildOK?: boolean | null;
connectionOK?: boolean | null;
initializing: boolean = false;
config: CommonClientConfig | CommonSourceConfig;
transformRules!: PlayTransformRules;
logger: Logger;
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
this.config = config;
}
initialize = async () => {
this.logger.debug('Attempting to initialize...');
try {
this.initializing = true;
await this.buildInitData();
this.buildTransformRules();
await this.checkConnection();
await this.testAuth();
this.logger.info('Fully Initialized!');
try {
await this.postInitialize();
} catch (e) {
this.logger.warn(new Error('Error occurred during post-initialization hook but was caught', {cause: e}));
}
return true;
} catch(e) {
this.logger.error(new Error('Initialization failed', {cause: e}));
return false;
} finally {
this.initializing = false;
}
}
public async buildInitData() {
if(this.buildOK) {
return;
}
try {
const res = await this.doBuildInitData();
if(res === undefined) {
this.buildOK = null;
this.logger.debug('No required data to build.');
return;
}
if (res === true) {
this.logger.verbose('Building required data init succeeded');
} else if (typeof res === 'string') {
this.logger.verbose(`Building required data init succeeded => ${res}`);
}
this.buildOK = true;
} catch (e) {
this.buildOK = false;
throw new Error('Building required data for initialization failed', {cause: e});
}
}
/**
* Build any data/config/objects required for this Source to communicate with upstream service
*
* * Return undefined if not possible or not required
* * Return TRUE if build succeeded
* * Return string if build succeeded and should log result
* * Throw error on failure
* */
protected async doBuildInitData(): Promise<true | string | undefined> {
return;
}
public buildTransformRules() {
try {
this.doBuildTransformRules();
} catch (e) {
this.buildOK = false;
throw new Error('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
}
}
protected doBuildTransformRules() {
const {
options: {
playTransform
} = {}
} = this.config;
if (playTransform === undefined) {
this.transformRules = {};
return;
}
const {
preCompare: preConfig,
compare: {
candidate: candidateConfig,
existing: existingConfig,
} = {},
postCompare: postConfig
} = playTransform;
let preCompare,
candidate,
existing,
postCompare;
try {
preCompare = configPartsToStrongParts(preConfig)
} catch (e) {
throw new Error('preCompare was not valid', {cause: e});
}
try {
candidate = configPartsToStrongParts(candidateConfig)
} catch (e) {
throw new Error('candidate was not valid', {cause: e});
}
try {
existing = configPartsToStrongParts(existingConfig)
} catch (e) {
throw new Error('existing was not valid', {cause: e});
}
try {
postCompare = configPartsToStrongParts(postConfig)
} catch (e) {
throw new Error('postCompare was not valid', {cause: e});
}
this.transformRules = {
preCompare,
compare: {
candidate,
existing,
},
postCompare,
}
}
public async checkConnection() {
try {
const res = await this.doCheckConnection();
if (res === undefined) {
this.logger.debug('Connection check was not required.');
this.connectionOK = null;
return;
} else if (res === true) {
this.logger.verbose('Connection check succeeded');
} else {
this.logger.verbose(`Connection check succeeded => ${res}`);
}
this.connectionOK = true;
} catch (e) {
this.connectionOK = false;
throw new Error('Communicating with upstream service failed', {cause: e});
}
}
/**
* Check Scrobbler upstream API/connection to ensure we can communicate
*
* * Return undefined if not possible or not required to check
* * Return TRUE if communication succeeded
* * Return string if communication succeeded and should log result
* * Throw error if communication failed
* */
protected async doCheckConnection(): Promise<true | string | undefined> {
return;
}
authGated = () => this.requiresAuth && !this.authed
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
protected doAuthentication = async (): Promise<boolean> => this.authed
// default init function, should be overridden if auth stage is required
testAuth = async (force: boolean = false) => {
if(!this.requiresAuth) {
return;
}
if(this.authed && !force) {
return;
}
try {
this.authed = await this.doAuthentication();
this.authFailure = !this.authed;
} catch (e) {
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
this.authed = false;
this.logger.error(new Error(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
}
}
public isReady() {
return (this.buildOK === null || this.buildOK === true) &&
(this.connectionOK === null || this.connectionOK === true)
&& !this.authGated();
}
public isUsable() {
return (this.buildOK === null || this.buildOK === true) &&
(this.connectionOK === null || this.connectionOK === true);
}
/**
* Override to perform some action after successfully initializing
*
* Results will be try-catched and swallowed/logged if an error is thrown. This will not affect initialized state.
* */
protected async postInitialize(): Promise<void> {
return;
}
public transformPlay = (play: PlayObject, hookType: TransformHook, log?: boolean) => {
let logger: Logger;
const labels = ['Play Transform', hookType];
const getLogger = () => logger !== undefined ? logger : childLogger(this.logger, labels);
try {
let hook: PlayTransformParts<SearchAndReplaceRegExp> | undefined;
switch (hookType) {
case TRANSFORM_HOOK.preCompare:
hook = this.transformRules.preCompare;
break;
case TRANSFORM_HOOK.candidate:
hook = this.transformRules.compare?.candidate;
break;
case TRANSFORM_HOOK.existing:
hook = this.transformRules.compare?.existing;
break;
case TRANSFORM_HOOK.postCompare:
hook = this.transformRules.postCompare;
break;
}
if (hook === undefined) {
return play;
}
const {
data: {
track,
artists,
albumArtists,
album
} = {}
} = play;
const transformedPlayData: Partial<ObjectPlayData> = {};
let isTransformed = false;
if (hook.title !== undefined && track !== undefined) {
try {
const t = searchAndReplace(track, hook.title);
if (t !== track) {
transformedPlayData.track = t.trim() === '' ? undefined : t;
isTransformed = true;
}
} catch (e) {
getLogger().warn(new Error(`Failed to transform title: ${track}`, {cause: e}));
}
}
if (hook.artists !== undefined && artists !== undefined && artists.length > 0) {
const transformedArtists: string[] = [];
let anyArtistTransformed = false;
for (const artist of artists) {
try {
const t = searchAndReplace(artist, hook.artists);
if (t !== artist) {
anyArtistTransformed = true;
isTransformed = true;
}
if(t.trim() !== '') {
transformedArtists.push(t);
}
} catch (e) {
getLogger().warn(new Error(`Failed to transform artist: ${artist}`, {cause: e}));
transformedArtists.push(artist);
}
}
if(anyArtistTransformed) {
transformedPlayData.artists = transformedArtists;
}
}
if (hook.artists !== undefined && albumArtists !== undefined && albumArtists.length > 0) {
const transformedArtists: string[] = [];
let anyArtistTransformed = false;
for (const artist of albumArtists) {
try {
const t = searchAndReplace(artist, hook.artists);
if (t !== artist) {
anyArtistTransformed = true;
isTransformed = true;
}
if(t.trim() !== '') {
transformedArtists.push(t);
}
} catch (e) {
getLogger().warn(new Error(`Failed to transform albumArtist: ${artist}`, {cause: e}));
transformedArtists.push(artist);
}
}
if(anyArtistTransformed) {
transformedPlayData.albumArtists = transformedArtists;
}
}
if (hook.album !== undefined && album !== undefined) {
try {
const t = searchAndReplace(album, hook.album);
if (t !== album) {
isTransformed = true;
transformedPlayData.album = t.trim() === '' ? undefined : t;
}
} catch (e) {
getLogger().warn(new Error(`Failed to transform album: ${album}`, {cause: e}));
}
}
if(isTransformed) {
const transformedPlay = {
...play,
data: {
...play.data,
...transformedPlayData
}
}
const shouldLog = log ?? this.config.options?.playTransform?.log ?? true;
if(shouldLog) {
this.logger.debug({labels}, `Play transformed by ${hookType}:
Original : ${buildTrackString(play, {include: ['artist', 'track', 'album']})}
Transformed : ${buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})}
`);
}
return transformedPlay;
}
return play;
} catch (e) {
getLogger().warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
return play;
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {ResponseError} from "superagent";
import { ResponseError } from "superagent";
export const isSuperAgentResponseError = (e: any): e is ResponseError => {
return typeof e === 'object'
+5 -3
View File
@@ -1,9 +1,11 @@
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
import { isArbitraryObject } from "../infrastructure/Atomic.js";
import ErrnoException = NodeJS.ErrnoException;
import { findCauseByFunc } from "../../utils.js";
export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH';
export const NETWORK_ERROR_CODES = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const isErrnoException = (error: unknown): error is ErrnoException => {
return isArbitraryObject(error) &&
@@ -19,7 +21,7 @@ export interface NodeNetworkException extends ErrnoException {
}
export const isNodeNetworkException = (error: unknown): error is NodeNetworkException => {
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code);
return isErrnoException(error) && NETWORK_ERROR_CODES.includes(error.code as NodeNetworkErrorCode);
}
export const hasNodeNetworkException = (error: unknown): boolean => {
+4 -4
View File
@@ -1,8 +1,8 @@
import {ErrorWithCause} from "pony-cause";
import { findCauseByFunc } from "../../utils.js";
import {Response} from 'superagent';
import { Response } from 'superagent';
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
export class UpstreamError<T = undefined> extends Error {
showStopper: boolean = false;
response?: Response
+88 -52
View File
@@ -1,18 +1,62 @@
import {Dayjs} from "dayjs";
import {FixedSizeList} from 'fixed-size-list';
import {Logger} from '@foxxmd/winston';
import { Logger } from '@foxxmd/logging';
import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
import { Dayjs } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
import {Request, Response} from "express";
import {NextFunction, ParamsDictionary, Query} from "express-serve-static-core";
import { LogLevel, logLevels, PlayMeta, PlayObject } from "../../../core/Atomic.js";
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast'];
export type SourceType =
'spotify'
| 'plex'
| 'tautulli'
| 'subsonic'
| 'jellyfin'
| 'lastfm'
| 'deezer'
| 'ytmusic'
| 'mpris'
| 'mopidy'
| 'listenbrainz'
| 'jriver'
| 'kodi'
| 'webscrobbler'
| 'chromecast'
| 'musikcube'
| 'mpd';
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
export const sourceTypes: SourceType[] = [
'spotify',
'plex',
'tautulli',
'subsonic',
'jellyfin',
'lastfm',
'deezer',
'ytmusic',
'mpris',
'mopidy',
'listenbrainz',
'jriver',
'kodi',
'webscrobbler',
'chromecast',
'musikcube',
'mpd'
];
export type ClientType = 'maloja' | 'lastfm' | 'listenbrainz';
export const clientTypes: ClientType[] = ['maloja', 'lastfm', 'listenbrainz'];
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export type ClientType =
'maloja'
| 'lastfm'
| 'listenbrainz';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
'listenbrainz'
];
export type InitState = 0 | 1 | 2;
export const NOT_INITIALIZED: InitState = 0;
@@ -27,7 +71,7 @@ export const READY: ReadyState = 2;
export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY];
export interface InternalConfig {
localUrl: string
localUrl: URL
configDir: string
logger: Logger
@@ -105,45 +149,6 @@ export interface RemoteIdentityParts {
agent: string | undefined
}
export interface LogConfig {
level?: string
file?: string | false
stream?: string
console?: string | false
}
export interface LogOptions {
/**
* Specify the minimum log level for all log outputs without their own level specified.
*
* Defaults to env `LOG_LEVEL` or `info` if not specified.
*
* @default 'info'
* */
level?: LogLevel
/**
* Specify the minimum log level to output to rotating files. If `false` no log files will be created.
* */
file?: LogLevel | false
/**
* Specify the minimum log level streamed to the UI
* */
stream?: LogLevel
/**
* Specify the minimum log level streamed to the console (or docker container)
* */
console?: LogLevel | false
}
export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
return Object.entries(obj).every(([key, val]) => {
if(key !== 'file') {
return val === undefined || logLevels.includes(val.toLocaleLowerCase());
}
return val === undefined || val === false || logLevels.includes(val.toLocaleLowerCase());
});
}
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
@@ -205,7 +210,7 @@ export interface numberFormatOptions {
}
}
export const DELIMITERS = [',','&','\/','\\'];
export const DELIMITERS = [',','&','/','\\'];
export const ARTIST_WEIGHT = 0.3;
export const TITLE_WEIGHT = 0.4;
@@ -232,3 +237,34 @@ export interface MdnsDeviceInfo {
type: string
addresses: string[]
}
export type AbstractApiOptions = Record<any, any> & { logger: Logger }
export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never }
export type SearchAndReplaceTerm = string | SearchAndReplaceRegExp;
export interface PlayTransformParts<T> {
title?: T[]
artists?: T[]
album?: T[]
}
export interface PlayTransformHooks<T> {
preCompare?: PlayTransformParts<T>
compare?: {
candidate?: PlayTransformParts<T>
existing?: PlayTransformParts<T>
}
postCompare?: PlayTransformParts<T>
}
export type PlayTransformRules = PlayTransformHooks<SearchAndReplaceRegExp>
export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare';
export const TRANSFORM_HOOK = {
preCompare: 'preCompare' as TransformHook,
candidate: 'candidate' as TransformHook,
existing: 'existing' as TransformHook,
postCompare: 'postCompare' as TransformHook,
}
@@ -1,20 +1,21 @@
import { CommonSourceOptions, ScrobbleThresholds, SourceRetryOptions } from "./source/index.js";
import { RequestRetryOptions } from "./common.js";
import { SourceAIOConfig } from "./source/sources.js";
import { LogOptions } from "@foxxmd/logging";
import { ClientAIOConfig } from "./client/clients.js";
import { CommonClientOptions } from "./client/index.js";
import { RequestRetryOptions } from "./common.js";
import { WebhookConfig } from "./health/webhooks.js";
import { LogOptions } from "../Atomic.js";
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
import { SourceAIOConfig } from "./source/sources.js";
export interface SourceDefaults extends SourceRetryOptions {
/**
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
* */
scrobbleThresholds?: ScrobbleThresholds
options?: CommonSourceOptions
export interface SourceDefaults extends CommonSourceOptions {
}
export interface ClientDefaults extends CommonClientOptions {
}
export interface AIOConfig {
sourceDefaults?: SourceDefaults
clientDefaults?: RequestRetryOptions
clientDefaults?: ClientDefaults
sources?: SourceAIOConfig[]
clients?: ClientAIOConfig[]
@@ -44,6 +45,13 @@ export interface AIOConfig {
logging?: LogOptions
/**
* Disable web server from running/listening on port.
*
* This will also make any ingress sources (Plex, Jellyfin, Tautulli, etc...) unusable
* */
disableWeb?: boolean
/**
* Enables ALL relevant logging and debug options for all sources/clients, when none are defined.
*
@@ -1,6 +1,6 @@
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
@@ -1,4 +1,4 @@
import { CommonConfig, CommonData } from "../common.js";
import { CommonConfig, CommonData, PlayTransformConfig, PlayTransformOptions, RequestRetryOptions } from "../common.js";
/**
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
@@ -28,50 +28,53 @@ export interface MatchLoggingOptions {
}
export interface CommonClientData extends CommonData {
}
export interface CommonClientOptions extends RequestRetryOptions {
/**
* default # of http request retries a client can make before error is thrown.
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
* @default true
* @examples [true]
* */
refreshEnabled?: boolean
/**
* Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago
*
* **In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.
*
* This setting should only be used in specific scenarios where MS is handling multiple "relaying" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.
*
* @examples [3]
* */
refreshStaleAfter?: number
/**
* The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.
* */
refreshInitialCount?: number
/**
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
* @default true
* @examples [true]
* */
checkExistingScrobbles?: boolean
/**
* Options used for increasing verbosity of logging in MS (used for debugging)
* */
verbose?: {
match?: MatchLoggingOptions
}
/**
* Number of times MS should automatically retry scrobbles in dead letter queue
*
* @default 1
* @examples [1]
* */
maxRequestRetries?: number
/**
* default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).
*
* @default 1.5
* @examples [1.5]
* */
retryMultiplier?: number
deadLetterRetries?: number
options?: {
/**
* Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history
* @default true
* @examples [true]
* */
refreshEnabled?: boolean
/**
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
* @default true
* @examples [true]
* */
checkExistingScrobbles?: boolean
/**
* Options used for increasing verbosity of logging in MS (used for debugging)
* */
verbose?: {
match?: MatchLoggingOptions
}
/**
* Number of times MS should automatically retry scrobbles in dead letter queue
*
* @default 1
* @examples [1]
* */
deadLetterRetries?: number
}
playTransform?: PlayTransformOptions
}
export interface CommonClientConfig extends CommonConfig {
@@ -85,4 +88,5 @@ export interface CommonClientConfig extends CommonConfig {
* Specific data required to configure this client
* */
data?: CommonClientData
options?: CommonClientOptions
}
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
export interface LastfmData extends RequestRetryOptions {
/**
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
export interface ListenBrainzData extends RequestRetryOptions{
/**
@@ -1,5 +1,5 @@
import { CommonClientConfig, CommonClientData } from "./index.js";
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
/**
@@ -1,3 +1,5 @@
import { keyOmit, PlayTransformHooks, SearchAndReplaceTerm } from "../Atomic.js";
export interface CommonConfig {
name?: string
data?: CommonData
@@ -10,14 +12,11 @@ export interface CommonConfig {
enable?: boolean
}
export interface CommonData {
[key: string]: any
options?: Record<string, any>
}
export type CommonData = keyOmit<{ [key: string]: any }, "options">
export interface RequestRetryOptions {
/**
* default # of http request retries a source can make before error is thrown
* default # of http request retries a source/client can make before error is thrown
*
* @default 1
* @examples [1]
@@ -50,3 +49,7 @@ export interface PollingOptions {
* */
maxInterval?: number
}
export type PlayTransformConfig = PlayTransformHooks<SearchAndReplaceTerm>;
export type PlayTransformOptions = PlayTransformConfig & { log?: boolean }
@@ -1,9 +1,11 @@
export interface WebhookPayload {
title?: string
message: string
priority: 'info' | 'warn' | 'error'
priority: Priority
}
export type Priority = 'info' | 'warn' | 'error';
export interface PrioritiesConfig {
/**
* @examples [5]
@@ -28,7 +30,7 @@ export interface CommonWebhookConfig {
*
* @examples ["gotify"]
* */
type: 'gotify' | 'ntfy'
type: 'gotify' | 'ntfy' | 'apprise'
/**
* A friendly name used to identify webhook config in logs
* */
@@ -90,4 +92,30 @@ export interface NtfyConfig extends CommonWebhookConfig {
priorities?: PrioritiesConfig
}
export type WebhookConfig = GotifyConfig | NtfyConfig;
export interface AppriseConfig extends CommonWebhookConfig {
/**
* The URL of the apprise-api server
*
* @examples ["http://192.168.0.100:8078"]
* */
host: string
/**
* If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send
* */
urls?: string | string[]
/**
* If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to
*
* Note: If multiple keys are defined then MS will attempt to POST to each one individually
* */
keys?: string | string[]
/**
* Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload
* */
tags?: string | string[]
}
export type WebhookConfig = GotifyConfig | NtfyConfig | AppriseConfig;
@@ -1,4 +1,4 @@
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
import { CommonConfig, CommonData, PlayTransformConfig, PlayTransformOptions, RequestRetryOptions } from "../common.js";
export interface SourceRetryOptions extends RequestRetryOptions {
/**
@@ -39,7 +39,7 @@ export interface ScrobbleThresholds {
percent?: number | null
}
export interface CommonSourceOptions {
export interface CommonSourceOptions extends SourceRetryOptions {
/**
* If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)
* then setting this option to true will make MS log the payload JSON to DEBUG output
@@ -83,13 +83,25 @@ export interface CommonSourceOptions {
* @examples [true, false]
* */
scrobbleBacklog?: boolean
}
export interface CommonSourceData extends CommonData, SourceRetryOptions {
/**
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
* */
scrobbleThresholds?: ScrobbleThresholds
/**
* The number of listens to fetch when scrobbling from backlog
*
* * Only applies if this source supports fetching a listen history
* * If not specified it defaults to the maximum number of listens the source API supports
* */
scrobbleBacklogCount?: number
playTransform?: PlayTransformOptions
}
export interface CommonSourceData extends CommonData {
}
export interface CommonSourceConfig extends CommonConfig {
@@ -17,33 +17,6 @@ export interface JellyData extends CommonSourceData {
* @examples [["MyServerName1"]]
* */
servers?: string | string[]
/**
* Additional options for jellyfin logging and tuning
* */
options?: {
/**
* Log raw Jellyfin webhook payload to debug
*
* @default false
* @examples [false]
* */
logPayload?: boolean
/**
* How MS should log when a Jellyfin event fails a defined filter (users/servers)
*
* * `false` => do not log
* * `debug` => log to DEBUG level
* * `warn` => log to WARN level (default)
*
* Hint: This is useful if you are sure this source is setup correctly and you have multiple other Jellyfin sources. Set to `debug` or `false` to reduce log noise.
*
* @default warn
* @examples ["warn"]
* */
logFilterFailure?: false | 'debug' | 'warn'
}
}
export interface JellySourceConfig extends CommonSourceConfig {
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface JRiverData extends CommonSourceData, PollingOptions {
/**
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface KodiData extends CommonSourceData, PollingOptions {
@@ -1,6 +1,6 @@
import { LastfmData } from "../client/lastfm.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
@@ -1,6 +1,6 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { ListenBrainzData } from "../client/listenbrainz.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
}
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface MopidyData extends CommonSourceData, PollingOptions {
/**
@@ -0,0 +1,70 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface MPDData extends CommonSourceData {
/**
* URL:PORT of the MPD server to connect to
*
* To use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections
*
* @examples ["localhost:6600"]
* @default "localhost:6600"
* */
url?: string
/**
* If using socket specify the path instead of url.
*
* trailing `~` is replaced by your home directory
* */
path?: string
/**
* Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords
* */
password?: string
}
export interface MPDSourceOptions extends CommonSourceOptions {
//disableDiscovery?: boolean
}
export interface MPDSourceConfig extends CommonSourceConfig {
data: MPDData
options: MPDSourceOptions
}
export interface MPDSourceAIOConfig extends MPDSourceConfig {
type: 'mpd'
}
export type PlayerState = 'play' | 'stop' | 'pause';
export interface StatusResponse {
state: PlayerState
/**
* Position within the current song in seconds
* */
elapsed?: number
/**
* Duration of the current song in seconds
* */
duration?: number
error?: string
}
export interface CurrentSongResponse {
file: string
time: number
name?: string
performer?: string
artist?: string
album?: string
albumartist?: string
title?: string
musicbrainz_albumartistid?: string
musicbrainz_albumid?: string
musicbrainz_artistid?: string
musicbrainz_releasetrackid?: string
musicbrainz_trackid?: string
}
@@ -0,0 +1,116 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export const PLAYBACK_STATUS_PLAYING_MC = 'playing';
export const PLAYBACK_STATUS_PAUSED_MC = 'paused';
export const PLAYBACK_STATUS_STOPPED_MC = 'stopped';
export type MCPlaybackStatus = 'playing' | 'stopped' | 'paused';
export interface MCResponseCommon {
id: string
name: string
type: 'response'
}
export interface MCRequestCommon {
type: 'request'
id: string
name: string
}
export interface MCTrackResponse {
album: string
album_artist: string
album_artist_id: number
album_id: number
artist: string
artist_id: number
external_id: string
genre: string
genre_id: number
id: number
thumbnail_id: number
title: string
track: number
}
export interface MCPlaybackOverviewResponse extends MCResponseCommon {
options: {
muted: boolean
play_queue_position: number
playing_current_time: number
playing_duration: number
playing_track: MCTrackResponse
repeat_mode: string
shuffled: boolean
state: MCPlaybackStatus
track_count: number
volume: number
}
}
export interface MCAuthenticateResponse extends MCResponseCommon {
options: {
authenticated: boolean
environment: {
api_version: number
app_version: string
http_server_enabled: boolean
http_server_port: number
sdk_version: number
}
}
}
export interface MCAuthenticateRequest extends MCRequestCommon {
name: 'authenticate',
device_id: string
options: {
password: string
}
}
export interface MCPlaybackOverviewRequest extends MCRequestCommon {
name: 'get_playback_overview'
device_id: string
}
export interface MusikcubeData extends CommonSourceData {
/**
* URL of the Musikcube Websocket (Metadata) server to connect to
*
* You MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation
* * musikcube -> settings -> server setup
*
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
*
* Parts => [default value]
*
* * Protocol => `ws://`
* * Hostname => `localhost`
* * Port => `7905`
*
*
* @examples ["ws://localhost:7905"]
* @default "ws://localhost:7905"
* */
url?: string
/**
* Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation
*
* * musikcube -> settings -> server setup -> password
* */
password: string
device_id?: string
}
export interface MusikcubeSourceConfig extends CommonSourceConfig {
data: MusikcubeData
}
export interface MusikcubeSourceAIOConfig extends MusikcubeSourceConfig {
type: 'musikcube'
}
@@ -25,26 +25,6 @@ export interface PlexSourceData extends CommonSourceData {
* @examples [["MyServerName"]]
* */
servers?: string | string[]
/**
* Additional options for Plex/Tautulli logging and tuning
* */
options?: {
/**
* How MS should log when a Plex/Tautulli event fails a defined filter (users/servers)
*
* * `false` => do not log
* * `debug` => log to DEBUG level
* * `warn` => log to WARN level (default)
*
* Hint: This is useful if you are sure this source is setup correctly and you have multiple other Plex/Tautulli sources. Set to `debug` or `false` to reduce log noise.
*
* @default warn
* @examples ["warn"]
* */
logFilterFailure?: false | 'debug' | 'warn'
}
}
export interface PlexSourceConfig extends CommonSourceConfig {
@@ -1,20 +1,56 @@
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { ChromecastSourceConfig, ChromecastSourceAIOConfig } from "./chromecast.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig;
export type SourceConfig =
SpotifySourceConfig
| PlexSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| SubSonicSourceConfig
| JellySourceConfig
| LastfmSourceConfig
| YTMusicSourceConfig
| MPRISSourceConfig
| MopidySourceConfig
| ListenBrainzSourceConfig
| JRiverSourceConfig
| KodiSourceConfig
| WebScrobblerSourceConfig
| ChromecastSourceConfig
| MusikcubeSourceConfig
| MPDSourceConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
| PlexSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| SubsonicSourceAIOConfig
| JellySourceAIOConfig
| LastFmSouceAIOConfig
| YTMusicSourceAIOConfig
| MPRISSourceAIOConfig
| MopidySourceAIOConfig
| ListenBrainzSourceAIOConfig
| JRiverSourceAIOConfig
| KodiSourceAIOConfig
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
| MusikcubeSourceAIOConfig
| MPDSourceAIOConfig;
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
/**
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface SubsonicData extends CommonSourceData, PollingOptions {
/**
@@ -37,6 +37,24 @@ export interface SubsonicData extends CommonSourceData, PollingOptions {
* @examples [30]
* */
maxInterval?: number
/**
* If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection
*
* WARNING: This should be used with caution as your traffic may not be encrypted.
*
* @default false
* */
ignoreTlsErrors?: boolean
/**
* Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT. This is less secure than the newer, recommended hashing authentication method but in some cases it is needed. See "Authentication" section here => https://www.subsonic.org/pages/api.jsp
*
* If this option is not specified it will be turned on if the subsonic server responds with error code 41 "Token authentication not supported for LDAP users." -- See Error Handling section => https://www.subsonic.org/pages/api.jsp
*
* @default false
* */
legacyAuthentication?: boolean
}
export interface SubSonicSourceConfig extends CommonSourceConfig {
data: SubsonicData
@@ -32,31 +32,6 @@ export interface WebScrobblerData extends CommonSourceData {
* @examples [["mixcloud","soundcloud","bandcamp"]]
* */
whitelist?: string | string[]
/**
* Additional options for WebScrobbler logging and tuning
* */
options?: {
/**
* Log raw WebScrobbler webhook payload to debug
*
* @default false
* @examples [false]
* */
logPayload?: boolean
/**
* How MS should log when a WebScrobbler event fails a defined filter
*
* * `false` => do not log
* * `debug` => log to DEBUG level
* * `warn` => log to WARN level (default)
*
* @default warn
* @examples ["warn"]
* */
logFilterFailure?: false | 'debug' | 'warn'
}
}
export interface WebScrobblerSourceConfig extends CommonSourceConfig {
@@ -1,7 +1,7 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface YTMusicData extends CommonSourceData, PollingOptions {
export interface YTMusicCredentials {
/**
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
*
@@ -17,8 +17,17 @@ export interface YTMusicData extends CommonSourceData, PollingOptions {
* */
authUser?: number
}
export interface YTMusicData extends YTMusicCredentials, CommonSourceData, PollingOptions {
}
export interface YTMusicSourceConfig extends CommonSourceConfig {
data: YTMusicData
options?: CommonSourceOptions & {
/**
* When true MS will log to DEBUG what parts of the cookie are updated by YTM
* */
logAuthUpdateChanges?: boolean
}
}
export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig {
@@ -50,6 +50,17 @@ declare module 'lastfm-node-client' {
}
}
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
export interface NowPlayingResponse {
nowplaying: {
ignoredMessage: {
code: number
'#text': string
}
}
}
export interface AuthGetSessionPayload {
token: string
}
@@ -92,7 +103,6 @@ declare module 'lastfm-node-client' {
},
duration: number,
date?: {
// @ts-ignore
uts: number,
},
'@attr'?: {
@@ -105,6 +115,7 @@ declare module 'lastfm-node-client' {
export default class LastFM {
constructor(apiKey: string, secret?: string, session?: string);
trackUpdateNowPlaying(params: NowPlayingPayload): Promise<NowPlayingResponse>
trackScrobble(params: TrackScrobblePayload): Promise<TrackScrobbleResponse>
authGetSession(params: AuthGetSessionPayload): Promise<AuthGetSessionResponse>
userGetInfo(): Promise<UserGetInfoResponse>
@@ -1,7 +1,8 @@
declare module 'passport-deezer' {
//import {Strategy as Oauth2Strategy} from 'passport-oauth2';
import {Strategy as PassportStrategy} from "passport";
import { Strategy as PassportStrategy } from "passport";
export class Strategy extends PassportStrategy {
constructor(options: any, verify: any);
}
+26 -363
View File
@@ -1,380 +1,43 @@
import { childLogger, Logger, loggerAppRolling, LogOptions, parseLogOptions, } from '@foxxmd/logging';
import { buildDestinationJsonPrettyStream, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
import { PassThrough, Transform } from "node:stream";
import path from "path";
import { projectDir } from "./index.js";
import * as winstonNs from '@foxxmd/winston';
import winstonDef from '@foxxmd/winston';
import {DuplexTransport} from "winston-duplex";
import { asLogOptions, LogConfig, LogOptions } from "./infrastructure/Atomic.js";
import process from "process";
import { fileOrDirectoryIsWriteable, mergeArr, parseBool } from "../utils.js";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {NullTransport} from 'winston-null';
import DailyRotateFile from 'winston-daily-rotate-file';
import dayjs from "dayjs";
import stringify from 'safe-stable-stringify';
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
import { LogInfo, LogLevel } from "../../core/Atomic.js";
import TransportStream from "winston-transport";
import {format} from 'logform';
const {combine, printf, timestamp, label, splat, errors} = format;
//const {transports} = winstonNew;
const {loggers, transports} = winstonDef;
import { projectDir } from "./index.js";
export let logPath = path.resolve(projectDir, `./logs`);
if (typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}
loggers.add('noop', {transports: [new NullTransport()]});
export const getLogger = (config: LogConfig = {}, name = 'app'): winstonNs.Logger => {
if (!loggers.has(name)) {
const errors: (Error | string)[] = [];
let options: LogOptions = {};
if (asLogOptions(config)) {
options = config;
} else {
errors.push(`Logging levels were not valid. Must be one of: 'error', 'warn', 'info', 'verbose', 'debug' -- 'file' may be false.`);
}
const {level: configLevel} = options;
const defaultLevel = process.env.LOG_LEVEL || (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'info');
let consoleLevel: string | boolean = process.env.CONSOLE_LEVEL || 'debug';
if(consoleLevel === 'false') {
consoleLevel = false;
}
let fileLevel: string | boolean = process.env.FILE_LEVEL || defaultLevel;
if(fileLevel === 'false') {
fileLevel = false;
}
const {
level = configLevel || defaultLevel,
file = configLevel || fileLevel,
stream = configLevel || 'debug',
console = configLevel || consoleLevel
} = options;
const myTransports: TransportStream[] = [
new DuplexTransport({
stream: {
transform(chunk, e, cb) {
cb(null, chunk);
},
objectMode: true,
},
name: 'duplex',
level: stream,
dump: false,
})
];
if(console !== false) {
myTransports.push(new transports.Console({
level: console,
}));
}
if (file !== false) {
const rotateTransport = new DailyRotateFile({
dirname: logPath,
createSymlink: true,
symlinkName: 'scrobble-current.log',
filename: 'scrobble-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '5m',
level: file,
});
try {
fileOrDirectoryIsWriteable(logPath);
// @ts-ignore
myTransports.push(rotateTransport);
} catch (e: any) {
let msg = 'WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory';
errors.push(new ErrorWithCause<Error>(msg, {cause: e}));
}
}
const loggerOptions: winstonNs.LoggerOptions = {
level: level,
format: labelledFormat(),
transports: myTransports,
};
loggers.add(name, loggerOptions);
const logger = loggers.get(name);
if (errors.length > 0) {
for (const e of errors) {
logger.error(e);
}
}
return logger;
}
return loggers.get(name);
export const initLogger = (): [Logger, Transform] => {
const opts = parseLogOptions({file: false, console: 'debug'})
const stream = new PassThrough({objectMode: true});
const logger = buildLogger('debug', [
buildDestinationStdout(opts.console),
buildDestinationJsonPrettyStream(opts.console, {destination: stream, object: true, colorize: true})
]);
return [logger, stream];
}
const breakSymbol = '<br />';
export const formatLogToHtml = (chunk: any) => {
const line = chunk.toString().replace('\n', breakSymbol)
.replace(/(debug)\s/gi, '<span class="debug blue">$1 </span>')
.replace(/(warn)\s/gi, '<span class="warn yellow">$1 </span>')
.replace(/(info)\s/gi, '<span class="info green">$1 </span>')
.replace(/(verbose)\s/gi, '<span class="verbose purple">$1 </span>')
.replace(/(error)\s/gi, '<span class="error red">$1 </span>')
.trim();
if(line.slice(-6) !== breakSymbol) {
return `${line}${breakSymbol}`;
}
return line;
export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassThrough]> => {
const stream = new PassThrough({objectMode: true});
const opts = parseLogOptions(config)
const logger = await loggerAppRolling(config, {
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
logDefaultPath: './logs/scrobble.log',
destinations: [
buildDestinationJsonPrettyStream(opts.console, {destination: stream, object: true, colorize: true})
]
});
return [logger, stream];
}
const levelSymbol = Symbol.for('level');
const s = splat();
//const errorsFormat = errors({stack: true});
const CWD = process.cwd();
const causeKeys = ['name', 'cause', 'showStopper']
export const defaultFormat = (defaultLabel = 'App') => printf(({
label,
[levelSymbol]: levelSym,
level,
message,
labels = [defaultLabel],
leaf,
timestamp,
durationMs,
[SPLAT]: splatObj,
stack,
id,
cause,
showStopper,
...rest
}) => {
const keys = Object.keys(rest);
let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify.default(rest) : '';
let msg = message;
let stackMsg = '';
if (stack !== undefined) {
const stackArr = stack.split('\n');
const stackTop = stackArr[0];
const cleanedStack = stackArr
.slice(1) // don't need actual error message since we are showing it as msg
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
.join('\n'); // rejoin with newline to preserve formatting
stackMsg = `\n${cleanedStack}`;
if (msg === undefined || msg === null || typeof message === 'object') {
msg = stackTop;
} else {
stackMsg = `\n${stackTop}${stackMsg}`
}
}
let nodes = Array.isArray(labels) ? labels : [labels];
if (leaf !== null && leaf !== undefined && !nodes.includes(leaf)) {
nodes.push(leaf);
}
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
});
// https://knowyourmeme.com/memes/cereal-guy
// this number will never overflow
let seqId: number = 0;
export const labelledFormat = (labelName = 'App') => {
const l = label({label: labelName, message: false});
return combine(
timestamp(
{
format: () => dayjs().local().format(),
}
),
{
transform: (info, opts) => {
info.id = seqId;
seqId++;
return info;
}
},
l,
s,
errorAwareFormat,
defaultFormat(labelName),
);
}
export const logLevels = {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
trace: 5,
silly: 6
};
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel): boolean => {
// @ts-ignore
const minLevel = logLevels[minLevelText];
let level: number;
if(typeof log === 'string') {
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
if (lineLevelMatch === null) {
return false;
}
// @ts-ignore
level = logLevels[lineLevelMatch[1]];
} else {
const lineLevelMatch = log.level;
// @ts-ignore
level = logLevels[lineLevelMatch];
}
return level <= minLevel;
}
export const isLogLevelMinLevel = (levelStr: LogLevel, minLevelStr: LogLevel): boolean => {
return logLevels[levelStr] <= logLevels[minLevelStr];
}
const isProbablyError = (val: any, explicitErrorName?: string) => {
if(typeof val !== 'object' || val === null) {
return false;
}
const {name, stack} = val;
if(explicitErrorName !== undefined) {
if(name !== undefined && name.toLowerCase().includes(explicitErrorName)) {
return true;
}
if(stack !== undefined && stack.trim().toLowerCase().indexOf(explicitErrorName.toLowerCase()) === 0) {
return true;
}
return false;
} else if(stack !== undefined) {
return true;
} else if(name !== undefined && name.toLowerCase().includes('error')) {
return true;
}
return false;
}
const errorAwareFormat = {
transform: (einfo: any, {stack = true}: any = {}) => {
// because winston logger.child() re-assigns its input to an object ALWAYS the object we recieve here will never actually be of type Error
const includeStack = stack && (!isProbablyError(einfo, 'simpleerror') && !isProbablyError(einfo.message, 'simpleerror'));
if (!isProbablyError(einfo.message) && !isProbablyError(einfo)) {
return einfo;
}
let info: any = {};
if (isProbablyError(einfo)) {
const tinfo = transformError(einfo);
info = Object.assign({}, tinfo, {
// @ts-ignore
level: einfo.level,
// @ts-ignore
[LEVEL]: einfo[LEVEL] || einfo.level,
message: tinfo.message,
// @ts-ignore
[MESSAGE]: tinfo[MESSAGE] || tinfo.message
});
if(includeStack) {
// so we have to create a dummy error and re-assign all error properties from our info object to it so we can get a proper stack trace
const dummyErr = new ErrorWithCause('');
const names = Object.getOwnPropertyNames(tinfo);
for(const k of names) {
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
// @ts-ignore
dummyErr[k] = tinfo[k];
}
}
// @ts-ignore
info.stack = stackWithCauses(dummyErr);
}
} else {
const err = transformError(einfo.message);
info = Object.assign({}, einfo, err);
// @ts-ignore
info.message = err.message;
// @ts-ignore
info[MESSAGE] = err.message;
if(includeStack) {
const dummyErr = new ErrorWithCause('');
// Error properties are not enumerable
// https://stackoverflow.com/a/18278145/1469797
const names = Object.getOwnPropertyNames(err);
for(const k of names) {
if(dummyErr.hasOwnProperty(k) || k === 'cause') {
// @ts-ignore
dummyErr[k] = err[k];
}
}
// @ts-ignore
info.stack = stackWithCauses(dummyErr);
}
}
// remove redundant message from stack and make stack causes easier to read
if(info.stack !== undefined) {
let cleanedStack = info.stack.replace(info.message, '');
cleanedStack = `${cleanedStack}`;
cleanedStack = cleanedStack.replaceAll('caused by:', '\ncaused by:');
info.stack = cleanedStack;
}
return info;
}
}
export const transformError = (err: Error): any => _transformError(err, new Set());
const _transformError = (err: Error, seen: Set<Error>) => {
if (!err || !isProbablyError(err)) {
return '';
}
if (seen.has(err)) {
return err;
}
try {
// @ts-ignore
let mOpts = err.matchOptions ?? matchOptions;
// @ts-ignore
const cause = err.cause as unknown;
if (cause !== undefined && cause instanceof Error) {
// @ts-ignore
err.cause = _transformError(cause, seen, mOpts);
}
return err;
} catch (e: any) {
// oops :(
// we're gonna swallow silently instead of reporting to avoid any infinite nesting and hopefully the original error looks funny enough to provide clues as to what to fix here
return err;
}
}
export class MaybeLogger {
logger?: winstonNs.Logger
logger?: Logger
constructor(logger?: winstonNs.Logger, label?: string) {
constructor(logger?: Logger, label?: string) {
if (logger !== undefined && label !== undefined) {
this.logger = logger.child({labels: [label]}, mergeArr);
this.logger = childLogger(logger, label);
} else {
this.logger = logger;
}
+74 -138
View File
@@ -16,71 +16,83 @@
"title": "ClientAIOConfig"
},
"CommonClientData": {
"title": "CommonClientData",
"type": "object"
},
"CommonClientOptions": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a client can make before error is thrown.",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"refreshForce": {
"default": false,
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
"examples": [
false
],
"title": "refreshForce",
"type": "boolean"
},
"refreshInitialCount": {
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
"title": "refreshInitialCount",
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "CommonClientData",
"title": "CommonClientOptions",
"type": "object"
},
"LastfmClientAIOConfig": {
@@ -127,6 +139,10 @@
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
},
"type": {
"enum": [
"lastfm"
@@ -155,7 +171,7 @@
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
@@ -238,6 +254,10 @@
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
},
"type": {
"enum": [
"listenbrainz"
@@ -258,57 +278,13 @@
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
@@ -372,6 +348,10 @@
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
},
"type": {
"enum": [
"maloja"
@@ -400,57 +380,13 @@
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
@@ -514,7 +450,7 @@
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+73 -137
View File
@@ -13,71 +13,83 @@
],
"definitions": {
"CommonClientData": {
"title": "CommonClientData",
"type": "object"
},
"CommonClientOptions": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a client can make before error is thrown.",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"refreshForce": {
"default": false,
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
"examples": [
false
],
"title": "refreshForce",
"type": "boolean"
},
"refreshInitialCount": {
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
"title": "refreshInitialCount",
"type": "number"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying).",
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"title": "retryMultiplier",
"type": "number"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "CommonClientData",
"title": "CommonClientOptions",
"type": "object"
},
"LastfmClientConfig": {
@@ -123,6 +135,10 @@
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
@@ -144,7 +160,7 @@
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
@@ -226,6 +242,10 @@
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
@@ -239,57 +259,13 @@
"properties": {
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
@@ -352,6 +328,10 @@
],
"title": "name",
"type": "string"
},
"options": {
"$ref": "#/definitions/CommonClientOptions",
"title": "options"
}
},
"required": [
@@ -373,57 +353,13 @@
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"description": "default # of http request retries a source/client can make before error is thrown",
"examples": [
1
],
"title": "maxRequestRetries",
"type": "number"
},
"options": {
"properties": {
"checkExistingScrobbles": {
"default": true,
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
"examples": [
true
],
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
"examples": [
true
],
"title": "refreshEnabled",
"type": "boolean"
},
"verbose": {
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
"properties": {
"match": {
"$ref": "#/definitions/MatchLoggingOptions",
"title": "match"
}
},
"title": "verbose",
"type": "object"
}
},
"title": "options",
"type": "object"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -1,9 +1,7 @@
import { mergeArr } from "../../utils.js";
import {Logger} from '@foxxmd/winston';
import { FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import winston from '@foxxmd/winston';
import { childLogger, Logger } from "@foxxmd/logging";
import { PlayObject } from "../../../core/Atomic.js";
import { capitalize } from "../../../core/StringUtils.js";
import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
export default abstract class AbstractApiClient {
name: string;
@@ -18,16 +16,20 @@ export default abstract class AbstractApiClient {
workingCredsPath?: string;
redirectUri?: string;
constructor(type: any, name: any, config = {}, options = {}) {
constructor(type: any, name: any, config = {}, options: AbstractApiOptions) {
this.type = type;
this.name = name;
const identifier = `API - ${capitalize(this.type)} - ${name}`;
this.logger = winston.loggers.get('app').child({labels: [identifier]}, mergeArr);
const {
logger: parentLogger,
...restOptions
} = options;
this.logger = childLogger(parentLogger, identifier);
this.config = config;
this.options = options;
this.options = restOptions;
}
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => {
static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject {
throw new Error('should be overridden');
}
}

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