Compare commits

...
113 Commits
Author SHA1 Message Date
FoxxMD b3a6beb8f4 docs(ytm): Add oauth warning 2025-09-10 12:57:42 +00:00
FoxxMD e86945355c fix: Add additional logging for ytm history request 2025-09-05 18:56:16 +00:00
FoxxMD 8dd9958b30 Use default scopes 2025-09-05 18:55:53 +00:00
FoxxMD 869c7dcfe1 fea(ytm): Allow user-defined oauth scopes 2025-09-02 13:07:46 +00:00
FoxxMD 7068da5fc2 test(ytm): Fix failing type assertion 2025-09-02 12:24:33 +00:00
FoxxMD 3704fba1f8 feat(ytm): Add debug logging #345 2025-09-02 12:18:21 +00:00
FoxxMD 6d99790f7d chore: Update ytm #345 2025-09-02 12:15:49 +00:00
Matt Foxx 3e7f1965e7 Merge pull request #341 from FoxxMD/nowPlayingPlayerUpdates
feat: Improve Player behavior for Sources only tracking Now Playing data
2025-08-27 11:23:24 -04:00
FoxxMD 87d05b7c75 fix(listenbrainz): Fix missing album/release_name for listenbrainz endpoint #342 2025-08-27 12:26:46 +00:00
FoxxMD aca23ed90d test: Update lz expected config count 2025-08-26 20:26:32 +00:00
FoxxMD a01c8e3f17 feat(ui): Add Now Playing behavior for ui Player
* show "Now Playing" instead of Unknown/Playing
* Don't show empty listened duration/percent
2025-08-26 20:22:12 +00:00
FoxxMD eba9bc1b6b feat(webscrobbler): Use Now Playing Player 2025-08-26 20:01:03 +00:00
FoxxMD 34be8add14 docs: Remove koito from listenbrainz example
Not really needed since koito has its own docs
2025-08-26 19:45:48 +00:00
FoxxMD bd44fa5609 feat: Change stale/orphan interval to match current play duration for Players that only track Now Playing data
#338
2025-08-26 19:40:18 +00:00
FoxxMD c81bdf608f fix(lastfm): Remove VA from album artist #340 2025-08-25 17:00:19 +00:00
FoxxMD e9e5987330 chore: Bump version for release 2025-08-20 13:23:16 +00:00
FoxxMD ce4b0f7aa7 docs: Add Maloja source to readme 2025-08-20 13:23:00 +00:00
Matt Foxx eb6e469dc9 Merge pull request #336 from FoxxMD/GH-295/malojaSource
feat: Add Maloja Source
2025-08-19 23:19:26 -04:00
FoxxMD 4f6fb4ffea fix(maloja): Remove null duration/length 2025-08-18 14:24:43 +00:00
FoxxMD 24df22781f fix: Don't removed undefined keys from nested objects
Don't want to break 3p libraries (dayjs)
2025-08-18 14:23:35 +00:00
FoxxMD abf532c6bb feat(maloja): Add Maloja Source #295 2025-08-06 16:48:39 +00:00
FoxxMD 976c7c0e82 refactor(maloja)!: Modernize Maloja API usage
* Move API code into own class for reusability with future Source
* Remove Maloja V2 compatibility
* Throw on connection if server version is too old or unknown
2025-08-06 15:44:59 +00:00
FoxxMD 06b23d6287 docs: Update Spotify docs and usage due to Redirect URI changes
As of April 2025 Spotify no longer allows insecure redirect URIs which complicates initial Spotify setup.

* Improve spotify App creation instructions
* Add several approaches to creating a valid redirect URI
* Remove Spotify as the default Source example in docs due to complexity of Redirect URI requirements

Closes #330
2025-08-01 17:28:16 +00:00
FoxxMD 38a15d6426 chore: Bump version for release 2025-08-01 12:50:29 +00:00
FoxxMD c2c86033cb feat(listenbrainz): Add recording_mbid to submit payload for Koito compatibility
Koito should accept track_mbid but it only uses recording_mbid, so include that in payload.

#333
2025-07-31 16:40:21 +00:00
FoxxMD 72c94ca1e3 feat(listenbrainz): Include additional_info from LZ source Play if none parsed for MS play 2025-07-31 16:32:16 +00:00
FoxxMD bf6bd4be55 fix(listenbrainz): Parse MB metadata from LZ Source listens and set in Play
Had not updated LZ source to parse metadata into Play since adding brainz metadata to Play object

#333
2025-07-31 15:53:43 +00:00
FoxxMD 53f78b02fb chore: Bump for release 2025-07-28 13:59:17 +00:00
FoxxMD 6f5a8a7bc7 docs: Improve wording around Now Playing state 2025-07-28 13:46:46 +00:00
FoxxMD 0d5dd34597 docs: Remove koito mentions from LZ docs
Full Koito integration should be used! Removing mentions reduces ambiguity for new users.
2025-07-24 17:51:16 +00:00
FoxxMD a93ec8dd7e docs: Add clarifying callout for multi-use components 2025-07-24 17:51:16 +00:00
Matt Foxx 1e488b576c Merge pull request #317 from FoxxMD/GH-314/plexjsVersion
fix(Plex): Temporary plex validation fix
2025-07-21 08:50:40 -04:00
Matt Foxx d3d8eb40b5 Merge pull request #329 from FoxxMD/repeatPlayPersistence
Repeat play detection improvements
2025-07-21 08:50:17 -04:00
FoxxMD db54761422 fix: Add last played data and check for repeat on new Play #318 2025-07-17 15:45:18 +00:00
FoxxMD aa9f20f544 Fix formatting for hints 2025-07-17 15:44:35 +00:00
FoxxMD 60371f52f8 refactor: Move Play position closeness and duration comparisons to own functions
* Hint for logs returns for all conditions
* Add tests
2025-07-17 15:08:09 +00:00
FoxxMD 87394fbce7 Remove patch
Didn't work anyways
2025-07-17 13:24:30 +00:00
FoxxMD 72341ff1de fix(plex): Temporary fix for library response validation #314
Catches and ignores outdated validation from @lukehagar/plexjs if we can verify raw value contains the required objects

https://github.com/LukeHagar/plexjs/issues/35
2025-07-17 13:24:30 +00:00
FoxxMD 63cebf3784 fix(plex): Ignore library response validation
As temporary fix for #314
2025-07-17 13:24:30 +00:00
FoxxMD 60f3f05f67 fix(Plex): Bump plexjs version to fix response validation
#314
2025-07-17 13:24:30 +00:00
FoxxMD ce39065c80 docs: Fix social card image 2025-07-16 13:33:10 +00:00
FoxxMD 3f39e9efa1 docs: Update docusaurus to 3.8 2025-07-16 13:19:04 +00:00
Matt Foxx f37dbd75c6 Merge pull request #328 from FoxxMD/GH-318/repeatDetection
feat: Track if play is a repeat and use in duplicate scrobble detecti…
2025-07-15 15:40:39 -04:00
FoxxMD 47ad7a3b74 feat: Track if play is a repeat and use in duplicate scrobble detection #318
* Use MS Player to mark Play as a repeat if session repeat is detected
* Remove FUZZY temporal accuracy as acceptable for detecting duplicate if candidate play is marked as repeat
2025-07-15 19:23:49 +00:00
FoxxMD 91747c0682 docs: Add Source client config guidance #325 2025-07-15 15:48:13 +00:00
FoxxMD 80f8495f8e fix(plex): Fix regression with allowing non-music when using librariesAllow 2025-07-14 20:19:13 +00:00
Matt Foxx c6bb258bf4 Merge pull request #324 from FoxxMD/lzAlbumField
Lz album field
2025-07-14 12:48:14 -04:00
FoxxMD e70abc593c chore: Update readme Koito entries 2025-07-14 14:55:29 +00:00
FoxxMD 22d73c6a7f fix(lastfm): Fix lastfm providing empty string for non-defined album #323 2025-07-14 14:48:50 +00:00
FoxxMD 82afe4195c chore: Bump version for release 2025-07-14 14:12:23 +00:00
FoxxMD 5e9409ab66 feat(plex): Add libraries allow/block warning 2025-07-14 13:49:40 +00:00
FoxxMD e64854eb14 fix(plex): Strict comparison of libnrary names #326
* Fixes libraries containing partial allow/disallow names from being able to pass filter
* Fix not checking valid library types
2025-07-14 13:43:22 +00:00
FoxxMD 333f70bd7e docs: hide navbar on scroll 2025-07-11 17:58:50 +00:00
FoxxMD ace7d5efc9 docs: Fix anchor scrolling by specifying logo height 2025-07-11 17:56:13 +00:00
FoxxMD cd14f82753 docs: Move updating instructions to own page 2025-07-11 17:27:22 +00:00
FoxxMD aba718aef1 docs: Move docker install instructions before local 2025-07-11 17:05:24 +00:00
FoxxMD c87497071d docs: Add updating instructions 2025-07-11 17:04:02 +00:00
FoxxMD 2a35e7cf5c fix(listenbrainz): Fix sending undefined release_name in scrobble #323 2025-07-10 19:39:08 +00:00
FoxxMD 3b98036687 docs: Remove old FAQ entries 2025-07-10 16:37:56 +00:00
FoxxMD 3fc935b96d feat(listenbrainz): Add additional player/service meta info for LZ scrobbles #224 2025-07-10 15:52:51 +00:00
FoxxMD 7586f226ba feat(listenbrainz): Add submission_client #224 2025-07-10 14:34:01 +00:00
FoxxMD 1a0e600db5 docs: Sort Source/Clients alphabetically
* Sort on readme/landing page and in configuration docs
* Makes skimming docs easier
2025-07-10 14:22:19 +00:00
FoxxMD 1dba5a6c6f docs: Add Koito to overview summary 2025-07-10 13:15:45 +00:00
FoxxMD 3ec0652fbb docs: Update docker installation wording 2025-07-10 13:11:17 +00:00
Matt Foxx e71d5983a4 Merge pull request #322 from FoxxMD/koito
Implement Koito Client/Source
2025-07-10 07:50:50 -04:00
FoxxMD bb9b5fc5cd fix: superagent type error false positive 2025-07-09 16:30:15 +00:00
FoxxMD 89fde5a6fe chore(listenbrainz): Add guidance in logging to switch to Koito source/client 2025-07-09 16:19:47 +00:00
FoxxMD 24ff8f2b0f docs: Add Koito client/source docs 2025-07-09 16:17:47 +00:00
FoxxMD f103563cb7 feat(koito): Implement Koito Source 2025-07-09 15:40:13 +00:00
FoxxMD 1767268a48 fix: Only return interpolated album value if not undefined 2025-07-09 15:39:22 +00:00
FoxxMD 8ab2d61742 feat(koito): Implement Koito client 2025-07-09 15:04:19 +00:00
FoxxMD 132580af6f chore: Update superagent types 2025-07-09 15:04:01 +00:00
FoxxMD 375c0123c6 feat(koito): Add koito types/config 2025-07-09 13:12:15 +00:00
FoxxMD 769002412d docs: Remove flatpak install guidance 2025-07-08 20:34:16 +00:00
FoxxMD 7fa567c1e4 docs(mpris): Add remote socket guidance 2025-07-08 20:13:10 +00:00
FoxxMD 8dfc9a6d55 refactor(mpris): Remove socat service in favor of simplified dbus connection
Can connect to dbus over TCP socket using ENV which is much simpler.
2025-07-08 19:53:55 +00:00
FoxxMD 0efc85c516 feat(mpris): Better handling of socket error on connection check
Prevents a low-level emitted error from completely crashing MS
2025-07-08 19:51:57 +00:00
FoxxMD dbb8e3c5e0 feat(mpris): Add s6 finish script to prevent restart of socat 2025-07-08 19:36:08 +00:00
FoxxMD f4f1eed645 feat: Add socat service 2025-07-08 14:38:22 -04:00
FoxxMD 0a31f51567 docs: Update MPRIS instructions for docker 2025-07-08 17:25:28 +00:00
FoxxMD c255138a6e docs: Add Koito quickstart instructions 2025-07-08 14:36:10 +00:00
Matt Foxx 856a05a357 Merge pull request #320 from FoxxMD/symfoniumPlex
Fix symfonium reporting issues for plex
2025-07-08 08:27:53 -04:00
FoxxMD 18915167f4 feat: Add more logging to apprise errors #158 2025-07-07 17:44:17 +00:00
FoxxMD 34b0995d6b fix(plex): Increase max drift to 35 seconds to account for symfonium lag #271
Symfonium only reports position every 30 seconds vs. plex/plexamp 15 seconds. Increase max drift so MS doesn't falsely seek and reset play state.
2025-07-07 15:04:43 +00:00
FoxxMD 4e5f38378e feat: Add RT Player metrics to player logging 2025-07-07 15:03:30 +00:00
FoxxMD 5d2c9261ae chore: Bump version for release 2025-07-07 12:22:23 +00:00
FoxxMD e039d4512c ci: Add optional rybbit analytics to docs build 2025-07-07 12:20:16 +00:00
FoxxMD 51509a3531 ci: Add rybbit variables for gh actions 2025-07-03 17:51:38 +00:00
FoxxMD 8f032f2379 feat(listenbrainz): Allow now playing reporting for koito
Doesn't affect scrobbling and means if/when NP is actually supported it'll work automatically
2025-07-03 13:10:52 +00:00
FoxxMD ff11e26219 Clean up logging for Now Playing 2025-07-03 13:10:13 +00:00
FoxxMD f004aa4cfd feat(listenbrainz): Implement Now Playing functionality 2025-07-03 12:50:40 +00:00
FoxxMD 2104f2a99d fix(now playing): Catch update errors but only log
We don't need to stop the scrobbler for now playing issues since its not a critical function. Just log as warning.
2025-07-03 12:19:34 +00:00
Matt Foxx 389129aadf Merge pull request #316 from msparkles/feat/lastfm-nowplaying
feat: lastfm nowplaying
2025-07-02 16:59:21 -04:00
FoxxMD f885726ed1 docs: Add Now Playing and configuration 2025-07-02 20:50:03 +00:00
FoxxMD 3cc452323d feat: Allow Now Playing to be globally toggled by ENV 2025-07-02 20:42:12 +00:00
FoxxMD 780acb32d7 fix(scrobbler): Fix duplicate map keys causing delay in Now Playing update 2025-07-02 17:03:48 +00:00
FoxxMD bf5fdb95f4 test: Add Now Playing updating and scheduler tests 2025-07-02 17:03:26 +00:00
FoxxMD 7d7ccf53bb test: Add Now Playing filter/sorting tests 2025-07-02 16:03:09 +00:00
Matt Foxx 94837b7e03 feat(subsonic): Implement filtering plays by user (#319)
* feat(subsonic): Implement filtering plays by user #301
2025-07-02 08:15:44 -04:00
FoxxMD a9c1c7083d feat: Add config source to startup logging #309 2025-07-01 19:33:10 +00:00
FoxxMD 8dd6fc15cc docs: Improve example config rendering for ambiguous configs
* Filter examples based on client/source configuration
* Add `type` to all configs in AIO examples

Fixes #310
2025-07-01 18:30:24 +00:00
FoxxMD 32ca57fc65 feat: Implement Now Playing queue, scheduler, and source sorting
* Add queue for aggregating Now Playing updates
* Filter play based on 1. source priority and 2. player stickiness or deterministic order
* Use toad scheduler to processing NP queue
2025-07-01 16:18:15 +00:00
FoxxMD 9d748f26c0 feat: Pass source type-name to playingNow 2025-07-01 14:55:38 +00:00
FoxxMD 5af1454f36 feat: Add Now Playing user config
* Enable/disable now playing reporting
* WIP source filters for now playing
2025-06-30 16:12:11 +00:00
FoxxMD bf7a660633 feat: Add Now Playing as class configuration and implement rate limit intervals
* Define if scrobble client can update Now Playing based on class property, use this to filter now playing update
* Use thresholds to determine when/if Now Playing should be updated
* Set LastfmScrobbler as supported for Now Playing
2025-06-30 15:29:51 +00:00
Madeline Sparkles f0723f23e8 feat: lastfm now-playing scrobbling 2025-06-30 00:14:02 +02:00
Madeline Sparkles 36357135cb fix(docs): remove archaic JSDocs on ScrobbleClients.scrobble 2025-06-29 23:17:28 +02:00
FoxxMD 8be08a5c29 chore: Bump for release 2025-06-20 20:09:21 +00:00
Matt Foxx 9fe5b9b671 Merge pull request #312 from FoxxMD/lzEnrich
feat: Enrich Listenbrainz submit payload
2025-06-20 16:07:43 -04:00
FoxxMD 720c07b008 feat(listenbrainz): Include more ids/fields in submit additional_info #311 2025-06-20 16:43:01 +00:00
FoxxMD 7fdf93c5d3 feat(spotify): Add spotify meta ids to play 2025-06-20 16:42:04 +00:00
FoxxMD a0fd466bcf dev: Add ENV only launch config 2025-06-19 18:15:45 +00:00
FoxxMD 35d62c913a fix: Guard against undefined type in config.json #310 2025-06-19 18:12:57 +00:00
100 changed files with 8085 additions and 5176 deletions
+3
View File
@@ -30,6 +30,9 @@ jobs:
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
+19
View File
@@ -16,6 +16,25 @@
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "dev (ENV Only)",
"type": "node",
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
"CONFIG_DIR": "./not-real"
},
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**",
// Ignore all dependencies (optional)
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "tsx",
"type": "node",
+18 -17
View File
@@ -12,34 +12,35 @@ alt="multi-scrobbler logo" width="180" height="180">
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
* Supports scrobbling from many **Sources**
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito-source)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja-source)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
* Supports scrobbling to many **Clients**
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
+20
View File
@@ -0,0 +1,20 @@
[
{
"name": "koito-client",
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "admin",
"url": "http://192.168.0.100:4110"
}
},
{
"name": "koito-source",
"configureAs": "source",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "admin",
"url": "http://192.168.0.100:4110"
}
}
]
+1 -11
View File
@@ -16,15 +16,5 @@
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
},
{
"name": "koitoClient",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD",
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
}
},
}
]
+11 -1
View File
@@ -1,7 +1,17 @@
[
{
"name": "myMaloja",
"name": "myMaloja-client",
"enable": true,
"configureAs": "client",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
}
},
{
"name": "myMaloja-source",
"enable": true,
"configureAs": "source",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
+2 -1
View File
@@ -5,7 +5,8 @@
"data": {
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
"password": "yourPassword"
"password": "yourPassword",
"usersAllow": ["yourUser"]
}
}
]
+3 -3
View File
@@ -4,9 +4,9 @@ services:
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
#- SPOTIFY_CLIENT_ID=
#- SPOTIFY_CLIENT_SECRET=
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
#- JELLYFIN_APIKEY=
#- JELLYFIN_URL=
#- JELLYFIN_USER=
#- MALOJA_URL=http://maloja:42010
#- MALOJA_API_KEY=
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
+8 -62
View File
@@ -5,22 +5,21 @@ toc_max_heading_level: 5
## Connection Issues
### Plex (Webhook)/Tautulli/Jellyfin(Webhook)/Webscrobbler don't connect
### Webscrobbler/Listenbrainz Endpoint doesn't connect {#ingress-connection}
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
These sources are **ingress-based** which means that multi-scrobbler waits for the Webscrobbler extension or app using Listenbrainz Endpoint to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
multi-scrobbler will log information about any server that connects to it for these services. In the logs it looks something like this:
```
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) Received valid data from server examplePlex for the first time.
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
[2025-07-10 12:32:21.417 -0400] INFO : [App] [API] [Ingress] [WebScrobbler] [Ingress] [WebScrobbler] Received request from a new remote address: ::ffff:127.0.0.1 (192.168.0.100) (UA: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0)
[2025-07-10 12:32:21.418 -0400] INFO : [App] [API] [Ingress] [WebScrobbler] [Ingress] [WebScrobbler] ::ffff:127.0.0.1 (192.168.0.100) (UA: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0) Received data for API URL slug '(none)' for the first time.
```
It also logs if a server tries to connect to a URL that it does not recognize:
```
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.100 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
```
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin/Webscrobbler is not able to connect to your multi-scrobbler instance at all.**
**So, if you do not see either of these in your logs then the Source is not able to connect to your multi-scrobbler instance at all.**
This is not something multi-scrobbler can fix and means you have an issue in your network.
@@ -34,9 +33,9 @@ First, turn on **debug** logging for multi-scrobbler by setting the environmenta
##### Check Host name and URL
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Plex/Tautulli/Jellyfin/Webscrobbler on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Webscrobbler/LFM Endpoint on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
EX `http://localhost:9078/api/webscrobbler` -> `http://192.168.0.140:9078/api/webscrobbler`
##### Check Firewall and Port Forwarding
@@ -44,53 +43,10 @@ If the machine multi-scrobbler is running on has a firewall ensure that port **9
##### Check Source Service Logs
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
###### Plex
Settings -> Manage -> Console
###### Tautulli
Check the command-line output of the application or docker logs.
###### Jellyfin
Administration -> Dashboard -> Advanced -> Logs
###### Webscrobbler
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
### Jellyfin (Webhook) has warnings about undefined or missing data
Make sure you have
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
You can verify the payload sent from the webhook by enabling Debug Mode with the env `DEBUG_MODE=true` which will output the raw payload to DEBUG level logging.
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
### Jellyfin (Webhook) has warnings about missing headers
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
```
[API] Jellyfin is not sending a request with valid headers...
```
A workaround that may fix this:
* In Webhook settings:
* [In the webhook you have already configured...](configuration/configuration.mdx#jellyfin)
* Add Request Header...
* **Key:** `Content-Type`
* **Value:** `application/json`
* Then Save
### Spotify/Deezer/LastFM won't authenticate
Ensure any **client id** or **secrets** are correct in your configuration.
@@ -104,10 +60,6 @@ If multi-scrobbler is not running on the same machine your browser is on then th
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
### Deezer is not working
Deezer has discontinued support for their API and the Deezer Source is now [**deprecated.**](configuration/configuration.mdx#deezer) See [this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
### Youtube Music fails after some time
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
@@ -152,12 +104,6 @@ This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scr
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
### Jellyfin (Webhook) does not scrobble tracks with multiple artists correctly
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
You can avoid this entirely by using the newer [Jellyfin API Source](configuration/configuration.mdx#jellyfin) which correctly handles multiple artists.
### Google Cast track information is missing/incorrect or MS player has weird times
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"label": "Configuration",
"position": 3,
"position": 4,
"link": {
"type": "generated-index",
"description": "Configuring Multi-Scrobbler and examples"
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"label": "Development",
"position": 4,
"position": 5,
"link": {
"type": "generated-index",
"description": "Developing for Multi-Scrobbler and tutorials"
-2
View File
@@ -6,8 +6,6 @@ title: Flatpak
description: Building Flatpak App locally
---
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
## Install Requirements
### Flatpak and flatpak-builder
+106 -136
View File
@@ -14,127 +14,6 @@ For the difference between **ENV** and **File** examples in this document see [C
:::
## Local Installation
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
### Nodejs
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
npm run docs:install && npm run build
npm run start
```
#### Rollup build error
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
```json
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
}
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.9.6"
}
}
```
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```shell
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
```
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>`./config/config.json`</summary>
```json title="./config/config.json"
{
"sources": [
{
"type": "spotify",
"clients": ["myConfig"],
"name": "mySpotifySource",
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
}
}
],
"clients": [
{
"type": "maloja",
"name": "myConfig",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
}
}
],
}
```
</details>
```shell
npm run start
```
</TabItem>
</Tabs>
:::tip
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
:::
### Flatpak
:::warning
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
* to use a [Docker](#docker) installation if possible or
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
:::
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
```shell
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
```
</TabItem>
<TabItem value="file" label="File">
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
```shell
flatpak run io.github.foxxmd.multiscrobbler
```
</TabItem>
</Tabs>
## Docker
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
@@ -172,7 +51,7 @@ Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
You should bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
<details>
@@ -202,7 +81,7 @@ You **should** bind a host directory into the container for storing configuratio
#### Networking
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin). The default container port is `9078`.
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you must map a port to the container in order to access the dashboard and use MS with some sources (Webscrobbler, LFM/LZ Endpoints). The default container port is `9078`.
<details>
@@ -270,17 +149,11 @@ set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
### Linux Host
#### Linux Host
::::info
:::note
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) or are running docker on MacOS/Windows this **DOES NOT** apply to you.
:::
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
If you are running this container with **Docker** on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
@@ -299,7 +172,7 @@ See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-com
The example scenario:
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
* [Jellyfin **Source**](../configuration/configuration.mdx#jellyfin)
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
* Serving app on port `9078`
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
@@ -311,12 +184,12 @@ The example scenario:
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
docker run --name multi-scrobbler -e "JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c" -e "JELLYFIN_URL=192.168.0.101:8096" -e "JELLYFIN_USER=MyUser" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="file" label="File">
```bash
docker run -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
docker run --name multi-scrobbler -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
</Tabs>
@@ -331,8 +204,9 @@ The example scenario:
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c
- JELLYFIN_URL=192.168.0.101:8096
- JELLYFIN_USER=MyUser
- BASE_URL=http://192.168.0.100:9078
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
@@ -347,4 +221,100 @@ The example scenario:
</TabItem>
</Tabs>
## Local Installation
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
### Nodejs
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
npm install
npm run docs:install && npm run build
npm run start
```
#### Rollup build error
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
```json
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
}
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.9.6"
}
}
```
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```shell
JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c JELLYFIN_URL=192.168.0.101:8096 JELLYFIN_USER=MyUser MALOJA_URL="http://domain.tld" node src/index.js
```
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>`./config/config.json`</summary>
```json title="./config/config.json"
{
"sources": [
{
"type": "jellyfin",
"clients": ["myConfig"],
"name": "myJellyfinSource",
"data": {
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
"url": "http://192.168.0.101:8096",
"user": "MyUser"
}
}
],
"clients": [
{
"type": "maloja",
"name": "myConfig",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
}
}
],
}
```
</details>
```shell
npm run start
```
</TabItem>
</Tabs>
:::tip
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
:::
### Flatpak
:::warning[Unsupported]
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
:::
+34 -9
View File
@@ -57,20 +57,17 @@ services:
```
</TabItem>
<TabItem value="spotify" label="Spotify">
To access your Spotify activity you must [register a Spotify application](https://developer.spotify.com/dashboard) to get a
**Client ID/Secret**.
To access your Spotify activity you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
**Client ID/Secret** and set a valid **Redirect URI**.
Refer to the [Spotify Authentication instructions in the docs.](./configuration/configuration.mdx#authenticate-spotify-with-multi-scrobbler)
When creating the application add this to **Redirect URIs**
```
http://192.168.0.100:9078/callback
```
After the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
Assuming you used the **HTTPS** method for your **Redirect URI** from the instructions above...after the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- SPOTIFY_CLIENT_ID=yourClientId
- SPOTIFY_CLIENT_SECRET=yourClientSecret
- SPOTIFY_REDIRECT_URI=https://multi-scrobbler.mydomain.com/callback
```
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the Spotify card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
@@ -130,6 +127,34 @@ services:
- LASTFM_SECRET=myApiSecret
```
</TabItem>
<TabItem value="koito" label="Koito">
Setup a [Koito server](https://koito.io/guides/installation/) if you have not already done this.
* [Create (or get) an API Key](https://koito.io/guides/scrobbler/)
* From your Koito server dashboard navigate to **Settings** (bottom-left icon) => **API Keys**
* Create a new Key (or use an existing) and Copy the key using the copy icon to the right of the key
* EX `pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL`
* Take note of your Koito username (used below as `myUser`)
* Determine your Koito URL
* This is the URL you use to access your Koito dashboard OR whatever URL is accessible from your multi-scrobbler instance.
* EX dashboard at `http://192.168.0.100:4110` => replace `myURL` below with `http://192.168.0.100:4110`
* [More info on the URL to use is the full docs](./configuration/configuration.mdx#koito)
Add/Substitute values above into these examples values and add them to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- KOTIO_TOKEN=myToken
- KOTIO_USER=myUser
- KOITO_URL=myURL
```
:::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.
:::
</TabItem>
</Tabs>
## Start Multi-Scrobbler
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Updating",
"position": 3,
"link": {
"type": "generated-index",
"description": "Way to update Multi-Scrobbler"
}
}
+46
View File
@@ -0,0 +1,46 @@
---
sidebar_position: 1
title: 'Overview'
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
## Updating
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction/consideration 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.
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`
### 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>
<TabItem value="docker-compose" label="Docker Compose">
```shell
docker compose pull
docker compose up -d
```
</TabItem>
</Tabs>
### Nodejs
Assuming the [repository was used for initial installation with `git clone`](../installation/installation.mdx#nodejs), update by pulling the latest commit:
```
git pull
```
and restart multi-scrobbler.
+24 -2
View File
@@ -5,7 +5,14 @@ import * as themes from 'prism-react-renderer';
const config: Config = {
future: {
experimental_faster: true
v4: {
removeLegacyPostBuildHeadAttribute: true, // required
},
experimental_faster: {
ssgWorkerThreads: true,
rspackBundler: true, // required flag
rspackPersistentCache: true, // new flag
},
},
title: 'Multi-Scrobbler',
tagline: 'Scrobble all the things',
@@ -85,12 +92,14 @@ const config: Config = {
themeConfig:
{
// Replace with your project's social card
image: 'img/docusaurus-social-card.jpg',
image: 'img/icon.png',
navbar: {
title: 'Multi-Scrobbler',
hideOnScroll: true,
logo: {
alt: 'Logo',
src: 'img/icon.svg',
height: 40
},
items: [
{
@@ -194,5 +203,18 @@ if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
}
config.scripts.push(script)
}
if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
const script = {
src: process.env.RY_ANALYTICS,
defer: true
}
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)
}
export default config;
+2860 -2517
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -14,20 +14,20 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.6.3",
"@docusaurus/faster": "^3.7.0",
"@docusaurus/preset-classic": "^3.6.3",
"@easyops-cn/docusaurus-search-local": "^0.48.3",
"@docusaurus/core": "3.8.1",
"@docusaurus/faster": "3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@easyops-cn/docusaurus-search-local": "0.51.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-json-schema-plugin": "^1.12.1",
"docusaurus-json-schema-plugin": "^1.14.0",
"docusaurus-theme-github-codeblock": "^2.0.2",
"json5": "^2.2.3",
"micromark-extension-directive": "^3.0.1",
"prism-react-renderer": "^2.3.0",
"raw-loader": "^4.0.2",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
+5 -2
View File
@@ -23,7 +23,7 @@ const AIOExample = (props: AIOProps) => {
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data);
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
@@ -31,9 +31,12 @@ const AIOExample = (props: AIOProps) => {
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
}
configObj[0].type = name;
const configType = client ? 'clients' : 'sources';
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
configObj = configObj.map(x => ({...x, type: name}));
const aio = {[configType]: configObj};
return <CodeBlock title="CONFIG_DIR/config.json" language="json5">{JSON.stringify(aio, null, 2)}</CodeBlock>
}
+49
View File
@@ -0,0 +1,49 @@
import React, { Fragment } 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';
export interface FileProps extends Omit<CodeBlockProps, 'children'> {
data: string
client?: boolean
}
const FileExample = (props: FileProps) => {
const {
data,
client = false,
...rest
} = props;
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
<p>Example component crashed because of error!</p>
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
}
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
return <CodeBlock {...rest} language="json5">{JSON.stringify(configObj, null, 2)}</CodeBlock>
}
const WrappedFileExample = (props: FileProps) => {
return <ErrorBoundary
fallback={({error}) => (
<div>
<p>Example component crashed because of error: {error.message}.</p>
</div>
)}
><FileExample {...props} /></ErrorBoundary>
}
export default WrappedFileExample;
+19 -17
View File
@@ -10,38 +10,40 @@ title: Overview
<img src={require('/img/icon.png').default} align="right" alt="multi-scrobbler logo" width="180" height="180"/>
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), [ListenBrainz](https://listenbrainz.org), and [Koito](https://koito.io/).
* Supports scrobbling from many **Sources**
* [Spotify](docs/configuration#spotify)
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Azuracast](docs/configuration#azuracast)
* [Deezer](docs/configuration#deezer)
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
* [Icecast](docs/configuration#icecast)
* [Jellyfin](docs/configuration#jellyfin)
* [WebScrobbler](docs/configuration#webscrobbler)
* [Youtube Music](docs/configuration#youtube-music)
* [JRiver](docs/configuration#jriver)
* [Kodi](docs/configuration#kodi)
* [Koito](docs/configuration#koito-source)
* [Last.fm](docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [Deezer](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Maloja](docs/configuration#maloja-source)
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
* [Kodi](docs/configuration#kodi)
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
* [Musikcube](docs/configuration#musikcube)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Musikcube](docs/configuration#musikcube)
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
* [Spotify](docs/configuration#spotify)
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [WebScrobbler](docs/configuration#webscrobbler)
* [VLC](docs/configuration#vlc)
* [Azuracast](docs/configuration#azuracast)
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
* [Icecast](docs/configuration#icecast)
* [Youtube Music](docs/configuration#youtube-music)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Koito](docs/configuration#koito)
* [Last.fm](docs/configuration#lastfm)
* [ListenBrainz](docs/configuration#listenbrainz)
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
* [Maloja](docs/configuration#maloja)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports [Now Playing](docs/configuration#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)
+37 -38
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.5",
"version": "0.9.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.5",
"version": "0.9.11",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -25,7 +25,7 @@
"@gr2m/net-interceptor": "^1.0.0",
"@jellyfin/sdk": "^0.11.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@lukehagar/plexjs": "^0.32.1",
"@lukehagar/plexjs": "^0.39.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
@@ -86,7 +86,7 @@
"vite-express": "^0.16.0",
"vlc-client": "^1.1.1",
"xml2js": "0.6.1",
"youtubei.js": "^13.1.0"
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@dbus-types/notifications": "^0.0.5",
@@ -116,7 +116,7 @@
"@types/react-dom": "^18.2.7",
"@types/react-window": "^1.8.5",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^4.1.16",
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"chai": "^4.3.6",
@@ -1049,15 +1049,6 @@
"npm": ">=9.0.0"
}
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
@@ -1543,9 +1534,9 @@
"integrity": "sha512-vduQZw2ctS3kIuSnCSSRiE4J90Y8WShR9xVG+e1lvFWksU2aTxjdkArcQqJ+XLm22JS380OZmrIPY1U06TAsng=="
},
"node_modules/@lukehagar/plexjs": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.32.1.tgz",
"integrity": "sha512-cEIpepR1/BJYQey2lbzPhy342ZR/JmkChXKS+bOQkGLg1F17n45Zf650W67sBKMvF63effNVT0iUF3Fi3D7yPQ==",
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.39.0.tgz",
"integrity": "sha512-BXNHTh1Z9K0bb5qFKgYZv7gOC9+wYzOsatp/+DRGShGhp40bH0lTShzD6acKjqlIvqi2Pm7UJnF8+yBipE9Qmg==",
"peerDependencies": {
"zod": ">= 3"
}
@@ -2803,6 +2794,13 @@
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2934,13 +2932,16 @@
"dev": true
},
"node_modules/@types/superagent": {
"version": "4.1.24",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.24.tgz",
"integrity": "sha512-mEafCgyKiMFin24SDzWN7yAADt4gt6YawFiNMp0QS5ZPboORfyxFt0s3VzJKhTaKg9py/4FUmrHLTNfJKt9Rbw==",
"version": "8.1.9",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "*",
"@types/node": "*"
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/testing-library__jest-dom": {
@@ -6904,12 +6905,13 @@
}
},
"node_modules/jintr": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz",
"integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.3.1.tgz",
"integrity": "sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"acorn": "^8.8.0"
}
@@ -12038,29 +12040,26 @@
}
},
"node_modules/youtubei.js": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-13.1.0.tgz",
"integrity": "sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==",
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-15.0.1.tgz",
"integrity": "sha512-2slapqJS5NuXKHvcACEknyVz0AjH/TrXaOhDM0q2twQKa54kCmfj+7B/2nGfd20uzAe29zW1ejk2qOc4ABuGkg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"jintr": "^3.2.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
"jintr": "^3.3.1",
"undici": "^6.21.3"
}
},
"node_modules/youtubei.js/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
"node": ">=18.17"
}
},
"node_modules/zod": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.5",
"version": "0.9.11",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -57,7 +57,7 @@
"@gr2m/net-interceptor": "^1.0.0",
"@jellyfin/sdk": "^0.11.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@lukehagar/plexjs": "^0.32.1",
"@lukehagar/plexjs": "^0.39.0",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
@@ -118,7 +118,7 @@
"vite-express": "^0.16.0",
"vlc-client": "^1.1.1",
"xml2js": "0.6.1",
"youtubei.js": "^13.1.0"
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@dbus-types/notifications": "^0.0.5",
@@ -148,7 +148,7 @@
"@types/react-dom": "^18.2.7",
"@types/react-window": "^1.8.5",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^4.1.16",
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^4.2.1",
"chai": "^4.3.6",
+27 -4
View File
@@ -26,11 +26,13 @@ export type SourceType =
| 'kodi'
| 'webscrobbler'
| 'chromecast'
| 'maloja'
| 'musikcube'
| 'mpd'
| 'vlc'
| 'icecast'
| 'azuracast';
| 'azuracast'
| 'koito';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -51,11 +53,13 @@ export const sourceTypes: SourceType[] = [
'kodi',
'webscrobbler',
'chromecast',
'maloja',
'musikcube',
'mpd',
'vlc',
'icecast',
'azuracast'
'azuracast',
'koito'
];
export const isSourceType = (data: string): data is SourceType => {
@@ -67,16 +71,31 @@ export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export type ClientType =
'maloja'
| 'lastfm'
| 'listenbrainz';
| 'listenbrainz'
| 'koito';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
'listenbrainz'
'listenbrainz',
'koito'
];
export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
}
export interface ComponentIdentifier {
type: SourceType | ClientType
name: string
}
export interface SourceIdentifier extends ComponentIdentifier {
type: SourceType
}
export interface ClientIdentifier extends ComponentIdentifier {
type: ClientType
}
export type InitState = 0 | 1 | 2;
export const NOT_INITIALIZED: InitState = 0;
export const INITIALIZING: InitState = 1;
@@ -194,6 +213,10 @@ export const DEFAULT_POLLING_MAX_INTERVAL = 30;
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
export const DEFAULT_CLOSE_POSITION_ABSOLUTE = 12;
export const DEFAULT_CLOSE_POSITION_PERCENT = 0.15;
export const DEFAULT_DURATION_REPEAT_ABSOLUTE = 120;
export const DEFAULT_DURATION_REPEAT_PERCENT = 0.50;
export interface ScrobbleThresholdResult {
passes: boolean
duration: {
@@ -1,7 +1,8 @@
import { KoitoClientAIOConfig, KoitoClientConfig } from "./koito.js";
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig;
@@ -66,6 +66,21 @@ export interface UpstreamRefreshOptions {
refreshInitialCount?: number
}
export interface NowPlayingOptions {
/**
* Configure if this Client should report Now Playing from Sources that can scrobble to it
*
* * `true` (default) => Report Now Playing from any eligible Source.
* * If multiple Sources are Playing then reported Play is based on alphabetical order of Source names
* * `false` => Do not report Now Playing
* * `string` list => list of Source `names` that should be allowed to report Now Playing. Order of list determine priority of Play to Report.
*
* @default true
* */
nowPlaying?: boolean | string[]
}
export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions {
/**
@@ -1,3 +1,6 @@
import { RequestRetryOptions } from "../common.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
export interface ListensResponse {
items: ListenObjectResponse[]
total_record_count: number
@@ -27,4 +30,41 @@ export interface TrackResponse {
export interface ArtistResponse {
id: number
name: string
}
export interface KoitoData extends RequestRetryOptions {
/**
* URL for the Koito server
*
* @examples ["http://192.168.0.100:4110"]
* */
url: string
/**
* User token for the user to scrobble for
*
* @examples ["pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL"]
* */
token: string
/**
* Username of the user to scrobble for
* */
username: string
}
export interface KoitoClientData extends KoitoData, CommonClientData {}
export interface KoitoClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Koito as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: KoitoClientData
}
export interface KoitoClientAIOConfig extends KoitoClientConfig {
type: 'koito'
}
@@ -1,7 +1,7 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
export interface LastfmData extends RequestRetryOptions {
export interface LastfmData extends CommonClientData, RequestRetryOptions {
/**
* API Key generated from Last.fm account
*
@@ -27,6 +27,10 @@ export interface LastfmData extends RequestRetryOptions {
redirectUri?: string
}
export interface LastfmClientOptions extends CommonClientOptions, NowPlayingOptions {
}
export interface LastfmClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using LastFM as a client
@@ -35,7 +39,8 @@ export interface LastfmClientConfig extends CommonClientConfig {
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: CommonClientData & LastfmData
data: LastfmData
options?: LastfmClientOptions
}
export interface LastfmClientAIOConfig extends LastfmClientConfig {
@@ -1,7 +1,7 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
export interface MalojaData extends RequestRetryOptions {
/**
* URL for maloja server
*
@@ -16,7 +16,18 @@ export interface MalojaClientData extends RequestRetryOptions, CommonClientData
apiKey: string
}
export interface MalojaClientData extends MalojaData, CommonClientData {
}
export interface MalojaClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Maloja as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: MalojaClientData
}
@@ -0,0 +1,22 @@
import { KoitoData } from "../client/koito.js";
import { ListenBrainzData } from "../client/listenbrainz.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface KoitoSourceData extends KoitoData, CommonSourceData, PollingOptions {
}
export interface KoitoSourceConfig extends CommonSourceConfig {
/**
* When used in `koito.config` this tells multi-scrobbler whether to use this data to configure a source or client.
*
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
data: KoitoSourceData
}
export interface KoitoSourceAIOConfig extends KoitoSourceConfig {
type: 'koito'
}
@@ -0,0 +1,21 @@
import { MalojaData } from "../client/maloja.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions {
}
export interface MalojaSourceConfig extends CommonSourceConfig {
/**
* When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client.
*
* @default source
* @examples ["source"]
* */
configureAs?: 'source'
data: MalojaSourceData
}
export interface MalojaSourceAIOConfig extends MalojaSourceConfig {
type: 'maloja'
}
@@ -21,6 +21,8 @@ import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
export type SourceConfig =
@@ -44,12 +46,14 @@ export type SourceConfig =
| KodiSourceConfig
| WebScrobblerSourceConfig
| ChromecastSourceConfig
| MalojaSourceConfig
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig;
| AzuracastSourceConfig
| KoitoSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -72,9 +76,11 @@ export type SourceAIOConfig =
| KodiSourceAIOConfig
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
| MalojaSourceAIOConfig
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
| VLCSourceAIOConfig
| IcecastSourceAIOConfig
| AzuracastSourceAIOConfig;
| AzuracastSourceAIOConfig
| KoitoSourceAIOConfig;
@@ -55,6 +55,13 @@ export interface SubsonicData extends CommonSourceData, PollingOptions {
* @default false
* */
legacyAuthentication?: boolean
/**
* Only scrobble for specific users (case-insensitive)
*
* If undefined or an empty string/list MS will scrobble activity from all users
* */
usersAllow?: string | string[]
}
export interface SubSonicSourceConfig extends CommonSourceConfig {
data: SubsonicData
+3
View File
@@ -96,6 +96,8 @@ export class JRiverApiClient extends AbstractApiClient {
token?: string;
version?: string;
constructor(name: any, config: JRiverData, options: AbstractApiOptions) {
super('JRiver', name, config, options);
const {
@@ -130,6 +132,7 @@ export class JRiverApiClient extends AbstractApiClient {
try {
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
const {body: { data } = {}} = resp;
this.version = data.ProgramVersion;
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
return true;
} catch (e) {
+7 -2
View File
@@ -46,6 +46,8 @@ export class KodiApiClient extends AbstractApiClient {
token?: string;
version?: string;
declare client: KodiClient;
constructor(name: any, config: KodiData, options: AbstractApiOptions) {
@@ -84,7 +86,7 @@ export class KodiApiClient extends AbstractApiClient {
return url;
}
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions = {}): PlayObject {
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
const {newFromSource = true} = options;
const {
@@ -113,6 +115,8 @@ export class KodiApiClient extends AbstractApiClient {
},
meta: {
source: 'kodi',
mediaPlayerName: 'Kodi',
mediaPlayerVersion: options.version,
trackId: id.toString(),
newFromSource,
trackProgressPosition,
@@ -135,6 +139,7 @@ export class KodiApiClient extends AbstractApiClient {
// https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties
const applicationInfo = await this.client.Application.GetProperties(['version']);
this.version = `${applicationInfo.version.major}.${applicationInfo.version.minor}`;
this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`);
return true;
} catch (e) {
@@ -180,7 +185,7 @@ export class KodiApiClient extends AbstractApiClient {
const itemInfo = await this.getPlayerItem(audioActive.playerid);
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive});
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive}, {version: this.version});
return [play];
}
+71 -91
View File
@@ -1,10 +1,7 @@
import dayjs, { Dayjs } from "dayjs";
import LastFm, {
AuthGetSessionResponse,
LastfmTrackUpdateRequest,
NowPlayingPayload,
NowPlayingResponse,
TrackObject,
LastfmTrackUpdateRequest, TrackObject,
TrackScrobblePayload,
UserGetInfoResponse
} from "lastfm-node-client";
@@ -74,11 +71,20 @@ export default class LastfmApiClient extends AbstractApiClient {
} = obj;
// arbitrary decision yikes
const artistStrings = splitByFirstFound(artists, [','], [artistName]);
let al = album;
if(al !== undefined) {
if(al === null) {
al = undefined;
} else if (al.trim() === '') {
// lastfm may provide empty string when album data is not defined
al = undefined;
}
}
return {
data: {
artists: [...new Set(artistStrings)] as string[],
track: title,
album,
album: al,
duration,
playDate: time !== undefined ? dayjs.unix(time) : undefined,
meta: {
@@ -142,9 +148,7 @@ export default class LastfmApiClient extends AbstractApiClient {
}
}
getAuthUrl = () => {
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
}
getAuthUrl = () => `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
authenticate = async (token: any) => {
const sessionRes: AuthGetSessionResponse = await this.client.authGetSession({token});
@@ -200,88 +204,6 @@ export default class LastfmApiClient extends AbstractApiClient {
throw e;
}
}
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
const {
data: {
artists = [],
album,
albumArtists = [],
track,
duration,
playDate,
meta: {
brainz: {
track: mbid
} = {},
} = {}
} = {}
} = playObj;
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
let artist: string;
if (artists.length === 0) {
artist = "";
} else {
artist = artists[0];
}
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
if(duration !== 0) {
additionalRichPayload.duration = duration;
}
const rawPayload: TrackScrobblePayload = {
artist: artist,
track,
album,
timestamp: getScrobbleTsSOCDate(playObj).unix(),
mbid,
...additionalRichPayload
};
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
if (albumArtists.length > 0) {
rawPayload.albumArtist = albumArtists[0];
}
// I don't know if its lastfm-node-client building the request params incorrectly
// or the last.fm api not handling the params correctly...
//
// ...but in either case if any of the below properties is undefined (possibly also null??)
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
// so remove all undefined keys from the object before passing to the api client
return removeUndefinedKeys(rawPayload);
}
updateNowPlaying = async (play: PlayObject) => {
try {
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
return client.trackUpdateNowPlaying(rest)
});
const {
nowplaying: {
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
}
return response;
} catch (e) {
if (!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
} else {
throw e;
}
}
}
}
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
@@ -339,4 +261,62 @@ export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject
}
return play;
}
}
export const playToClientPayload = (playObj: PlayObject): TrackScrobblePayload => {
const {
data: {
artists = [],
album,
albumArtists = [],
track,
duration,
playDate,
meta: {
brainz: {
track: mbid
} = {},
} = {}
} = {}
} = playObj;
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
let artist: string;
if (artists.length === 0) {
artist = "";
} else {
artist = artists[0];
}
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
if(duration !== 0) {
additionalRichPayload.duration = duration;
}
const rawPayload: TrackScrobblePayload = {
artist: artist,
track,
album,
timestamp: getScrobbleTsSOCDate(playObj).unix(),
mbid,
...additionalRichPayload
};
// LFM ignores scrobbles where album artist is VA
// https://github.com/FoxxMD/multi-scrobbler/issues/340#issuecomment-3220774257
const nonVaAlbumArtists = albumArtists.filter(x => x.trim().toLocaleLowerCase() !== 'va');
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
if (nonVaAlbumArtists.length > 0) {
rawPayload.albumArtist = nonVaAlbumArtists[0];
}
// I don't know if its lastfm-node-client building the request params incorrectly
// or the last.fm api not handling the params correctly...
//
// ...but in either case if any of the below properties is undefined (possibly also null??)
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
// so remove all undefined keys from the object before passing to the api client
return removeUndefinedKeys(rawPayload);
}
+191 -137
View File
@@ -1,10 +1,11 @@
import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject, URLData } from "../../../core/Atomic.js";
import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
normalizeListenbrainzUrl,
normalizeStr,
parseArtistCredits,
parseCredits,
@@ -17,90 +18,15 @@ import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions }
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
import AbstractApiClient from "./AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
import { parseRegexSingleOrFail } from '../../utils.js';
import { removeUndefinedKeys } from '../../utils.js';
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
import { version } from '../../ioc.js';
import { ListenPayload, ListenResponse, ListenType, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from './listenbrainz/interfaces.js';
export interface ArtistMBIDMapping {
artist_credit_name: string
artist_mbid: string
join_phrase: string
}
export interface MinimumTrack {
artist_name: string;
track_name: string;
release_name?: string;
}
export interface AdditionalTrackInfo {
artist_mbids?: string[]
release_mbid?: string
release_group_mbid?: string
recording_mbid?: string
submission_client?: string
submission_client_version?: string
spotify_id?: string
media_player?: string
media_player_version?: string
music_service?: string
music_service_name?: string
origin_url?: string
tags?: string[]
duration?: number
duration_ms?: number
track_mbid?: string
work_mbids?: string[]
}
export interface Track {
artist_name: string;
track_name: string;
release_name?: string;
artist_mbids?: string[];
artist_msid?: string;
recording_mbid?: string;
release_mbid?: string;
release_msid?: string;
tags?: string[];
duration?: number
}
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: string
}
export interface TrackPayload extends MinimumTrack {
additional_info?: AdditionalTrackInfo
}
export interface ListenPayload {
listened_at: Date | number;
recording_msid?: string;
track_metadata: TrackPayload;
}
export interface SubmitPayload {
listen_type: 'single' | 'playing_now',
payload: [ListenPayload]
}
export interface TrackResponse extends MinimumTrack {
duration: number
additional_info: AdditionalTrackInfoResponse
mbid_mapping: {
recording_name?: string
artist_mbids?: string[]
artists?: ArtistMBIDMapping[]
caa_id?: number
caa_release_mbid?: string
recording_mbid?: string
release_mbid?: string
}
interface SubmitOptions {
log?: boolean
listenType?: ListenType
}
export interface ListensResponse {
@@ -108,16 +34,6 @@ export interface ListensResponse {
listens: ListenResponse[];
}
export interface ListenResponse {
inserted_at: number
listened_at: number;
recording_msid?: string;
track_metadata: TrackResponse;
}
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
export class ListenbrainzApiClient extends AbstractApiClient {
declare config: ListenBrainzClientData;
@@ -130,9 +46,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
url = 'https://api.listenbrainz.org/'
} = config;
let cleanUrl = url;
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
if(pathedUrl !== undefined) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
cleanUrl = url.replace(LZ_VERSION_PATH, '');
cleanUrl = pathedUrl;
}
this.url = normalizeWebAddress(cleanUrl);
@@ -216,7 +133,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
try {
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.');
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play. Please consider switching to the full Koito Source/Client.');
return true;
} catch (e) {
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
@@ -307,9 +224,13 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
submitListen = async (play: PlayObject, log: boolean = false) => {
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
const { log = false, listenType = 'single'} = options;
try {
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]};
if(listenType === 'playing_now') {
delete listenPayload.payload[0].listened_at;
}
if(log) {
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
}
@@ -327,45 +248,13 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
static playToListenPayload(play: PlayObject): ListenPayload {
const {
data: {
playDate,
artists = [],
// MB doesn't use this during submission AFAIK
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
albumArtists = [],
album,
track,
duration,
meta: {
brainz = {}
} = {}
}
} = play;
return {
listened_at: getScrobbleTsSOCDate(play).unix(),
track_metadata: {
artist_name: artists[0],
track_name: track,
release_name: album,
additional_info: {
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
track_mbid: brainz.track,
artist_mbids: brainz.artist,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup
}
}
}
}
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
const {
listened_at = dayjs().unix(),
track_metadata: {
artist_name,
track_name,
release_name,
additional_info: {
duration,
track_mbid,
@@ -381,6 +270,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
track: track_name,
artists: [artist_name],
album: release_name,
duration,
meta: {
brainz: {
@@ -406,7 +296,12 @@ export class ListenbrainzApiClient extends AbstractApiClient {
mbid_mapping: {
recording_name,
artists: artistMappings = [],
recording_mbid: mRecordingMbid
recording_mbid: mRecordingMbid,
release_mbid
} = {},
additional_info: {
release_artists_names = [],
release_group_mbid
} = {}
} = {}
} = listen;
@@ -428,6 +323,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
let filteredSubmittedArtistName = artist_name;
let filteredSubmittedTrackName = track_name;
let primaryArtist;
if(artistMappings.length > 0) {
// first get mapped artists they have joiners we usually look for
@@ -524,7 +421,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
// now we check that primary artist exists in mapped artists
const candidatedPrimaryArtist = artistsFromUserValues[0];
const normalizedCandidatePrimary = normalizeStr(candidatedPrimaryArtist);
const primaryArtist = mappedArtists.find(x => {
primaryArtist = mappedArtists.find(x => {
if(normalizeStr(x) === normalizedCandidatePrimary)
{
return true;
@@ -544,6 +441,12 @@ export class ListenbrainzApiClient extends AbstractApiClient {
// now we have the primary artist matched!
// at this point we assume any differences between user and MB artists is a data discrepancy rather than being outright wrong
/*
*
* Can safely use musicbrainz metadata beyond this point!
*
*/
// next we match up user artists with mapped artists in order to use "more correct" spelling/punctuation/etc. from MB mapping
const mappedUserArtists: string[] = [];
for(const userArtist of artistsFromUserValues) {
@@ -605,14 +508,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
normalTrackName = recording_name;
}
return {
const brainzMetaRaw: BrainzMeta = {
...(naivePlay.data.meta.brainz ?? {}),
artist: artistMappings.map(x => x.artist_mbid),
album: release_mbid,
releaseGroup: release_group_mbid
}
// this should always find an artist but to be safe...
const primaryArtistMBMapping = artistMappings.find(x => x.artist_credit_name === primaryArtist);
if(primaryArtistMBMapping !== undefined) {
// only include as primary if musicbrainz does not disagree with us
if(release_artists_names.length === 0 || (release_artists_names.length > 0 && release_artists_names.includes(primaryArtist))) {
brainzMetaRaw.albumArtist = primaryArtistMBMapping.artist_mbid;
}
}
const brainzMeta = removeUndefinedKeys(brainzMetaRaw);
const play: PlayObject = {
data: {
...naivePlay.data,
track: normalTrackName,
artists: derivedArtists,
artists: derivedArtists
},
meta: naivePlay.meta
}
if(brainzMeta !== undefined) {
play.data.meta = {
brainz: brainzMeta
}
}
return play;
}
/**
@@ -639,7 +568,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
} = {},
mbid_mapping: {
recording_mbid: mRecordingMbid
} = {}
} = {},
additional_info = {},
} = {}
} = listen;
@@ -670,7 +600,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
artists = uniqueNormalizedStrArr(artists);
return {
const play: PlayObject = {
data: {
playDate: dayjs.unix(listened_at),
track: normalTrackName,
@@ -680,11 +610,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
},
meta: {
source: 'listenbrainz',
trackId,
playId,
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
}
}
const brainzMeta: BrainzMeta = {};
if(Object.keys(additional_info).length > 0) {
brainzMeta.additionalInfo = additional_info;
}
// we shouldn't include more metdata here because we don't know if the MB mapped data is actually correct
if(trackId !== undefined) {
brainzMeta.track = trackId;
play.meta.trackid = trackId;
}
if(Object.keys(brainzMeta).length > 0) {
play.data.meta = {
brainz: brainzMeta
}
}
return play;
}
static submitToPlayObj(submitObj: SubmitPayload, playObj: PlayObject): PlayObject {
@@ -706,3 +654,109 @@ export class ListenbrainzApiClient extends AbstractApiClient {
return ListenbrainzApiClient.listenResponseToPlay(obj);
}
}
export const playToListenPayload = (play: PlayObject): ListenPayload => {
const {
data: {
playDate,
artists = [],
// MB doesn't use this during submission AFAIK
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
albumArtists = [],
album,
track,
duration,
meta: {
brainz = {},
spotify = {},
} = {}
},
meta: {
mediaPlayerName,
mediaPlayerVersion,
musicService,
}
} = play;
// using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
const msAdditionalInfo = brainz.additionalInfo ?? {};
let addInfo: SubmitListenAdditionalTrackInfo = {
// all artists
artist_names: Array.from(new Set([...artists, ...albumArtists])),
// primary artist
release_artist_name: artists[0],
release_artist_names: [artists[0]],
// use data from LZ response, if this Play was originally from LZ Source
media_player: mediaPlayerName ?? msAdditionalInfo.media_player,
media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version,
music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service,
spotify_id: spotify.track ?? msAdditionalInfo.spotify_id,
spotify_album_id: spotify.album ?? msAdditionalInfo.spotify_album_id,
spotify_artist_ids: spotify.artist ?? msAdditionalInfo.spotify_artist_ids
};
addInfo = removeUndefinedKeys(addInfo)
// possible lastfm provides an empty album field when no album data is found
let al = album;
if(al !== undefined && al !== null) {
if(al.trim() === '') {
al = undefined;
}
}
const minTrackData: MinimumTrack = removeUndefinedKeys({
artist_name: Array.from(new Set([...artists, ...albumArtists])).join(', '),
track_name: track,
release_name: al,
});
return {
listened_at: getScrobbleTsSOCDate(play).unix(),
track_metadata: {
...minTrackData,
additional_info: {
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
track_mbid: brainz.track,
recording_mbid: brainz.track,
artist_mbids: brainz.artist,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup,
submission_client: 'multi-scrobbler',
submission_client_version: version,
...addInfo
}
}
}
}
const musicServices = {
spotify: 'spotify.com',
bandcamp: 'bandcamp.com',
['youtube music']: 'music.youtube.com',
youtube: 'youtube.com',
deezer: 'deezer.com',
tidal: 'tidal.com',
apple: 'music.apple.com',
archive: 'archive.org',
soundcloud: 'soundcloud.com',
jamendo: 'jamendo.com',
play: 'play.google.com'
}
/**
* Converts MS musicService to LZ cononical Music Service Name, if one exists
* @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details
* */
const musicServiceToCononical = (str: string): string | undefined => {
const lower = str.trim().toLocaleLowerCase();
for(const [k, v] of Object.entries(musicServices)) {
if(lower.includes(k)) {
return v;
}
}
return undefined;
}
+178 -8
View File
@@ -1,19 +1,186 @@
import dayjs from "dayjs";
import { PlayObject } from "../../../../core/Atomic.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
import { PlayObject, URLData } from "../../../../core/Atomic.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js";
import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
import request, { Request, Response } from 'superagent';
import { UpstreamError } from "../../errors/UpstreamError.js";
import { playToListenPayload } from "../ListenbrainzApiClient.js";
import { SubmitPayload } from '../listenbrainz/interfaces.js';
import { ListenType } from '../listenbrainz/interfaces.js';
import { parseRegexSingleOrFail } from "../../../utils.js";
interface SubmitOptions {
log?: boolean
listenType?: ListenType
}
const KOITO_LZ_PATH: RegExp = new RegExp(/^\/apis\/listenbrainz(\/?1?\/?)?$/);
export class KoitoApiClient extends AbstractApiClient {
constructor(name: any, config: any, options: AbstractApiOptions) {
super('Koito', name, config, options);
declare config: KoitoData;
url: URLData;
constructor(name: any, config: KoitoData, options: AbstractApiOptions) {
super('Koito', name, config, options);
const {
url
} = this.config;
const u = normalizeWebAddress(url);
if(u.url.pathname === '/') {
this.url = u;
} else if(parseRegexSingleOrFail(KOITO_LZ_PATH, u.url.pathname) !== undefined) {
this.logger.verbose('Detected Koito Server URL path only contains listenbrainz prefix. Removing this for API calls so non-listenbrainz paths work correctly.');
this.url = normalizeWebAddress(getBaseFromUrl(u.url).toString());
} else {
this.logger.verbose('It looks like Koito server URL contains non-standard parts (not "just" /apis/listenbrainz"). MS will assume this is the ROOT path for the Koito server.');
this.url = u;
}
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
}
callApi = async <T = Response>(req: Request, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
req.set('Authorization', `Token ${this.config.token}`);
return await req as T;
} catch (e) {
const {
message,
err,
status,
response: {
body = undefined,
text = undefined,
} = {}
} = e;
// TODO check err for network exception
if (status !== undefined) {
const statusMsg = `(HTTP Status ${status})`;
const msgParts = [];
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
const showStopper = status !== 400;
if (body !== undefined) {
if (typeof body === 'object') {
if ('code' in body) {
msgParts.push(`Code ${body.code}`);
}
if ('error' in body) {
msgParts.push(`Error => ${body.error}`);
}
if ('message' in body) {
msgParts.push(`Message => ${body.error}`);
}
} else if (typeof body === 'string') {
msgParts.push(`Response => ${body}`);
}
}
if (msgParts.length === 0 && text !== undefined && text.trim() !== '') {
msgParts.push(`Response => ${text}`);
}
throw new UpstreamError(`Koito API Request Failed => ${[statusMsg, ...msgParts].join(' | ')}`, { cause: e, showStopper });
}
throw e;
}
}
testConnection = async () => {
try {
await isPortReachableConnect(this.url.port, { host: this.url.url.hostname });
} catch (e) {
throw new Error(`Koito server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e });
}
try {
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url, 'apis/web/v1/stats')}`));
if(resp.type !== 'application/json') {
throw new Error(`Expected response from ${resp.request.url} to be 'application/json' but got ${resp.type}. Is the Normalized Koito URL correct?`);
}
} catch (e) {
let allowedHint = '';
if(e.cause !== undefined && 'response' in e.cause) {
if(e.cause.response.status === 403) {
allowedHint = ` HINT: 403 usually means Koito env KOITO_ALLOWED_HOSTS is not configured correctly to allowed requests from multi-scrobbler. Check Koito logs for warnings.`
}
}
throw new Error(`A server exists at ${this.url.url.hostname}:${this.url.port} but is not responding to API calls as expected.${allowedHint}`, { cause: e });
}
}
testAuth = async () => {
try {
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`));
return true;
} catch (e) {
throw new Error('Could not validate Koito API Key', { cause: e });
}
}
getUserListens = async (maxTracks: number): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${joinedUrl(this.url.url, '/apis/web/v1/listens')}`)
.query({
period: 'all_time',
page: 0,
limit: maxTracks
})
);
const { body } = resp as any;
return body as ListensResponse;
} catch (e) {
throw e;
}
}
getRecentlyPlayed = async (maxTracks: number): Promise<PlayObject[]> => {
try {
const resp = await this.getUserListens(maxTracks);
return resp.items.map(x => listenObjectResponseToPlay(x, { url: this.url.url }));
} catch (e) {
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
return [];
}
}
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
const { log = false, listenType = 'single' } = options;
try {
const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] };
if (listenType === 'playing_now') {
delete listenPayload.payload[0].listened_at;
}
if (log) {
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
}
// response consists of {"status": "ok"}
// so no useful information
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
// TODO may we should make a call to recent-listens to get the parsed scrobble?
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/submit-listens')}`).type('json').send(listenPayload));
if (log) {
this.logger.debug(`Submit Response: ${resp.text}`)
}
return listenPayload;
} catch (e) {
throw e;
}
}
}
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => {
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => {
const play: PlayObject = {
data: {
track: obj.track.title,
@@ -24,10 +191,13 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {
meta: {
source: 'Koito',
newFromSource: options.newFromSource ?? false,
trackId: obj.track.id.toString()
trackId: obj.track.id.toString(),
url: {
web: options.url !== undefined ? joinedUrl(options.url, `/track/${obj.track.id.toString()}`).toString() : undefined
}
}
}
if(obj.track.musicbrainz_id !== null) {
if (obj.track.musicbrainz_id !== null) {
play.data.meta = {
brainz: {
track: obj.track.musicbrainz_id
+161
View File
@@ -0,0 +1,161 @@
/*
* https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview
*/
/** A unique product a Recording is issued on.
*
* This is like an album (release group) but is specific to the type, year, catalog, etc... for this release
*
* EX: 1984 US release of "The Wall" by "Pink Floyd", release on label "Columbia Records" with catalog number "C2K 36183"
*
* @see https://musicbrainz.org/doc/Release
*
* Referred to in MB api response as release_mbid
*
*/
export type ReleaseMbid = string;/** The "abstract", non-unique album/single/EP the Recording belongs to
*
* This is what people normally think of as an album (release group)
*
* EX: "The Wall" by "Pink Floyd"
*
* @see https://musicbrainz.org/doc/Release
*
* Referred to in MB api response as release_group -> mbid
*
*/
export type ReleaseGroupMbid = string;
/** A unique mix/edit/master of a Work
*
* This is like a song but is unique to the master/edit of the song
*
*
* * Album version of the track "Into the Blue" by "Moby"
* * Remix "Into the Blue (Buzz Boys Main Room Mayhem mix)" by "Moby"
*
* @see https://musicbrainz.org/doc/Recording
*
* Referred to in MB api response as recording_mbid
*/
export type RecordingMbid = string;
/** The "abstract", non-unique Song produced by an Artist
*
* All Recordings "belong" to a single Work
*
* EX: Song "Into the Blue" by "Moby"
*
* @see Song "Into the Blue" by "Moby"
*/
export type WorkMbid = string;
/** A musician or group or musicians that release music
*
* @see https://musicbrainz.org/doc/Artist
*
* MB does not distinguish between Artist and Album Artists in API responses except for by release_artist_name in additional_info
* All artists/album artists are included in mbid_mappings artists
*
*/
export type ArtistMbid = string;
/** A unique, random identifier used for each scrobble. Not the same as recording_mbid */
export type RecordingMsid = string;
export interface ArtistMBIDMapping {
artist_credit_name: string;
artist_mbid: ArtistMbid;
join_phrase: string;
}
export interface MinimumTrack {
artist_name: string;
track_name: string;
release_name?: string;
}
export interface AdditionalTrackInfo {
artist_mbids?: ArtistMbid[];
release_mbid?: ReleaseMbid;
release_group_mbid?: ReleaseGroupMbid;
recording_mbid?: RecordingMbid;
submission_client?: string;
submission_client_version?: string;
spotify_id?: string;
media_player?: string;
media_player_version?: string;
music_service?: string;
music_service_name?: string;
origin_url?: string;
tags?: string[];
duration?: number;
duration_ms?: number;
track_mbid?: string;
work_mbids?: WorkMbid[];
}
export interface Track {
artist_name: string;
track_name: string;
release_name?: string;
artist_mbids?: ArtistMbid[];
artist_msid?: ArtistMbid;
recording_mbid?: string;
release_mbid?: ReleaseMbid;
release_msid?: string;
tags?: string[];
duration?: number;
}
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: RecordingMsid;
release_artist_name?: string;
release_artists_names?: string;
spotify_album_id?: string;
spotify_album_artist_ids?: string[];
spotify_artist_ids?: string[];
}
// using submit-listens example from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
export interface SubmitListenAdditionalTrackInfo extends AdditionalTrackInfo {
artist_names?: string[];
release_artist_name?: string;
release_artist_names?: string[];
spotify_album_id?: string;
spotify_album_artist_ids?: string[];
spotify_artist_ids?: string[];
albumartist?: string;
}
export interface TrackPayload extends MinimumTrack {
additional_info?: SubmitListenAdditionalTrackInfo;
}
export interface ListenPayload {
listened_at: Date | number;
recording_msid?: RecordingMsid;
track_metadata: TrackPayload;
}
export type ListenType = 'single' | 'playing_now';
export interface SubmitPayload {
listen_type: ListenType;
payload: [ListenPayload];
}
export interface TrackResponse extends MinimumTrack {
duration: number;
additional_info: AdditionalTrackInfoResponse;
mbid_mapping: {
recording_name?: string;
artist_mbids?: ArtistMbid[];
artists?: ArtistMBIDMapping[];
caa_id?: number;
/** cover album archive mbid, not related to anything else I think */
caa_release_mbid?: string;
recording_mbid?: RecordingMbid;
release_mbid?: ReleaseMbid;
};
}
export interface ListenResponse {
inserted_at: number;
listened_at: number;
recording_msid?: RecordingMsid;
track_metadata: TrackResponse;
}
+422
View File
@@ -0,0 +1,422 @@
import dayjs from 'dayjs';
import request, { SuperAgentRequest, Response } from 'superagent';
import compareVersions from "compare-versions";
import AbstractApiClient from "../AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
import { MalojaData } from "../../infrastructure/config/client/maloja.js";
import { PlayObject, URLData } from "../../../../core/Atomic.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
import { isNodeNetworkException } from "../../errors/NodeErrors.js";
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep } from "../../../utils.js";
import { UpstreamError } from "../../errors/UpstreamError.js";
import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js";
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js';
import { buildTrackString } from '../../../../core/StringUtils.js';
export class MalojaApiClient extends AbstractApiClient {
declare config: MalojaData;
url: URLData;
serverVersion: any;
constructor(name: any, config: MalojaData, options: AbstractApiOptions) {
super('Maloja', name, config, options);
const {
url
} = this.config;
const u = normalizeWebAddress(url);
this.url = u;
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
}
callApi = async <T = Response>(req: SuperAgentRequest, retries = 0): Promise<T> => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
return await req as T;
} catch (e) {
if ((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
if (retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
} else {
throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, { cause: e, showStopper: true });
}
} else if (isSuperAgentResponseError(e)) {
const {
message,
response: {
status,
body,
} = {},
response,
} = e;
if (isMalojaAPIErrorBody(body)) {
throw new UpstreamError(buildMalojaErrorString(body), { cause: e })
} else {
throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, { cause: e })
}
} else {
throw new Error('Unexpected error occurred during API call', { cause: e });
}
}
}
testConnection = async () => {
try {
await isPortReachableConnect(this.url.port, { host: this.url.url.hostname });
} catch (e) {
throw new Error(`Maloja server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e });
}
try {
const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`));
const {
statusCode,
body: {
version = [],
versionstring = '',
} = {},
} = serverInfoResp;
if (statusCode >= 300) {
throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
}
this.logger.info('Communication test succeeded.');
if (version.length === 0) {
throw new Error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. Maloja versions below 3.0.0 are not supported.');
} else {
this.logger.info(`Maloja Server Version: ${versionstring}`);
this.serverVersion = versionstring;
if (compareVersions(versionstring, '3.0.0') < 0) {
throw new Error(`Maloja versions below 3.0.0 are not supported.`);
} else if (compareVersions(versionstring, '3.2.0') < 0) {
this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
}
}
return true;
} catch (e) {
throw new Error('Communication test failed', { cause: e })
}
}
testHealth = async () => {
try {
const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`), 0);
const {
statusCode,
body: {
db_status: {
healthy = false,
rebuildinprogress = false,
complete = false,
}
} = {},
} = serverInfoResp;
if (statusCode >= 300) {
throw new Error(`Server responded with NOT OK status: ${statusCode}`);
}
if (rebuildinprogress) {
throw new Error(`Server is rebuilding database`);
}
if (!healthy) {
throw new Error('Server responded that it is not healthy');
}
return true
} catch (e) {
throw new Error('Error encountered while testing server health', { cause: e });
}
}
testAuth = async () => {
try {
const resp = await this.callApi(request
.get(`${this.url.url}/apis/mlj_1/test`)
.query({ key: this.config.apiKey }));
const {
status,
body: {
status: bodyStatus,
} = {},
body = {},
text = '',
} = resp;
if (bodyStatus.toLocaleLowerCase() === 'ok') {
this.logger.info('Auth test passed!');
return true;
} else {
this.logger.error('Maloja API Response', {
status,
body,
text: text.slice(0, 50)
});
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
}
} catch (e) {
throw e;
}
}
getRecentScrobbles = async (limit: number) => {
const resp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/scrobbles?perpage=${limit}`));
const {
body: {
list = [],
} = {},
} = resp;
return list.map(formatPlayObj);
}
scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => {
const {
data: {
album,
albumArtists = [],
duration,
} = {},
meta: {
newFromSource = false,
} = {}
} = playObj;
const sType = newFromSource ? 'New' : 'Backlog';
const pd = getScrobbleTsSOCDate(playObj);
const scrobbleData = playToScrobblePayload(playObj, this.config.apiKey);
try {
const response = await this.callApi(request.post(`${this.url.url}/apis/mlj_1/newscrobble`)
.type('json')
.send(scrobbleData));
let scrobbleResponse: MalojaScrobbleData,
scrobbledPlay: PlayObject;
let responseBody: MalojaScrobbleV3ResponseData;
let warnStr: string;
responseBody = response.body;
const {
track,
status,
warnings = [],
} = responseBody;
if (status === 'success') {
if (track !== undefined) {
scrobbleResponse = {
time: pd.unix(),
track: {
...track,
length: duration
},
}
if (album !== undefined) {
const {
album: malojaAlbum = {},
} = track;
scrobbleResponse.track.album = {
name: album,
artists: albumArtists,
...malojaAlbum,
}
}
}
if (warnings.length > 0) {
for (const w of warnings) {
warnStr = builMalojadWarningString(w);
if (warnStr.includes('The submitted scrobble was not added')) {
throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false });
}
this.logger.warn(`Maloja Warning: ${warnStr}`);
}
}
} else {
throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false });
}
return [scrobbleResponse, responseBody, warnStr]
} catch (e) {
this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData });
const responseError = getMalojaResponseError(e);
if (responseError !== undefined) {
if (responseError.status < 500 && e instanceof UpstreamError) {
e.showStopper = false;
}
if (responseError.response?.text !== undefined) {
this.logger.error('Raw Response:', { text: responseError.response?.text });
}
}
throw e;
}
}
}
export const buildMalojaErrorString = (body: MalojaResponseV3CommonData) => {
let valString: string | undefined = undefined;
const {
status,
error: {
type,
value,
desc
} = {}
} = body;
if (value !== undefined && value !== null) {
if (typeof value === 'string') {
valString = value;
} else if (Array.isArray(value)) {
valString = value.map(x => {
if (typeof x === 'string') {
return x;
}
return JSON.stringify(x);
}).join(', ');
} else {
valString = JSON.stringify(value);
}
}
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
}
export const builMalojadWarningString = (w: MalojaScrobbleWarning): string => {
const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
let vals: string[] = [];
if (w.value !== null && w.value !== undefined) {
if (Array.isArray(w.value)) {
vals = w.value;
} else {
vals.push(w.value);
}
}
if (vals.length > 0) {
parts.push(vals.join(' | '));
}
return parts.join(' => ');
}
export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject => {
let artists,
title,
album,
duration,
time,
listenedFor;
const { url } = options;
// scrobble data structure changed for v3
const {
// when the track was scrobbled
time: mTime,
track: {
artists: mArtists = [],
title: mTitle,
album: mAlbum,
// length of the track
length: mLength,
} = {},
// how long the track was listened to before it was scrobbled
duration: mDuration,
} = obj;
artists = mArtists;
time = mTime;
title = mTitle;
duration = getNonEmptyVal(mLength);
listenedFor = getNonEmptyVal(mDuration);
if (mAlbum !== null) {
const {
albumtitle,
name: mAlbumName,
artists: albumArtists = []
} = mAlbum || {};
album = albumtitle ?? mAlbumName;
}
const artistStrings = artists.reduce((acc: any, curr: any) => {
let aString;
if (typeof curr === 'string') {
aString = curr;
} else if (typeof curr === 'object') {
aString = curr.name;
}
const aStrings = aString.split(',');
return [...acc, ...aStrings];
}, []);
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
return {
data: removeUndefinedKeys({
artists: [...new Set(artistStrings)] as string[],
track: title,
album,
duration,
listenedFor,
playDate: dayjs.unix(time),
}),
meta: {
source: 'Maloja',
url: {
web: `${url}/track?${urlParams.toString()}`
}
}
}
}
export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => {
const {
data: {
artists = [],
albumArtists = [],
album,
track,
duration,
listenedFor
} = {}
} = playObj;
const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
const scrobbleData: MalojaScrobbleV3RequestData = {
title: track,
artists,
album,
key: apiKey,
time: pd.unix(),
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
length: duration,
};
if (listenedFor !== undefined && listenedFor > 0) {
scrobbleData.duration = listenedFor;
}
if (albumArtists.length > 0) {
scrobbleData.albumartists = albumArtists;
}
return scrobbleData;
}
+4 -23
View File
@@ -3,20 +3,6 @@ import { ResponseError } from "superagent";
import { findCauseByFunc } from "../../../utils/ErrorUtils.js";
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
export interface MalojaV2ScrobbleData {
artists: string[]
title: string
album: string
/**
* Length of the track
* */
duration: number
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
}
export interface MalojaAlbumData {
name?: string
albumtitle?: string
@@ -40,14 +26,14 @@ export interface MalojaV3ScrobbleData {
/**
* how long the track was listened to before it was scrobbled
* */
duration: number
duration?: number
}
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export type MalojaScrobbleData = MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
/** The auth key used to scrobble */
key: string
key?: string
/** name of the track */
title: string
/** name of the album */
@@ -60,16 +46,11 @@ export interface MalojaScrobbleRequestData {
duration?: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
/** comma-separated list of artists for this track */
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
/** a list of artists for this track */
artists: string[]
/** a list of artists for the album the track is on */
albumartists: string[]
albumartists?: string[]
/** skip server-side metadata parsing */
nofix?: boolean
}
+6
View File
@@ -61,8 +61,14 @@ export type Metadata =
}
| Record<string, never>;
export interface Connector {
id: string
js: string
label: string
}
export interface WebScrobblerSong {
controllerTabId: string | number;
connector: Connector
parsed: ParsedSongData;
processed: ProcessedSongData;
noRegex: ProcessedSongData;
+1 -1
View File
@@ -12,7 +12,7 @@ import ScrobbleSources from "./sources/ScrobbleSources.js";
import { generateBaseURL } from "./utils/NetworkUtils.js";
import { PassThrough } from "stream";
let version: string = 'unknown';
export let version: string = 'unknown';
export const parseVersion = async () => {
version = await getVersion({priority: ['env', 'git', 'file']});
+28 -2
View File
@@ -13,6 +13,7 @@ import {
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { URLData } from "../../core/Atomic.js";
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
import { isDebugMode } from "../utils.js";
const shortKey = truncateStringToLength(10);
@@ -98,6 +99,9 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
this.logger.verbose(`Pushed notification to Config ${shortKey(key)}`);
} catch (e: any) {
this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to Config ${shortKey(key)}`, {cause: e}));
if(isDebugMode()) {
this.logger.debug({ body }, 'Apprise Request')
}
}
}
}
@@ -114,6 +118,9 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
this.logger.verbose(`Pushed notification to URLs`);
} catch (e: any) {
this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e});
if(isDebugMode()) {
this.logger.debug({ body }, 'Apprise Request')
}
}
}
@@ -138,8 +145,27 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
} = {}
} = e;
const errorMsgs = [message];
if (typeof jsonBody === 'object' && jsonBody.error !== undefined) {
errorMsgs.push(jsonBody.error);
if(typeof jsonBody === 'object') {
let appriseError = 'Apprise Error Response';
if ('error' in jsonBody) {
appriseError = `${appriseError}: ${jsonBody.error}`;
}
if('details' in jsonBody) {
const appriseDetails = [];
if(Array.isArray(jsonBody.details)) {
for(const detailEntry of jsonBody.details) {
try {
appriseDetails.push(`(${detailEntry[0]}) ${detailEntry[2]}`);
} catch (e) {
appriseDetails.push(JSON.stringify(detailEntry));
}
}
} else {
appriseDetails.push(JSON.stringify(jsonBody.details));
}
appriseError = `${appriseError} --> ${appriseDetails.join(' || ')} <--`;
}
errorMsgs.push(appriseError);
}
throw new UpstreamError(`Apprise API Request failed => (${status}) ${errorMsgs.join(' => ')}`, {response: e.response});
} else {
@@ -3,17 +3,13 @@ import dayjs, { Dayjs } from "dayjs";
import EventEmitter from "events";
import { FixedSizeList } from 'fixed-size-list';
import { nanoid } from "nanoid";
import { Simulate } from "react-dom/test-utils";
import { MarkOptional } from "ts-essentials";
import {
DeadLetterScrobble,
PlayObject,
QueuedScrobble,
TA_CLOSE,
TA_DEFAULT_ACCURACY,
TA_DURING,
QueuedScrobble, TA_DURING,
TA_FUZZY,
TrackStringOptions,
TrackStringOptions
} from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
@@ -25,15 +21,21 @@ import {
DEFAULT_RETRY_MULTIPLIER,
DUP_SCORE_THRESHOLD,
FormatPlayObjectOptions,
PlayPlatformId,
ScrobbledPlayObject,
SourceIdentifier,
TIME_WEIGHT,
TITLE_WEIGHT, TRANSFORM_HOOK,
} from "../common/infrastructure/Atomic.js";
import { CommonClientConfig, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
import { CommonClientConfig, NowPlayingOptions, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
import { Notifiers } from "../notifier/Notifiers.js";
import {
comparingMultipleArtists,
genGroupId,
genGroupIdStr,
genGroupIdStrFromPlay,
isDebugMode,
parseBool,
playObjDataMatch,
pollingBackoff,
setIntersection,
@@ -49,12 +51,18 @@ import {
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>;
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
name: string;
type: ClientType;
scheduler: ToadScheduler = new ToadScheduler();
protected MAX_STORED_SCROBBLES = 40;
protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES;
@@ -79,6 +87,16 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
queuedScrobbles: QueuedScrobble<PlayObject>[] = [];
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
supportsNowPlaying: boolean = false;
nowPlayingEnabled: boolean;
nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined;
nowPlayingThresholds: [number,number] = [10,30];
nowPlayingLastUpdated?: Dayjs;
nowPlayingLastPlay?: PlayObject;
nowPlayingQueue: NowPlayingQueue = new Map();
nowPlayingTaskInterval: number = 5000;
npLogger: Logger;
declare config: CommonClientConfig;
notifier: Notifiers;
@@ -89,6 +107,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
this.type = type;
this.name = name;
this.logger = childLogger(logger, this.getIdentifier());
this.npLogger = childLogger(this.logger, 'Now Playing');
this.notifier = notifier;
this.emitter = emitter;
@@ -155,13 +174,137 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
this.emitEvent('notify', payload);
}
protected initializeNowPlaying() {
if (this.supportsNowPlaying) {
const {
options = {},
} = this.config;
// for future use...if we let user manually toggle now playing off/on
if(this.nowPlayingEnabled === undefined) {
const npEnv = process.env.NOW_PLAYING;
if('nowPlaying' in options) {
const nowOpts = options as NowPlayingOptions;
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by 'nowPlaying' config`);
} else if (npEnv !== undefined) {
this.nowPlayingEnabled = parseBool(npEnv);
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by global ENV`);
} else {
this.nowPlayingEnabled = true;
this.npLogger.debug(`Enabled by default config`);
}
}
this.initializeNowPlayingFilter();
this.initializeNowPlayingSchedule();
} else {
this.npLogger.debug('Unsupported feature, disabled.');
}
}
protected initializeNowPlayingSchedule() {
const t = new AsyncTask('Playing Now', (): Promise<any> => {
return this.processingPlayingNow();
}, (err: Error) => {
this.npLogger.error(new Error('Unexpected error while processing Now Playing queue', {cause: err}));
});
this.scheduler.removeById('pn_task');
// even though we are processing every 5 seconds the interval that Now Playing is updated at, and that the queue is cleared on,
// is still set by shouldUpdatePlayingNow()
// 5 seconds makes sure our granularity for updates is decently fast *when* we do need to actually update
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({milliseconds: this.nowPlayingTaskInterval}, t, {id: 'pn_task'}));
}
protected initializeNowPlayingFilter() {
const {
options = {},
} = this.config;
if (this.supportsNowPlaying) {
let sourceFilter: (queue: NowPlayingQueue) => PlatformMappedPlays | undefined;
// sources default to being filters by name-type, alphabetically
sourceFilter = (queue: NowPlayingQueue) => {
const sorted = Array.from(queue.entries()).sort((a, b) => a[0].localeCompare(b[0]));
return sorted[0][1];
}
if ('nowPlaying' in options) {
const nowOpts = options as NowPlayingOptions;
if (this.nowPlayingEnabled === undefined) {
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
}
if (Array.isArray(nowOpts.nowPlaying)) {
// if user defined priority list of source names then we use that instead, look for source name in name-type
sourceFilter = (queue: NowPlayingQueue) => {
const entries = Array.from(queue.entries());
for (const s of nowOpts.nowPlaying as string[]) {
const sLower = s.toLocaleLowerCase();
const validSource = entries.find(x => x[0].toLocaleLowerCase().includes(sLower));
if (validSource !== undefined) {
return validSource[1];
}
}
return undefined;
}
}
}
this.nowPlayingFilter = (queue: NowPlayingQueue) => {
if (queue.size === 0) {
return undefined;
}
// get list of play(ers) for top-priority Source
const platformPlays = sourceFilter(queue);
if (platformPlays === undefined) {
return undefined;
}
// if only one player then return it
const plays = Array.from(platformPlays);
if (plays.length === 1) {
return plays[0][1].play;
}
// else we need to sort players to determine which to report
// if a now playing play already exists use that platform, if any matches...
// this way we aren't flip-flopping between multiple players for reporting now playing
// (keeps reporting sticky based on first reported)
if (this.nowPlayingLastPlay !== undefined) {
const lastNowPlayingPlat = genGroupIdStrFromPlay(this.nowPlayingLastPlay);
for (const [platform, data] of plays) {
if (platform === lastNowPlayingPlat) {
return data.play;
}
}
}
// otherwise sort platform alphabetically and take first
plays.sort((a, b) => a[0].localeCompare(b[0]));
return plays[0][1].play;
}
}
}
protected async postInitialize(): Promise<void> {
const {
options: {
refreshInitialCount = this.MAX_INITIAL_SCROBBLES_FETCH
} = {}
} = {},
options = {},
} = this.config;
this.initializeNowPlaying();
let initialLimit = refreshInitialCount;
if(refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
this.logger.warn(`Defined initial scrobbles count (${refreshInitialCount}) higher than maximum allowed (${this.MAX_INITIAL_SCROBBLES_FETCH}). Will use max instead.`);
@@ -396,6 +539,11 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
// only check for fuzzy if we know this play is NOT a repeat
// otherwise we may get a false positive on the previously played track ending time == repeat start time
// -- this is info we only know if play was generated from MS player so we can be reasonably sure
const looseTimeAccuracy = playObj.data.repeat ? [TA_DURING] : [TA_FUZZY, TA_DURING];
existingScrobble = this.recentScrobbles.find((xPre) => {
const x = this.transformPlay(xPre, TRANSFORM_HOOK.existing);
@@ -407,7 +555,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
let timeMatch = 0;
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
timeMatch = 1;
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, [TA_FUZZY, TA_DURING])) {
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
timeMatch = 0.6;
}
@@ -777,6 +925,74 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
this.emitEvent('deadLetter', {dead: deadData});
}
queuePlayingNow = (data: PlayObject, source: SourceIdentifier) => {
const sourceId = `${source.name}-${source.type}`;
if(isDebugMode()) {
this.npLogger.debug(`Queueing ${buildTrackString(data, {include: ['artist', 'track', 'platform']})} from ${sourceId}`);
}
const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map();
platformPlays.set(genGroupIdStrFromPlay(data), {play: data, source});
this.nowPlayingQueue.set(sourceId, platformPlays);
}
processingPlayingNow = async (): Promise<void> => {
if(this.supportsNowPlaying && this.nowPlayingEnabled) {
const play = this.nowPlayingFilter(this.nowPlayingQueue);
if(play === undefined) {
return;
}
if(this.shouldUpdatePlayingNow(play)) {
try {
await this.doPlayingNow(play);
this.npLogger.debug(`Now Playing updated.`);
this.emitEvent('nowPlayingUpdated', play);
} catch (e) {
this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e}));
}
this.nowPlayingLastPlay = play;
this.nowPlayingLastUpdated = dayjs();
// only clear queue after we have updated Now Playing, this way we always have the latest and "most complete"
// set of Source Player updates available for filtering/sorting
this.nowPlayingQueue = new Map();
}
}
}
shouldUpdatePlayingNow = (data: PlayObject): boolean => {
if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing has not yet been set! Should update`);
}
return true;
}
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
// update if play *has* changed and time since last update is greater than min interval
// this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently
if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[0] < lastUpdateDiff) {
if(isDebugMode()) {
this.npLogger.debug(`New Play differs from previous Now Playing and time since update > ${lastUpdateDiff}s, should update`);
}
return true;
}
// update if play *has not* changed but last update is greater than max interval
// this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long
if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[1] < lastUpdateDiff) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing has not been updated in > ${lastUpdateDiff}s, should update`);
}
return true;
}
if(isDebugMode()) {
this.npLogger.debug(`Updated Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago, not updating`);
}
return false;
}
protected doPlayingNow = (data: PlayObject): Promise<any> => Promise.resolve(undefined)
public emitEvent = (eventName: string, payload: object) => {
this.emitter.emit(eventName, {
+105
View File
@@ -0,0 +1,105 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js";
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
export default class KoitoScrobbler extends AbstractScrobbleClient {
api: KoitoApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: KoitoClientConfig;
constructor(name: any, config: KoitoClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
super('koito', name, config, notifier, emitter, logger);
this.api = new KoitoApiClient(name, config.data, {logger: this.logger});
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
// 1000 is way too high. maxing at 100
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
this.supportsNowPlaying = true;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => listenObjectResponseToPlay(obj, options);
public playToClientPayload(playObject: PlayObject): object {
return playToListenPayload(playObject);
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
token,
} = {}
} = this.config;
if (token === undefined) {
throw new Error('Must provide a User Token');
}
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
return true;
}
doAuthentication = async () => {
try {
return await this.api.testAuth();
} catch (e) {
if(isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Koito API');
}
throw e;
}
}
getScrobblesForRefresh = async (limit: number) => {
return await this.api.getRecentlyPlayed(limit);
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
doScrobble = async (playObj: PlayObject) => {
const {
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
try {
await this.api.submitListen(playObj, { log: isDebugMode()});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
return playObj;
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
throw new UpstreamError(`Error occurred while making Koito API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
doPlayingNow = async (data: PlayObject) => {
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
} catch (e) {
throw new UpstreamError(`Error occurred while making Koito API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
}
+30 -5
View File
@@ -1,14 +1,13 @@
import { Logger } from "@foxxmd/logging";
import dayjs from 'dayjs';
import EventEmitter from "events";
import { TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
import { NowPlayingResponse, TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
import LastfmApiClient, { playToClientPayload } from "../common/vendor/LastfmApiClient.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
@@ -26,6 +25,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
this.api = new LastfmApiClient(name, config.data, {...options, logger})
// https://www.last.fm/api/show/user.getRecentTracks
this.MAX_INITIAL_SCROBBLES_FETCH = 200;
this.supportsNowPlaying = true;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
@@ -103,7 +103,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
public playToClientPayload(playObject: PlayObject): object {
return this.api.playToClientPayload(playObject);
return playToClientPayload(playObject);
}
doScrobble = async (playObj: PlayObject) => {
@@ -116,7 +116,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
const sType = newFromSource ? 'New' : 'Backlog';
const scrobblePayload = this.api.playToClientPayload(playObj);
const scrobblePayload = playToClientPayload(playObj);
try {
const response = await this.api.callApi<TrackScrobbleResponse>((client: any) => client.trackScrobble(
@@ -170,4 +170,29 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
this.logger.debug('Raw Payload: ', scrobblePayload);
}
}
doPlayingNow = async (data: PlayObject) => {
try {
const {timestamp, mbid, ...rest} = playToClientPayload(data);
const response = await this.api.callApi<NowPlayingResponse>((client: any) => client.trackUpdateNowPlaying(rest));
const {
nowplaying: {
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
}
return response;
} catch (e) {
if (!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: false});
} else {
throw e;
}
}
}
}
@@ -1,5 +1,4 @@
import { Logger } from "@foxxmd/logging";
import dayjs from 'dayjs';
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
@@ -7,10 +6,12 @@ import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
import { ListenbrainzApiClient, ListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { ListenbrainzApiClient, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
@@ -27,6 +28,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
// 1000 is way too high. maxing at 100
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
this.supportsNowPlaying = true;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
@@ -68,7 +70,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
public playToClientPayload(playObj: PlayObject): ListenPayload {
return ListenbrainzApiClient.playToListenPayload(playObj);
return playToListenPayload(playObj);
}
doScrobble = async (playObj: PlayObject) => {
@@ -80,7 +82,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
} = playObj;
try {
await this.api.submitListen(playObj, true);
await this.api.submitListen(playObj, { log: isDebugMode()});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
@@ -93,4 +95,12 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
throw new UpstreamError(`Error occurred while making Listenbrainz API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
doPlayingNow = async (data: PlayObject) => {
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
} catch (e) {
throw new UpstreamError(`Error occurred while making Listenbrainz API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
}
+38 -449
View File
@@ -1,32 +1,17 @@
import { Logger } from "@foxxmd/logging";
import compareVersions from 'compare-versions';
import dayjs from 'dayjs';
import { childLogger, Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import normalizeUrl from "normalize-url";
import request, { SuperAgentRequest } from 'superagent';
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
import {
getMalojaResponseError,
isMalojaAPIErrorBody,
MalojaResponseV3CommonData,
MalojaScrobbleData,
MalojaScrobbleRequestData,
MalojaScrobbleV2RequestData,
MalojaScrobbleV3RequestData,
MalojaScrobbleV3ResponseData, MalojaScrobbleWarning,
MalojaV2ScrobbleData,
MalojaV3ScrobbleData,
} from "../common/vendor/maloja/interfaces.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { parseRetryAfterSecsFromObj, sleep } from "../utils.js";
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js";
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
@@ -36,208 +21,20 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
serverVersion: any;
webUrl: string;
api: MalojaApiClient;
declare config: MalojaClientConfig
constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
super('maloja', name, config, notifier, emitter,logger);
super('maloja', name, config, notifier, emitter, logger);
this.api = new MalojaApiClient(name, this.config.data, { logger: childLogger(this.logger, 'API') });
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
}
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
let artists,
title,
album,
duration,
time,
listenedFor;
const {serverVersion, url} = options;
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
// scrobble data structure changed for v3
const {
// when the track was scrobbled
time: mTime,
track: {
artists: mArtists = [],
title: mTitle,
album: mAlbum,
// length of the track
length: mLength,
} = {},
// how long the track was listened to before it was scrobbled
duration: mDuration,
} = obj as MalojaV3ScrobbleData;
artists = mArtists;
time = mTime;
title = mTitle;
duration = mLength;
listenedFor = mDuration;
if(mAlbum !== null) {
const {
albumtitle,
name: mAlbumName,
artists: albumArtists = []
} = mAlbum || {};
album = albumtitle ?? mAlbumName;
}
} else {
// scrobble data structure for v2 and below
const {
artists: mArtists = [],
title: mTitle,
album: mAlbum,
duration: mDuration,
time: mTime,
} = obj as MalojaV2ScrobbleData;
artists = mArtists;
title = mTitle;
album = mAlbum;
duration = mDuration;
time = mTime;
}
const artistStrings = artists.reduce((acc: any, curr: any) => {
let aString;
if (typeof curr === 'string') {
aString = curr;
} else if (typeof curr === 'object') {
aString = curr.name;
}
const aStrings = aString.split(',');
return [...acc, ...aStrings];
}, []);
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
return {
data: {
artists: [...new Set(artistStrings)] as string[],
track: title,
album,
duration,
listenedFor,
playDate: dayjs.unix(time),
},
meta: {
source: 'Maloja',
url: {
web: `${url}/track?${urlParams.toString()}`
}
}
}
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl});
callApi = async (req: SuperAgentRequest, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.data;
try {
return await req;
} catch (e) {
if((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
} else {
throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, {cause : e, showStopper: true});
}
} else if(isSuperAgentResponseError(e)) {
const {
message,
response: {
status,
body,
} = {},
response,
} = e;
if(isMalojaAPIErrorBody(body)) {
throw new UpstreamError(buildErrorString(body), {cause: e})
} else {
throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e})
}
} else {
throw new Error('Unexpected error occurred during API call', {cause : e});
}
}
}
testConnection = async () => {
const {url} = this.config.data;
try {
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
const {
statusCode,
body: {
version = [],
versionstring = '',
} = {},
} = serverInfoResp;
if (statusCode >= 300) {
throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
}
this.logger.info('Communication test succeeded.');
if (version.length === 0) {
this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. multi-scrobbler will most likely not work with this server.');
} else {
this.logger.info(`Maloja Server Version: ${versionstring}`);
this.serverVersion = versionstring;
if(compareVersions(versionstring, '3.0.0') < 0) {
this.logger.warn(`Support for Maloja versions below 3.0.0 is DEPRECATED and will be removed in a future minor release.`);
} else if(compareVersions(versionstring, '3.2.0') < 0) {
this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
}
}
return true;
} catch (e) {
throw new Error('Communication test failed', {cause: e})
}
}
testHealth = async () => {
const {url} = this.config.data;
try {
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0);
const {
statusCode,
body: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
db_status: {
healthy = false,
rebuildinprogress = false,
complete = false,
}
} = {},
} = serverInfoResp;
if (statusCode >= 300) {
throw new Error(`Server responded with NOT OK status: ${statusCode}`);
}
if(rebuildinprogress) {
throw new Error(`Server is rebuilding database`);
}
if(!healthy) {
throw new Error('Server responded that it is not healthy');
}
return true
} catch (e) {
throw new Error('Error encountered while testing server health', {cause: e});
}
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => formatMalojaScrobbleToPlay(obj, { url: this.webUrl });
protected async doBuildInitData(): Promise<true | string | undefined> {
const {data: {url, apiKey} = {}} = this.config;
const { data: { url, apiKey } = {} } = this.config;
if (apiKey === undefined) {
throw new Error("'apiKey' not found in config!");
}
@@ -249,291 +46,83 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.testConnection();
await this.testHealth();
return true;
try {
await this.api.testConnection();
await this.api.testHealth();
return true;
} catch (e) {
throw e;
}
}
doAuthentication = async () => {
const {url, apiKey} = this.config.data;
const { data: { url, apiKey } = {} } = this.config;
if (apiKey === undefined) {
throw new Error("'apiKey' not found in config!");
}
try {
const resp = await this.callApi(request
.get(`${url}/apis/mlj_1/test`)
.query({key: apiKey}));
const {
status,
body: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
status: bodyStatus,
} = {},
body = {},
text = '',
} = resp;
if (bodyStatus.toLocaleLowerCase() === 'ok') {
this.logger.info('Auth test passed!');
return true;
} else {
this.logger.error('Maloja API Response', {
status,
body,
text: text.slice(0, 50)
});
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {cause: new Error(`Maloja API Response was ${status}: ${text.slice(0,50)}`)})
}
await this.api.testAuth();
return true;
} catch (e) {
if(e instanceof UpstreamError) {
if((e?.cause as any)?.status === 403) {
// may be an older version that doesn't support auth readiness before db upgrade
// and if it was before api was accessible during db build then test would fail during testConnection()
if(compareVersions(this.serverVersion, '2.12.19') < 0) {
if(!(await this.isReady())) {
throw new UpstreamError(`Could not test auth because server is not ready`, {showStopper: false});
}
}
}
if (isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Maloja API');
}
throw e;
}
}
getScrobblesForRefresh = async (limit: number) => {
const {url} = this.config.data;
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
const {
body: {
list = [],
} = {},
} = resp;
return list.map((x: any) => this.formatPlayObj(x));
}
cleanSourceSearchTitle = (playObj: PlayObject) => {
const {
data: {
track,
artists: sourceArtists = [],
} = {},
} = playObj;
let lowerTitle = track.toLocaleLowerCase();
lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
// also remove [artist] from the track if found since that gets removed as well
const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle);
// remove any whitespace in parenthesis
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
// replace parenthesis
.replace('()', '')
.replace('( )', '')
.trim();
return lowerTitle;
return await this.api.getRecentScrobbles(limit);
}
alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined
public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData {
const {apiKey} = this.config.data;
const { apiKey } = this.config.data;
const {
data: {
artists = [],
albumArtists = [],
album,
track,
duration,
listenedFor
} = {}
} = playObj;
const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
const scrobbleData: MalojaScrobbleRequestData = {
title: track,
album,
key: apiKey,
time: pd.unix(),
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
length: duration,
};
if(listenedFor !== undefined && listenedFor > 0) {
scrobbleData.duration = listenedFor;
}
// 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
(scrobbleData as MalojaScrobbleV3RequestData).artists = artists;
if(albumArtists.length > 0) {
(scrobbleData as MalojaScrobbleV3RequestData).albumartists = albumArtists;
}
} else {
// maloja seems to detect this deliminator much better than commas
// also less likely artist has a forward slash in their name than a comma
(scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
}
return scrobbleData;
return playToScrobblePayload(playObj);
}
doScrobble = async (playObj: PlayObject) => {
const {url, apiKey} = this.config.data;
const {
data: {
album,
duration,
playDate,
} = {},
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
const pd = getScrobbleTsSOCDate(playObj);
const scrobbleData = playToScrobblePayload(playObj);
const sType = newFromSource ? 'New' : 'Backlog';
const scrobbleData = this.playToClientPayload(playObj);
let responseBody: MalojaScrobbleV3ResponseData;
let scrobbledPlay: PlayObject;
try {
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
.type('json')
.send(scrobbleData));
const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj);
let scrobbleResponse: any | undefined = undefined,
scrobbledPlay: PlayObject;
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
responseBody = response.body;
const {
track,
status,
warnings = [],
} = responseBody;
if(status === 'success') {
if(track !== undefined) {
scrobbleResponse = {
time: pd.unix(),
track: {
...track,
length: duration
},
}
if (album !== undefined) {
const {
album: malojaAlbum = {},
} = track;
scrobbleResponse.track.album = {
...malojaAlbum,
name: album
}
}
}
if(warnings.length > 0) {
for(const w of warnings) {
const warnStr = buildWarningString(w);
if(warnStr.includes('The submitted scrobble was not added')) {
throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false});
}
this.logger.warn(`Maloja Warning: ${warnStr}`);
}
}
} else {
throw new UpstreamError(buildErrorString(response), {showStopper: false});
}
} else {
const {
body: {
track: {
time: mTime = pd.unix(),
duration: mDuration = duration,
album: mAlbum = album,
...rest
} = {}
} = {}
} = response;
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
}
let warning = '';
if(scrobbleResponse === undefined) {
if (scrobbleResp === undefined) {
warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`;
scrobbledPlay = playObj;
} else {
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
scrobbledPlay = this.formatPlayObj(scrobbleResp)
}
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
if(warning !== '') {
if (warning !== '') {
this.logger.warn(`${scrobbleInfo} | ${warning}`);
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`);
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`);
} else {
this.logger.info(scrobbleInfo);
}
return scrobbledPlay;
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
const responseError = getMalojaResponseError(e);
if(responseError !== undefined) {
if(responseError.status < 500 && e instanceof UpstreamError) {
e.showStopper = false;
}
if(responseError.response?.text !== undefined) {
this.logger.error('Raw Response:', { text: responseError.response?.text });
}
}
await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' });
throw e;
} finally {
this.logger.debug('Raw Payload:', scrobbleData);
}
}
}
const buildErrorString = (body: MalojaResponseV3CommonData) => {
let valString: string | undefined = undefined;
const {
status,
error: {
type,
value,
desc
} = {}
} = body;
if(value !== undefined && value !== null) {
if(typeof value === 'string') {
valString = value;
} else if(Array.isArray(value)) {
valString = value.map(x => {
if(typeof x === 'string') {
return x;
}
return JSON.stringify(x);
}).join(', ');
} else {
valString = JSON.stringify(value);
}
}
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
}
const buildWarningString = (w: MalojaScrobbleWarning): string => {
const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
let vals: string[] = [];
if(w.value !== null && w.value !== undefined) {
if(Array.isArray(w.value)) {
vals = w.value;
} else {
vals.push(w.value);
}
}
if(vals.length > 0) {
parts.push(vals.join(' | '));
}
return parts.join(' => ');
}
}
+75 -18
View File
@@ -1,8 +1,8 @@
/* eslint-disable no-case-declarations */
import { childLogger, Logger } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { PlayObject } from "../../core/Atomic.js";
import { ClientType, clientTypes, ConfigMeta, isClientType } from "../common/infrastructure/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { ClientType, clientTypes, ConfigMeta, isClientType, REPORTED_PLAYER_STATUSES, SourceIdentifier } from "../common/infrastructure/Atomic.js";
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
@@ -10,7 +10,7 @@ import { ListenBrainzClientConfig } from "../common/infrastructure/config/client
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { readJson, thresholdResultSummary } from "../utils.js";
import { isDebugMode, readJson } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js";
import { validateJson } from "../utils/ValidationUtils.js";
@@ -19,6 +19,8 @@ import LastfmScrobbler from "./LastfmScrobbler.js";
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
import MalojaScrobbler from "./MalojaScrobbler.js";
import { Definition } from 'ts-json-schema-generator';
import KoitoScrobbler from './KoitoScrobbler.js';
import { KoitoClientConfig } from '../common/infrastructure/config/client/koito.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -45,6 +47,12 @@ export default class ScrobbleClients {
this.localUrl = localUrl;
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing) {
this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
}
});
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
await this.scrobble(payload.data, payload.options);
});
@@ -93,6 +101,9 @@ export default class ScrobbleClients {
case 'listenbrainz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzClientConfig");
break;
case 'koito':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoClientConfig");
break;
}
}
return this.schemaDefinitions[type];
@@ -121,13 +132,18 @@ export default class ScrobbleClients {
clientDefaults = cd;
for (const [index, c] of mainConfigClientConfigs.entries()) {
const {name = 'unnamed'} = c;
if(c.type === undefined) {
const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`;
this.logger.error(invalidMsgType);
continue;
}
if(!isClientType(c.type.toLocaleLowerCase())) {
const invalidTypeMsg = `Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`;
//this.emitter.emit('error', new Error(invalidTypeMsg));
this.logger.error(invalidTypeMsg);
continue;
}
if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) {
if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) {
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`);
continue;
}
@@ -203,6 +219,23 @@ export default class ScrobbleClients {
})
}
break;
case 'koito':
const koit = {
url: process.env.KOITO_URL,
token: process.env.KOITO_TOKEN,
username: process.env.KOITO_USER
};
if (!Object.values(koit).every(x => x === undefined)) {
configs.push({
type: 'koito',
name: 'unnamed-koito',
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: koit
})
}
break;
default:
break;
}
@@ -229,15 +262,14 @@ export default class ScrobbleClients {
continue;
}
for(const [i,rawConf] of rawClientConfigs.entries()) {
if(['lastfm','listenbrainz'].includes(clientType) &&
((rawConf as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source'))
if(['lastfm','listenbrainz','koito'].includes(clientType) &&
((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig).configureAs === 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`);
continue;
}
try {
const validConfig = validateJson<ClientConfig>(rawConf, this.getSchemaByType(clientType), this.logger);
// @ts-expect-error configureAs should exist
const {configureAs = defaultConfigureAs} = validConfig;
if (configureAs === 'client') {
const parsedConfig: ParsedConfig = {
@@ -287,7 +319,7 @@ ${sources.join('\n')}`);
try {
await this.addClient(c, clientDefaults, notifier);
} catch(e) {
const addError = new Error(`Client ${c.name} was not added because it had unrecoverable errors`, {cause: e});
const addError = new Error(`Client ${c.name} from ${c.source} was not added because it had unrecoverable errors`, {cause: e});
this.emitter.emit('error', addError);
this.logger.error(addError);
}
@@ -299,17 +331,17 @@ ${sources.join('\n')}`);
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}*/
const {type, name, enable = true, data: d = {}} = clientConfig;
const {type, name, enable = true, source, data: d = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) client was disabled by config`);
this.logger.warn({labels: [`${type} - ${name}`]}, `Client from ${source} was disabled by config`);
return;
}
// add defaults
const data = {...defaults, ...d};
let newClient;
this.logger.debug(`Constructing ${type} (${name}) client...`);
this.logger.debug({labels: [`${type} - ${name}`]}, `Constructing Client from ${source}`);
switch (type) {
case 'maloja':
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.emitter, this.logger);
@@ -320,23 +352,48 @@ ${sources.join('\n')}`);
case 'listenbrainz':
newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as ListenBrainzClientConfig, {}, notifier, this.emitter, this.logger);
break;
case 'koito':
newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as KoitoClientConfig, {}, notifier, this.emitter, this.logger);
break;
default:
break;
}
if(newClient === undefined) {
// really shouldn't get here!
throw new Error(`Client of type ${type} was not recognized??`);
throw new Error(`Client of type ${type} from ${source} was not recognized??`);
}
newClient.logger.info('Client Added');
newClient.logger.info(`Client Added from ${source}`);
this.clients.push(newClient);
}
/**
* @param {*} data
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
* @returns {Array}
*/
playingNow = async (data: (PlayObject | PlayObject[]), options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
scrobbleTo = [],
scrobbleFrom,
} = options;
if (this.clients.length === 0) {
this.logger.warn('Cannot update Now Playing! No clients are configured.');
}
for (const client of this.clients) {
if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
continue;
}
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
if(isDebugMode()) {
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`);
}
continue;
}
for (const playObj of playObjs) {
client.queuePlayingNow(playObj, scrobbleFrom);
}
}
}
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
+3 -1
View File
@@ -255,7 +255,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp
duration
},
meta: {
trackProgressPosition: elapsed
trackProgressPosition: elapsed,
source: 'azuracast',
mediaPlayerName: 'Azuracast'
}
}
}
+3 -1
View File
@@ -60,6 +60,7 @@ export default class DeezerInternalSource extends MemorySource {
this.jar = new CookieJar();
const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
// @ts-expect-error not correct structure
this.agent = request.agent().use((req) => req.agent(mixedAgent));
}
@@ -79,7 +80,8 @@ export default class DeezerInternalSource extends MemorySource {
newFromSource,
url: {
web: `https://www.deezer.com/track/${obj.SNG_ID}`
}
},
musicService: 'Deezer',
}
};
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
+1
View File
@@ -81,6 +81,7 @@ export default class DeezerSource extends AbstractSource {
},
meta: {
source: 'Deezer',
musicService: 'Deezer',
trackId: id,
newFromSource,
url: {
@@ -7,6 +7,7 @@ import {
InternalConfig,
NO_USER,
PlayerStateData,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
ReportedPlayerStatus
} from "../common/infrastructure/Atomic.js";
@@ -15,6 +16,9 @@ import MemorySource from "./MemorySource.js";
import { LastFMEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlfm.js";
import { LastfmTrackUpdateRequest, NowPlayingPayload, TrackScrobblePayload } from "lastfm-node-client";
import { scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.js";
import { Logger } from "@foxxmd/logging";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i);
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i);
@@ -77,6 +81,8 @@ export class EndpointLastfmSource extends MemorySource {
}
}
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => {
@@ -7,13 +7,19 @@ import {
InternalConfig,
NO_USER,
PlayerStateData,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
ReportedPlayerStatus
} from "../common/infrastructure/Atomic.js";
import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js";
import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { SubmitPayload } from '../common/vendor/listenbrainz/interfaces.js';
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
import { parseRegexSingleOrFail } from "../utils.js";
import MemorySource from "./MemorySource.js";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
import { Logger } from "@foxxmd/logging";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i);
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
@@ -92,6 +98,8 @@ export class EndpointListenbrainzSource extends MemorySource {
}
}
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
+2 -1
View File
@@ -213,7 +213,8 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions =
artists
},
meta: {
source: 'icecast'
source: 'icecast',
mediaPlayerName: 'Icecast'
}
}
}
+4 -2
View File
@@ -81,7 +81,7 @@ export class JRiverSource extends MemoryPositionalSource {
}
}
static formatPlayObj(obj: Info, options: FormatPlayObjectOptions = {}): PlayObject {
static formatPlayObj(obj: Info, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
const {newFromSource = true} = options;
const {
@@ -109,6 +109,8 @@ export class JRiverSource extends MemoryPositionalSource {
},
meta: {
source: 'mopidy',
mediaPlayerName: 'JRiver',
mediaPlayerVersion: options.version,
trackId: FileKey,
newFromSource,
trackProgressPosition: trackProgressPosition !== undefined ? Math.round(Number.parseInt(trackProgressPosition.toString()) / 1000) : undefined,
@@ -136,7 +138,7 @@ export class JRiverSource extends MemoryPositionalSource {
if(data !== undefined) {
const {State} = data;
if(State !== PLAYER_STATE.STOPPED) {
play = [JRiverSource.formatPlayObj(data)];
play = [JRiverSource.formatPlayObj(data, {version: this.client.version})];
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export class KodiSource extends MemoryPositionalSource {
}
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
static formatPlayObj(obj: any, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
return KodiApiClient.formatPlayObj(obj, options);
}
+77
View File
@@ -0,0 +1,77 @@
import EventEmitter from "events";
import request from "superagent";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
export default class KoitoSource extends MemorySource {
api: KoitoApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: KoitoSourceConfig;
constructor(name: any, config: KoitoSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
interval = 15,
maxInterval = 60,
...restData
} = {}
} = config;
super('koito', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
this.canPoll = true;
this.canBacklog = true;
this.api = new KoitoApiClient(name, config.data, {logger: this.logger});
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.supportsUpstreamRecentlyPlayed = true
this.SCROBBLE_BACKLOG_COUNT = 100;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return listenObjectResponseToPlay(obj, options); }
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
return true;
}
doAuthentication = async () => {
if(this.config.data.token === undefined) {
throw new Error('Must provide a User Token in configuration');
}
try {
return await this.api.testAuth();
} catch (e) {
if(isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Koito API');
}
throw e;
}
}
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
this.processRecentPlays([]);
return await this.api.getRecentlyPlayed(limit);
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
try {
return await this.api.getRecentlyPlayed(20);
} catch (e) {
throw e;
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
}
+6 -1
View File
@@ -4,12 +4,15 @@ import { TrackObject, UserGetRecentTracksResponse } from "lastfm-node-client";
import request from "superagent";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
import { sortByOldestPlayDate } from "../utils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { Logger } from "@foxxmd/logging";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
export default class LastfmSource extends MemorySource {
@@ -149,4 +152,6 @@ export default class LastfmSource extends MemorySource {
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
+6 -1
View File
@@ -2,12 +2,15 @@ import EventEmitter from "events";
import request from "superagent";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
import { Logger } from "@foxxmd/logging";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
export default class ListenbrainzSource extends MemorySource {
@@ -89,4 +92,6 @@ export default class ListenbrainzSource extends MemorySource {
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
+1
View File
@@ -198,6 +198,7 @@ export class MPDSource extends MemoryPositionalSource {
meta: {
brainz,
trackProgressPosition: options.trackProgressPosition,
mediaPlayerName: 'mpd'
}
}
}
+78 -5
View File
@@ -1,6 +1,6 @@
import { Interfaces as Notifications } from '@dbus-types/notifications'
import dayjs from "dayjs";
import { DBusInterface, sessionBus } from 'dbus-ts';
import { DBusInterface, MessageBus, sessionBus, Connection, ConnectOpts } from 'dbus-ts';
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
@@ -17,6 +17,9 @@ import { removeDuplicates } from "../utils.js";
import { findCauseByMessage } from "../utils/ErrorUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { Readable, Writable } from 'stream';
import net from 'net';
import pEvent from 'p-event';
export class MPRISSource extends MemorySource {
@@ -78,7 +81,7 @@ export class MPRISSource extends MemorySource {
playDate: dayjs()
},
meta: {
source: 'dbus',
source: 'mpris',
trackId: trackid,
newFromSource,
url: {
@@ -101,9 +104,26 @@ export class MPRISSource extends MemorySource {
}
protected getDBus = async () => {
const busNew = await sessionBus<Notifications>();
const obj = await busNew.getInterface('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus');
return obj;
const conn = new Connection(createStream({}));
try {
const res = await Promise.race([
pEvent(conn, 'error'),
conn.init()
])
if(res instanceof Error) {
throw res;
}
} catch (e) {
throw new Error('Failed to connected to session bus', {cause: e});
}
try {
const promise = sessionBus<Notifications>();
const busNew = await sessionBus<Notifications>();
return await busNew.getInterface('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus');
} catch (e) {
throw new Error('Failed to get DBus interface', {cause: e});
}
}
protected listNew = async () => {
@@ -239,3 +259,56 @@ const convertDBusExceptionToError = (e: any): Error => {
return err;
}
// unfortunately had to recreate this function from dbus-ts/Connection
// in order to be able to create the Connection class without immediate init
// so we can catch errors
const createStream = (opts: ConnectOpts): Readable&Writable => {
if (typeof opts !== 'object') {
opts = {};
}
if ("stream" in opts) {
return opts.stream;
}
if ("socket" in opts) {
return net.createConnection(opts.socket);
}
if ("port" in opts) {
return net.createConnection(opts.port, opts.host);
}
const busAddress = opts.busAddress || process.env.DBUS_SESSION_BUS_ADDRESS;
if (!busAddress) throw new Error('unknown bus address');
const addresses = busAddress.split(';');
for (let i = 0; i < addresses.length; ++i) {
const address = addresses[i];
const familyParams = address.split(':');
const family = familyParams[0];
const params: any = {};
familyParams[1].split(',').map(function(p) {
const keyVal = p.split('=');
params[keyVal[0]] = keyVal[1];
});
try {
switch (family.toLowerCase()) {
case 'tcp':
return net.createConnection(params.port, (params.host || 'localhost'));
case 'unix':
if (params.socket) return net.createConnection(params.socket);
if (params.path) return net.createConnection(params.path);
throw new Error(
"not enough parameters for 'unix' connection - you need to specify 'socket' or 'path' parameter"
);
default:
throw new Error('unknown address type:' + family);
}
} catch (e) {
if (i < addresses.length - 1) {
console.warn(e.message);
} else {
throw e;
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import EventEmitter from "events";
import request from "superagent";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js";
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
export default class MalojaSource extends MemorySource {
api: MalojaApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: MalojaSourceConfig;
constructor(name: any, config: MalojaSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
interval = 15,
maxInterval = 60,
...restData
} = {}
} = config;
super('maloja', name, { ...config, data: { interval, maxInterval, ...restData } }, internal, emitter);
this.canPoll = true;
this.canBacklog = true;
this.api = new MalojaApiClient(name, config.data, { logger: this.logger });
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
this.supportsUpstreamRecentlyPlayed = true
this.SCROBBLE_BACKLOG_COUNT = 20;
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
}
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
return true;
}
doAuthentication = async () => {
if (this.config.data.apiKey === undefined) {
throw new Error(`Must provide 'apiKey' in configuration`);
}
try {
return await this.api.testAuth();
} catch (e) {
if (isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Maloja API');
}
throw e;
}
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const { limit = 20 } = options;
this.processRecentPlays([]);
return await this.api.getRecentScrobbles(limit);
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
try {
return await this.api.getRecentScrobbles(20);
} catch (e) {
throw e;
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({ formatted: true, ...options })
}
+14 -7
View File
@@ -6,15 +6,12 @@ import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import {
asPlayerStateData,
asPlayerStateDataMaybePlay,
CALCULATED_PLAYER_STATUSES,
InternalConfig,
PlayerStateData,
PlayerStateDataMaybePlay,
InternalConfig, PlayerStateDataMaybePlay,
PlayPlatformId,
ProgressAwarePlayObject,
SourceType,
SourceType
} from "../common/infrastructure/Atomic.js";
import { PollingOptions } from "../common/infrastructure/config/common.js";
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
@@ -92,7 +89,12 @@ export default class MemorySource extends AbstractSource {
const stateHash = objectHash.sha1(state);
if(stateHash !== this.playerState.get(key)) {
this.playerState.set(key, stateHash);
this.emitEvent('playerUpdate', state);
this.emitEvent('playerUpdate', {
...state,
options: {
scrobbleTo: this.clients
}
});
}
if(this.config.options?.logPlayerState === true || isDebugMode()) {
player.logSummary();
@@ -264,7 +266,12 @@ export default class MemorySource extends AbstractSource {
}
const apiState = player.getApiState();
this.playerState.set(key, objectHash.sha1(apiState))
this.emitEvent('playerUpdate', apiState);
this.emitEvent('playerUpdate', {
...apiState,
options: {
scrobbleTo: this.clients
}
});
} else {
const playFromCleanup = this.cleanupPlayer(key);
if(playFromCleanup !== undefined) {
+2 -1
View File
@@ -173,7 +173,8 @@ export class MopidySource extends MemoryPositionalSource {
trackId: uri,
newFromSource,
trackProgressPosition: trackProgressPosition !== undefined ? Math.round(trackProgressPosition / 1000) : undefined,
deviceId: name,
mediaPlayerName: 'Mopidy'
//deviceId: name,
}
}
}
+8 -2
View File
@@ -18,6 +18,7 @@ export class MusicCastSource extends MemoryPositionalSource {
declare config: MusicCastSourceConfig;
urlData!: URLData;
version?: string;
constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
@@ -57,6 +58,9 @@ export class MusicCastSource extends MemoryPositionalSource {
const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString())
if (resp.body !== undefined && typeof resp.body === 'object') {
const deviceInfo = resp.body as DeviceInfoResponse;
if(deviceInfo.api_version !== undefined) {
this.version = deviceInfo.api_version.toString();
}
this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`);
} else {
this.logger.warn('Could not get device info! Ignoring but probably not good...');
@@ -130,7 +134,7 @@ export class MusicCastSource extends MemoryPositionalSource {
}
}
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject => {
const {
play_time,
@@ -152,7 +156,9 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F
},
meta: {
trackProgressPosition: play_time,
deviceId: 'input' in obj ? obj.input : 'cd'
deviceId: 'input' in obj ? obj.input : 'cd',
mediaPlayerName: 'MusicCast',
mediaPlayerVersion: options.version
}
}
}
+5 -1
View File
@@ -40,6 +40,7 @@ export class MusikcubeSource extends MemoryPositionalSource {
client!: WS;
deviceId: string
version?: string
constructor(name: any, config: MusikcubeSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
@@ -109,6 +110,7 @@ export class MusikcubeSource extends MemoryPositionalSource {
this.client.addEventListener('message', (e) => {
const data = getMessageData<any>(e);
if(isAuthenticateResponse(data)) {
this.version = data.options.environment.app_version;
wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`);
}
});
@@ -197,7 +199,9 @@ export class MusikcubeSource extends MemoryPositionalSource {
meta: {
trackProgressPosition: playing_current_time,
deviceId: this.deviceId,
trackId: external_id
trackId: external_id,
mediaPlayerName: 'Musikcube',
mediaPlayerVersion: this.version
}
}
}
@@ -16,7 +16,7 @@ import { PollingOptions } from "../../common/infrastructure/config/common.js";
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js";
import { ListenProgress } from "./ListenProgress.js";
import { ListenRange, ListenRangePositional } from "./ListenRange.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
export interface PlayerStateIntervals {
staleInterval?: number
@@ -75,11 +75,15 @@ export abstract class AbstractPlayerState {
currentPlay?: PlayObject
playFirstSeenAt?: Dayjs
playLastUpdatedAt?: Dayjs
isRepeatPlay?: boolean = false;
currentListenRange?: ListenRange
listenRanges: ListenRange[] = [];
createdAt: Dayjs = dayjs();
stateLastUpdatedAt: Dayjs = dayjs();
lastPlay?: PlayObject
lastPlayUpdatedAt?: Dayjs
protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) {
this.platformId = platformId;
this.logger = childLogger(logger, `Player ${this.platformIdStr}`);
@@ -94,6 +98,14 @@ export abstract class AbstractPlayerState {
protected abstract newListenProgress(data?: Partial<PlayProgress>): ListenProgress;
protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange;
protected getStaleInterval(): number {
return this.stateIntervalOptions.staleInterval;
}
protected getOrphanedInterval(): number {
return this.stateIntervalOptions.orphanedInterval;
}
get platformIdStr() {
return genGroupIdStr(this.platformId);
}
@@ -104,7 +116,7 @@ export abstract class AbstractPlayerState {
isUpdateStale(reportedTS?: Dayjs) {
if (this.currentPlay !== undefined) {
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.getStaleInterval();
}
return false;
}
@@ -113,7 +125,7 @@ export abstract class AbstractPlayerState {
const isStale = this.isUpdateStale(reportedTS);
if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale;
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`);
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.getStaleInterval()}s)`);
// end current listening sessions
this.currentListenSessionEnd();
}
@@ -121,18 +133,18 @@ export abstract class AbstractPlayerState {
}
isOrphaned() {
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval;
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval();
}
isDead() {
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2;
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval()* 2;
}
checkOrphaned() {
const isOrphaned = this.isOrphaned();
if (isOrphaned && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.orphaned) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.orphaned;
this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.stateIntervalOptions.orphanedInterval}s)`);
this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.getOrphanedInterval()}s)`);
}
return isOrphaned;
}
@@ -181,6 +193,9 @@ export abstract class AbstractPlayerState {
this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`)
this.currentListenSessionEnd();
const played = this.getPlayedObject(true);
this.isRepeatPlay = false;
this.lastPlay = played;
this.lastPlayUpdatedAt = dayjs();
this.setCurrentPlay(state, {reportedTS});
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
@@ -194,6 +209,8 @@ export abstract class AbstractPlayerState {
this.currentListenSessionEnd();
const played = this.getPlayedObject(true);
play.data.playDate = dayjs();
this.isRepeatPlay = true;
this.logger.debug('New Play is a repeat');
this.setCurrentPlay(state, {reportedTS});
return [this.getPlayedObject(), played];
} else {
@@ -212,6 +229,17 @@ export abstract class AbstractPlayerState {
this.currentListenSessionContinue(state.position, reportedTS);
}
} else {
this.isRepeatPlay = false;
// compensate for Players that report as STOPPED between Plays
// -- should we check for closeToPlayStart() as well?
if(this.lastPlay !== undefined) {
const lastPlayDiff = Math.abs(this.lastPlayUpdatedAt.diff(dayjs(), 's'));
const shortDiff = lastPlayDiff < 20;
const lastPlayMatch = playObjDataMatch(play, this.lastPlay);
this.isRepeatPlay = shortDiff && lastPlayMatch;
this.logger.debug(`Last Play ${shortDiff ? 'was' : 'was not'} within 20s of new Player session and ${lastPlayMatch ? 'does' : 'does not'} match new Play -- ${this.isRepeatPlay ? 'is' : 'is not'} a repeat Play`);
}
this.setCurrentPlay(state);
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
}
@@ -226,11 +254,14 @@ export abstract class AbstractPlayerState {
protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); }
protected clearPlayer() {
this.lastPlay = this.currentPlay;
this.lastPlayUpdatedAt = dayjs();
this.currentPlay = undefined;
this.playLastUpdatedAt = undefined;
this.playFirstSeenAt = undefined;
this.listenRanges = [];
this.currentListenRange = undefined;
this.isRepeatPlay = false;
}
protected stopPlayer() {
@@ -255,7 +286,8 @@ export abstract class AbstractPlayerState {
playDate: this.playFirstSeenAt,
listenedFor: this.getListenDuration(),
listenRanges: ranges,
playDateCompleted: completed ? dayjs() : undefined
playDateCompleted: completed ? dayjs() : undefined,
repeat: this.isRepeatPlay
},
meta: this.currentPlay.meta
}
@@ -279,6 +311,13 @@ export abstract class AbstractPlayerState {
protected abstract currentListenSessionEnd();
/** Check if new Player Position was seeked to a Position that indicates user is repeating the track
*
* True if:
* * New position is close to start of Play and...
* * Listened duraton is more than 2 minutes/50% of Play OR...
* * Previous Position was close to end of Play
*/
protected isSessionRepeat(position?: number, reportedTS?: Dayjs) {
if(this.currentListenRange === undefined) {
return false;
@@ -287,45 +326,32 @@ export abstract class AbstractPlayerState {
if (isSeeked === false || seekPos > 0) {
return false;
}
const hints: string[] = [];
let repeatHint = `New Position (${position})`;
const trackDur = this.currentPlay.data.duration;
// user is within 10 seconds or 10% of start of track
const closeStartNum = position <= 12;
if(closeStartNum) {
repeatHint = `${repeatHint} is within 12 seconds of track start`;
}
const closeStartPer = (trackDur !== undefined && ((position / trackDur) <= 0.15));
if(!closeStartNum && closeStartPer) {
repeatHint = `${repeatHint} is within 15% of track start (${formatNumber((position/trackDur)*100)}%).`;
}
if (closeStartNum || closeStartPer) {
// user has played at least 2 minutes or 50% of track
// new position is close to start of Play
const [closeStart, closeStartHint] = closeToPlayStart(this.currentPlay, position, {hintPrefix: false});
hints.push(closeStartHint);
if (closeStart) {
const playerDur = this.getListenDuration();
const closeDurNum = playerDur >= 120;
if(closeDurNum) {
repeatHint = `${repeatHint} and listened to more than 120s (${playerDur}s)`
}
const closeDurPer = (trackDur !== undefined && (playerDur / trackDur) >= 0.5);
if(!closeDurNum && closeDurPer) {
repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).`
}
if (closeDurNum || closeDurPer) {
this.logger.verbose(repeatHint);
const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(this.currentPlay, playerDur, {hintPrefix: false});
// user has played at least 2 minutes or 50% of track
if (repeatDurationOk) {
this.logger.verbose(`${repeatHint} ${[closeStartHint, repeatDurationHint].join(' and ')}`);
return true;
}
if (trackDur !== undefined && this.currentListenRange.getPosition() !== undefined) {
const lastPos = this.currentListenRange.getPosition();
// or last position is within 10 seconds (or 10%) of end of track
const nearEndNum = (trackDur - lastPos < 12);
if(nearEndNum) {
repeatHint = `${repeatHint} and previous position was within 12 seconds of track end.`;
}
const nearEndPos = ((lastPos / trackDur) > 0.85);
if(!nearEndNum && nearEndPos) {
repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`;
}
if(nearEndNum || nearEndPos) {
this.logger.verbose(repeatHint);
const lastPos = this.currentListenRange.getPosition();
if (trackDur !== undefined && lastPos !== undefined) {
const [nearEnd, nearEndHint] = closeToPlayEnd(this.currentPlay, lastPos, {hintPrefix: false});
// last position is close to end of Play
if(nearEnd) {
this.logger.verbose(`${repeatHint} ${[closeStartHint, nearEndHint].join(' and ')}`);
return true;
}
}
@@ -368,7 +394,7 @@ export abstract class AbstractPlayerState {
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Player Updated At: ${todayAwareFormat(this.stateLastUpdatedAt)} | Play Updated At: ${this.playLastUpdatedAt === undefined ? 'N/A' : todayAwareFormat(this.playLastUpdatedAt)}`);
let progress = '';
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s Reported | `;
}
let listenedPercent = '';
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
@@ -378,6 +404,10 @@ export abstract class AbstractPlayerState {
})
}
parts.push(`${progress}Listened For: ${formatNumber(this.getListenDuration(), {toFixed: 0})}s ${listenedPercent}`);
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentListenRange.rtTruth) {
const rtProgress = `${progressBar((this.currentListenRange.rtPlayer.getPosition() / 1000) / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.rtPlayer.getPosition() / 1000, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s`;
parts.push(`${rtProgress} Realtime | Drifted ${formatNumber(Math.abs(this.currentListenRange.getDrift() / 1000), {toFixed: 1})}s (Max ${formatNumber(this.currentListenRange.getAllowedDrift() / 1000, {toFixed: 1})})`);
}
return parts.join('\n');
}
@@ -144,6 +144,10 @@ export class ListenRangePositional extends ListenRange {
return Math.abs(this.getDrift((position ?? this.end.position))) > this.allowedDrift;
}
getAllowedDrift() {
return this.allowedDrift;
}
setRangeEnd(data: ListenProgressPositional | PlayProgressPositional/* , force?: boolean */) {
const endProgress = data instanceof ListenProgressPositional ? data : new ListenProgressPositional(data)
// if(this.rtTruth) {
@@ -0,0 +1,33 @@
import { SourcePlayerObj } from "../../../core/Atomic.js";
import { GenericPlayerState } from "./GenericPlayerState.js";
/**
* This Player is only used for displaying data reported to EndpointListenbrainzSource, and parsed from ListenbrainzSource, as playing_now
* and the behvior for Listenbrainz server is to display playing now info with a timeout equal to duraion of the submitted track
* https://github.com/FoxxMD/multi-scrobbler/discussions/338
*
* We'll use duration as a generic timeout for any Source that *only* parses Now Playing data for Player
*/
export class NowPlayingPlayerState extends GenericPlayerState {
protected getStaleInterval() {
if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
return this.currentPlay.data.duration;
}
return super.getStaleInterval();
}
protected getOrphanedInterval() {
if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
return this.currentPlay.data.duration;
}
return super.getOrphanedInterval();
}
public getApiState(): SourcePlayerObj {
const data = super.getApiState();
data.nowPlayingMode = true;
return data;
}
}
@@ -6,7 +6,7 @@ import { PositionalPlayerState } from "./PositionalPlayerState.js";
export class PlexPlayerState extends PositionalPlayerState {
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})});
super(logger, platformId, {allowedDrift: 35000, rtTruth: true, ...(opts || {})});
this.gracefulEndBuffer = this.allowedDrift / 1000;
}
+42 -7
View File
@@ -15,9 +15,6 @@ import { genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, isDebugMo
import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js";
import { PlexAPI } from "@lukehagar/plexjs";
import {
SDKValidationError,
} from "@lukehagar/plexjs/sdk/models/errors";
import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { isPortReachable, joinedUrl } from '../utils/NetworkUtils.js';
import normalizeUrl from 'normalize-url';
@@ -29,6 +26,7 @@ import { AbstractPlayerState, PlayerStateOptions } from './PlayerState/AbstractP
import { Logger } from '@foxxmd/logging';
import { MemoryPositionalSource } from './MemoryPositionalSource.js';
import { FixedSizeList } from 'fixed-size-list';
import { SDKValidationError } from '@lukehagar/plexjs/sdk/models/errors/sdkvalidationerror.js';
const shortDeviceId = truncateStringToLength(10, '');
@@ -118,6 +116,10 @@ export default class PlexApiSource extends MemoryPositionalSource {
this.librariesAllow = parseArrayFromMaybeString(librariesAllow, {lower: true});
this.librariesBlock = parseArrayFromMaybeString(librariesBlock, {lower: true});
if(this.librariesBlock.length > 0 && this.librariesAllow.length > 0) {
this.logger.warn(`When both 'librariesAllow' and 'librariesBlock' are specified only 'librariesAllow' is used.`);
}
const normal = normalizeUrl(this.config.data.url, {removeSingleSlash: true});
this.address = new URL(normal);
this.logger.debug(`Config URL: ${this.config.data.url} | Normalized: ${this.address.toString()}`);
@@ -183,6 +185,17 @@ export default class PlexApiSource extends MemoryPositionalSource {
this.libraries = libraries.object.mediaContainer.directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid}));
} catch (e) {
if(e instanceof SDKValidationError) {
if((e.rawValue as any).object?.MediaContainer?.Directory !== undefined) {
// ensure directory has required values
const ok = (e.rawValue as any).object?.MediaContainer?.Directory.every(x => x.title !== undefined && x.type !== undefined && x.uuid !== undefined);
if(ok) {
this.libraries = (e.rawValue as any).object.MediaContainer.Directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid}));
return;
}
}
this.logger.debug({ rawValue: e.rawValue }, 'Plex Response');
}
throw new Error('Unable to get server libraries', {cause: e});
}
@@ -232,19 +245,22 @@ export default class PlexApiSource extends MemoryPositionalSource {
if(state.play !== undefined) {
const allowedLibraries = this.getAllowedLibraries();
if(allowedLibraries.length > 0 && !allowedLibraries.some(x => state.play.meta.library.toLocaleLowerCase().includes(x.name.toLocaleLowerCase()))) {
if(allowedLibraries.length > 0 && !allowedLibraries.some(x => state.play.meta.library.toLocaleLowerCase() === x.name.toLocaleLowerCase())) {
return `media not included in librariesAllow`;
}
if(allowedLibraries.length === 0) {
const blockedLibraries = this.getBlockedLibraries();
if(blockedLibraries.length > 0) {
const blockedLibrary = blockedLibraries.find(x => state.play.meta.library.toLocaleLowerCase().includes(x.name.toLocaleLowerCase()));
const blockedLibrary = blockedLibraries.find(x => state.play.meta.library.toLocaleLowerCase() === x.name.toLocaleLowerCase());
if(blockedLibrary !== undefined) {
return `media included in librariesBlock '${blockedLibrary.name}'`;
}
}
// this is inside this block because we SHOULD allow non-music libraries if
// user specified name in librariesAllow
// -- so only check for this if nothing is specified
if(!this.getValidLibraries().some(x => state.play.meta.library === x.name)) {
return `media not included in a valid library`;
}
@@ -339,7 +355,7 @@ export default class PlexApiSource extends MemoryPositionalSource {
library,
deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]),
sessionId: sessionKey,
trackProgressPosition: viewOffset / 1000
trackProgressPosition: viewOffset / 1000,
}
}
}
@@ -445,3 +461,22 @@ ${JSON.stringify(obj)}`);
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PlexPlayerState(logger, id, opts);
}
async function streamToString(stream: any) {
const reader = stream.getReader();
const textDecoder = new TextDecoder();
let result = '';
async function read() {
const { done, value } = await reader.read();
if (done) {
return result;
}
result += textDecoder.decode(value, { stream: true });
return read();
}
return read();
}
+33 -10
View File
@@ -25,6 +25,7 @@ import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js";
import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js";
import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js";
@@ -65,6 +66,9 @@ import PlexApiSource from './PlexApiSource.js';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
import { IcecastSource } from './IcecastSource.js';
import DeezerInternalSource from './DeezerInternalSource.js';
import KoitoSource from './KoitoSource.js';
import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js';
import MalojaSource from './MalojaSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -163,6 +167,9 @@ export default class ScrobbleSources {
case 'ytmusic':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig");
break;
case 'maloja':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaSourceConfig");
break;
case 'mpris':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig");
break;
@@ -199,6 +206,9 @@ export default class ScrobbleSources {
case 'azuracast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig");
break;
case 'koito':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoSourceConfig");
break;
}
}
return this.schemaDefinitions[type];
@@ -265,13 +275,19 @@ export default class ScrobbleSources {
sourceDefaults = this.buildSourceDefaults(sd);
for (const [index, c] of mainConfigSourcesConfigs.entries()) {
const {name = 'unnamed'} = c;
if(!isSourceType(c.type.toLocaleLowerCase())) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source type of '${c.type}'. Must be one of ${sourceTypes.join(' | ')}`;
if(c.type === undefined) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[sourceType]" must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
}
if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source'))
if(!isSourceType(c.type.toLocaleLowerCase())) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source "type" of "${c.type}". Must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
}
if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`);
continue;
@@ -686,8 +702,8 @@ export default class ScrobbleSources {
continue;
}
for (const [i,rawConf] of sourceConfigs.entries()) {
if(['lastfm','listenbrainz'].includes(sourceType) &&
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source'))
if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) &&
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
continue;
@@ -747,7 +763,7 @@ export default class ScrobbleSources {
try {
await this.addSource(c, sourceDefaults);
} catch(e) {
const addError = new Error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`, {cause: e});
const addError = new Error(`Source ${c.name} of type ${c.type} from source ${c.source} was not added because of unrecoverable errors`, {cause: e});
this.emitter.emit('error', addError);
this.logger.error(addError);
}
@@ -762,17 +778,17 @@ export default class ScrobbleSources {
// throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
// }
const {type, name, data: d = {}, enable = true, options: clientOptions = {}} = clientConfig;
const {type, name, data: d = {}, enable = true, source, options: clientOptions = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) source was disabled by config`);
this.logger.warn({labels: [`${type} - ${name}`]},`Source from ${source} was disabled by config`);
return;
}
// add defaults
const compositeConfig: SourceConfig = {...clientConfig, data: d, options: {...defaults, ...clientOptions}};
this.logger.debug(`(${name}) Constructing ${type} source`);
this.logger.debug({labels: [`${type} - ${name}`]},`Constructing Source from ${source}...`);
let newSource: AbstractSource;
switch (type) {
case 'spotify':
@@ -859,15 +875,22 @@ export default class ScrobbleSources {
case 'azuracast':
newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter);
break;
case 'koito':
newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter);
break;
case 'maloja':
newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter);
break;
default:
break;
}
if(newSource === undefined) {
// really shouldn't get here!
this.logger.error(new Error(`Source of type ${type} was not recognized??`));
this.logger.error(new Error(`Source of type ${type} from ${source} was not recognized??`));
return;
}
this.sources.push(newSource);
newSource.logger.info(`Source Added from ${source}`);
}
}
+12 -2
View File
@@ -2,7 +2,7 @@ import dayjs, { Dayjs } from "dayjs";
import EventEmitter from "events";
import SpotifyWebApi from "spotify-web-api-node";
import request from 'superagent';
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js";
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
@@ -155,6 +155,7 @@ export default class SpotifySource extends MemoryPositionalSource {
}
const {
id: albumId,
name: albumName,
artists: albumArtists = [],
images = []
@@ -183,11 +184,20 @@ export default class SpotifySource extends MemoryPositionalSource {
track: name,
duration: duration_ms / 1000,
playDate: played_at,
playDateCompleted
playDateCompleted,
meta: {
spotify: {
track: id,
artist: artists.map(x => x.id),
albumArtist: actualAlbumArtists.map(x => x.id),
album: albumId
}
}
},
meta: {
deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`,
source: 'Spotify',
musicService: 'Spotify',
trackId: id,
trackProgressPosition: playbackPosition,
scrobbleTsSOC,
+49 -5
View File
@@ -16,6 +16,16 @@ import MemorySource from "./MemorySource.js";
dayjs.extend(isSameOrAfter);
interface SourceIdentifierData {
/** Subsonic Version */
version?: string,
/** Media Player name */
type?: string,
/** Media Player version */
serverVersion?: string,
openSubsonic?: boolean
}
export class SubsonicSource extends MemorySource {
requiresAuth = true;
@@ -24,6 +34,10 @@ export class SubsonicSource extends MemorySource {
declare config: SubSonicSourceConfig;
usersAllow: string[] = [];
sourceData: SourceIdentifierData = {};
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
@@ -36,8 +50,16 @@ export class SubsonicSource extends MemorySource {
this.canPoll = true;
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject {
const {
newFromSource = false,
sourceData: {
version,
type,
serverVersion,
openSubsonic
} = {},
} = options;
const {
id,
title,
@@ -48,6 +70,7 @@ export class SubsonicSource extends MemorySource {
playerId,
username,
} = obj;
return {
data: {
artists: [artist],
@@ -63,7 +86,9 @@ export class SubsonicSource extends MemorySource {
trackId: id,
newFromSource,
user: username,
deviceId: playerId
deviceId: playerId,
mediaPlayerName: type ?? `${openSubsonic ? 'Open ' : ''}Subsonic`,
mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version
}
}
}
@@ -193,6 +218,22 @@ export class SubsonicSource extends MemorySource {
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
}
let usersAllowVal = this.config.data.usersAllow;
if(usersAllowVal !== undefined && usersAllowVal !== null) {
if(!Array.isArray(usersAllowVal)) {
usersAllowVal = [usersAllowVal];
}
if(usersAllowVal.filter(x => x.toString().trim() !== '').length > 0) {
this.usersAllow = usersAllowVal.map(x => x.toString().trim());
}
}
if(this.usersAllow.length === 0) {
this.logger.verbose('Will monitor plays by all users');
} else {
this.logger.verbose(`Will only monitor plays for the following users: ${this.usersAllow.join(', ')}`);
}
return true;
}
@@ -200,6 +241,7 @@ export class SubsonicSource extends MemorySource {
const {url} = this.config.data;
try {
const resp = await this.callApi(request.get(`${url}/rest/ping`));
this.sourceData = resp as SourceIdentifierData;
this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`);
return true;
} catch (e) {
@@ -208,6 +250,7 @@ export class SubsonicSource extends MemorySource {
if(subResponseError !== undefined) {
const resp = getSubsonicResponse(subResponseError.response)
this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`);
this.sourceData = resp as SourceIdentifierData;
return true;
}
@@ -244,8 +287,9 @@ export class SubsonicSource extends MemorySource {
} = {}
} = resp;
// sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays
const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj));
return this.processRecentPlays(deduped);
const deduped = removeDuplicates(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData})));
const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase()));
return this.processRecentPlays(userFiltered);
}
}
+5
View File
@@ -31,6 +31,7 @@ export class VLCSource extends MemoryPositionalSource {
port?: number
client!: VLC.Client;
deviceId: string
vlcVersion?: string;
filenamePatterns: RegExp[] = [];
constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
@@ -110,6 +111,7 @@ export class VLCSource extends MemoryPositionalSource {
try {
const status = await this.client.status();
this.logger.info(`Connected successfully, found VLC ${status.version}`);
this.vlcVersion = status.version;
return true;
} catch (e) {
let friendlyError: string | undefined;
@@ -222,6 +224,9 @@ export class VLCSource extends MemoryPositionalSource {
},
meta: {
trackProgressPosition: time,
source: 'vlc',
mediaPlayerName: 'VLC',
mediaPlayerVersion: this.vlcVersion
}
}
}
+10
View File
@@ -6,6 +6,7 @@ import {
InternalConfig,
NO_USER,
PlayerStateData,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
ReportedPlayerStatus,
} from "../common/infrastructure/Atomic.js";
@@ -18,6 +19,9 @@ import {
import { joinedUrl } from "../utils/NetworkUtils.js";
import MemorySource from "./MemorySource.js";
import { Logger } from "@foxxmd/logging";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
export class WebScrobblerSource extends MemorySource {
@@ -93,6 +97,9 @@ export class WebScrobblerSource extends MemorySource {
} = {}): PlayObject {
const {
connectorLabel,
connector: {
label: connectorL
} = {},
controllerTabId = 'UNK',
processed,
parsed: {
@@ -137,6 +144,7 @@ export class WebScrobblerSource extends MemorySource {
origin: originUrl
},
deviceId: `${connectorLabel}-${controllerTabId}`,
musicService: connectorL,
scrobbleAllowed: isScrobblingAllowed,
nowPlaying: options.nowPlaying ?? false
}
@@ -181,4 +189,6 @@ export class WebScrobblerSource extends MemorySource {
}
}
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
+49 -16
View File
@@ -19,10 +19,8 @@ import {
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { FixedSizeList } from "fixed-size-list";
import { todayAwareFormat } from "../utils/TimeUtils.js";
import { RestType } from "ts-json-schema-generator";
import { parseArtistCredits, parseCredits } from "../utils/StringUtils.js";
import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js";
export interface HistoryIngressResult {
plays: PlayObject[],
@@ -72,18 +70,43 @@ export const ytiHistoryResponseFromShelfToPlays = (res: ApiResponse, options: {n
return items;
}
const GOOGLE_OAUTH_OPTS: GenerateAuthUrlOpts = {
access_type: 'offline',
scope: [
"http://gdata.youtube.com",
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/youtube-paid-content",
"https://www.googleapis.com/auth/accounts.reauth",
],
include_granted_scopes: true,
prompt: 'consent',
};
const DEFAULT_SCOPES = [
"http://gdata.youtube.com",
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/youtube-paid-content",
"https://www.googleapis.com/auth/accounts.reauth",
];
const VALID_SCOPES = [
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/youtube-paid-content",
]
const getGoogleOauthOpts = (): GenerateAuthUrlOpts => {
let scopes: string[];
const userInput = parseArrayFromMaybeString(process.env.YTM_SCOPES);
if (userInput.length > 0) {
scopes = userInput.map(x => {
if (x.toLocaleLowerCase() === 'default') {
return DEFAULT_SCOPES;
} else if (x.toLocaleLowerCase() === 'valid') {
return VALID_SCOPES;
}
return x;
}).flat(1);
} else {
scopes = DEFAULT_SCOPES;
}
return {
access_type: 'offline',
scope: scopes,
include_granted_scopes: true,
prompt: 'consent',
};
}
export default class YTMusicSource extends AbstractSource {
@@ -162,6 +185,9 @@ export default class YTMusicSource extends AbstractSource {
this.authed = true;
await this.yti.session.oauth.cacheCredentials();
});
if(isDebugMode()) {
Log.setLevel(Log.Level.DEBUG);
}
}
protected configureCustomOauth() {
@@ -203,7 +229,9 @@ export default class YTMusicSource extends AbstractSource {
redirectUri: this.redirectUri,
});
const authorizationUrl = this.oauthClient.generateAuthUrl(GOOGLE_OAUTH_OPTS);
const scopeOpts = getGoogleOauthOpts();
this.logger.debug(`Using scopes:\n${(scopeOpts.scope as string[]).join('\n')}`)
const authorizationUrl = this.oauthClient.generateAuthUrl(getGoogleOauthOpts());
this.verificationUrl = authorizationUrl;
}
@@ -395,6 +423,7 @@ Redirect URI : ${this.redirectUri}`);
},
meta: {
source: 'YTMusic',
musicService: 'Youtube Music',
trackId: id,
newFromSource,
comment: shelf
@@ -419,6 +448,10 @@ Redirect URI : ${this.redirectUri}`);
});
return res;
} catch (e) {
const info = loggedErrorExtra(e);
if(info !== undefined) {
this.logger.error(info, 'Additional API response details')
}
throw e;
}
}
+1 -2
View File
@@ -92,8 +92,7 @@ describe('Sample Configs', function () {
await copyFile(samplePath(componentType), `${componentType}.json`);
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
expect(clients.clients).length(expectedCount);
expect(clients.clients).length(1);
});
}
});
+19
View File
@@ -0,0 +1,19 @@
import chai, { expect } from 'chai';
import asPromised from 'chai-as-promised';
import { after, before, describe, it } from 'mocha';
import { generatePlay } from "../utils/PlayTestUtils.js";
import { playToClientPayload } from '../../common/vendor/LastfmApiClient.js';
chai.use(asPromised);
describe('#LFM Scrobble Payload Behavior', function () {
it('Should remove VA from album artist', function() {
const play = generatePlay({albumArtists: ['VA']});
expect(playToClientPayload(play).albumArtist).to.be.undefined;
const okPlay = generatePlay({albumArtists: ['My Dude']});
expect(playToClientPayload(okPlay).albumArtist).eq('My Dude');
});
});
@@ -6,7 +6,8 @@ import { http, HttpResponse } from "msw";
import { PlayObject } from "../../../core/Atomic.js";
import { UpstreamError } from "../../common/errors/UpstreamError.js";
import { ListenbrainzApiClient, ListenResponse } from "../../common/vendor/ListenbrainzApiClient.js";
import { ListenbrainzApiClient } from "../../common/vendor/ListenbrainzApiClient.js";
import { ListenResponse } from '../../common/vendor/listenbrainz/interfaces.js';
import { ExpectedResults } from "../utils/interfaces.js";
import { withRequestInterception } from "../utils/networking.js";
import artistWithProperJoiner from './correctlyMapped/artistProperHasJoinerInName.json';
+34
View File
@@ -366,6 +366,7 @@ describe('Player listen ranges', function () {
const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds'));
assert.isDefined(prevPlay);
assert.isTrue(curr.data.repeat)
assert.equal(player.getListenDuration(), 0);
});
@@ -389,6 +390,7 @@ describe('Player listen ranges', function () {
player.currentListenRange.rtPlayer.setPosition(381000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 20}), dayjs().add(30, 'seconds'));
assert.isTrue(curr.data.repeat);
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
@@ -411,9 +413,41 @@ describe('Player listen ranges', function () {
player.currentListenRange.rtPlayer.setPosition(50000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
assert.isTrue(curr.data.repeat)
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
it('Resets repeat status when updated with non-matching play', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 70;
player.update(testState({play: positioned, position: 0}));
player.currentListenRange.rtPlayer.setPosition(40000);
player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds'));
const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds'));
assert.isTrue(isRepeat);
positioned.meta.trackProgressPosition = 2;
player.currentListenRange.rtPlayer.setPosition(50000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
assert.isTrue(curr.data.repeat)
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
player.currentListenRange.rtPlayer.setPosition(55000);
const [currNew, prevPlayRepeat] = player.update(testState({play: generatePlay(), position: 1}), dayjs().add(55, 'seconds'));
assert.isDefined(prevPlayRepeat)
assert.isTrue(prevPlayRepeat.data.repeat);
assert.isDefined(currNew);
assert.isFalse(currNew.data.repeat);
});
});
});
});
+9
View File
@@ -134,18 +134,27 @@ describe("Plex API Source", function() {
it('Should allow activity based on libraries allow', async function () {
const s = await createSource({...defaultCreds, librariesAllow: ['music']});
s.libraries.push({name: 'SomeOtherLibrary', collectionType: 'artist', uuid: '43543'});
s.libraries.push({name: 'Study Music', collectionType: 'artist', uuid: '435437'});
s.libraries.push({name: 'Music', collectionType: 'artist', uuid: '4354378'});
expect(s.isActivityValid(validPlayerState, validSession)).to.be.true;
expect(s.isActivityValid(playWithMeta({library: 'SomeOtherLibrary'}), nowPlayingSession({librarySectionTitle: 'SomeOtherLibrary'}))).to.not.be.true;
expect(s.isActivityValid(playWithMeta({library: 'Study Music'}), nowPlayingSession({librarySectionTitle: 'Study Music'}))).to.not.be.true;
expect(s.isActivityValid(playWithMeta({library: 'Music'}), nowPlayingSession({librarySectionTitle: 'Music'}))).to.be.true;
await s.destroy();
});
it('Should disallow activity based on libraries block', async function () {
const s = await createSource({...defaultCreds, librariesBlock: ['music']});
s.libraries.push({name: 'CoolVideos', collectionType: 'artist', uuid: '43543'});
s.libraries.push({name: 'Study Music', collectionType: 'artist', uuid: '435437'});
s.libraries.push({name: 'Music', collectionType: 'artist', uuid: '4354378'});
expect(s.isActivityValid(validPlayerState, validSession)).to.not.be.true;
expect(s.isActivityValid(playWithMeta({library: 'CoolVideos'}), nowPlayingSession({librarySectionTitle: 'CoolVideos'}))).to.be.true;
expect(s.isActivityValid(playWithMeta({library: 'Study Music'}), nowPlayingSession({librarySectionTitle: 'Study Music'}))).to.be.true;
expect(s.isActivityValid(playWithMeta({library: 'Music'}), nowPlayingSession({librarySectionTitle: 'Music'}))).to.not.be.true;
await s.destroy();
});
+14 -2
View File
@@ -4,15 +4,16 @@ import request from "superagent";
import { PlayObject } from "../../../core/Atomic.js";
import { Notifiers } from "../../notifier/Notifiers.js";
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.js";
import { CommonClientConfig, CommonClientOptions, NowPlayingOptions } from "../../common/infrastructure/config/client/index.js";
export class TestScrobbler extends AbstractScrobbleClient {
testRecentScrobbles: PlayObject[] = [];
constructor() {
constructor(config: CommonClientConfig = {name: 'test'}) {
const logger = loggerTest;
const notifier = new Notifiers(new EventEmitter(), new EventEmitter(), new EventEmitter(), logger);
super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger);
super('test', 'Test', {name: 'test', ...config}, notifier, new EventEmitter(), logger);
}
protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> {
@@ -49,3 +50,14 @@ export class TestAuthScrobbler extends TestScrobbler {
}
}
}
export type TestNowPlayingConfig = CommonClientConfig & {options?: CommonClientOptions & NowPlayingOptions};
export class NowPlayingScrobbler extends TestScrobbler {
declare config: TestNowPlayingConfig
constructor(config?: TestNowPlayingConfig) {
super(config);
this.supportsNowPlaying = true;
}
}
+253 -3
View File
@@ -7,13 +7,15 @@ import { after, before, describe, it } from 'mocha';
import { http, HttpResponse } from 'msw';
import pEvent from 'p-event';
import { PlayObject } from "../../../core/Atomic.js";
import { sleep } from "../../utils.js";
import { genGroupIdStr, sleep } from "../../utils.js";
import mixedDuration from '../plays/mixedDuration.json';
import withDuration from '../plays/withDuration.json';
import { MockNetworkError, withRequestInterception } from "../utils/networking.js";
import { asPlays, generatePlay, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import MockDate from 'mockdate';
import { TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
import { PlayPlatformId } from '../../common/infrastructure/Atomic.js';
chai.use(asPromised);
@@ -244,6 +246,23 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
assert.isFalse(await testScrobbler.alreadyScrobbled(ballad2));
});
it('Is not detected as duplicate when play date matches fuzzy but play is marked as repeat', async function () {
const recent = normalizePlays(normalizedWithDur, {
initialDate: firstPlayDate,
defaultMeta: {source: 'jellyfin'}
});
testScrobbler.recentScrobbles = recent;
const repeatPlay = clone(recent[recent.length - 1]);
repeatPlay.data.playDate = repeatPlay.data.playDate.add(repeatPlay.data.duration + 2, 's');
assert.isTrue(await testScrobbler.alreadyScrobbled(repeatPlay));
repeatPlay.data.repeat = true;
assert.isFalse(await testScrobbler.alreadyScrobbled(repeatPlay));
});
});
});
@@ -737,3 +756,234 @@ describe('Manages scrobble queue', function() {
await testScrobbler.tryStopScrobbling()
});
});
describe('Now Playing', function() {
describe('Filtering Aggregated Updates', function() {
it('When no Now Playing exists, chooses play based on sorted platform id', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('When Now Playing platform does not exist in queued plays, chooses play based on sorted platform id', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
npScrobbler.nowPlayingLastPlay = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('Chooses play based on existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
const stickyNp = generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)});
npScrobbler.nowPlayingLastPlay = stickyNp
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(secondPlatform));
});
it('Sorts Sources alphabetically when using default Source sorting', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(a.meta.deviceId);
});
it('Sorts Sources based on user config', async function () {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(b.meta.deviceId);
});
it('Does not report if source is not in user config', async function () {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const c = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport).to.be.undefined;
});
});
describe('Updating', function () {
it('Should update if no existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.true;
});
it('Should update if previous Now Playing matches updated and last updated diff is greater than upper limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
expect(res).to.be.true;
});
it('Should NOT update if previous Now Playing matches updated and last updated diff is less than upper limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
expect(res).to.be.false;
});
it('Should update if previous Now Playing does NOT match updated and last updated diff is greater than lower limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.true;
});
it('Should NOT update if previous Now Playing does NOT match updated and last updated diff is less than than lower limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.false;
});
});
describe('Scheduling', function () {
this.afterEach(() => {
MockDate.reset();
});
it('Should update when no existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
npScrobbler.nowPlayingTaskInterval = 10;
await npScrobbler.initialize();
npScrobbler.scheduler.startById('pn_task');
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(res).is.not.undefined;
});
it('Should update when updated does not match Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
npScrobbler.nowPlayingTaskInterval = 10;
await npScrobbler.initialize();
npScrobbler.scheduler.startById('pn_task');
const now = dayjs();
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(res).is.not.undefined;
MockDate.set(now.add(npScrobbler.nowPlayingThresholds[0] + 3, 's').toDate());
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(resUpdate).is.not.undefined;
});
});
});
-36
View File
@@ -425,42 +425,6 @@ describe('Player Cleanup', function () {
});
});
describe('Scrobble Threshold Checks', function() {
it('uses defaults when no user-configured thresholds are passed', function() {
const results = timePassesScrobbleThreshold({}, 1, 1);
expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD);
expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD);
});
it('uses user-configured thresholds when passed', function() {
const results = timePassesScrobbleThreshold({
duration: 20,
percent: 15
}, 1, 1);
expect(results.duration.threshold).to.eq(20);
expect(results.percent.threshold).to.eq(15);
});
it('passes when duration is above threshold', function() {
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1);
expect(results.duration.passes).is.true;
expect(results.passes).is.true;
});
it('passes when percent is above threshold', function() {
const results = timePassesScrobbleThreshold({}, 30, 50);
expect(results.percent.passes).is.true;
expect(results.passes).is.true;
});
it('handles zero duration', function() {
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0);
expect(results.duration.passes).is.true;
expect(results.passes).is.true;
});
});
const generateDeezerSource = (options: DeezerInternalSourceOptions = {}) => {
return new DeezerInternalSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
}
@@ -0,0 +1,105 @@
import { assert, expect } from 'chai';
import { describe, it } from 'mocha';
import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timePassesScrobbleThreshold } from '../../utils/TimeUtils.js';
import { DEFAULT_CLOSE_POSITION_ABSOLUTE, DEFAULT_CLOSE_POSITION_PERCENT, DEFAULT_DURATION_REPEAT_ABSOLUTE, DEFAULT_DURATION_REPEAT_PERCENT, DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from '../../common/infrastructure/Atomic.js';
import { generatePlay } from '../utils/PlayTestUtils.js';
describe('Play Position', function() {
describe('Close To Start', function() {
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
expect(closeToPlayStart(generatePlay(), 10)[0]).is.true;
});
it(`Is not close when later than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
expect(closeToPlayStart(generatePlay({duration: 60}), 13)[0]).is.false;
});
it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
expect(closeToPlayStart(generatePlay({duration: 300}), 40)[0]).is.true;
});
it(`Is not close when greater than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
expect(closeToPlayStart(generatePlay({duration: 60}), 15)[0]).is.false;
});
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds and no duration`, function() {
expect(closeToPlayStart(generatePlay({duration: undefined}), 10)[0]).is.true;
});
});
describe('Close To End', function() {
it(`Is not close when no duration`, function() {
expect(closeToPlayEnd(generatePlay({duration: undefined}), 50)[0]).is.false;
});
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
expect(closeToPlayEnd(generatePlay({duration: 60}), 50)[0]).is.true;
});
it(`Is not close when earlier than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
expect(closeToPlayEnd(generatePlay({duration: 60}), 45)[0]).is.false;
});
it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
expect(closeToPlayEnd(generatePlay({duration: 300}), 261)[0]).is.true;
});
it(`Is not close when less than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
expect(closeToPlayEnd(generatePlay({duration: 300}), 240)[0]).is.false;
});
});
});
describe('Play Repeat Duration', function() {
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() {
expect(repeatDurationPlayed(generatePlay({duration: 300}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true;
});
it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() {
expect(repeatDurationPlayed(generatePlay({duration: 300}), 10)[0]).is.false;
});
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() {
expect(repeatDurationPlayed(generatePlay({duration: 60}), 40)[0]).is.true;
});
it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() {
expect(repeatDurationPlayed(generatePlay({duration: 60}), 15)[0]).is.false;
});
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds and no duration`, function() {
expect(repeatDurationPlayed(generatePlay({duration: undefined}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true;
});
});
describe('Scrobble Threshold Checks', function() {
it('uses defaults when no user-configured thresholds are passed', function() {
const results = timePassesScrobbleThreshold({}, 1, 1);
expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD);
expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD);
});
it('uses user-configured thresholds when passed', function() {
const results = timePassesScrobbleThreshold({
duration: 20,
percent: 15
}, 1, 1);
expect(results.duration.threshold).to.eq(20);
expect(results.percent.threshold).to.eq(15);
});
it('passes when duration is above threshold', function() {
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1);
expect(results.duration.passes).is.true;
expect(results.passes).is.true;
});
it('passes when percent is above threshold', function() {
const results = timePassesScrobbleThreshold({}, 30, 50);
expect(results.percent.passes).is.true;
expect(results.passes).is.true;
});
it('handles zero duration', function() {
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0);
expect(results.duration.passes).is.true;
expect(results.passes).is.true;
});
});
+7 -1
View File
@@ -1,4 +1,4 @@
import { faker } from '@faker-js/faker';
import { faker, fakerEL, FakerError } from '@faker-js/faker';
import dayjs, { Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration.js";
import isBetween from "dayjs/plugin/isBetween.js";
@@ -167,6 +167,12 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl
}
}
export const generatePlayPlatformId = (deviceId?: string, userId?: string): PlayPlatformId => {
const did = deviceId ?? [NO_DEVICE, faker.hacker.noun(), faker.hacker.noun()][faker.number.int({min: 0, max: 2})];
const uid = userId ?? [NO_USER, faker.internet.username(), faker.internet.username()][faker.number.int({min: 0, max: 2})];
return [did, uid];
}
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject[] => {
return Array.from(Array(numberOfPlays), () => generatePlay(data, meta));
}
+4 -3
View File
@@ -4,12 +4,13 @@ import chai, { assert, expect } from 'chai';
import asPromised from 'chai-as-promised';
import clone from "clone";
import YTMusicSource, { ytiHistoryResponseFromShelfToPlays, ytiHistoryResponseToListItems } from "../../sources/YTMusicSource.js";
import ytHistoryRes from './ytres.json' assert {type: 'json'};
import ytHistoryRes from './ytres.json' with {type: 'json'};
import EventEmitter from "events";
import { generatePlay, generatePlays, normalizePlays } from '../utils/PlayTestUtils.js';
import { YTMusicSourceConfig } from '../../common/infrastructure/config/source/ytmusic.js';
import { sleep } from '../../utils.js';
import dayjs from 'dayjs';
import { ApiResponse } from 'youtubei.js';
chai.use(asPromised);
@@ -33,12 +34,12 @@ const createYtSource = (opts?: {
describe('Parses History', function () {
it(`Parses a history response to tracks`, async function () {
const items = ytiHistoryResponseToListItems(ytHistoryRes);
const items = ytiHistoryResponseToListItems(ytHistoryRes as ApiResponse);
expect(items).length(10);
});
it(`Parses a history response plays with shelf name`, async function () {
const items = ytiHistoryResponseFromShelfToPlays(ytHistoryRes);
const items = ytiHistoryResponseFromShelfToPlays(ytHistoryRes as ApiResponse);
expect(items[0]?.meta?.comment).to.eq('March 2023');
});
});
+27 -12
View File
@@ -285,7 +285,9 @@ export const removeUndefinedKeys = <T extends Record<string, any>>(obj: T): T |
if(Array.isArray(obj[key])) {
newObj[key] = obj[key];
} else if (obj[key] === Object(obj[key])) {
newObj[key] = removeUndefinedKeys(obj[key]);
// dumb assign nested objects
// bc they may be third party library-objects that use prototyping and we don't want to mess with
newObj[key] = obj[key];
} else if (obj[key] !== undefined) {
newObj[key] = obj[key];
}
@@ -609,23 +611,36 @@ export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayOb
return eArtists.length > 1 || cArtists.length > 1;
}
export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable<T> | undefined => {
export interface NonEmptyOptions<T> {
ofType?: string,
test?: (val: T) => boolean
}
export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => {
for(const v of values) {
if(v === undefined || v === null) {
continue;
const nonEmptyVal = getNonEmptyVal(v, options);
if(nonEmptyVal !== undefined) {
return nonEmptyVal as T;
}
if(options.ofType !== undefined && typeof v !== options.ofType) {
continue;
}
if(options.test !== undefined && options.test(v as T) === false) {
continue;
}
return v as T;
}
return undefined;
}
export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, {ofType: 'string', test: (v) => v.trim() !== ''});
export const getNonEmptyVal = <T = unknown>(value: unknown, options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => {
if (value === undefined || value === null) {
return undefined;
}
if (options.ofType !== undefined && typeof value !== options.ofType) {
return undefined;
}
if (options.test !== undefined && options.test(value as T) === false) {
return undefined;
}
return value as T;
}
const nonEmptyStringOpts: NonEmptyOptions<string> = { ofType: 'string', test: (v) => v.trim() !== '' };
export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, nonEmptyStringOpts);
export const getNonEmptyString = (value: unknown) => getNonEmptyVal<string>(value, nonEmptyStringOpts);
/**
* Runs the function `fn`
+10
View File
@@ -366,3 +366,13 @@ export const buildStatePlayerPlayIdententifyingInfo = (data: PlayObject | Player
}
return idInfo;
}
export const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
export const normalizeListenbrainzUrl = (urlVal: string): string | undefined => {
if (parseRegexSingleOrFail(LZ_VERSION_PATH, urlVal)) {
return urlVal.replace(LZ_VERSION_PATH, '');
}
return undefined;
}
+82
View File
@@ -17,6 +17,10 @@ import {
} from "../../core/Atomic.js";
import { capitalize } from "../../core/StringUtils.js";
import {
DEFAULT_CLOSE_POSITION_ABSOLUTE,
DEFAULT_CLOSE_POSITION_PERCENT,
DEFAULT_DURATION_REPEAT_ABSOLUTE,
DEFAULT_DURATION_REPEAT_PERCENT,
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
lowGranularitySources,
@@ -329,4 +333,82 @@ export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Mi
}
// EX 01:15:45
return new Date(ms).toISOString().substring(11, 19);
}
/** Is Position earlier than X seconds or Y% percent of the start of a Play? */
export const closeToPlayStart = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
const {
absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE,
percent = DEFAULT_CLOSE_POSITION_PERCENT,
hintPrefix = true
} = thresholds;
let hintStart = hintPrefix ? `Position (${position}) ` : '';
const trackDur = play.data.duration;
const closeStartNum = position <= absolute;
const hints: string[] = [];
hints.push(`${closeStartNum ? 'is' : 'is not'} within ${absolute}s of track start`);
let closeStartPer = false;
if(trackDur !== undefined) {
const positionPercent = (position / trackDur);
closeStartPer = (positionPercent <= percent);
if(!closeStartNum) {
hints.push(`${closeStartPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track start (${formatNumber(positionPercent*100)}%)`);
}
}
return [closeStartNum || closeStartPer, `${hintStart}${hints.join(' and ')}`];
}
/** Is Position closer than X seconds or Y% percent of the end of a Play? */
export const closeToPlayEnd = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
const {
absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE,
percent = DEFAULT_CLOSE_POSITION_PERCENT,
hintPrefix = true
} = thresholds;
let hintStart = hintPrefix ? `Position (${position}) ` : '';
const trackDur = play.data.duration;
if(trackDur === undefined) {
return [false, `Cannot determine how close Position ${position} is to end of track because no duration data is available.`];
}
const nearEndNum = trackDur - position <= absolute;
const hints: string[] = [];
hints.push(`${nearEndNum ? 'is' : 'is not'} within ${absolute}s of track end`);
const positionPercent = 1 - (position / trackDur);
const nearEndPer = (positionPercent < percent);
if(!nearEndNum) {
hints.push(`${nearEndPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track end (${formatNumber(positionPercent*100)}%)`);
}
return [nearEndNum || nearEndPer, `${hintStart}${hints.join(' and ')}`];
}
/** Has more than X seconds or Y% percent of Play duration been played? */
export const repeatDurationPlayed = (play: PlayObject, duration: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
const {
absolute = DEFAULT_DURATION_REPEAT_ABSOLUTE,
percent = DEFAULT_DURATION_REPEAT_PERCENT,
hintPrefix = true
} = thresholds;
let hintStart = hintPrefix ? `Duration listened (${duration}s) ` : '';
const trackDur = play.data.duration;
const absPlayed = duration >= absolute;
const hints: string[] = [];
hints.push(`${absPlayed ? 'is' : 'is not'} more than ${absolute}s`);
let majorityDurationPercent = false;
if(trackDur !== undefined) {
const durationPercent = (duration / trackDur);
majorityDurationPercent = (durationPercent >= percent);
if(!absPlayed) {
hints.push(`${majorityDurationPercent ? 'is' : 'is not'} more than ${formatNumber(percent * 100, {toFixed: 0})}% of track duration (${formatNumber(durationPercent*100)}%)`);
}
}
return [absPlayed || majorityDurationPercent, `${hintStart}${hints.join(' and ')}`];
}
+12 -8
View File
@@ -42,6 +42,7 @@ art = {},
} = {},
play,
listenedDuration = 0,
nowPlayingMode = false,
status: {
calculated = '???',
reported,
@@ -51,14 +52,17 @@ art = {},
} = data;
let durPer = null;
if(duration !== undefined && duration !== null && duration !== 0) {
if(listenedDuration === 0 || listenedDuration === null) {
durPer = ' (0%)';
} else {
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
if(!nowPlayingMode) {
if(duration !== undefined && duration !== null && duration !== 0) {
if(listenedDuration === 0 || listenedDuration === null) {
durPer = ' (0%)';
} else {
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
}
}
}
const [viewMode, setViewMode] = useState('player');
const toggleViewMode = useCallback(() => {
@@ -94,10 +98,10 @@ art = {},
<p className="subtitle">{calculated !== 'stopped' ? artists.join(' / ') : '-'}</p>
</div>
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
<PlayerTimestamp duration={duration} indeterminate={(calculated === 'playing' && data.position === undefined) || nowPlayingMode} current={data.position || 0} />
<div className="flex">
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
<p className="stats flex-1 text-left">Status: {['unknown','playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}</p>
<p className="stats flex-1 text-right">Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
</div>
</section>
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
+46 -1
View File
@@ -1,6 +1,7 @@
import { LogDataPretty, LogLevel } from "@foxxmd/logging";
import { Dayjs } from "dayjs";
import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js";
import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js";
export interface SourceStatusData {
status: string;
@@ -66,12 +67,38 @@ export interface ListenRangeData {
end: ListenProgress
}
/** https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview */
export interface BrainzMeta {
/**
* artist_mbids
*
* All artists, including ft guests etc... go here */
artist?: string[]
/**
* artists_mbid
*
* If multiple artists for track this is the "original" artist who is releasing the single/album */
albumArtist?: string
/**
* release_mbid
*
* The unique release like --> 1984 US release of "The Wall" by "Pink Floyd", release on label "Columbia Records" with catalog number "C2K 36183"
* */
album?: string
/** Unique track id, recording_mbid */
track?: string
/**
*
* The "consolidated" album like --> "The Wall" by "Pink Floyd" */
releaseGroup?: string
additionalInfo?: AdditionalTrackInfoResponse
}
export interface SpotifyMeta {
artist?: string[]
albumArtist?: string[]
album?: string
track?: string
releaseGroup?: string
}
export interface TrackData {
@@ -86,6 +113,7 @@ export interface TrackData {
meta?: {
brainz?: BrainzMeta
spotify?: SpotifyMeta
}
}
@@ -98,11 +126,17 @@ export interface PlayData extends TrackData {
listenedFor?: number
listenRanges?: ListenRangeData[]
playDateCompleted?: Dayjs | string
repeat?: boolean
}
export interface PlayMeta {
source?: string
/*
* If applicable, the name of the Service providing the track (Spotify, Tidal, etc...)
*/
musicService?: string
/**
* Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc...
* */
@@ -151,6 +185,16 @@ export interface PlayMeta {
* The position the "player" is at in the track at the time the play was reported, in seconds
* */
trackProgressPosition?: number
/*
* Name of the media player (program)
*/
mediaPlayerName?: string
/*
* Version of the media player (program)
*/
mediaPlayerVersion?: string
/**
* A unique identifier for the device playing this track
* */
@@ -213,6 +257,7 @@ export interface SourcePlayerObj {
playerLastUpdatedAt: string
position?: Second
listenedDuration: Second
nowPlayingMode?: boolean
status: {
reported: string
calculated: string
+6 -1
View File
@@ -33,7 +33,12 @@ export const truncateStringToLength = (length: any, truncStr = '...') => (val: a
export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input;
export const defaultReducer = (acc, curr) => `${acc} ${curr}`;
export const defaultArtistFunc = (a: string[]) => a.join(' / ');
export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `--- ${input}` : input;
export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => {
if(input === undefined) {
return undefined;
}
return hasExistingParts ? `--- ${input}` : input
};
export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t === undefined ? '@ N/A' : `@ ${t.local().format()} ${i === undefined ? '' : (i === SCROBBLE_TS_SOC_START ? '(S)' : '(C)')}`;
export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;
export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`;