Compare commits

...
132 Commits
Author SHA1 Message Date
FoxxMD e04514f6a1 chore(release): Bump version for release 2026-05-05 11:46:16 +00:00
FoxxMD 00e9755fb1 chore: Override rate-limit-threshold to use new version with reduced dependencies
Borewit/rate-limit-threshold#110
2026-05-05 11:46:16 +00:00
Matt Foxx 5ba4729f83 Merge pull request #585 from FoxxMD/webscrobblerPathFix
fix(webscrobbler): Convert express wildcard path to regex
2026-05-04 16:54:13 -04:00
FoxxMD e5df4f2752 docs: Remove extra dupe detection section from client overview
Direct to new dupe guidance page instead
2026-05-04 18:28:08 +00:00
FoxxMD d11db4cb8d docs: Add duplicate scrobble guidance
#582
2026-05-04 18:18:09 +00:00
FoxxMD 14f5f60c11 ci: add canonical search-replace reference comment 2026-05-04 18:15:49 +00:00
FoxxMD 4d7eadbd38 fix(webscrobbler): Convert express wildcard path to regex
New path wildcard from expressjs 5 doesn't seem to work. Use tried and try regex instead

Fixes #583
2026-05-04 14:01:02 +00:00
Matt Foxx 747b62289a Merge pull request #577 from FoxxMD/stalePlayerImprovement
feat: Improve stale player behavior
2026-05-04 09:05:54 -04:00
Matt Foxx 1eeb5e6b08 Merge pull request #580 from FoxxMD/lzEndpointRouteImprovements
Lz endpoint route improvements
2026-05-02 16:24:21 -04:00
FoxxMD ac01348266 docs: try to replace canonical urls for github.io with new site 2026-05-02 04:08:03 +00:00
FoxxMD 3028f83d47 fix(discord): Workaround activity minimum character limit
#578
2026-05-01 18:56:36 +00:00
FoxxMD a02d43acea feat(discord): Implement fallback to release group art if present
#578
2026-05-01 18:43:09 +00:00
FoxxMD c7679a0a62 docs: Fix missing backticks causing template errors on version equality 2026-05-01 16:56:01 +00:00
Gabe Farrell d11ac3d6ed chore: Remove references to KOITO_ALLOWED_HOSTS (#581)
* chore: update and correct references to KOITO_ALLOWED_HOSTS
* remove protocol from host
* make it clear env belongs to koito
2026-05-01 12:47:17 -04:00
Matt Foxx 8a09c91713 Merge pull request #565 from FoxxMD/lastfmCleanup
Lastfm cleanup
2026-05-01 12:39:19 -04:00
FoxxMD 81d24f1753 fix(transformer): try-catch cached steps and fallback to non-cache generation
#578
2026-05-01 15:14:35 +00:00
FoxxMD 5be12aab15 fix(cache): Explicitly define auth/metadata as memory when testing file caching 2026-05-01 15:14:11 +00:00
FoxxMD 47c600ed8d feat(endpointlz): Implement proper username handling for validate and now-playing routes 2026-05-01 14:07:43 +00:00
FoxxMD 5f84fefd5d feat(endpointlz): Add stubbed playing-now route to make some clients happy(er)
#579
2026-05-01 13:11:39 +00:00
FoxxMD a143aa4193 fix(endpointlz): Fix fallback route not returning a response
#579
2026-05-01 13:10:41 +00:00
FoxxMD 23a3dfa5d1 fix: Workaround for old play structure 2026-05-01 00:01:50 +00:00
Matt Foxx 3005d36db0 Merge pull request #575 from FoxxMD/mpdPosition
fix(mpd): Improve position tracking
2026-04-30 18:58:45 -04:00
FoxxMD ada9d70be9 docs(mpd): Add permission guidance 2026-04-30 22:54:57 +00:00
FoxxMD 9d839db2e1 feat(mpd): Rewrite with newer MPD client for better payload parsing #574
mpd-api was missing fields and had inconsistent data.
mpd-js is actually typed and returns better parsed data.
2026-04-30 22:50:09 +00:00
FoxxMD ca20b23fbc fix(now playing): Prefer fresher players
* Don't keep sticky now playing if the player has gone stale/orphaned
* When multiple players are present, prefer those that are not stale/orphaned

#480
2026-04-30 17:39:31 +00:00
FoxxMD 577cff3abf test(player): Fix incorrect variable used for advanced timestamp 2026-04-30 17:20:27 +00:00
FoxxMD c4786bdfe2 fix(player): Initialize player state in the past to prevent false positive on stale update check if update happens at the same time player is initialized 2026-04-30 17:19:48 +00:00
FoxxMD df0da7054c feat(player): Skip stale updates based on state timestamp and prevent dead players from being created from stale updates
#480
2026-04-30 16:39:03 +00:00
FoxxMD 272a336a22 feat(source): Add timestamps to play state to track player and play last updated, if present 2026-04-30 16:38:04 +00:00
Matt Foxx ec4a51074e Merge pull request #570 from FoxxMD/fixListenRange
Fix listen range
2026-04-22 13:27:32 -04:00
FoxxMD 5a4bcb2949 fix(discord): Include known spotify cdn for media providers #569 2026-04-22 13:46:38 +00:00
FoxxMD 77387c292b refactor: Replace fast-deep-equal with fast-equals
fast-deep-equal is abandoned and has bugs that are fixed in fast-equals

Potentially fixes #569
2026-04-22 13:43:14 +00:00
FoxxMD 4caf1c8cdb fix: Fix missing await on stagger mapper initial sleep 2026-04-22 13:40:29 +00:00
FoxxMD 981805aa96 fix: Simplify listenrange usage
* Use and return only plain PlayProgress on player state finish
  * Removes the need to serialize/deserialize ListenProgress class and compare/persist toad scheduler or timeout functions
* Potentially fixes timeout comparison stack error #569
2026-04-22 13:40:00 +00:00
FoxxMD 60e3242c44 fix: Check undefined on correct property
#567
2026-04-19 21:34:42 +00:00
FoxxMD e7e1417b0b ci: Add Cloudflare Pages hook trigger to docs deployment 2026-04-17 18:56:47 +00:00
FoxxMD bf765be174 docs: order BASE_SITE first to allow overriding other envs 2026-04-17 18:30:22 +00:00
FoxxMD e27c301932 docs: Add base site env detection for cloudflare pages 2026-04-17 18:22:45 +00:00
FoxxMD fc4606585d fix(lastfm): Fix total results reporting for librefm 2026-04-17 16:14:30 +00:00
FoxxMD fd80101a7a feat(lastfm): Add more logging to auth process and clean up host detection 2026-04-17 16:05:39 +00:00
FoxxMD 2cbc5542d1 chore(release): Bump version for release 2026-04-17 13:24:45 +00:00
Matt Foxx 6f15f15722 Merge pull request #563 from FoxxMD/middlewareFix
fix: Web endpoint fix
2026-04-17 09:22:25 -04:00
FoxxMD ff1d9248c6 fix: Add missing next() invocation
#561
2026-04-17 12:52:48 +00:00
FoxxMD 218ece8f5c fix(vite): Allow requests from anywhere during dev 2026-04-17 12:49:55 +00:00
FoxxMD 3adac7c6e0 docs: Improve updating guidance 2026-04-16 13:29:34 +00:00
FoxxMD 1c78da7985 chore(release): Bump verion for release 2026-04-16 12:26:39 +00:00
FoxxMD 457279465c ci: Add build date to image build workflow 2026-04-16 02:05:53 +00:00
FoxxMD d551c8563f feat(docker): Add proper opencontainer labels 2026-04-16 02:05:38 +00:00
FoxxMD 75bd4a04d6 fix(transform): Fix uninitialized logger 2026-04-15 20:34:40 +00:00
FoxxMD 339232c368 perf(transform): Defer logger creation until after it is determined a hook will be used 2026-04-15 18:40:21 +00:00
Matt Foxx 8741ff25ea Merge pull request #557 from Druidblack/master
update for Yandex music
2026-04-11 00:04:24 -04:00
Druidblack 8a952cb576 error correction at startup. 2026-04-09 22:32:19 +03:00
Druidblack eeb55aa02e added multiPlatform
added multiPlatform and support for receiving data from Yandex smart speakers
2026-04-09 22:31:18 +03:00
Druidblack 6a81fc1be2 Update ymbridge.json.example 2026-04-09 22:28:32 +03:00
FoxxMD a47e2a6242 docs: Update editUrl 2026-04-09 15:45:29 +00:00
FoxxMD dadaa6df35 ci: reduce dependabot npm updates to security only
Version updates are too big and unhelpful
2026-04-09 14:35:51 +00:00
FoxxMD fb19191209 ci: Update node version for sanity test 2026-04-09 14:08:58 +00:00
Matt Foxx 2f53a60da1 Merge pull request #548 from FoxxMD/express5
chore: Update to expressjs 5
2026-04-09 09:34:29 -04:00
FoxxMD ab2f397fab refactor: update express types related packages 2026-04-09 13:21:50 +00:00
FoxxMD db2dd8fb8b refactor: Refactor wildcard path usage for express5 2026-04-09 13:16:56 +00:00
FoxxMD 135439f509 fix(scrobbler): Fix stagger map generation variable usage 2026-04-09 13:16:33 +00:00
FoxxMD d743b849fe refactor: replace awaitjs/express with express usage 2026-04-09 13:04:47 +00:00
FoxxMD 9885768cad refactor: Install expressjs@5 2026-04-09 12:58:09 +00:00
FoxxMD c6e6bbb87b chore: Override awaitjs/express types to fix peer dependency conflict 2026-04-09 12:53:53 +00:00
FoxxMD bbc2351ebc ci: Fix npm groups to apply only by version/security
Some @types dev packages are tied to prod packages, cant really spearate them.
2026-04-09 12:30:52 +00:00
FoxxMD b547db9045 ci: group gha version updates 2026-04-09 12:25:09 +00:00
Matt Foxx 28c2e9873a Merge pull request #537 from FoxxMD/dependabot/github_actions/actions/download-artifact-8.0.1
chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1
2026-04-09 08:20:39 -04:00
Matt Foxx 6bcd3f9516 Merge pull request #536 from FoxxMD/dependabot/github_actions/actions/upload-artifact-7.0.0
chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.0
2026-04-09 08:16:49 -04:00
Matt Foxx 11b2632d02 Merge pull request #538 from FoxxMD/dependabot/github_actions/docker/metadata-action-6.0.0
chore(deps): bump docker/metadata-action from 5.9.0 to 6.0.0
2026-04-08 23:51:10 -04:00
Matt Foxx fb6dbe307d Merge pull request #539 from FoxxMD/dependabot/github_actions/marocchino/sticky-pull-request-comment-3.0.3
chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.4 to 3.0.3
2026-04-08 23:50:13 -04:00
FoxxMD 2474b792c3 ci: try wildcard on github actions 2026-04-09 03:39:12 +00:00
FoxxMD 3007c949a0 ci: Group github action dependabot updates 2026-04-09 03:35:21 +00:00
dependabot[bot] 63533534df chore(deps): bump marocchino/sticky-pull-request-comment
Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2.9.4 to 3.0.3.
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/773744901bac0e8cbb5a0dc842800d45e9b2b405...d4d6b0936434b21bc8345ad45a440c5f7d2c40ff)

---
updated-dependencies:
- dependency-name: marocchino/sticky-pull-request-comment
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:57 +00:00
dependabot[bot] 5c03062976 chore(deps): bump docker/metadata-action from 5.9.0 to 6.0.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.9.0 to 6.0.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/318604b99e75e41977312d83839a89be02ca4893...030e881283bb7a6894de51c315a6bfe6a94e05cf)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:53 +00:00
dependabot[bot] dbe41fa5ab chore(deps): bump actions/download-artifact from 4.3.0 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:49 +00:00
dependabot[bot] 5454f817e5 chore(deps): bump actions/upload-artifact from 4.6.2 to 7.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 03:32:44 +00:00
FoxxMD 8d8f8260b5 ci: Back to one object with multiple directories 2026-04-09 03:31:53 +00:00
FoxxMD 62bb00571f ci: Group dependabot updates 2026-04-09 03:12:32 +00:00
FoxxMD c10537d658 sync package-lock.json 2026-04-09 02:55:47 +00:00
FoxxMD e9ccc319e9 try to get dependabot to use a specific npm version 2026-04-09 02:55:02 +00:00
FoxxMD 54803c6ef9 feat(docker): Make colored console output the default
Can be turned off by setting COLORED_STD=false in any environment
2026-04-09 02:35:40 +00:00
FoxxMD a3d37a864a chore: Update tsx dependency 2026-04-09 02:34:48 +00:00
Matt Foxx 394570e5a3 Merge pull request #529 from FoxxMD/fontendDevPackages
refactor: Reduce docker image size by moving frontend packages to devDependencies
2026-04-08 22:33:37 -04:00
FoxxMD 2bbfdf424f refactor: further removal of frontend package to devDependencies 2026-04-09 02:20:01 +00:00
FoxxMD 9785ad6ba7 refactor: Remove unused react-dom imports 2026-04-09 02:19:20 +00:00
FoxxMD fe7d77c03d fix(docker): Ignore dist folder 2026-04-09 02:19:00 +00:00
FoxxMD 0e7aa92f5b refactor: Reduce docker image by moving frontend packages to devDependencies
They are bundled by vite/esbuild into /dist folder, shouldn't need to keep them in node_modules
2026-04-09 01:48:08 +00:00
FoxxMD 2ff80283b9 fix: Build vite-express config based on production env to avoid making vite fallback to development (somehow) 2026-04-09 01:27:22 +00:00
FoxxMD 4f34b1a055 feat(docker): use prune-mod to remove unneeded files in image 2026-04-08 20:58:18 +00:00
FoxxMD 4effdd6df5 refactor(docker): Improve image size by removing some icon folders 2026-04-08 20:39:56 +00:00
FoxxMD af3ce9dea7 fix(docker): Fix missing patches/tsconfig for docs on docker build 2026-04-08 20:39:56 +00:00
Matt Foxx f0c5cb524f Merge pull request #527 from FoxxMD/docusaurusSocialCardNew
docs: Generate social cards per page
2026-04-08 15:48:04 -04:00
FoxxMD 46883384b6 docs: Use netlify deploy url if present 2026-04-08 19:46:00 +00:00
FoxxMD 7bc5ab3d0f docs: Specify source for ambiguous components 2026-04-08 19:31:50 +00:00
FoxxMD fd15d15124 dcs: First pass at more descriptive social card text 2026-04-08 19:30:02 +00:00
FoxxMD 14da0795f1 docs: Finalize a working social card strategy
* Helper script to build a static font
* Move all image rendering code into own folder
* Simplify generated text to description OR title
* Use text-wrap balance to make it look prettier
* Add tagline and increase font sizes to fill card dimensions
2026-04-08 19:14:43 +00:00
FoxxMD 3faf0ee7f9 social cards working but need descriptions in each front matter 2026-04-07 15:13:23 +00:00
FoxxMD 78ddb15dcf almost working
svg still doesn't render
2026-04-07 14:14:25 +00:00
FoxxMD 760373c227 docs: Replace instances of foxxmd.github.io 2026-04-07 12:44:41 +00:00
FoxxMD 6bb7b17e97 docs: Update readme with new doc site base url 2026-04-07 12:39:41 +00:00
FoxxMD 231156d452 docs: fix base site env usage 2026-04-07 12:32:31 +00:00
FoxxMD d85628b4c9 docs: Add BASE_SITE env 2026-04-07 12:27:33 +00:00
FoxxMD db0e74aec3 feat(client): Strict ENV types 2026-04-06 20:10:25 +00:00
FoxxMD a918a96d34 feat(sources): Better type enforcement 2026-04-06 20:07:30 +00:00
FoxxMD bb51915085 feat: Increase transform play concurrency
Since MB rate limiting is done by api calient we can safely increase number of plays transformed without risking MB errors from rate limits
2026-04-06 19:12:19 +00:00
Matt Foxx 7c177dfe03 Merge pull request #521 from FoxxMD/processingOverhaul
refactor: Overhaul long-running processes
2026-04-06 15:11:11 -04:00
Matt Foxx cf5e7ca1fe Merge pull request #523 from Druidblack/master
feat: Add Yandex Music source
2026-04-06 14:58:11 -04:00
Druidblack fd43ff86c8 added NO_DEVICE and NO_USER 2026-04-06 21:36:21 +03:00
FoxxMD 194a0e78ff ci: Update checkout and setup-node versions due to node deprecation 2026-04-06 18:28:00 +00:00
FoxxMD 04027e87e7 ci: fix step if statement 2026-04-06 18:04:22 +00:00
FoxxMD a998060bca ci: Add docs repo dispatch 2026-04-06 18:01:10 +00:00
Druidblack 5b16e8857e Removed optional settings 2026-04-06 20:57:36 +03:00
FoxxMD c53ce7c517 docs: Update analytics 2026-04-06 17:03:43 +00:00
FoxxMD 7897dc9503 docs: Add Yandex Music Source docs 2026-04-06 15:04:56 +00:00
Matt Foxx 160ddac3e4 Merge pull request #524 from FoxxMD/docusaursUpdateAndFix
Docusaurs update and fix
2026-04-06 10:35:58 -04:00
FoxxMD 59b4f27294 fix dep changes in wrong file 2026-04-06 14:34:33 +00:00
FoxxMD 21f42bf45b try to trigger netlify 2026-04-06 14:28:08 +00:00
FoxxMD 3e8f170f9a chore(docs): Move markdown link warn into non-deprecated setting 2026-04-06 14:24:27 +00:00
FoxxMD 15fbed09a5 fix(docs): Override lodash to fix assignWith error
https://github.com/lodash/lodash/issues/6167#issuecomment-4172977775
2026-04-06 14:24:10 +00:00
FoxxMD 52b301e729 feat(docs): Update docusaurus to 3.9.2 2026-04-06 14:23:40 +00:00
FoxxMD 0bef197521 feat(yandex): Add env source config 2026-04-06 14:06:05 +00:00
FoxxMD f16750609a feat(yandex): Handle bridge errors as upstream with some response context 2026-04-06 13:58:43 +00:00
FoxxMD c39ef29597 fix: Add missing type to sourcestatusdata 2026-04-06 13:48:49 +00:00
FoxxMD 72639616b7 fix(jellyfin): Fix typo on devicesBlock env assignment #522 2026-04-06 12:53:04 +00:00
Druidblack effe870104 fix time track
fixed the calculation of the track playback if it has ended and the new ones have not started.
2026-04-05 20:56:18 +03:00
Druidblack ee753389f7 Add files via upload 2026-04-05 19:36:13 +03:00
Druidblack 5f4a5cb815 add yandex music 2026-04-05 19:35:00 +03:00
FoxxMD 0607aedf04 feat(musicbrainz): Handle rate limiting within api calls
Instead of using staggerMap with MB transformer settings, handle at api call level for each host
2026-04-02 21:23:26 +00:00
FoxxMD 1f165096b3 feat(client): make scrobble processing abortable 2026-04-02 19:42:59 +00:00
FoxxMD fb6f6a18c8 feat(source): Make discover abortable 2026-04-02 18:41:45 +00:00
FoxxMD 03f8b20704 refactor: Implement abortable source polling
Use abort-controller-x with signals to interrupt polling process

#483
2026-04-02 18:36:20 +00:00
FoxxMD c49cdba718 fix: Initialize plays in correct scope 2026-04-02 01:29:56 +00:00
144 changed files with 7621 additions and 3121 deletions
+1
View File
@@ -13,6 +13,7 @@ config/*.json
config/*.bak
config/*.p8
/docs
dist
/logs
.flatpak-builder
**/generated-sources.*
+18
View File
@@ -6,6 +6,11 @@
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
groups:
sec-updates:
applies-to: "security-updates"
patterns:
- "*"
directories:
- "/"
- "/docsite"
@@ -17,3 +22,16 @@ updates:
# Check for updates once a week
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
groups:
sec-updates:
applies-to: "security-updates"
patterns:
- "*"
version-updates:
applies-to: "version-updates"
patterns:
- "*"
schedule:
interval: "weekly"
+31 -7
View File
@@ -12,12 +12,12 @@ jobs:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
with:
node-version: 20
node-version: 24
cache: npm
- name: Install app dev dependencies
@@ -28,22 +28,25 @@ jobs:
- name: Install docusaurus dependencies
run: npm ci
working-directory: ./docsite
- name: Build website
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
RY_ANALYTICS: ${{ vars.RY_ANALYTICS }}
RY_ANALYTICS_SITEID: ${{ vars.RY_ANALYTICS_SITEID }}
RY_ANALYTICS_REPLAY: ${{ vars.RY_ANALYTICS_REPLAY }}
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
working-directory: ./docsite
- name: Replace canonical
working-directory: ./docsite/build
# https://stackoverflow.com/a/74211242/1469797
run: grep '<link data-rh="true" rel="canonical" href="https://foxxmd.github.io' . -lr | xargs sed -i 's/<link data-rh="true" rel="canonical" href="https:\/\/foxxmd.github.io\/docs/<link data-rh="true" rel="canonical" href="https:\/\/docs.multi-scrobbler.app/g'
# Popular action to deploy to GitHub Pages:
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 #3.9.3
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e #4.0.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Build output to publish to the `gh-pages` branch:
@@ -55,3 +58,24 @@ jobs:
# You can swap them out with your own user credentials.
user_name: github-actions[bot]
user_email: 41898282+github-actions[bot]@users.noreply.github.com
- name: Trigger dependent build
if: ${{ vars.DOCS_REPO != ''}}
run: |
# Trigger builds in dependent repositories
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
"https://api.github.com/repos/${{ vars.DOCS_REPO }}/dispatches" \
-d "{
\"event_type\": \"release\",
\"client_payload\": {
\"commit\": \"${{ github.sha }}\"
}
}"
- name: Trigger CF Pages build
if: ${{ vars.CF_PAGES == 'true' }}
run: |
# trigger CF pages hook deployment
curl -d "" \
"${{ secrets.CF_PAGES_HOOK }}"
+8 -5
View File
@@ -51,7 +51,9 @@ jobs:
strings=("${VARS_DOCKERHUB_SLUG}" "${VARS_GHCR_SLUG}")
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "BUILD_DATE=$date" >> $GITHUB_ENV
env:
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
@@ -104,6 +106,7 @@ jobs:
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
BUILD_DATE=${{ env.BUILD_DATE }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
@@ -117,7 +120,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -136,7 +139,7 @@ jobs:
- test
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
with:
path: /tmp/digests
pattern: digests-*
@@ -162,7 +165,7 @@ jobs:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
with:
images: |
${{ vars.DOCKERHUB_SLUG }}
@@ -220,7 +223,7 @@ jobs:
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
steps:
- name: Create comment
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 #2.9.4
uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff #3.0.3
with:
recreate: true
header: "pr-release"
+7 -4
View File
@@ -66,13 +66,15 @@ jobs:
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "BUILD_DATE=$date" >> $GITHUB_ENV
env:
VARS_DOCKERHUB_SLUG: ${{ vars.DOCKERHUB_SLUG }}
VARS_GHCR_SLUG: ${{vars.GHCR_SLUG}}
- name: Check out the repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
@@ -126,6 +128,7 @@ jobs:
with:
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
BUILD_DATE=${{ env.BUILD_DATE }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
@@ -140,7 +143,7 @@ jobs:
env:
VARS_DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 #4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #7.0.0
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -159,7 +162,7 @@ jobs:
- test
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #8.0.1
with:
path: /tmp/digests
pattern: digests-*
@@ -185,7 +188,7 @@ jobs:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 #5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf #6.0.0
with:
images: |
${{ vars.DOCKERHUB_SLUG }}
+3 -3
View File
@@ -6,7 +6,7 @@ on:
node-version:
description: "Node version"
required: false
default: '20.x'
default: '24.x'
type: string
ref:
description: "Ref to checkout"
@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4.3.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #4.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
+1
View File
@@ -151,3 +151,4 @@ tmp-*
*storybook.log
storybook-static
lib
+22 -4
View File
@@ -65,7 +65,8 @@ FROM base AS build
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
COPY --chown=abc:abc docsite/patches ./docsite/patches
COPY --chown=abc:abc docsite/package*.json docsite/tsconfig* ./docsite/
# for debugging, so the build fails faster when timing out (arm64)
#RUN npm config set fetch-retries 1 && \
@@ -92,18 +93,21 @@ RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
FROM base AS app
LABEL org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler"
COPY --chown=abc:abc *.json *.js *.ts index.html ./
COPY --chown=abc:abc patches ./patches
# frontend build from vite/esbuild
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
# backend
COPY --from=build --chown=abc:abc /app/src/backend /app/src/backend
COPY --from=build --chown=abc:abc /app/src/core /app/src/core
# docusaurus docs
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
ENV COLORED_STD=true
# https://stackoverflow.com/a/63640896/1469797
ARG APP_BUILD_VERSION
@@ -114,8 +118,22 @@ RUN npm ci --omit=dev --no-audit \
&& npm uninstall ts-json-schema-generator \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
&& npx @usex/prune-mod -w \
&& rm -rf /root/.cache
ARG BUILD_DATE=0
LABEL org.opencontainers.image.version="$APP_BUILD_VERSION" \
org.opencontainers.image.source="https://github.com/FoxxMD/multi-scrobbler" \
org.opencontainers.image.documentation="https://docs.multi-scrobbler.app" \
org.opencontainers.image.description="Scrobble from multiple sources to multiple clients" \
org.opencontainers.image.title="Multi-Scrobbler" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.authors="FoxxMD" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.url="https://docs.multi-scrobbler.app" \
maintainer="FoxxMD" \
build_version="$APP_BUILD_VERSION"
ARG webPort=9078
ENV PORT=$webPort
+54 -54
View File
@@ -3,7 +3,7 @@
[![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/)
[![Docs](https://img.shields.io/badge/Read%20The%20Docs-1082c2)](https://docs.multi-scrobbler.app/)
<img src="/assets/icon.png" align="right"
@@ -11,56 +11,56 @@ alt="multi-scrobbler logo" width="180" height="180">
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
* Supports scrobbling from many [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources)
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/azuracast)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/configuration/sources/deezer)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/google-cast)
* [Icecast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/icecast)
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jellyfin)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/configuration/sources/jriver)
* [Kodi](https://foxxmd.github.io/multi-scrobbler/configuration/sources/kodi)
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/sources/koito-source)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-endpoint)
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/librefm-source)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-source)
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-endpoint)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/sources/maloja)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mopidy)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpd)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/configuration/sources/mpris)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/configuration/sources/musikcube)
* [Music Assistant](https://foxxmd.github.io/multi-scrobbler/configuration/sources/listenbrainz-endpoint#music-assistant)
* [Plex](https://foxxmd.github.io/multi-scrobbler/configuration/sources/plex)
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/sources/rocksky-source)
* [Sonos](https://foxxmd.github.io/multi-scrobbler/configuration/sources/sonos)
* [Spotify](https://foxxmd.github.io/multi-scrobbler/configuration/sources/spotify)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/sources/tealfm-source)
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/configuration/sources/webscrobbler)
* [VLC](https://foxxmd.github.io/multi-scrobbler/configuration/sources/vlc)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/configuration/sources/yamaha-musiccast)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients)
* [Discord](https://foxxmd.github.io/multi-scrobbler/configuration/clients/discord) (Now Playing)
* [Koito](https://foxxmd.github.io/multi-scrobbler/configuration/clients/koito)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/lastfm)
* [Libre.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/librefm)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/configuration/clients/listenbrainz)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/configuration/clients/maloja)
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/configuration/clients/rocksky)
* [teal.fm](https://foxxmd.github.io/multi-scrobbler/configuration/clients/tealfm)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/configuration#webhook-configurations), [healthcheck endpoints](https://foxxmd.github.io/multi-scrobbler/configuration#health-endpoint), or [prometheus metrics](https://foxxmd.github.io/multi-scrobbler/configuration/#prometheus).
* Supports [Now Playing](https://foxxmd.github.io/multi-scrobbler/configuration/clients#now-playing) for scrobble Clients
* Supports scrobbling from many [**Sources**](https://docs.multi-scrobbler.app/configuration/sources)
* [Azuracast](https://docs.multi-scrobbler.app/configuration/sources/azuracast)
* [Deezer](https://docs.multi-scrobbler.app/configuration/sources/deezer)
* [Google Cast (Chromecast)](https://docs.multi-scrobbler.app/configuration/sources/google-cast)
* [Icecast](https://docs.multi-scrobbler.app/configuration/sources/icecast)
* [Jellyfin](https://docs.multi-scrobbler.app/configuration/sources/jellyfin)
* [JRiver](https://docs.multi-scrobbler.app/configuration/sources/jriver)
* [Kodi](https://docs.multi-scrobbler.app/configuration/sources/kodi)
* [Koito](https://docs.multi-scrobbler.app/configuration/sources/koito-source)
* [Last.fm](https://docs.multi-scrobbler.app/configuration/sources/lastfm-source)
* [Last.fm (Endpoint)](https://docs.multi-scrobbler.app/configuration/sources/lastfm-endpoint)
* [Libre.fm](https://docs.multi-scrobbler.app/configuration/sources/librefm-source)
* [ListenBrainz](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-source)
* [ListenBrainz (Endpoint)](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-endpoint)
* [Maloja](https://docs.multi-scrobbler.app/configuration/sources/maloja)
* [Mopidy](https://docs.multi-scrobbler.app/configuration/sources/mopidy)
* [MPD (Music Player Daemon)](https://docs.multi-scrobbler.app/configuration/sources/mpd)
* [MPRIS (Linux Desktop)](https://docs.multi-scrobbler.app/configuration/sources/mpris)
* [Musikcube](https://docs.multi-scrobbler.app/configuration/sources/musikcube)
* [Music Assistant](https://docs.multi-scrobbler.app/configuration/sources/listenbrainz-endpoint#music-assistant)
* [Plex](https://docs.multi-scrobbler.app/configuration/sources/plex)
* [Rocksky](https://docs.multi-scrobbler.app/configuration/sources/rocksky-source)
* [Sonos](https://docs.multi-scrobbler.app/configuration/sources/sonos)
* [Spotify](https://docs.multi-scrobbler.app/configuration/sources/spotify)
* [Subsonic-compatible APIs](https://docs.multi-scrobbler.app/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [teal.fm](https://docs.multi-scrobbler.app/configuration/sources/tealfm-source)
* [WebScrobbler](https://docs.multi-scrobbler.app/configuration/sources/webscrobbler)
* [VLC](https://docs.multi-scrobbler.app/configuration/sources/vlc)
* [Yamaha MusicCast](https://docs.multi-scrobbler.app/configuration/sources/yamaha-musiccast)
* [Youtube Music](https://docs.multi-scrobbler.app/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](https://docs.multi-scrobbler.app/configuration/clients)
* [Discord](https://docs.multi-scrobbler.app/configuration/clients/discord) (Now Playing)
* [Koito](https://docs.multi-scrobbler.app/configuration/clients/koito)
* [Last.fm](https://docs.multi-scrobbler.app/configuration/clients/lastfm)
* [Libre.fm](https://docs.multi-scrobbler.app/configuration/clients/librefm)
* [ListenBrainz](https://docs.multi-scrobbler.app/configuration/clients/listenbrainz)
* [Maloja](https://docs.multi-scrobbler.app/configuration/clients/maloja)
* [Rocksky](https://docs.multi-scrobbler.app/configuration/clients/rocksky)
* [teal.fm](https://docs.multi-scrobbler.app/configuration/clients/tealfm)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://docs.multi-scrobbler.app/configuration#webhook-configurations), [healthcheck endpoints](https://docs.multi-scrobbler.app/configuration#health-endpoint), or [prometheus metrics](https://docs.multi-scrobbler.app/configuration/#prometheus).
* Supports [Now Playing](https://docs.multi-scrobbler.app/configuration/clients#now-playing) for scrobble Clients
* 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](ttps://foxxmd.github.io/multi-scrobbler/configuration#configuration-types)
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/configuration/transforms)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/installation#nodejs)
* Modify data before scrobbling with [regular expression or search patterns](https://docs.multi-scrobbler.app/configuration/transforms)
* Install using [Docker images for x86/ARM](https://docs.multi-scrobbler.app/installation#docker) or [locally with NodeJS](https://docs.multi-scrobbler.app/installation#nodejs)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler//quickstart)
[**Quick Start Guide**](https://docs.multi-scrobbler.app//quickstart)
<img src="/docsite/static/img/status-ui.png" width="800">
@@ -73,15 +73,15 @@ A dockerized app that monitors your music listening activity from *everywhere* a
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources/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.
Yes! You can use [Last.fm as a **Source**](https://docs.multi-scrobbler.app/configuration/sources/lastfm-source) or [Listenbrainz as a **Source**](https://docs.multi-scrobbler.app/configuration/sources/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?
You set up [configurations](https://foxxmd.github.io/multi-scrobbler/configuration) for one or more [**Sources**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) and one or more [**Clients**](https://foxxmd.github.io/multi-scrobbler/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
You set up [configurations](https://docs.multi-scrobbler.app/configuration) for one or more [**Sources**](https://docs.multi-scrobbler.app/configuration/sources) and one or more [**Clients**](https://docs.multi-scrobbler.app/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
### Source
A [**Source**](https://foxxmd.github.io/multi-scrobbler/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
A [**Source**](https://docs.multi-scrobbler.app/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
Source configurations consist of:
@@ -91,7 +91,7 @@ Source configurations consist of:
### Client
A [**Client**](https://foxxmd.github.io/multi-scrobbler/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
A [**Client**](https://docs.multi-scrobbler.app/configuration/clients) is an application that stores the historical information about what music you have played (scrobbles). Examples are **Koito, Last.fm, Listenbrainz**...
Client configurations consist of:
@@ -100,15 +100,15 @@ Client configurations consist of:
## Quick Start
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/quickstart)
[See the **Quick Start Guide**](https://docs.multi-scrobbler.app/quickstart)
## Installation
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/installation)
[See the **Installation** documentation](https://docs.multi-scrobbler.app/installation)
## Configuration
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/configuration)
[See the **Configuration** documentation](https://docs.multi-scrobbler.app/configuration)
## Usage
@@ -123,11 +123,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](https://foxxmd.github.io/multi-scrobbler/FAQ) before creating an issue!
Having issues with connections or configuration? Check the [FAQ](https://docs.multi-scrobbler.app/FAQ) before creating an issue!
## Development
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/development/dev-common)
[Detailed architecture and development guides for Sources/Clients](https://docs.multi-scrobbler.app/development/dev-common)
## License
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "Yandex Music",
"type": "ymbridge",
"data": {
"url": "http://192.168.1.161:9980",
"apiKey": "change-me",
"interval": 3
}
}
]
+2
View File
@@ -4,6 +4,8 @@ services:
multi-scrobbler:
build:
context: .
args:
BUILD_DATE: "${BUILD_DATE}"
env_file:
- path: ./.env
required: false
+7
View File
@@ -1,6 +1,7 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
title: FAQ
---
## Connection Issues
@@ -113,6 +114,12 @@ It means the JSON in your configuration file is not valid. Copy and paste your c
## Scrobbling Issues
### How do I prevent duplicate scrobbles?
Multi-scrobbler has a [robust duplicate scrobble detection system](/configuration/duplicates#how-duplicates-are-detected) that should handle this for you in the majority of usecases. If you have a [simple setup without any circular dependencies](/configuration/duplicates#valid-configs) then you are already protected against duplicates.
If your scenario is more complex or you have a service(s) acting as **both** a **Client** and **Source** you should refer to the [**Duplicate Scrobble Guidance**](/configuration/duplicates#valid-configs) page to determine if you can avoid a configuration that may result in duplicate scrobbles.
### 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.
+3 -53
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Scrobble Clients
toc_max_heading_level: 4
---
@@ -33,60 +34,9 @@ For each Play, MS fetches (cached) scrobbles from the Client in a time range inc
* Temporal closeness of the Play's timestamp to the existing scrobble's timestamp
* Whether MS detected the Play as a repeat (applicable to Sources that report realtime Player data)
Detailed scoring breakdowns against each existing scrobble are logged at the `TRACE` level.
Detailed scoring breakdowns against each existing scrobble are logged at the `TRACE` logging level or in the [debug data](/help#copy-play-debug-data) for a scrobble.
<details>
<summary>Detailed Explanation</summary>
A **candidate** (to be scrobbled) Play is first transformed using the configured [`compare.candidate` Hook](/configuration/transforms/#hook), if any exists.
Next, MS checks (up to) the last 100 scrobbles *in-memory* scrobbles that it has made. These are *not* from the Client but the actual scrobbles MS made while it has been running. The data from these scrobbles is much richer than what is usually parsed from the Client which makes it easier to detect duplicates from.
If no in-memory scrobble matches then MS starts comparing the candidate against historical scrobbles fetched from an inclusive time range of the candidate's timestamp.
<h3>Matching Title/Artist/Album</h3>
For these string-based values MS uses an [token-order-invariant](https://github.com/FoxxMD/multi-scrobbler/blob/0dfa4c7aad6df98e13aee6d395827665c2414adb/src/backend/utils/StringUtils.ts#L299) method that scores string similarity based on a (token count) weighted average of two [similarity](https://foxxmd.github.io/string-sameness/#md:strategies) algorithms, [Levenshtien Distance](https://en.wikipedia.org/wiki/Levenshtein_distance) and [Dice's Coefficient](https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient). The weighted average ensures that very long strings (Track titles) are scored with a confidence proportional to their length.
<h3>Matching Timestamp</h3>
Timestamps are scored on how temporally close they are. There are four possible scores with decreasing value:
* **Exact** - Timestamps are within 1 second of each other
* **Close** - Timestamps are within `threshold` seconds of each other
* This is determined by the smallest update interval of Source
* Some Sources (subsonic) only update every 60 seconds so this is the smallest "close" value possible. Most are 10 seconds. Signified by `(Needed <10s)` in the breakdown example below.
* **Fuzzy** - One timestamp is within `threshold` seconds of the *end* of the other timestamp
* Sources can set the scrobble timestamp at different times. Some do it when the track is started listening to, some when it ends, some when the *player* stops.
* Where possible, MS knows and keeps track of when this timestamp *should* be, for each Source. If it's not possible then Fuzzy may be allowed.
* **None** - There is no correlation between timestamps
<h3>Scoring and Breakdows</h3>
A candidate Play must score >= 1 to be detected as a duplicate of an existing scrobble.
Each score and a breakdown of the scores for its individual components can be see at the `TRACE` logging level or in the [debug data](/help#copy-play-debug-data) for a scrobble. An example:
```
* Artist: 0.06 * 0.3 = 0.02
* Title: 0.04 * 0.4 = 0.02
* Time: (Exact) 1 * 0.5 = 0.50
* Existing: 19:13:33-04:00 - Candidate: 19:13:33-04:00
* Temporal Sameness: Exact
* Play Diff: 0s (Needed <10s)
* Range Comparison N/A
Score 0.54 => No Match
```
In each component equation, the first number is the similarity (or temporal closeness).
* 0 = no correlation
* 1 = exactly the same
The second number is the *weight* of that component in the final score.
</details>
See the [**Duplicate Scrobble Guidance**](/configuration/duplicates#how-duplicates-are-detected) page to learn how duplicates are detected and how you can ensure your configuration avoids possible duplicates.
### Dead Scrobbles
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Discord
description: Discord Client Overview
toc_max_heading_level: 4
---
+2 -2
View File
@@ -45,7 +45,7 @@ Using the URL path `/apis/listenbrainz` [Koito docs describe](https://koito.io/g
:::tip
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLOWED_HOSTS` set correctly in your Koito environment/container. Ensure it is set to the IP address or domain name that you use to access Koito with e.g. `koito.mydomain.com`.
:::
@@ -57,4 +57,4 @@ Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito
| `KOITO_TOKEN` | Yes | | API Key from your Koito Account |
| `KOITO_USER` | Yes | | Your Koito username |
| `KOITO_URL` | No | | The base URL for the Koito server |
</Config>
</Config>
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Configuration
toc_max_heading_level: 3
---
import Tabs from '@theme/Tabs';
+267
View File
@@ -0,0 +1,267 @@
---
sidebar_position: 4
title: Duplicate Scrobble Guidance
description: Common Scenarios and Solutions for Duplicate Scrobbles
---
Multi-scrobbler's flexibility in configuration allows you to build some complex pipelines for scrobbling but it can also be a footgun for duplicating scrobbles.
## How Duplicates Are Detected
Multi-scrobbler uses multiple techniques for evaluating the **similarity** of text in scrobbles against existing data.
These techniques are used on all text from a scrobble (title, artist, album...):
<details>
<summary>Character and string normalization</summary>
Symbols and multiple whitespaces are removed. All characters are converted to lowercase. Non ascii characters are converted to their ascii counterparts.
```
this string! is the. same => this string is the same
this string is the same => this string is the same
ThIs STRING iS THe SAMe => this string is the same
Dina Ögon => Dina Ogon
Nanã => Nana
```
</details>
<details>
<summary>Word-order invariant text similarity</summary>
Strings with multiple words (tokens) are re-arranged so that tokens are ordered to be as close to other, similar tokens are possible:
Example 1
```
String A: there change is => there change is
String B: there is change => there change is
```
Example 2
```
String A: you, doin hwat are => you doin hwat are
String B: what are you doing => you doing what are
```
Then, the strings are independently compared using [Levenshtein Distance](https://en.wikipedia.org/wiki/Levenshtein_distance) and [Dice's Coefficient](https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient). The algorithm that gives the highest score for similarity is used so that matching is aggressive.
</details>
For **Artists**, Multi-scrobbler attempts to extract unique Artists from common patterns in **single-string** artist values and track **titles**.
<details>
<summary>Matching Common Artist Patterns</summary>
Common joiners and patterns are detected in artist/track strings and the artists are extracted. Example of common joiners are: `ft.` `feat` `vs.` etc...
Example 1
```
Melendi, Ryan Lewis, The Righteous Brothers (featuring Joan Jett & The Blackhearts, Robin Schulz)
becomes
Melendi, Ryan Lewis, The Righteous Brothers, Joan Jett, The Blackhearts, Robin Schulz
```
Example 2
```
Childish Gambino - 12.38 (feat. 21 Savage, Ink & Kadhja Bonet)
becomes
Childish Gambino, 21 Savage, Ink, Kadhja Bonet - 12.38
```
:::note
* MS uses "smart" heuristics to prevent artists with actual punctuation (`Tyler, The Creator`) from accidentally being separated
* The extraction examples above are done **only for matching** other scrobble data (which is also parsed like the above), the resulting scrobble data is never transformed unless the user explicitly configures it via a [native transform](/configuration/transforms/native)
:::
</details>
The **timestamps** for Scrobbles are compared at different levels of granularity based on where they originate from.
<details>
<summary>Temporally Comparing Scrobbles</summary>
Timestamp closeness for two scrobbles can be classified in one of these states:
**Exact**
Timestamps differ by 1 seconds or less.
**Close**
Timestamps differ by less than the known granularity of the Source they are from. For most Sources (and all Clients) this is 10 seconds or less. For some sources (subsonic) the granularity is 60 seconds because they only report "now playing" changes every 60 seconds.
**During**
If the Play was monitoring in real-time by Multi-scrobbler then it knows the time range during which the Play was listened to. If an existing scrobble with similar artist/track/album has a timestamp that occurred *during* the known listening time then it is classified as during.
**Fuzzy**
If the timestamp occurs within a few seconds +/- of (existing scrobble + track duration) then, potentially, the Source timestamped the existing scrobble at the *end* of when the user listened to it, rather than when the user *started* listening to it. This is common for Spotify plays.
___
Each of the above classification results in a different score that is *roughly* descending from the order given above. Exact score is based on the Souce of the scrobble and if it was monitored in real-time by Multi-Scrobbler.
</details>
Finally, all of the above comparison/normalization techniques are combined when comparing a "new" Play/Scrobble against existing data. Each comparison is given a weighted score. If the score is above the match threshold then it is marked as a duplicated.
<details>
<summary>Match Scoring Example</summary>
```
Frédéric Chopin - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:51-04:00 <-- New scrobble
Frédéric Chopin / Krystian Zimerman - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:48-04:00 <-- Existing scrobble from client
```
After comparing against all existing scrobbles from the client, in a timerange around the new scrobble, MS finds a match and logs the match along with the score breakdown of the consistuent parts:
```
Source:
Frédéric Chopin - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:51-04:00 (S)
Closest Scrobble:
Frédéric Chopin / Krystian Zimerman - Ballade No. 4 in F Minor, Op. 52 @ 2026-05-04T13:01:48-04:00 (S)
Artist: (0.30 + Whole Match Bonus 0.52) * (0.3 + Whole Match Bonus 0.05) = 0.29
Title: 1.00 * 0.4 = 0.40
Time: (Close) 1 * 0.5 = 0.50
Time Detail => Existing: 13:01:48-04:00 - Candidate: 13:01:51-04:00 | Temporal Sameness: Close | Play Diff: 3s (Needed <10s) | Range Comparison N/A
Score 1.19 => Matched!
```
</details>
All of the above techniques and scoring gives Multi-scrobbler a *very good chance* of detecting duplicate scrobbles, regardless of where they were originally from. Of Multi-Scrobbler's [400+ unit tests](https://github.com/FoxxMD/multi-scrobbler/tree/master/src/backend/tests), 70+ are related to testing scoring, artist extraction, and [fuzzing](https://en.wikipedia.org/wiki/Fuzzing) string normalization/simiarlity.
The dupe detection is so good that it is virtually gauranteed for [most configurations](#valid-configs) and is used for *backlogging* missed scrobbles for Sources that support fetching history.
**However**, it's not possible to gaurantee this for *all* possible Multi-scrobbler configurations. See the below sections to learn more.
## Configurations Where Duplicates DO NOT Occur {#valid-configs}
* Any Source/Client configuration where there is only one Source and one Client and they are not the same service/account
* Any Source/Client configuration where scrobbles only flow in **one direction**
* IE you *do not* have a Last.fm **Client** Account A that is *also* a Last.fm **Source** Account A
:::tip
**The majority of simple Multi-scrobbler configurations fall into these categories. If your workflow is straightforward you do not need to worry about duplicates at all.**
:::
Multi-scrobbler can handle recieving the same data from a Source multiple times. This is fine because the data is *always the same* and *always unique* to each Source. Since MS scrobbled the data to your clients you can be pretty confident that the *same* cleanup/matching process for that data will result in an indentical/similar match to the existing scrobble.
IE Same inputs in -> same inputs out.
**Even this scenario with multiple, mixed Sources is OK:**
<img className='image-center' src={require('/img/diagrams/unidirectional-flow.png').default}/>
Because the scrobbles coming from each **Source** are **independent** of each other. And they end up in Clients that are not *also* used as Sources.
:::tip
This is a common scenario when scrobbles are *backlogged* from a Source IE on startup MS fetches the last 50 Plays from Spotify/Lastfm/Listenbrainz.
:::
## Configurations Where Duplicates CAN Occur {#invalid-configs}
### Upstream Scrobbles Sent to Multiple Sources
In this scenario your device using Spotify is configured in MS as a Spotify **Source** but it **also** is scrobbling to Last.fm (via Pano Scrobbler, as an example).
<img className='image-center' src={require('/img/diagrams/dupe sources.png').default}/>
❌ **Time Race Condition**: Potentially, the scrobbles arrive in Multi-scrobbler at exactly the same time. There are no existing scrobbles in Koito to check against, yet. So Multi-scrobbler scrobbles both to Koito, resulting in duplicate scrobbles.
❌ **Different Scrobble Data**: Pano Scrobbler, or Last.fm, [may modify the scrobble data.](#lastfm) When MS recieves the scrobble from Last.fm it may be *just different enough* that it does not get detected as a duplicate.
### Sources and Clients are Dependent {#dependent-source-client}
In this scenario, you have a **Client** that is also a **Source** and that Source is feeding scrobbles to a different Client that receives scrobbles from everywhere (or has some overlap with the original Client).
<img className='image-center' src={require('/img/diagrams/duped client source.png').default}/>
❌ **Different Scrobble Data**: [Last.fm may modify the scrobble data.](#lastfm) When MS recieves the scrobble from Last.fm it may be *just different enough* that it does not get detected as a duplicate.
❌ **Consistency Issues**: If the Koito Client has a backed up queue or is not currently scrobbling (imagine it was Last.fm or Listenbrainz, and they had upstream availability issues), then there is no existing scrobble yet and nothing to match against.
## Configuration Solutions to Avoid Duplicates
### Independent Sources
Use a configuration mentioned in [Configurations Where Duplicates DO NOT Occur](#valid-configs): make sure all of your Sources are **independent**. The activity/scrobbles from each Source do not have any upstream duplicates of activity.
<img className='image-center' src={require('/img/diagrams/independent source.png').default}/>
### Alternative Sources
Imagine you have a scenario where a device/player can **only** scrobble to Last.fm. But you want to use MS for all other devices/sources and still have all your scrobbles end up in your "main" Last.fm account for social reasons/stats.
Instead of using the same Last.fm account for all activity and potentially having [dependent client/sources](#dependent-source-client), create an *additional* last.fm account that is used *only for the device that needs it*. This ensures the scrobbles from that device are isolated from the main account and do not prevent duplicates, so you can continue to use the main account as an MS Client:
<img className='image-center' src={require('/img/diagrams/alt source.png').default}/>
### Mirror-Only Clients or Limiting Scrobble Destination {#isolated-sources}
In Multi-scrobbler, by default, all Sources scrobble to all Clients. Using [Limit Scrobble Destinations](/configuration/sources/#limiting-scrobble-destination) configuration can ensure that your dependent Source/Clients only scrobble to destinations where the scrobbles won't be duplicated. And/or limit other Sources to *not* scrobble to dependent Sources/Clients.
Here is an example where all Sources only scrobble to a *dependent* Last.fm Client, and then the *dependent* Last.fm Source only scrobbles to a "mirror-only" (backup) Koito Client:
<img className='image-center' src={require('/img/diagrams/mirror source.png').default}/>
### Normalize All Scrobbles
If using one of the above solutions is not possible you can also try to *aggressively normalize all scrobble data* using [the Musicbrainz transform stage](/configuration/transforms/musicbrainz).
In this scenario you still have [dependent Source/Clients](#dependent-source-client) that are [not isolated](#isolated-sources) but you can *potentially* improve duplicate detection by first using a transform stage to match scrobbles against the Musibrainz database and normalize their track/artist/album data.
:::warning
**There is no gaurantee this will work** but it is better than nothing. You should attempt to re-configure MS to use any of the other solutions before using this one.
:::
<img className='image-center' src={require('/img/diagrams/normalized dupes.png').default}/>
## The Last.fm Problem {#lastfm}
Last.fm will happily accept almost any scrobble data you throw at it.
It can then decide, opaquely, to match or modify that scrobble to some more "official" data it has server-side. This could be modifying the artists, title, or album sent in the scrobble. It can also mean modifying the [`mbid` (unique identifier)](https://musicbrainz.org/doc/MusicBrainz_Identifier) or adding what it _thinks_ is the correct one.
There is nothing in the scrobble history API response from Last.fm that identifies it has done any of this. And [it's not super great at being right about these modifications](https://github.com/FoxxMD/multi-scrobbler/issues/333#issuecomment-3145161339).
What this means for you, the user, is that this is very plausible scenario:
* You scrobble `Artist Foo, Artist Bar - My Cool Track` to Last.fm via Pano Scrobbler (outside of Multi-scrobbler)
* Last.fm modifies the data to `The Artist Foo - My Cool Track ft. Artist Bar (Album Version)`
* You _also_ scrobble `Artist Foo, Artist Bar - My Cool Track` to Multi-scrobbler via Pano LFM endpoint **Source**
* MS forwards this to Koito Client as-is
* Soon after, MS polls your Last.fm **Source** for new scrobbles and finds `The Artist Foo - My Cool Track ft. Artist Bar (Album Version)`
* It queries Koito to see if this was scrobbled
* It sees that a scrobble did happen at almost the same time BUT the track titles and artist strings are _just different enough_ that it decides it's not a duplicate
* MS forwards the scrobble to Koito and you now have a duplicate
This behavior is unique to Last.fm. It's truly the **worst** out of all the scrobbler services in terms of preserving your data as-is, or providing a unqiue identifier for your data.
+2 -1
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 3
sidebar_position: 5
title: Kitchen Sink
description: Kitchen Sink Example
---
# Example Complex Scenario
@@ -2,6 +2,7 @@
title: Icecast
toc_min_heading_level: 2
toc_max_heading_level: 5
description: Icecast Source Configuration
---
import Tabs from '@theme/Tabs';
@@ -1,5 +1,6 @@
---
title: Koito
description: Koito (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Last.fm
description: Last.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: Libre.fm
description: Libre.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -109,9 +109,10 @@ To troubleshoot any errors, and assuming you are using Home Assistant, view the
## Configuration
<Config config="ListenbrainzEndpointSourceConfig" fileContent={ListenbrainzEndpointConfig} name="endpointlz">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
| `LZE_USERNAME` | No | | (Optional) A fake username that will be returned for token validation and on now-playing responses. If none is provided the Source's name is used instead. |
</Config>
@@ -1,5 +1,6 @@
---
title: Listenbrainz
description: Listenbrainz (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -18,6 +18,8 @@ bind_to_address "any" # or a specific ipv4/v6 address
port "6600"
```
Multi-scrobbler requires only the [`read`](https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords) permission in order to monitor your activity.
## Configuration
<Config config="MPDSourceConfig" fileContent={MPDConfig} name="mpd">
@@ -1,5 +1,6 @@
---
title: Rocksky
description: Rocksky (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Overview
description: Scrobble Sources
toc_max_heading_level: 4
---
@@ -40,6 +41,7 @@ A **Source** is a data source that contains information about tracks you are pla
| [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
| [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
| [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ |
| [Yandex Music](/configuration/sources/yandex-music) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | | ✅ | ✅ | ❌ |
| [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
## Features
@@ -1,5 +1,6 @@
---
title: Teal.fm
description: Teal.fm (Source)
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -0,0 +1,79 @@
---
title: Yandex Music
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import JsonConfig from '!!raw-loader!@site/../config/ymbridge.json.example';
Monitor your [**Yandex Music**](https://music.yandex.com) listening activity in real-time.
This Source requires a third party docker container to communicate with Yandex Music, [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge).
<details>
<summary>Example Docker Compose with `yandex-music-bridge`</summary>
```yaml title="~/msData/docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT
# ...your other sources/clients here
# add for yandex music
// highlight-start
- YMBRIDGE_URL=http://yandex-music-bridge:9980
- YMBRIDGE_API_KEY=change-me
// highlight-end
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
// highlight-start
yandex-music-bridge:
image: ghcr.io/druidblack/yandex-music-bridge:latest
environment:
- TZ=Europe/Moscow
- YM_TOKEN=AgAAAAACO3_345345
- YM_API_KEY=change-me
- YM_PORT=9980
- YM_LANGUAGE=ru
- YM_ENABLE_YNISON=true
- YM_PUSH_TTL=45
- YM_QUEUE_CACHE_TTL=15
- YM_LOG_LEVEL=INFO
ports:
- "9980:9980"
restart: unless-stopped
// highlight-end
```
You will need to acquire your own token for `YM_TOKEN` used in [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) using the instructions provided there.
</details>
<DetailsAdmo type="tip" summary="Need Help?">
If your issue is specifically related to [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) (errors in the container, usage instructions, general Yandex setup like token etc...) please open an issue on **that repository** instead of Multi-Scrobbler's.
If you have issues specifically with the MS Source please mention [**Druidblack**](https://github.com/Druidblack) (`@Druidblack`) when opening an [issue](https://github.com/FoxxMD/multi-scrobbler/issues/new/choose) or [discussion](https://github.com/FoxxMD/multi-scrobbler/discussions/new/choose). Yandex Music is [not available](https://en.wikipedia.org/wiki/Yandex_Music) in the country of the main Multi-Scrobbler developer (FoxxMD) so they can only help troubleshooting general MS issues related to Yandex Music.
</DetailsAdmo>
## Configuration
<Config config="YandexMusicBridgeSourceConfig" fileContent={JsonConfig} name="ymbridge">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | :------------------------------------------------------------------------ |
| `YMBRIDGE_URL` | Yes | | `http://URL:PORT` for the `yandex-music-bridge` container |
| `YMBRIDGE_API_KEY` | No | | The same key used for `YM_API_KEY` on the `yandex-music-bridge` container |
</Config>
@@ -1,5 +1,6 @@
---
title: Musicbrainz Stage
description: Enhancing Scrobbles with Musicbrainz
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -139,7 +140,7 @@ Example:
### Rules and Hooks
[Add your Stage](https://foxxmd.github.io/multi-scrobbler/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook):
[Add your Stage](/configuration/transforms/#stage) to a Source or Client by specifying it in a [Hook](/configuration/transforms/#hook):
```json5 title="subsonic.json"
[
@@ -1,5 +1,6 @@
---
title: Native Stage
description: Enhancing Scrobbles with Built-in Parsing
toc_min_heading_level: 2
toc_max_heading_level: 5
---
@@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 3
title: Enhancing Scrobbles
toc_max_heading_level: 5
---
@@ -1,5 +1,6 @@
---
title: User Stage
description: Enhancing Scrobbles with Regex Parsing
toc_min_heading_level: 2
toc_max_heading_level: 5
---
-8
View File
@@ -1,8 +0,0 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 3
title: Client Development/Tutorial
---
To do...
+2 -2
View File
@@ -3,7 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 1
title: Common Development
description: Start here for MS development
description: Common Development Knowledge
---
# Development
@@ -177,7 +177,7 @@ For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](htt
## Creating Clients and Sources
* [Source Development and Tutorial](dev-source.md)
* [Client Development and Tutorial](dev-client.md)
* Client Development and Tutorial (TBD)
## Profiling
+1
View File
@@ -3,6 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 2
title: Source Development/Tutorial
description: Developing a new Source
---
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).
+1 -1
View File
@@ -3,7 +3,7 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
sidebar_position: 4
title: Flatpak
description: Building Flatpak App locally
description: Building Flatpak App
---
## Install Requirements
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: 'Help'
title: Help
---
## Getting Useful Data
+9
View File
@@ -41,6 +41,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [WebScrobbler](/configuration/sources/webscrobbler)
* [VLC](/configuration/sources/vlc)
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
* [Yandex Music](/configuration/sources/yandex-music)
* [Youtube Music](/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](/configuration/clients)
* [Discord](/configuration/clients/discord) (Now Playing)
@@ -83,6 +84,14 @@ Use the **Last.fm** tab in the [Quickstart Sources](/quickstart/#setup-sources)
You set up [configurations](/configuration) for one or more [**Sources**](/configuration/sources) and one or more [**Clients**](/configuration/clients). MS monitors all of your configured **Sources**. When new tracks are played by a Source MS grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to forward to.
<details>
<summary>Diagram</summary>
<img className='image-center' src={require('/img/diagrams/ms-visual.png').default}/>
</details>
### Source
A [**Source**](/configuration/sources) is a data source that contains information about music you are playing or have listened to, such as: a desktop player, web music player, or cloud music service. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Navidrome**, etc...
+4 -3
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 1
title: 'Overview'
title: Overview
description: Installation
---
import Tabs from '@theme/Tabs';
@@ -220,10 +221,10 @@ After installation see [service.md](service.md) to configure multi-scrobbler to
### Nodejs
Clone this repository somewhere and then install from the working directory
Clone this repository somewhere using the latest [git tag](/updating#git) and then install from the working directory.
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
git clone --branch <LATEST_RELEASE_TAG> https://github.com/FoxxMD/multi-scrobbler.git
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
+2 -1
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: 'As a Service'
title: As a Service
description: Local Install as a Service
---
If you have multi-scrobbler installed [locally](installation.mdx#nodejs) you can enable it to run as a background service when you login.
+4 -4
View File
@@ -1,5 +1,5 @@
---
title: 'Quickstart'
title: Quickstart
sidebar_position: 2
---
@@ -254,9 +254,9 @@ Modify the `lastfm.json` file you created in the [Setup Sources](#setup-sources)
```
:::tip
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
For Koito versions `<=0.1.7`, you must have the environment variable `KOITO_ALLOWED_HOSTS` set correctly in your Koito environment/container. Ensure it is set to the IP address or domain name that you use to access Koito with e.g. `koito.mydomain.com`.
:::
</TabItem>
+2 -2
View File
@@ -2,7 +2,7 @@
"label": "Updating",
"position": 4,
"link": {
"type": "generated-index",
"description": "Way to update Multi-Scrobbler"
"type": "doc",
"id": "updating/updating"
}
}
+62 -21
View File
@@ -1,6 +1,7 @@
---
sidebar_position: 1
title: 'Overview'
title: Overview
description: Updating Multi-Scrobbler
---
import Tabs from '@theme/Tabs';
@@ -9,38 +10,78 @@ import CodeBlock from '@theme/CodeBlock';
## Updating
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction/consideration when updating.
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction when updating.
The majority of major/minor version releases introduce only *new* functionality but occasionally may also include breaking changes in behavior or configuration. These changes, and how to update configs accordingly, are detailed in [Github Release Notes](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading.
Any **breaking changes** will be related to [configuration](/configuration) that has been deprecated/changed, or tooling that usually only affects [Local Installations.](/installation#local-installation)
Alternatively, multi-scrobbler version can be pinned using [git tags](https://github.com/FoxxMD/multi-scrobbler/tags) or docker image tags that match release versions IE `foxxmd/multi-scrobbler:0.9.7`
These changes, and how to update configs accordingly, are detailed in [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading [minor or major versions.](#versioning)
### Docker
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
```shell
docker image pull foxxmd/multi-scrobbler:latest
docker container stop multi-scrobbler
docker container rm multi-scrobbler
docker run --name multi-scrobbler ...
```
The last command is the [same as the command used to initialy run multi-scrobbler.](../installation/installation.mdx?runType=docker#docker-usage-example)
</TabItem>
<Tabs groupId="installType" queryString>
<TabItem value="docker-compose" label="Docker Compose">
```shell
docker compose pull
docker compose up -d
```
</TabItem>
<TabItem value="local" query="Local (Nodejs)">
Assuming the [repository was used for initial installation with `git clone`](/installation#local-installation), update by pulling the latest commit:
```
git pull
```
and restart multi-scrobbler.
</TabItem>
</Tabs>
### Nodejs
## Versioning
Assuming the [repository was used for initial installation with `git clone`](../installation/installation.mdx#nodejs), update by pulling the latest commit:
Multi-scrobbler versioning uses **semver** without a major version (for now):
```
git pull
* **Minor** version bumps (`0.12.2` => `0.13.0`) **may have breaking changes** or include major new behavior
* **Patch** version bumps (`0.12.2` => `0.12.3`) may have new behavior or fix bugs, but have no breaking changes. These can be safely upgraded unattended.
Check the [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases) for breaking changes, changelogs, and any migration steps required between minor versions.
### Git
The default [`master`](https://github.com/FoxxMD/multi-scrobbler/tree/master/) branch is **not stable**. The goal is to have it be *functional* bleeding-edge development with non-functioning development isolated to pull requests and other branches.
For a stable release use the latest [git tag](https://github.com/FoxxMD/multi-scrobbler/tags) EX:
```shell
git clone --branch 0.13.0 https://github.com/FoxxMD/multi-scrobbler.git
```
and restart multi-scrobbler.
### Docker
Docker image tags use this pattern:
* `:latest` is the latest **stable** [release](https://github.com/FoxxMD/multi-scrobbler/releases)
* IE If the latest released version was `0.13.0` then `:latest` is also `0.13.0`
* `:latest` will *always* be the latest release. Before pulling a new image you should check if the version bump has breaking changes.
* `:edge` is built automatically from the [`master` branch](#git)
* Pinned semver tags are published alongside [releases](https://github.com/FoxxMD/multi-scrobbler/releases) EX `:0.12.2`
Additionally, [Pull Requests](https://github.com/FoxxMD/multi-scrobbler/pulls) may have docker images published with tags like `:pr-521`. These are not guaranteed to be stable and should only be used for testing.
## What Version Am I Using?
The current version of Multi-Scrobbler can be found in several places:
* Open the Web Dashboard, the version is displayed next the Multi-Scrobbler logo in the top left corner
* On startup the version is immediately printed to logs EX:
* `[2026-04-16 09:17:32.428 -0400] INFO : [Init] Version: 0.13.0`
* Checking the running docker container or image for the `org.opencontainers.image.version` label
When running MS from a non-pinned source (like `:edge` tag, a pull request, or git branch) the version will be shown as the **branch** name and **short commit hash** EX
```
master-1c78da79
```
:::tip
Using the above information you can verify if you have pulled and successfully recreated a docker container with a new version. If the version string does not change from the previous version running then you are still running the old container
:::
+66 -30
View File
@@ -1,8 +1,32 @@
import type * as Preset from '@docusaurus/preset-classic';
import * as Preset from '@docusaurus/preset-classic';
import type { Config } from '@docusaurus/types';
import * as themes from 'prism-react-renderer';
import * as Renderers from './lib/socialCard/ImageRenderers.js';
import path from 'node:path';
//import sidebars from './sidebars';
let baseSite: string = 'https://foxxmd.github.io';
const baseUrlEnvs = [
// To build with base URL via normal docker build with build-args/env
// or if specified, to override the subsequent set ENVs
process.env.BASE_SITE,
// To build with base URL set via netlify CI
// https://docs.netlify.com/build/configure-builds/environment-variables/#deploy-urls-and-metadata
process.env.DEPLOY_PRIME_URL,
// To build with base URL set via cloudflare pages CI
// https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables
process.env.CF_PAGES_URL
];
for(const baseUrlVal of baseUrlEnvs) {
if(baseUrlVal !== undefined && baseUrlVal !== null && baseUrlVal.trim() !== '') {
baseSite = baseUrlVal;
break;
}
}
console.log(`Base URL => ${baseSite}`);
const config: Config = {
future: {
v4: {
@@ -19,7 +43,7 @@ const config: Config = {
favicon: 'img/favicon.ico',
// Set the production url of your site here
url: 'https://foxxmd.github.io',
url: baseSite,
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: process.env.DOCS_BASE !== undefined && process.env.DOCS_BASE !== '' ? process.env.DOCS_BASE : '/docs',
@@ -31,8 +55,12 @@ const config: Config = {
trailingSlash: true,
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
}
},
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
@@ -52,8 +80,15 @@ const config: Config = {
routeBasePath: '/',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
editUrl: (params: {
version: string;
versionDocsDirPath: string;
docPath: string;
permalink: string;
locale: string;
}) => {
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
}
},
// blog: {
// showReadingTime: true,
@@ -122,6 +157,16 @@ const config: Config = {
},
]
}
],
[
'@bony_chops/docusaurus-og',
{
path: './preview-images', // relative to the build directory
imageRenderers: {
'docusaurus-plugin-content-docs': Renderers.docs,
'docusaurus-plugin-content-pages': Renderers.docs,
},
},
]
],
themeConfig:
@@ -154,7 +199,7 @@ const config: Config = {
position: 'right',
},
{
href: 'https://foxxmd.github.io/multi-scrobbler/',
href: 'https://multi-scrobbler.app',
label: 'Website',
position: 'right',
},
@@ -193,7 +238,7 @@ const config: Config = {
},
{
label: 'Website',
href: 'https://foxxmd.github.io/multi-scrobbler/',
href: 'https://multi-scrobbler.app',
},
],
},
@@ -216,28 +261,22 @@ const config: Config = {
showRunmeLink: false,
runmeLinkLabel: 'Checkout via Runme'
}
} satisfies Preset.ThemeConfig,
headTags: [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: process.env.GSITEVERIFICATION ?? 'none'
}
}
]
} satisfies Preset.ThemeConfig
};
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
const script = {
src: process.env.ANALYTICS,
defer: true
}
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
}
config.scripts.push(script)
const gverify = process.env.GSITEVERIFICATION;
if(gverify !== undefined && gverify !== null && gverify.trim() !== '') {
config.headTags = [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: gverify
}
}
]
}
if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
const script = {
src: process.env.RY_ANALYTICS,
@@ -246,9 +285,6 @@ if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
if (process.env.RY_ANALYTICS_SITEID !== undefined && process.env.RY_ANALYTICS_SITEID !== '') {
script['data-site-id'] = process.env.RY_ANALYTICS_SITEID;
}
if (process.env.RY_ANALYTICS_REPLAY !== undefined && process.env.RY_ANALYTICS_REPLAY !== '') {
script['data-session-replay'] = process.env.RY_ANALYTICS_REPLAY;
}
config.scripts.push(script)
}
+3878 -1413
View File
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -1,10 +1,10 @@
{
"name": "my-website",
"version": "0.0.0",
"name": "multi-scrobbler-docs",
"version": "0.12.1",
"private": true,
"scripts": {
"start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build",
"build": "npm run -s prebuild && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@@ -12,13 +12,16 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"postinstall": "patch-package"
"postinstall": "patch-package",
"previews:build-renderer": "tsc --project tsconfig.client.json || exit 0",
"prebuild": "npm run -s previews:build-renderer"
},
"dependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/faster": "3.8.1",
"@docusaurus/plugin-client-redirects": "^3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@bony_chops/docusaurus-og": "^1.0.3-beta.3",
"@docusaurus/core": "3.9.2",
"@docusaurus/faster": "3.9.2",
"@docusaurus/plugin-client-redirects": "^3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@easyops-cn/docusaurus-search-local": "0.51.1",
"@mdx-js/react": "^3.0.0",
"ajv": "^8.18.0",
@@ -39,10 +42,10 @@
"sass": "^1.97.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
"@docusaurus/tsconfig": "^3.6.3",
"@docusaurus/types": "^3.6.3",
"@types/react": "^18.2.29",
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"@types/react": "^19.2.4",
"typescript": "~5.6.2"
},
"browserslist": {
@@ -60,5 +63,9 @@
"engines": {
"node": ">=24.14.0",
"npm": ">=11.12.1"
},
"overrides": {
"lodash": "4.18.1",
"satori": "0.25.0"
}
}
@@ -0,0 +1,42 @@
diff --git a/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js b/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
index b308c02..e0d291a 100644
--- a/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
+++ b/node_modules/@bony_chops/docusaurus-og/lib/server/docs.plugin.js
@@ -93,7 +93,16 @@ class DocsPlugin {
await document.write();
}
};
- getHtmlPath = (doc) => doc.metadata?.permalink &&
- path.join(this.context.outDir, doc.metadata.permalink, 'index.html');
+ getHtmlPath = (doc) => {
+ if(doc.metadata?.permalink !== undefined) {
+ let relativePerma = doc.metadata.permalink;
+ if(this.context.baseUrl !== undefined && this.context.baseUrl !== '/') {
+ relativePerma = path.join('/', relativePerma.replace(this.context.baseUrl, ''));
+ }
+ const p = path.join(this.context.outDir, relativePerma, 'index.html');
+ return p;
+ }
+ return undefined;
+ }
}
exports.DocsPlugin = DocsPlugin;
diff --git a/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js b/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
index 4676f56..03f03a5 100644
--- a/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
+++ b/node_modules/@bony_chops/docusaurus-og/lib/server/pages.plugin.js
@@ -101,6 +101,13 @@ class PagesPlugin {
await document.write();
}
};
- getHtmlPath = (permalink) => path.join(this.context.outDir, permalink, 'index.html');
+ getHtmlPath = (permalink) => {
+ let relativePerma = permalink;
+ if(this.context.baseUrl !== undefined && this.context.baseUrl !== '/') {
+ relativePerma = path.join('/', relativePerma.replace(this.context.baseUrl, ''));
+ }
+ const p = path.join(this.context.outDir, relativePerma, 'index.html');
+ return p;
+ }
}
exports.PagesPlugin = PagesPlugin;
-3
View File
@@ -2,9 +2,6 @@ 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;
import json5 from 'json5';
export interface AIOProps {
@@ -2,9 +2,6 @@ import React, { Fragment, useMemo } from "react"
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
import FileExample, { FileProps } from "./FileExample";
-3
View File
@@ -1,9 +1,6 @@
import React, { Fragment, useMemo } from "react"
import CodeBlock, { Props as CodeBlockProps } from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import { useTypedLocalStorage } from "./useLocalStorage";
import ButtonGroup from "./ButtonGroup";
import EditorFetch from '@site/src/components/Schema/SchemaEditorFetch';
+6 -7
View File
@@ -6,10 +6,10 @@ import CodeBlock from '@theme/CodeBlock';
import Schema from "@site/static/schemas/aio.json";
import ConfigExample from "@site/static/configExample.json";
import './Playground.scss';
import { JsonSchemaViewer } from 'cf-json-schema-viz';
import { JSONSchema, JsonSchemaViewer } from 'cf-json-schema-viz';
import "./json-schema-viewer-styles.css";
import "./modern-json-react-styles.css";
import { ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import { Mode, ReactJsonEditor, createAjvValidator, type Content } from 'modern-react-json-editor';
import f from "ajv-formats"
import DetailsAdmo from "./AdmonitionDetails";
@@ -47,10 +47,9 @@ function PlaygroundInner(): JSX.Element {
<p>Use this to understand how to write a valid config.</p>
</DetailsAdmo>
<JsonSchemaViewer
name="Todos Model"
schema={Schema}
expanded={false}
hideTopBar={false}
schema={Schema as JSONSchema}
// expanded={false}
// hideTopBar={false}
renderRootTreeLines={true}
emptyText="No schema defined"
defaultExpandedDepth={0}
@@ -93,7 +92,7 @@ function PlaygroundInner(): JSX.Element {
validator={validator}
theme={colorMode}
content={data}
mode="text"
mode={"text" as Mode.text}
style={{height: 'initial'}}
/>
)}
@@ -1,7 +0,0 @@
import Schema from "@site/static/aio.json";
import Explorer from './Explorer';
export default function ExplorerSSR(): JSX.Element {
return <Explorer schema={Schema}/>
}
@@ -4,7 +4,7 @@ import { useColorMode } from '@docusaurus/theme-common';
const STRINGIFY_JSON = (json: unknown) => JSON.stringify(json, null, "\t");
import "../modern-json-react-styles.css";
import { JSONSchema, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import { JSONSchema, Mode, ReactJsonEditor, createAjvValidator, type Content, type Validator } from 'modern-react-json-editor';
import f from "ajv-formats"
import json5 from 'json5';
@@ -58,7 +58,7 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
<ReactJsonEditor
validator={validator}
theme={colorMode}
mode="text"
mode={"text" as Mode.text}
content={initialData}
style={{height: 'initial'}}
/>
@@ -1,50 +0,0 @@
import { useContext, createContext } from "react"
// Type I need for useRef
import type { MonacoEditorTypes } from "@theme/MonacoEditor"
export type State = {
// The full schema
// We might need to scope it with a JSON Pointer
fullSchema: unknown
// The current schema displayed (after the json pointer)
userSchema: unknown
// The current json pointer
jsonPointer: string
// Schema editor ref
schemaRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
// Data editor ref
editorRef: undefined | MonacoEditorTypes.IStandaloneCodeEditor
// Data on the data editor
// When using "Infer schema from data", it would be annoying to lose our written
data: string
// Errors in schema
schemaErrors: MonacoEditorTypes.IMarkerData[]
// Errors in data
dataErrors: MonacoEditorTypes.IMarkerData[]
}
export type Playground = {
// state
state: State
// update function
updateState: (_: Partial<State>) => void
}
export const PlaygroundContext = createContext<Playground>({
state: {
fullSchema: {},
userSchema: {},
jsonPointer: "",
data: "{}",
schemaRef: undefined,
editorRef: undefined,
schemaErrors: [],
dataErrors: [],
},
updateState: () => {},
})
export const usePlaygroundContext = () => useContext(PlaygroundContext)
export const PlaygroundContextProvider = PlaygroundContext.Provider
+7
View File
@@ -90,4 +90,11 @@ details[class^='details_'].alert--info {
--ifm-spacing-vertical: 0.5rem;
}
}
/** https://stackoverflow.com/a/79808430/1469797 */
img.image-center {
display: block;
margin-left: auto;
margin-right: auto;
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Playground(): JSX.Element {
return (
<Layout
title={`Playground`}
description="Config Playground"
description="Playground"
>
<PlaygroundComponent />
</Layout>
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
import type { DocsPageData, ImageRenderer } from '@bony_chops/docusaurus-og'
import { readFileSync } from 'fs'
import { join } from 'path'
// const logo = (<svg
// id="Layer_1"
// data-name="Layer 1"
// xmlns="http://www.w3.org/2000/svg"
// width="128"
// height="128"
// viewBox="0 0 128 128"
// fill="#c34a36"
// >
// <path className="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z" />
// <path className="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z" />
// </svg>);
const DocContent = (props: { data: DocsPageData }) => {
const { data } = props;
return (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
textAlign: 'center',
alignItems: 'flex-start',
backgroundColor: "#1b1b1d",
color: 'white',
justifyContent: 'flex-start',
flexDirection: 'column',
flexWrap: 'wrap',
padding: '3rem 3rem 0 3rem',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '30px'
}}>
<img src="https://docs.multi-scrobbler.app/img/icon.png" width={120} height={120} />
<div style={{
fontSize: 125,
//fontWeight: 600
}}>Multi-Scrobbler</div>
</div>
<div style={{
fontSize: 50,
color: "#c8c8c8",
marginTop: '1rem'
}}
>Listen everywhere, Scrobble anywhere</div>
<div style={{
marginTop: '4rem',
display: 'flex',
alignContent: 'flex-start',
justifyContent: 'flex-start',
flexDirection: 'column',
gap: '10px',
paddingLeft: '0.5em',
fontSize: 75
}}>
<div style={{
display: 'flex',
textWrap: 'balance',
textAlign: 'left'
}}>{data.metadata.frontMatter?.description ?? data.metadata.title}</div>
</div>
</div>
);
}
export const docs: ImageRenderer<DocsPageData> = (data, context) => [
<DocContent data={data} />,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: readFileSync(
// must be a STATIC (not variable) font due to a bug in satori
// https://github.com/vercel/satori/issues/162
// use static-font.sh to convert a variable font to static
join(__dirname, '../../src/socialCard/AdwaitaSans-Regular-StaticSubset.ttf'),
),
weight: 400,
style: 'normal',
},
],
},
]
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# based on https://github.com/Kikobeats/og.kikobeats.com/blob/master/static-font.sh
# 1) Freeze SF Pro to Bold (700) and make it fully static
fonttools varLib.instancer AdwaitaSans-Regular.ttf \
wght=400 \
--static \
-o AdwaitaSans-Regular-Static.ttf
# 2) Subset to only the glyphs you actually need (Satori-friendly)
fonttools subset AdwaitaSans-Regular-Static.ttf \
--text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#\$%^&*()-_=+[]{};:'\",.<>/? " \
--no-hinting \
--name-legacy \
--output-file=AdwaitaSans-Regular-StaticSubset.ttf
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
Sitemap: https://foxxmd.github.io/multi-scrobbler/sitemap.xml
Sitemap: https://docs.multi-scrobbler.app/sitemap.xml
User-agent: *
Allow: /
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"noEmit": false,
"composite": true,
"incremental": true,
"esModuleInterop": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo-client",
"rootDir": "src",
"outDir": "lib",
"module": "CommonJS",
"target": "esnext",
"jsx": "react-jsx",
"types": ["node"],
"baseUrl": "./docs",
"lib": ["DOM"]
},
"include": ["src/socialCard/ImageRenderers.tsx"]
}
+1
View File
@@ -2,6 +2,7 @@
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": ".",
"resolveJsonModule": true,
"types": ["node", "@docusaurus/module-type-aliases", "@docusaurus/theme-classic","docusaurus-plugin-sass"]
+1110 -769
View File
File diff suppressed because it is too large Load Diff
+39 -36
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.12.1",
"version": "0.13.2",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -16,7 +16,7 @@
"build:backend": "tsc -p src/backend && npm run -s schema:app",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
"docs:install": "cd docsite && npm ci --no-audit",
"docs:install": "cd docsite && npm install --no-audit",
"docs:start": "cd docsite && npm start",
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
@@ -34,7 +34,7 @@
},
"engines": {
"node": ">=24.14.0",
"npm": ">=11.12.1"
"npm": "^11.12.1"
},
"repository": {
"type": "git",
@@ -52,15 +52,8 @@
"@atcute/identity-resolver-node": "^1.0.3",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@awaitjs/express": "^0.6.3",
"@chakra-ui/react": "^3.34.0",
"@curvenote/ansi-to-react": "^7.0.0",
"@donedeal0/superdiff": "^1.1.1",
"@emotion/react": "^11.14.0",
"@ewanc26/tid": "^1.0.2",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.6",
@@ -71,24 +64,19 @@
"@kenyip/backoff-strategies": "^1.0.4",
"@keyv/valkey": "^1.0.8",
"@lukehagar/plexjs": "^0.39.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
"@svrooij/sonos": "^2.5.0",
"@tailwindcss/vite": "^4.1.16",
"@types/express-serve-static-core": "^4.19.6",
"@xhayper/discord-rpc": "^1.3.0",
"abort-controller-x": "^0.5.0",
"address": "^1.2.2",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"autoprefixer": "^10.4.17",
"avahi-browse": "^1.1.4",
"better-sse": "^0.8.0",
"body-parser": "^2.2.2",
"cacheable": "^1.10.4",
"castv2": "^0.1.10",
"clone": "^2.1.2",
"clsx": "^2.0.0",
"common-tags": "^1.8.2",
"compare-versions": "^4.1.2",
"concat-stream": "^2.0.0",
@@ -97,9 +85,9 @@
"dbus-ts": "^0.0.7",
"discord.js": "^14.26.0",
"dotenv": "^10.0.0",
"express": "^4.22.1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"fast-deep-equal": "^3.1.3",
"fast-equals": "^6.0.0",
"fixed-size-list": "^0.3.0",
"flat-cache": "^6.1.13",
"formidable": "^3.5",
@@ -113,16 +101,14 @@
"json-diff-ts": "^5.0.0-alpha.2",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"jsondiffpatch-react": "^1.0.8",
"keyv": "^5.5.0",
"kodi-api": "^0.2.1",
"lastfm-ts-api": "^2.6.0",
"merge-error-cause": "^5.0.2",
"mopidy": "^1.3.0",
"mpd-api": "^1.1.2",
"mpc-js": "^2.1.1",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"next-themes": "^0.4.6",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.15.2",
@@ -133,41 +119,40 @@
"passport-deezer": "^0.2.0",
"patch-package": "^8.0.1",
"prom-client": "^15.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.30.3",
"react-virtuoso": "^4.18.3",
"round-robin-js": "^3.0.10",
"sass": "^1.93.3",
"serialize-error": "^13.0.1",
"shiki": "^4.0.2",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^4.1.16",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"tsx": "^4.21.0",
"typeson": "^9.0.4",
"typeson-registry": "^11.1.1",
"undici": "7.24.6",
"vite-express": "^0.16.0",
"vite-express": "^0.22.1",
"vlc-client": "^1.1.1",
"xml2js": "0.6.2",
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@chakra-ui/react": "^3.34.0",
"@chromatic-com/storybook": "^5.0.1",
"@curvenote/ansi-to-react": "^7.0.0",
"@dbus-types/notifications": "^0.0.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.1",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@gr2m/net-interceptor": "^1.0.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.7",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-docs": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@tailwindcss/vite": "^4.2.2",
"@tsconfig/node18": "^1.0.1",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.5",
@@ -175,8 +160,9 @@
"@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/express": "^5.0.6",
"@types/express-serve-static-core": "^5.1.1",
"@types/express-session": "^1.18.2",
"@types/formidable": "^3.4.5",
"@types/jest": "^27.5.2",
"@types/jscodeshift": "^0.11.6",
@@ -192,20 +178,34 @@
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.27",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"chai-as-promised": "^8.0.2",
"clsx": "^2.1.1",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-storybook": "10.1.11",
"git-cliff": "^2.12.0",
"jsondiffpatch-react": "^1.0.8",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.12.10",
"neotraverse": "^0.6.18",
"next-themes": "^0.4.6",
"nodemon": "^3.0.3",
"playwright": "^1.58.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-redux": "^8.1.3",
"react-router-dom": "^6.30.3",
"react-virtuoso": "^4.18.4",
"sass": "^1.99.0",
"shiki": "^4.0.2",
"sinon": "^21.0.2",
"storybook": "10.2.0",
"tailwindcss": "^4.2.2",
"ts-essentials": "^10.1.1",
"typescript": "^5.9.3",
"typescript-eslint": "^7.0.1",
@@ -227,6 +227,9 @@
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
},
"musicbrainz-api": {
"rate-limit-threshold": "0.2.1"
}
}
}
+34 -21
View File
@@ -2,8 +2,6 @@ import { childLogger, Logger } from "@foxxmd/logging";
import {
cacheFunctions,
} from "@foxxmd/regex-buddy-core";
import deepEqual from 'fast-deep-equal';
import { Simulate } from "react-dom/test-utils";
import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
@@ -28,6 +26,7 @@ import { MSCache } from "./Cache.js";
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.js";
import clone from "clone";
import { loggerNoop } from "./MaybeLogger.js";
import { objectsEqual } from "../utils/DataUtils.js";
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
@@ -167,9 +166,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
const asyncId = nanoid(6);
let logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
let logger: Logger;
try {
let hook: StageConfig[];
@@ -193,6 +190,9 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return play;
}
const asyncId = nanoid(6);
logger = childLogger(this.logger, ['Play Transform', hookType, asyncId]);
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
@@ -204,7 +204,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
logger.debug(`Transform start for => ${buildTrackString(play)}`);
let transformedPlay: PlayObject = clone(play);
let cacheOk: boolean = true;
let cacheOk: boolean = true,
generateSteps = true;
const steps: LifecycleStep[] = [];
const opts = {
@@ -213,22 +214,29 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
if(cachedSteps !== undefined) {
generateSteps = false;
// don't re-cache cached steps
cacheOk = false;
// only patch play if steps didn't end in a failure that we are okay with returning partial from
let shouldTransform = true;
const lastCachedStep = cachedSteps[cachedSteps.length - 1];
if(lastCachedStep.error !== undefined && lastCachedStep.flowKnownState !== 'skip' && !lastCachedStep.returnPartial) {
shouldTransform = false;
}
for(const s of cachedSteps) {
steps.push({...s, cached: true});
if(shouldTransform && s.patch !== undefined) {
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
try {
// only patch play if steps didn't end in a failure that we are okay with returning partial from
let shouldTransform = true;
const lastCachedStep = cachedSteps[cachedSteps.length - 1];
if(lastCachedStep.error !== undefined && lastCachedStep.flowKnownState !== 'skip' && !lastCachedStep.returnPartial) {
shouldTransform = false;
}
for(const s of cachedSteps) {
steps.push({...s, cached: true});
if(shouldTransform && s.patch !== undefined) {
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
}
}
} catch (e) {
logger.warn(new Error('Error occurred while trying to use cached steps. Falling back to generating fresh transform steps', {cause: e}));
generateSteps = true;
}
} else {
}
if(generateSteps) {
for(const hookItem of hook) {
const [step, stepPlay] = await this.generateStepFromStage(transformedPlay, hookItem, hookType, opts);
steps.push(step);
@@ -317,7 +325,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return transformedPlay;
} catch (e) {
logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
const err = new Error(`Unexpected error occurred, returning original play.`, {cause: e});
if(logger === undefined) {
this.logger.warn(err);
} else {
logger.warn(err);
}
return play;
}
}
@@ -337,7 +350,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}`
const existingStepIndex = playTruth.meta.lifecycle.steps.findIndex(x => x.name === stepName && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
const step: LifecycleStep = existingStepIndex !== -1 && playTruth.meta.lifecycle.steps[existingStepIndex] !== undefined ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
name: stepName,
source: this.getIdentifier(),
}
@@ -401,7 +414,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
} else {
step.flowResult = onSuccess;
if (!deepEqual(playTruth.data, newTransformedPlay.data)) {
if (!objectsEqual(playTruth.data, newTransformedPlay.data)) {
const o = JSON.parse(JSON.stringify(playTruth.data));
const t = JSON.parse(JSON.stringify(newTransformedPlay.data));
const patch = diffObjects(o, t); // jdiff.diff(o, t);
@@ -1,9 +1,7 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { Simulate } from "react-dom/test-utils";
import {truncateStringToLength } from "../../core/StringUtils.js";
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
import { hasUpstreamError } from "./errors/UpstreamError.js";
import play = Simulate.play;
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js";
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
+32 -8
View File
@@ -1,7 +1,8 @@
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import mergeErrorCause from 'merge-error-cause';
import { findCauseByFunc, findCauseByReference } from "../../utils/ErrorUtils.js";
import { findCauseByFunc, findCauseByReference, isAbortReasonErrorLike } from "../../utils/ErrorUtils.js";
import { UpstreamError, UpstreamErrorOptions } from "./UpstreamError.js";
import { isAbortError } from "abort-controller-x";
export abstract class NamedError extends Error {
public abstract name: string;
@@ -39,6 +40,19 @@ export class SimpleError extends Error implements HasSimpleError {
simple: boolean;
name = 'Error';
stackShortened: boolean = false;
shortenStack() {
const atIndex = parseRegexSingle(STACK_AT_REGEX,this.stack);
if(atIndex !== undefined) {
const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length);
if(firstn !== -1) {
this.stack = this.stack.slice(0, firstn);
this.stackShortened = true;
}
}
}
public constructor(msg: string, options?: ErrorOptions & { simple?: boolean, shortStack?: boolean }) {
super(msg, options);
const {
@@ -46,14 +60,9 @@ export class SimpleError extends Error implements HasSimpleError {
shortStack = false
} = options || {};
this.simple = simple;
Error.captureStackTrace(this, this.constructor);
if(shortStack) {
const atIndex = parseRegexSingle(STACK_AT_REGEX,this.stack);
if(atIndex !== undefined) {
const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length);
if(firstn !== -1) {
this.stack = this.stack.slice(0, firstn);
}
}
this.shortenStack();
}
}
}
@@ -104,4 +113,19 @@ export class ScrobbleSubmitError<T extends (object | string) = object> extends U
super(message, options);
this.payload = options?.payload;
}
}
export class AbortedError extends SimpleError {
name = 'Aborted Operation';
}
export const generateLoggableAbortReason = (msg: string, signal: AbortSignal): AbortedError => {
const reason = signal.reason;
let err: AbortedError;
if(isAbortReasonErrorLike(signal)) {
err = new AbortedError(msg, {cause: reason});
} else {
err = new AbortedError(`${msg} => ${reason ?? 'No Reason Given'}`, {simple: true, shortStack: true});
}
Error.captureStackTrace(err, generateLoggableAbortReason);
return err;
}
+13 -4
View File
@@ -1,7 +1,7 @@
import { Logger } from '@foxxmd/logging';
import { Dayjs, ManipulateType } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { type NextFunction, type ParamsDictionary, type Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { isPlayObject, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, UnixTimestamp } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
@@ -66,10 +66,19 @@ export const CALCULATED_PLAYER_STATUSES = {
orphaned: 'orphaned' as CalculatedPlayerStatus,
}
export type ConfigureAsSource = 'source';
export type ConfigureAsClient = 'client';
export type ConfigureAs = ConfigureAsSource | ConfigureAsClient;
export const CONFIGURE_AS = {
source: 'source',
client: 'client'
} as const satisfies Record<string, ConfigureAs>
export interface ConfigMeta {
source: string
mode?: string
configureAs: string
configureAs: ConfigureAs
}
export type SourceData = (PlayObject | PlayerStateData);
@@ -85,7 +94,8 @@ export interface PlayerStateDataMaybePlay {
play?: PlayObject
status?: ReportedPlayerStatus
position?: number
timestamp?: Dayjs
stateUpdatedAt?: Dayjs
playUpdatedAt?: Dayjs
}
export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play)
@@ -291,7 +301,6 @@ export interface CacheConfigOptions {
export interface MusicbrainzApiConfigData {
url?: string
rateLimit?: [number, number]
contact: string,
apiKey?: string
requestTimeout?: number
@@ -28,7 +28,7 @@ export interface MatchLoggingOptions {
confidenceBreakdown?: boolean
}
export interface CommonClientData extends CommonData {
export interface CommonClientData {
}
export interface UpstreamRefreshOptions {
@@ -12,7 +12,7 @@ export interface CommonConfig {
enable?: boolean
}
export type CommonData = keyOmit<{ [key: string]: any }, "options">
export type CommonData = Record<string, any>; // keyOmit<{ [key: string]: any }, "options">
export interface RequestRetryOptions {
/**
@@ -22,6 +22,8 @@ export interface DeezerData extends CommonSourceData, PollingOptions {
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri?: string
accessToken?: string
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
@@ -59,6 +61,8 @@ export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
export type DeezerAIOCompatConfig = DeezerSourceAIOConfig | DeezerInternalAIOConfig;
export interface DeezerInternalTrackData {
/** Song Id */
SNG_ID: string
@@ -13,14 +13,23 @@ export interface ListenbrainzEndpointData extends CommonSourceData {
*
* If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used
* */
slug?: string | null
slug?: string
/**
* If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source
*
* See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests
* */
token?: string | null
token?: string
/**
* The listenbrainz "username" to associate with this Source
*
* Will be returned in validate-token responses and used to determine a response for playing-now, if your client supports it
*
* If no value is configured then the Source's name will be used
*/
username?: string
}
export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig {
@@ -119,7 +119,7 @@ export interface ManualListeningOptions {
systemScrobble?: boolean
}
export interface CommonSourceData extends CommonData {
export interface CommonSourceData {
}
@@ -1,7 +1,7 @@
import { PollingOptions } from "../common.js";
import { PollingOptions, RequestRetryOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface JRiverData extends CommonSourceData, PollingOptions {
export interface JRiverData extends CommonSourceData, PollingOptions, RequestRetryOptions {
/**
* URL of the JRiver HTTP server to connect to
*
@@ -13,7 +13,7 @@ export interface KoitoSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: KoitoSourceData
}
@@ -11,7 +11,7 @@ export interface LastfmSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: LastFmSourceData
}
@@ -12,7 +12,7 @@ export interface ListenBrainzSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: ListenBrainzSourceData
}
@@ -12,7 +12,7 @@ export interface MalojaSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: MalojaSourceData
}
@@ -1,6 +1,7 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface MPDData extends CommonSourceData {
export interface MPDData extends CommonSourceData, PollingOptions {
/**
* URL:PORT of the MPD server to connect to
*
@@ -38,33 +39,4 @@ 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
}
export type PlayerState = 'play' | 'stop' | 'pause';
@@ -16,7 +16,7 @@ export interface RockskySourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: RockskySourceData
options?: RockskySourceOptions
}
@@ -2,7 +2,7 @@ import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig, DeezerCompatConfig } from "./deezer.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig, DeezerCompatConfig, DeezerAIOCompatConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
@@ -19,6 +19,7 @@ import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig } from "./ymbridge.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
@@ -31,8 +32,7 @@ import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js";
export type SourceConfig =
SpotifySourceConfig
| PlexApiSourceConfig
| DeezerSourceConfig
| DeezerInternalSourceConfig
| DeezerCompatConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
@@ -40,6 +40,7 @@ export type SourceConfig =
| LastfmSourceConfig
| LibrefmSourceConfig
| YTMusicSourceConfig
| YandexMusicBridgeSourceConfig
| MPRISSourceConfig
| MopidySourceConfig
| ListenBrainzSourceConfig
@@ -62,8 +63,7 @@ export type SourceConfig =
export type SourceAIOConfig =
SpotifySourceAIOConfig
| PlexApiSourceAIOConfig
| DeezerSourceAIOConfig
| DeezerInternalAIOConfig
| DeezerAIOCompatConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
@@ -71,6 +71,7 @@ export type SourceAIOConfig =
| LastFmSouceAIOConfig
| LibrefmSouceAIOConfig
| YTMusicSourceAIOConfig
| YandexMusicBridgeSourceAIOConfig
| MPRISSourceAIOConfig
| MopidySourceAIOConfig
| ListenBrainzSourceAIOConfig
@@ -108,6 +109,7 @@ export type JellyApiSourceConfigs = JellyApiSourceConfig[];
export type LastfmSourceConfigs = LastfmSourceConfig[];
export type LibrefmSourceConfigs = LibrefmSourceConfig[];
export type YTMusicSourceConfigs = YTMusicSourceConfig[];
export type YandexMusicBridgeSourceConfigs = YandexMusicBridgeSourceConfig[];
export type MPRISSourceConfigs = MPRISSourceConfig[];
export type MopidySourceConfigs = MopidySourceConfig[];
export type ListenBrainzSourceConfigs = ListenBrainzSourceConfig[];
@@ -139,6 +141,7 @@ export type SourceType =
| 'endpointlz'
| 'endpointlfm'
| 'ytmusic'
| 'ymbridge'
| 'mpris'
| 'mopidy'
| 'musiccast'
@@ -169,6 +172,7 @@ export const sourceTypes: SourceType[] = [
'endpointlz',
'endpointlfm',
'ytmusic',
'ymbridge',
'mpris',
'mopidy',
'musiccast',
@@ -201,6 +205,7 @@ export const atomicSourceInterfaces = [
'LastfmSourceConfig',
'LibrefmSourceConfig',
'YTMusicSourceConfig',
'YandexMusicBridgeSourceConfig',
'MalojaSourceConfig',
'MPRISSourceConfig',
'MopidySourceConfig',
@@ -15,7 +15,7 @@ export interface TealSourceConfig extends CommonSourceConfig {
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
configureAs: 'source'
data: TealSourceData
options?: TealSourceOptions
}
@@ -6,7 +6,7 @@ export interface VLCData extends CommonSourceData, PollingOptions {
/**
* URL:PORT of the VLC server to connect to
*
* To use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc
* To use this you must have the Web (http) interface module enabled and a password set
*
* @examples ["localhost:8080"]
* @default "localhost:8080"
@@ -0,0 +1,17 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface YandexMusicBridgeData extends CommonSourceData, PollingOptions {
/** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */
url: string
/** Optional API key sent as X-API-Key to the bridge */
apiKey?: string
}
export interface YandexMusicBridgeSourceConfig extends CommonSourceConfig {
data?: YandexMusicBridgeData
}
export interface YandexMusicBridgeSourceAIOConfig extends YandexMusicBridgeSourceConfig {
type: 'ymbridge'
}
@@ -356,8 +356,8 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
super(config, options);
this.clientCache = options.clientCache;
this.staggerOpts = {
initialInterval: 300,
maxRandomStagger: 300
initialInterval: 0,
maxRandomStagger: 100
}
}
@@ -214,7 +214,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr
for(const artist of play.data.artists) {
const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x !== undefined);
const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x.res !== undefined);
if(matchedIgnoreArtists.length > 0) {
logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`);
artists.push(artist);
+56 -16
View File
@@ -16,7 +16,8 @@ import { LastFMUser, LastFMAuth, LastFMTrack, LastFMUserGetRecentTracksResponse,
import clone from 'clone';
import { IncomingMessage } from "http";
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js";
import { ScrobbleSubmitError } from "../errors/MSErrors.js";
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.js";
import { redactString } from "@foxxmd/redact-string";
const badErrors = [
'api key suspended',
@@ -43,8 +44,8 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
user?: string;
declare config: LastfmData;
sessionKey?: string;
lastRefreshed?: number
urlBase: string;
path: string;
upstreamName: string = 'Last.fm';
@@ -141,23 +142,31 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
getAuthUrl = () => {
if(this.url.url.host === LASTFM_HOST) {
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
const isLastFm = this.url.url.host === LASTFM_HOST;
let authUrl: string;
if(isLastFm) {
this.logger.debug('Detected last.fm host');
authUrl = `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
} else {
this.logger.debug('Detected non-last.fm host (probably libre.fm)');
const aUrl = new URL(this.url.url.toString());
aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
aUrl.pathname = '/api/auth';
authUrl = aUrl.toString();
}
const aUrl = new URL(this.url.url.toString());
aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`;
aUrl.pathname = '/api/auth';
return aUrl.toString();
const redactedUrl = authUrl.replace(this.config.apiKey, redactString(this.config.apiKey, 6));
this.logger.debug(`Generated auth url: ${redactedUrl}`);
return authUrl;
}
authenticate = async (token: any) => {
this.logger.debug(`Recieved token from auth callback: ${redactString(token, 10)}`);
const auth = new LastFMAuth(this.config.apiKey, this.config.secret, undefined, {
hostname: this.url.url.host,
path: this.url.url.pathname
});
this.logger.debug('Trying to get session key from last.fm...');
const sessionRes = await auth.getSession({token});
const {
session: {
@@ -165,18 +174,23 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
name, // username
} = {}
} = sessionRes;
this.lastRefreshed = dayjs().unix();
this.logger.debug(`Created session for user ${name}! Session key: ${redactString(sessionKey, 6)} | Refreshed At ${this.lastRefreshed}`);
this.sessionKey = sessionKey;
this.user = name;
this.user = name;
this.initApi();
await writeFile(this.workingCredsPath, JSON.stringify({
sessionKey,
name,
lastRefreshed: this.lastRefreshed
}));
this.logger.trace(`Wrote session data to ${this.workingCredsPath}`);
}
protected initApi = () => {
this.logger.debug(`Creating new API instances with: API Key ${redactString(this.config.apiKey, 6)} | Secret: ${redactString(this.config.secret, 6)} | Session Key ${redactString(this.sessionKey, 6)} | Host ${this.url.url.host} | Path ${this.url.url.pathname}`);
this.userApi = new LastFMUser(this.config.apiKey, this.config.secret, this.sessionKey, {
hostname: this.url.url.host,
path: this.url.url.pathname
@@ -190,12 +204,19 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
initialize = async (): Promise<true> => {
try {
this.logger.debug(`Trying to load credentials from file path: ${this.workingCredsPath}`);
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false, interpolateEnvs: false});
const {sessionKey, name} = creds || {};
const {sessionKey, name, lastRefreshed} = creds || {};
this.sessionKey = sessionKey;
this.user = name;
if(lastRefreshed !== undefined) {
this.lastRefreshed = lastRefreshed;
}
if(this.sessionKey !== undefined) {
this.logger.trace('Found session key during initialization, initing api instances');
this.initApi();
} else {
this.logger.trace('No session key found during initialization, not initing api instances');
}
return true;
} catch (e) {
@@ -218,16 +239,20 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
if (this.sessionKey === undefined) {
this.logger.warn('No session key found. User interaction for authentication required.');
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
throw new Error('No session key found. User interaction for authentication required.');
throw new SimpleError('No session key found. User interaction for authentication required.');
}
try {
// existing lastfm clients are ok with getting user from getInfo
// but libre throws an error
// so, if not libre allow empty user and set user from response to comply with new librefm implementation/refactor
const infoPayload: Record<string, any> = {};
if(this.urlBase !== LASTFM_HOST) {
const infoPayload: Record<string, any> = {
sk: this.sessionKey,
};
if(this.url.url.host !== LASTFM_HOST) {
this.logger.debug(`Adding ${this.user} to getInfo payload due to non-last.fm host`);
infoPayload.user = this.user;
}
this.logger.debug(`Using sk:${redactString(this.sessionKey, 6)} in getInfo payload for auth test...`);
const resp = await this.callApi<LastFMUserGetInfoResponse>(() => this.userApi.getInfo(infoPayload));
if(this.user === undefined) {
this.user = resp.user.name;
@@ -266,6 +291,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
'@attr': {
total,
totalPages,
perPage,
page
}
} = {},
@@ -306,7 +332,21 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
}
}, []);
return { data: plays, meta: { ...fetchOptions, total: parseInt(total, 10), more: fetchOptions.cursor < parseInt(totalPages, 10) } };
let totalNum: number | undefined;
// libre.fm does not return total, only perPage and totalPages
if(total !== undefined) {
if(typeof total === 'string') {
totalNum = parseInt(total, 10);
} else {
totalNum = total;
}
} else if(perPage !== undefined && totalPages !== undefined) {
const pp = typeof perPage === 'string' ? parseInt(perPage, 10) : perPage;
const tp = typeof totalPages === 'string' ? parseInt(totalPages, 10): totalPages;
totalNum = pp * tp;
}
return { data: plays, meta: { ...fetchOptions, total: totalNum, more: fetchOptions.cursor < parseInt(totalPages, 10) } };
}
+1 -1
View File
@@ -61,7 +61,7 @@ export class RockSkyApiClient extends AbstractApiClient {
doCallLZApi = async <T = Response>(req: Request, retries = 0): Promise<T> => {
try {
req.set('Authorization', `Token ${this.config.key ?? this.config.token}`);
req.set('Authorization', `Token ${this.config.key}`);
return await req as T;
} catch (e) {
const {
+17 -6
View File
@@ -110,13 +110,24 @@ export class DiscordAbstractClient extends AbstractApiClient {
if(art === artworkDefaultUrl) {
const play = isPlayObject(data) ? data : data.play;
if(play.data.meta?.brainz?.album !== undefined) {
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.album, {size: 250});
if(albumArt !== undefined) {
art = albumArt;
if(play.data.meta?.brainz?.album !== undefined || play.data.meta?.brainz?.releaseGroup !== undefined) {
let usedCAA = false;
if(play.data.meta?.brainz?.album !== undefined) {
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.album, 'release', {size: 250});
if(albumArt !== undefined) {
art = albumArt;
usedCAA = true;
}
}
} else if(isDebugMode()) {
this.logger.debug('No brainz Release MBID to use for cover art archive');
if(!usedCAA && play.data.meta?.brainz?.releaseGroup !== undefined) {
const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.releaseGroup, 'release-group', {size: 250});
if(albumArt !== undefined) {
art = albumArt;
usedCAA = true;
}
}
} else {
this.logger.trace('No brainz Release MBID to use for cover art archive');
}
}

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