Compare commits

...
103 Commits
Author SHA1 Message Date
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
FoxxMD ede2d9f71a chore: Bump version for release 2025-06-19 16:22:39 +00:00
Matt Foxx acccc32813 Merge pull request #296 from FoxxMD/deezer-internal
feat: Implement Deezer Internal (ARL) Source
2025-06-17 20:41:08 -04:00
FoxxMD 69f73ff09f docs(deezer): Rewrite Deezer Source with ARL implementation
* Add ARL implementation docs and warnings
* Update json examples to include both types of deezer configs
2025-06-17 15:16:13 +00:00
FoxxMD 305f3c42ae fix(deezer): options are optional 2025-06-17 15:14:01 +00:00
FoxxMD d6ac4c34c5 feat: Implement better during/range temporal comparisons
* Refactor temporal comparisons to provide granular "during" thresholds
* Use duration for ignoring discovery with aggressive mode in Deezer #296
2025-06-17 10:10:34 -04:00
FoxxMD 9c6eeaafdb feat: Fuzzy diff threshold configurable
* Refactor fuzzy diff theshold to be configurable
* Configure aggressive fuzzy discovery for Deezer to be 40 seconds #296
2025-06-17 10:10:34 -04:00
FoxxMD 6bd841fea2 feat(deezer internal): Add fuzzy discovery ignore option
Allows user-configurable decision on whether to ignore fuzzy-matched discovered plays
2025-06-17 10:10:34 -04:00
FoxxMD e05c46e9ce fix(deezer official): Fix missing interval interface property 2025-06-17 10:10:34 -04:00
FoxxMD e94109dc75 feat: Implement Deezer Internal (ARL) Source 2025-06-17 10:10:34 -04:00
FoxxMD a68dea5193 chore: Bump version for release 2025-06-16 19:22:43 +00:00
FoxxMD b5d56b75d7 test(listenbrainz): Fix expected client count 2025-06-16 16:06:57 +00:00
FoxxMD c8daffb935 docs(listenbrainz): Add Koito compatibility and config example #300 2025-06-16 16:02:13 +00:00
FoxxMD 18a7f26c54 feat(listenbrainz): Implement limited support for Koito as Listenbrainz Source/Client #300
* Support Koito server detection via public route
  * Warns users of limited support on detection
* Implement play history retrival for Koito server
2025-06-16 15:49:39 +00:00
FoxxMD e7395e79a5 feat(listenbrainz): Detect /1/ version prefix on base URL
Remove /1/ from base URL to further normalize user URL and make config easier #300
2025-06-16 14:15:48 +00:00
FoxxMD 08ee6d379f fix(listenbrainz): Normalize URL and join parts correctly
* Normalize base URL to make user URL configuration easier/more robust
* Use joinPath for concetenating URL to avoid missing slashes #300
2025-06-16 14:04:15 +00:00
Matt Foxx 9c692539ce Merge pull request #298 from FoxxMD/jellyfin-musicbrainz
feat(jellyfin): Add musicbrainz metadata #297
2025-06-04 14:15:09 -04:00
FoxxMD 5cd5061f20 feat(jellyfin): Add musicbrainz metadata #297 2025-06-04 13:44:15 +00:00
90 changed files with 8072 additions and 4756 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",
+17 -15
View File
@@ -12,32 +12,34 @@ 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)
* [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)
* [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)
* [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
+13
View File
@@ -0,0 +1,13 @@
[
{
"name": "DeezerOfficalDeprecated",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
}
}
]
+2 -5
View File
@@ -1,13 +1,10 @@
[
{
"name": "FoxxMDeezer",
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
"arl": "UOsRPjT3U5Dhaaup3xQ30DWFoSgqugdSYzL3QE743waaVy2eur2ckyJcZ4RV123Q3PsbKXkLwA6ahc9XmEfev6BtfSP8LujE3JswWRQogZCDUOwVzWO5sAig",
}
}
]
+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"
}
}
]
+11 -1
View File
@@ -16,5 +16,15 @@
"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"
}
},
]
+2 -1
View File
@@ -5,7 +5,8 @@
"data": {
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
"password": "yourPassword"
"password": "yourPassword",
"usersAllow": ["yourUser"]
}
}
]
+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
+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
+208 -240
View File
@@ -14,6 +14,212 @@ For the difference between **ENV** and **File** examples in this document see [C
:::
## Docker
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
:::info[Available Images]
<Tabs>
<TabItem value="docker" label="Dockerhub">
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
docker.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
<TabItem value="ghcr" label="Github Packages">
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
```
ghcr.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
</Tabs>
:::
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
<details>
<summary>`docker-compose.yml`</summary>
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
</details>
Recommended configuration steps for docker or docker-compose usage:
#### Storage
You should bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
```shell
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
volumes:
- "./config:/config"
```
</TabItem>
</Tabs>
</details>
#### Networking
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you must map a port to the container in order to access the dashboard and use MS with some sources (Webscrobbler, LFM/LZ Endpoints). The default container port is `9078`.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
```shell
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
ports:
- "9078:9078"
```
</TabItem>
</Tabs>
</details>
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
```shell
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
environment:
- BASE_URL=http://hostMachineIP
```
</TabItem>
</Tabs>
</details>
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
#### Linux Host
::::info
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:
* `id -u` -- prints UID
* `id -g` -- prints GID
::::
### Docker Usage Example
:::tip
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
:::
The example scenario:
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
* Serving app on port `9078`
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
* Config/data directory on host machine in a directory next to `docker-compose.yml`
* Linux uid/gid is `1000:1000`
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```bash
docker run --name multi-scrobbler -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="file" label="File">
```bash
docker run --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>
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- BASE_URL=http://192.168.0.100:9078
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
- PUID=1000
- PGID=1000
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
</TabItem>
</Tabs>
## Local Installation
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
@@ -105,246 +311,8 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
### 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
:::
:::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)
#### Usage Examples
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
```shell
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
```
</TabItem>
<TabItem value="file" label="File">
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
```shell
flatpak run io.github.foxxmd.multiscrobbler
```
</TabItem>
</Tabs>
## Docker
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
:::info[Available Images]
<Tabs>
<TabItem value="docker" label="Dockerhub">
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
docker.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
<TabItem value="ghcr" label="Github Packages">
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
```
ghcr.io/foxxmd/multi-scrobbler:latest
```
</TabItem>
</Tabs>
:::
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
<details>
<summary>`docker-compose.yml`</summary>
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
</details>
Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
```shell
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
volumes:
- "./config:/config"
```
</TabItem>
</Tabs>
</details>
#### Networking
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin). The default container port is `9078`.
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
```shell
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
ports:
- "9078:9078"
```
</TabItem>
</Tabs>
</details>
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
<details>
<summary>Example</summary>
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
```shell
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
environment:
- BASE_URL=http://hostMachineIP
```
</TabItem>
</Tabs>
</details>
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
### Linux Host
::::info
:::note
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) or are running docker on MacOS/Windows this **DOES NOT** apply to you.
:::
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
::::
### Docker Usage Example
:::tip
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
:::
The example scenario:
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
* Serving app on port `9078`
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
* Config/data directory on host machine in a directory next to `docker-compose.yml`
* Linux uid/gid is `1000:1000`
<Tabs groupId="runType" queryString>
<TabItem value="docker" label="Docker">
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
```
</TabItem>
<TabItem value="file" label="File">
```bash
docker run -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
```
</TabItem>
</Tabs>
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- BASE_URL=http://192.168.0.100:9078
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
- PUID=1000
- PGID=1000
volumes:
- "./config:/config"
ports:
- "9078:9078"
restart: unless-stopped
```
</TabItem>
</Tabs>
:::
+28
View File
@@ -130,6 +130,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;
+18 -15
View File
@@ -10,36 +10,39 @@ 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)
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
* [Kodi](docs/configuration#kodi)
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
* [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)
* [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)
+138 -49
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.3",
"version": "0.9.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.3",
"version": "0.9.9",
"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",
@@ -53,6 +53,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
@@ -79,6 +80,7 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
@@ -114,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",
@@ -490,6 +492,32 @@
"tough-cookie": "^4.1.4"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@curvenote/ansi-to-react": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@curvenote/ansi-to-react/-/ansi-to-react-7.0.0.tgz",
@@ -1025,6 +1053,7 @@
"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"
}
@@ -1514,9 +1543,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"
}
@@ -2774,6 +2803,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",
@@ -2905,13 +2941,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": {
@@ -3254,12 +3293,10 @@
}
},
"node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"dependencies": {
"debug": "^4.3.4"
},
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
@@ -6015,6 +6052,30 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-cookie-agent": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.1.tgz",
"integrity": "sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0",
"undici": "^7.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -9095,10 +9156,17 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -9142,7 +9210,8 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -9481,7 +9550,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "4.1.8",
@@ -10538,6 +10608,24 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -10631,27 +10719,15 @@
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
"node": ">=16"
}
},
"node_modules/tr46": {
@@ -10947,14 +11023,14 @@
"dev": true
},
"node_modules/undici": {
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
"integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=14.0"
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
@@ -11032,6 +11108,7 @@
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -11984,6 +12061,18 @@
"undici": "^5.19.1"
}
},
"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==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.3",
"version": "0.9.9",
"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",
@@ -85,6 +85,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
@@ -111,6 +112,7 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
@@ -146,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",
+25 -4
View File
@@ -30,7 +30,8 @@ export type SourceType =
| 'mpd'
| 'vlc'
| 'icecast'
| 'azuracast';
| 'azuracast'
| 'koito';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -55,7 +56,8 @@ export const sourceTypes: SourceType[] = [
'mpd',
'vlc',
'icecast',
'azuracast'
'azuracast',
'koito'
];
export const isSourceType = (data: string): data is SourceType => {
@@ -67,16 +69,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 +211,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 {
/**
@@ -0,0 +1,70 @@
import { RequestRetryOptions } from "../common.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
export interface ListensResponse {
items: ListenObjectResponse[]
total_record_count: number
items_per_page: number
has_next_page: boolean
current_page: number
}
export interface ListenObjectResponse {
/** ISO8601 timestamp */
time: string
track: TrackResponse
}
export interface TrackResponse {
id: number
title: string
artists: ArtistResponse[]
musicbrainz_id: string | null
listen_count: number
duration: number
image: string | null
album_id: number
time_listened: number
}
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,6 +1,8 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { Second } from "../../../../../core/Atomic.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface DeezerData extends CommonSourceData {
export interface DeezerData extends CommonSourceData, PollingOptions {
/**
* deezer client id
*
@@ -20,13 +22,6 @@ export interface DeezerData extends CommonSourceData {
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri?: string
/**
* optional, how long to wait before calling spotify for new tracks (in seconds)
*
* @default 60
* @examples [60]
* */
interval?: number
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
@@ -35,3 +30,51 @@ export interface DeezerSourceConfig extends CommonSourceConfig {
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
type: 'deezer'
}
export interface DeezerInternalData extends CommonSourceData, PollingOptions {
/** ARL retrieved from Deezer response header */
arl: string
/** User agent
*
* @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0"
*/
userAgent?: string
}
export interface DeezerInternalSourceConfig extends CommonSourceConfig {
data: DeezerInternalData
options?: DeezerInternalSourceOptions
}
export interface DeezerInternalSourceOptions extends CommonSourceOptions {
fuzzyDiscoveryIgnore?: boolean | 'aggressive'
}
export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
type: 'deezer'
}
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
export interface DeezerInternalTrackData {
/** Song Id */
SNG_ID: string
/** Date listened as unix timestamp in seconds */
TS: Second
/** Album Id */
ALB_ID: string
/** Album Title */
ALB_TITLE: string
/** Album Art Id */
ALB_PICTURE: string
/** Artist Id */
ART_ID: string
/** Artist Name */
ART_NAME: string
/** Song Title */
SNG_TITLE: string
/** Time listened to track in seconds */
DURATION: Second
__TYPE__: 'song' | string
}
@@ -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'
}
@@ -2,7 +2,7 @@ import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
@@ -21,6 +21,7 @@ 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";
export type SourceConfig =
@@ -29,6 +30,7 @@ export type SourceConfig =
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| DeezerInternalSourceConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
@@ -48,7 +50,8 @@ export type SourceConfig =
| MPDSourceConfig
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig;
| AzuracastSourceConfig
| KoitoSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -56,6 +59,7 @@ export type SourceAIOConfig =
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| DeezerInternalAIOConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
@@ -75,4 +79,5 @@ export type SourceAIOConfig =
| 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];
}
+12 -35
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});
@@ -255,33 +259,6 @@ export default class LastfmApiClient extends AbstractApiClient {
// 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 => {
+200 -46
View File
@@ -1,10 +1,11 @@
import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject } from "../../../core/Atomic.js";
import { PlayObject, URLData } from "../../../core/Atomic.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
normalizeListenbrainzUrl,
normalizeStr,
parseArtistCredits,
parseCredits,
@@ -16,6 +17,12 @@ import { UpstreamError } from "../errors/UpstreamError.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
import AbstractApiClient from "./AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
import { parseRegexSingleOrFail, removeUndefinedKeys } from '../../utils.js';
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
import { log } from 'console';
import { version } from '../../ioc.js';
export interface ArtistMBIDMapping {
@@ -70,8 +77,21 @@ export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: 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?: AdditionalTrackInfo
additional_info?: SubmitListenAdditionalTrackInfo
}
export interface ListenPayload {
@@ -80,11 +100,18 @@ export interface ListenPayload {
track_metadata: TrackPayload;
}
export type ListenType = 'single' | 'playing_now';
export interface SubmitPayload {
listen_type: 'single' | 'playing_now',
listen_type: ListenType,
payload: [ListenPayload]
}
interface SubmitOptions {
log?: boolean
listenType?: ListenType
}
export interface TrackResponse extends MinimumTrack {
duration: number
additional_info: AdditionalTrackInfoResponse
@@ -111,18 +138,26 @@ export interface ListenResponse {
recording_msid?: string;
track_metadata: TrackResponse;
}
export class ListenbrainzApiClient extends AbstractApiClient {
declare config: ListenBrainzClientData;
url: string;
url: URLData;
isKoito: boolean = false;
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
super('ListenBrainz', name, config, options);
const {
url = 'https://api.listenbrainz.org/'
} = config;
this.url = url;
let cleanUrl = url;
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
if(pathedUrl !== undefined) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
cleanUrl = pathedUrl;
}
this.url = normalizeWebAddress(cleanUrl);
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
}
@@ -178,7 +213,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testConnection = async () => {
try {
const resp = await this.callApi(request.get(this.url))
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
this.isKoito = await this.checkKoito();
return true;
} catch (e) {
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
@@ -190,18 +226,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testAuth = async () => {
try {
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
return true;
} catch (e) {
throw e;
}
}
async checkKoito(): Promise<boolean> {
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. 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.');
}
return false;
}
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -217,11 +265,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
.query({
period: 'all_time',
page: 0,
limit: maxTracks
})
);
const { body } = resp as any;
return body as KoitoListensResponse;
} catch (e) {
throw e;
}
}
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -238,6 +304,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
if(this.isKoito) {
return this.getRecentlyPlayedKoito(maxTracks)
}
try {
const resp = await this.getUserListens(maxTracks, user);
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
@@ -247,9 +317,24 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
submitListen = async (play: PlayObject, log: boolean = false) => {
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
try {
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
const resp = await this.getUserListensKoito(maxTracks);
return resp.items.map(x => listenObjectResponseToPlay(x));
} 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)}`);
}
@@ -257,7 +342,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
// 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(`${this.url}1/submit-listens`).type('json').send(listenPayload));
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
if(log) {
this.logger.debug(`Submit Response: ${resp.text}`)
}
@@ -267,39 +352,6 @@ 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(),
@@ -646,3 +698,105 @@ 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
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]],
media_player: mediaPlayerName,
media_player_version: mediaPlayerVersion,
music_service: musicService !== undefined ? musicServiceToCononical(musicService) : undefined,
spotify_id: spotify.track,
spotify_album_id: spotify.album,
spotify_artist_ids: spotify.artist
};
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,
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;
}
+207
View File
@@ -0,0 +1,207 @@
import dayjs from "dayjs";
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 { normalizeListenbrainzUrl } from "../../../utils/StringUtils.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
import request, { Request, Response } from 'superagent';
import { UpstreamError } from "../../errors/UpstreamError.js";
import { ListenType, playToListenPayload, SubmitPayload } from "../ListenbrainzApiClient.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 {
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, url?: URL } = {}): PlayObject => {
const play: PlayObject = {
data: {
track: obj.track.title,
artists: (obj.track.artists ?? []).map(x => x.name),
duration: obj.track.duration,
playDate: dayjs(obj.time)
},
meta: {
source: 'Koito',
newFromSource: options.newFromSource ?? false,
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) {
play.data.meta = {
brainz: {
track: obj.track.musicbrainz_id
}
}
}
return play;
}
+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 {
+228 -10
View File
@@ -3,15 +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,
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";
@@ -23,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,
@@ -42,17 +46,23 @@ import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/E
import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js";
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalAccuracyToString,
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;
@@ -77,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;
@@ -87,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;
@@ -153,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.`);
@@ -315,7 +460,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
const temporalComparison = comparePlayTemporally(x.play, playObj);
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
return hasAcceptableTemporalAccuracy(temporalComparison.match)
});
return [matchPlayDate, dtInvariantMatches];
@@ -394,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);
@@ -403,9 +553,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const temporalComparison = comparePlayTemporally(x, playObj);
let timeMatch = 0;
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
timeMatch = 1;
} else if(temporalComparison.match === TA_FUZZY) {
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
timeMatch = 0.6;
}
@@ -775,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, {
+107
View File
@@ -0,0 +1,107 @@
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";
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, 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)});
}
}
}
+27 -2
View File
@@ -1,7 +1,6 @@
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";
@@ -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);
@@ -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} = this.api.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;
}
}
}
}
@@ -7,16 +7,18 @@ 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, ListenPayload, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzClientConfig;
@@ -26,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);
@@ -44,6 +47,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
this.isKoito = this.api.isKoito;
return true;
}
@@ -66,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) => {
@@ -78,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)}`);
@@ -91,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)});
}
}
}
+75 -17
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,8 +262,8 @@ 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;
@@ -287,7 +320,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 +332,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 +353,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 {
+12 -8
View File
@@ -2,7 +2,7 @@ import { childLogger, LogDataPretty } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import { FixedSizeList } from "fixed-size-list";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY } from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
import {
@@ -33,11 +33,12 @@ import {
sortByNewestPlayDate,
sortByOldestPlayDate,
} from "../utils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
export interface RecentlyPlayedOptions {
limit?: number
@@ -80,7 +81,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
emitter: EventEmitter;
protected SCROBBLE_BACKLOG_COUNT: number = 20;
protected SCROBBLE_BACKLOG_COUNT: number = 30;
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
@@ -130,7 +131,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
protected addPlayToDiscovered = (play: PlayObject) => {
const platformId = this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID;
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(200);
list.add(play);
this.recentDiscoveredPlays.set(platformId, list);
this.tracksDiscovered++;
@@ -152,7 +153,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
return [];
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
protected getExistingDiscoveredLists = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject[][] => {
const lists: PlayObject[][] = [];
if(opts.checkAll !== true) {
lists.push(this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID));
@@ -168,19 +169,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
});
}
return lists;
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
}
return undefined;
//const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID);
//return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
}
alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => {
+3 -1
View File
@@ -255,7 +255,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp
duration
},
meta: {
trackProgressPosition: elapsed
trackProgressPosition: elapsed,
source: 'azuracast',
mediaPlayerName: 'Azuracast'
}
}
}
+281
View File
@@ -0,0 +1,281 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import request, { Request, Response, SuperAgent } from 'superagent';
import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js";
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { CookieJar, Cookie } from 'tough-cookie';
import { MixedCookieAgent } from 'http-cookie-agent/http';
import MemorySource from "./MemorySource.js";
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
interface DeezerHistoryResponse {
errors: []
results: {
data: DeezerInternalTrackData[]
error: string[]
}
}
export default class DeezerInternalSource extends MemorySource {
requiresAuth = true;
requiresAuthInteraction = false;
csrfToken?: string;
agent: request.SuperAgentStatic & request.Request
jar: CookieJar
declare config: DeezerInternalSourceConfig;
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('deezer', name, config, internal, emitter);
const {
data: {
interval = 60,
...rest
} = {},
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
// @ts-expect-error not correct structure
this.config.data = {
...rest,
interval,
};
this.canPoll = true;
this.canBacklog = true;
this.supportsUpstreamRecentlyPlayed = true;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
// https://developers.deezer.com/api/user/history
// https://stackoverflow.com/a/19497151/1469797
this.SCROBBLE_BACKLOG_COUNT = 50;
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));
}
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const play: PlayObject = {
data: {
artists: [obj.ART_NAME],
album: obj.ALB_TITLE,
track: obj.SNG_TITLE,
duration: obj.DURATION,
playDate: dayjs(obj.TS * 1000),
},
meta: {
source: 'Deezer',
trackId: obj.SNG_ID,
newFromSource,
url: {
web: `https://www.deezer.com/track/${obj.SNG_ID}`
},
musicService: 'Deezer',
}
};
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
play.meta.art = {
album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
}
}
return play;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
if (this.config.data.arl === undefined) {
throw new Error('arl must be defined in configuration');
}
this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get('https://deezer.com');
return true;
} catch (e) {
throw e;
}
}
doAuthentication = async () => {
try {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'deezer.getUserData'
})
const resp = await this.callApi(req);
return true;
} catch (e) {
throw e;
}
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'user.getSongsHistory'
})
.set('Content-Type', 'application/json')
.send({
nb: 30,
start: 0
});
// returns listening history in descending order (newest to oldest)
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
for(const e of resp.results.error) {
this.logger.warn(`Error returned in history response: ${e}`);
}
const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song');
if(nonSong.length > 0) {
const nonSongTypes = [];
for(const n of nonSong) {
if(!nonSongTypes.includes(n.__TYPE__)) {
nonSongTypes.push(n.__TYPE__);
}
}
this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
}
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.options;
req.query({
input: 3,
api_version: '1.0',
api_token: this.csrfToken ?? ''
});
setRequestHeaders(req);
try {
const resp = await req;
const {
body: {
error,
results
} = {}
} = resp;
if('checkForm' in results) {
this.csrfToken = results.checkForm;
}
if (error !== undefined && error.length > 0) {
const err = new Error((error as string[]).join(' | '));
throw err;
}
return resp.body;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response,
} = e;
const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
throw new Error(msg, {cause: e});
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
const fuzzyIndex = list.findIndex(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
let temporalOptions: TemporalPlayComparisonOptions = {};
const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
temporalOptions = {
fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
duringReferences: ['duration', 'listenedFor', 'range']
}
temporalAccuracy.push(TA_DURING);
}
return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
});
if(fuzzyIndex !== -1) {
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
// always return fuzzy match as existing
// likely will make MS miss scrobbles for repeated plays
return list[fuzzyIndex];
}
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
// last discovered play was this one, or next played play was also this one
// so we'll assume this means the play is on repeat, don't count as existing
return undefined;
}
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
// so this is likely a duplicate deezer should not have reported
return list[fuzzyIndex];
}
}
}
return undefined;
}
}
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
req
.set('Pragma', 'no-cache')
.set('Origin', 'https://www.deezer.com')
.set('Accept-Encoding', 'gzip, deflate, br')
.set('Accept-Language', 'en-US,en;q=0.9')
.set('User-Agent', userAgent)
.set('Accept', '*/*')
.set('Cache-Control', 'no-cache')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Connection', 'keep-alive')
.set('Referer', 'https://www.deezer.com/login')
.set('DNT', '1')
const ct = req.get('Content-Type');
if(ct === '' || ct === null || ct === undefined) {
req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
}
const buildInternalUrl = (method: string, token: string = ''): URL => {
const params = new URLSearchParams([
['api_version', '1.0'],
['input', '3']
]);
params.append('method', method);
params.append('api_token', token);
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
return u;
}
+2 -1
View File
@@ -81,6 +81,7 @@ export default class DeezerSource extends AbstractSource {
},
meta: {
source: 'Deezer',
musicService: 'Deezer',
trackId: id,
newFromSource,
url: {
@@ -91,7 +92,7 @@ export default class DeezerSource extends AbstractSource {
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. Refer to the MS documentation for a new Deezer Source implementation.');
try {
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
+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})];
}
}
+25 -2
View File
@@ -46,7 +46,7 @@ import EventEmitter from "events";
import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { PlayObject } from "../../core/Atomic.js";
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
FormatPlayObjectOptions,
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
RunTimeTicks,
Type, // should be BaseItemKind.Audio
UserData,
ProviderIds = {}
} = obj;
return {
const meta: BrainzMeta = {};
if(ProviderIds.MusicBrainzAlbum !== undefined) {
meta.album = ProviderIds.MusicBrainzAlbum;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzArtist !== undefined) {
meta.artist = [ProviderIds.MusicBrainzArtist];
}
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
}
const play: PlayObject = {
data: {
artists: Artists,
album: Album,
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
source: 'Jellyfin',
}
}
if(Object.keys(meta).length > 0) {
play.data.meta = { brainz: meta };
}
return play;
}
getRecentlyPlayed = async (options = {}) => {
+3 -3
View File
@@ -1,7 +1,7 @@
import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT } from "../../core/Atomic.js";
import {
buildTrackString,
combinePartsToString,
@@ -19,7 +19,7 @@ import {
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import MemorySource from "./MemorySource.js";
@@ -313,7 +313,7 @@ export default class JellyfinSource extends MemorySource {
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
}
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
if(hasAcceptableTemporalAccuracy(temporalResult.match)) {
existingTracked = currPlay;
}
break;
+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})
}
+9 -1
View File
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
export default class ListenbrainzSource extends MemorySource {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzSourceConfig;
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get(this.api.url);
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
const isKoito = await this.api.checkKoito();
this.api.isKoito = isKoito;
this.isKoito = isKoito;
return true;
} catch (e) {
if(isNodeNetworkException(e)) {
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
if(this.isKoito) {
return await this.api.getRecentlyPlayedKoito(limit);
}
const now = await this.api.getPlayingNow();
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
return await this.api.getRecentlyPlayed(limit);
+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;
}
}
}
}
+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}`);
@@ -181,6 +185,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 +201,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 +221,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 +246,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 +278,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 +303,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 +318,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 +386,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 +396,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) {
@@ -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();
}
+35 -13
View File
@@ -5,7 +5,7 @@ import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } fro
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
import {
@@ -64,6 +64,9 @@ import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
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';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -139,7 +142,7 @@ export default class ScrobbleSources {
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig");
break;
case 'deezer':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerCompatConfig");
break;
case 'endpointlz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
@@ -198,6 +201,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];
@@ -264,13 +270,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;
@@ -411,6 +423,7 @@ export default class ScrobbleSources {
clientSecret: process.env.DEEZER_CLIENT_SECRET,
redirectUri: process.env.DEEZER_REDIRECT_URI,
accessToken: process.env.DEEZER_ACCESS_TOKEN,
arl: process.env.DEEZER_ARL,
};
if (!Object.values(d).every(x => x === undefined)) {
configs.push({
@@ -684,8 +697,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'].includes(sourceType) &&
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
continue;
@@ -745,7 +758,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);
}
@@ -760,17 +773,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':
@@ -802,7 +815,12 @@ export default class ScrobbleSources {
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, this.internalConfig, this.emitter);
break;
case 'deezer':
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
const deezerConfig = compositeConfig as DeezerCompatConfig;
if(deezerConfig.data.arl !== undefined) {
newSource = await new DeezerInternalSource(name, compositeConfig as DeezerInternalSourceConfig, this.internalConfig, this.emitter);
} else {
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
}
break;
case 'ytmusic':
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter);
@@ -852,15 +870,19 @@ 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;
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
}
}
}
@@ -93,6 +93,9 @@ export class WebScrobblerSource extends MemorySource {
} = {}): PlayObject {
const {
connectorLabel,
connector: {
label: connectorL
} = {},
controllerTabId = 'UNK',
processed,
parsed: {
@@ -137,6 +140,7 @@ export class WebScrobblerSource extends MemorySource {
origin: originUrl
},
deviceId: `${connectorLabel}-${controllerTabId}`,
musicService: connectorL,
scrobbleAllowed: isScrobblingAllowed,
nowPlaying: options.nowPlaying ?? false
}
+1
View File
@@ -395,6 +395,7 @@ Redirect URI : ${this.redirectUri}`);
},
meta: {
source: 'YTMusic',
musicService: 'Youtube Music',
trackId: id,
newFromSource,
comment: shelf
+2 -1
View File
@@ -92,7 +92,8 @@ 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));
expect(clients.clients).length(1);
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
expect(clients.clients).length(expectedCount);
});
}
});
+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;
});
});
});
+134 -28
View File
@@ -6,7 +6,7 @@ import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
@@ -19,6 +19,8 @@ import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
import { sleep } from "../../utils.js";
import DeezerInternalSource from "../../sources/DeezerInternalSource.js";
import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js";
chai.use(asPromised);
@@ -423,38 +425,142 @@ describe('Player Cleanup', function () {
});
});
describe('Scrobble Threshold Checks', function() {
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);
}
const firstPlayDate = dayjs().subtract(1, 'hour');
const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate});
const lastPlay = normalizedPlays[normalizePlays.length - 1];
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);
describe('Deezer Internal Source', function() {
describe('When fuzzyDiscoveryIgnore is not defined or false', function () {
it('discovers fuzzy play', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource();
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
});
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);
describe('When fuzzyDiscoveryIgnore is true', function () {
it('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('discovers fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
it('discovers fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length);
});
});
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;
});
describe('When fuzzyDiscoveryIgnore is aggressive', function () {
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('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
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 source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover play found during duration of previous', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const duringPlay = clone(targetPlay);
duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([duringPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play with delay of up to 40 seconds', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('it does not discover fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length - 1);
});
});
});
@@ -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));
}
@@ -1,8 +1,8 @@
import { loggerTest } from "@foxxmd/logging";
import { assert } from 'chai';
import { assert, expect } from 'chai';
import clone from "clone";
import { describe, it } from 'mocha';
import { playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { generatePlay, generatePlays } from "./PlayTestUtils.js";
import { PlayObject } from "../../../core/Atomic.js";
@@ -249,4 +249,37 @@ describe('Compare lists by order', function () {
});
});
describe('Source Play Comparisons', function() {
describe('Generic Source Comparison', function() {
it('matches identical plays', function() {
expect(genericSourcePlayMatch(newPlay, newPlay)).to.be.true;
});
it('matches identical plays with close timestamps', function() {
const closePlay = clone(newPlay);
closePlay.data.playDate = closePlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, closePlay)).to.be.true;
});
it('does not match unique plays', function() {
expect(genericSourcePlayMatch(newPlay, generatePlay())).to.be.false;
});
it('does not match unique plays with exact timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate;
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
it('does not match unique plays with close timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
});
});
});
+3
View File
@@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
return finalUrl;
}
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
let v4: string,
+7 -1
View File
@@ -1,6 +1,8 @@
import { getListDiff, ListDiff } from "@donedeal0/superdiff";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { playObjDataMatch } from "../utils.js";
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
export const metaInvariantTransform = (play: PlayObject): PlayObject => {
@@ -221,3 +223,7 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu
return `${a} => ${b}`;
}).join('\n');
}
export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean =>
playObjDataMatch(a, b)
&& hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t);
+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;
}
+155 -41
View File
@@ -1,11 +1,14 @@
import dayjs, { Dayjs } from "dayjs";
import isToday from 'dayjs/plugin/isToday.js';
import {
AcceptableTemporalDuringReference,
PlayObject,
SCROBBLE_TS_SOC_END,
SCROBBLE_TS_SOC_START,
ScrobbleTsSOC,
TA_CLOSE,
TA_DEFAULT_ACCURACY,
TA_DURING,
TA_EXACT,
TA_FUZZY,
TA_NONE,
@@ -14,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,
@@ -38,31 +45,31 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
}
if (data.date.fuzzyDurationDiff !== undefined) {
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.date.fuzzyListenedDiff !== undefined) {
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
}
if (data.range !== undefined) {
if (data.range === false) {
parts.push('Candidate not played during Existing tracked listening');
} else {
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
}
} else {
if(data.range === undefined) {
parts.push('Range Comparison N/A');
} else if(data.range.type === 'none') {
parts.push(`Candidate not played during Existing ${data.duringReferences.join(' or ')}`);
} else {
parts.push(`Candidate played during tracked listening range from Existing "${data.range.type}" ${data.range.timestamps[0].format('HH:mm:ssZ')} => ${data.range.timestamps[1].format('HH:mm:ssZ')}`);
}
return parts.join(' | ');
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
export interface TemporalPlayComparisonOptions {
diffThreshold?: number,
fuzzyDuration?: boolean,
useListRanges?: boolean
} = {}): TemporalPlayComparison => {
fuzzyDiffThreshold?: number
duringReferences?: AcceptableTemporalDuringReference
}
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: TemporalPlayComparisonOptions = {}): TemporalPlayComparison => {
const result: TemporalPlayComparison = {
match: TA_NONE
};
const {
meta: {
@@ -98,9 +105,15 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const {
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
fuzzyDuration = false,
useListRanges = true,
fuzzyDiffThreshold = 10,
duringReferences = ['range']
} = options;
const result: TemporalPlayComparison = {
match: TA_NONE,
duringReferences
};
// cant compare!
if (existingTsSOCDate === undefined || candidateTsSOCDate === undefined) {
return result;
@@ -115,7 +128,8 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
const scrobblePlayDiff = Math.abs(existingTsSOCDate.unix() - candidateTsSOCDate.unix());
result.date = {
threshold: diffThreshold,
diff: scrobblePlayDiff
diff: scrobblePlayDiff,
fuzzyDiffThreshold
};
if(scrobblePlayDiff <= 1) {
@@ -124,29 +138,57 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
result.match = TA_CLOSE;
}
if (useListRanges && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = range;
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
result.match = TA_CLOSE;
if(result.match !== TA_NONE) {
return result;
}
if(duringReferences.length > 0) {
if (duringReferences.includes('range') && existingRanges !== undefined) {
// since we know when the existing track was listened to
// we can check if the new track play date took place while the existing one was being listened to
// which would indicate (assuming same source) the new track is a duplicate
for (const range of existingRanges) {
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
result.range = {
type: 'range',
timestamps: [range.start.timestamp, range.end.timestamp]
}
result.match = TA_DURING;
return result;
}
break;
}
}
if (result.range === undefined) {
result.range = false;
if(duringReferences.includes('listenedFor') && existingPlay.data.listenedFor !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'listenedFor',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's')]
}
return result;
}
}
if(duringReferences.includes('duration') && existingPlay.data.duration !== undefined) {
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's'))) {
result.match = TA_DURING;
result.range = {
type: 'duration',
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's')]
}
return result;
}
}
}
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
// so check if the duration matches the diff between the two play dates
if (result.match === TA_NONE && referenceDuration !== undefined) {
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
if (result.date.fuzzyDurationDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyDurationDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY;
}
}
@@ -155,7 +197,7 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
// so check if there is a close match between candidate play date and source + listened for
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
if (result.date.fuzzyListenedDiff <= 10) { // TODO use finer comparison for this?
if (result.date.fuzzyListenedDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
result.match = TA_FUZZY
}
}
@@ -194,15 +236,7 @@ export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, seco
}
}
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
if(typeof expected === 'number') {
if(typeof found === 'number') {
return found <= expected;
}
return false;
}
return found === false;
}
export const hasAcceptableTemporalAccuracy = (found: TemporalAccuracy, expected: TemporalAccuracy[] = TA_DEFAULT_ACCURACY): boolean => expected.includes(found);
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
switch(acc) {
@@ -212,7 +246,9 @@ export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
return 'close';
case 3:
return 'fuzzy';
case false:
case 4:
return 'during';
case 99:
return 'no correlation';
}
}
@@ -297,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 ')}`];
}
+49 -3
View File
@@ -74,6 +74,13 @@ export interface BrainzMeta {
releaseGroup?: string
}
export interface SpotifyMeta {
artist?: string[]
albumArtist?: string[]
album?: string
track?: string
}
export interface TrackData {
artists?: string[]
albumArtists?: string[]
@@ -86,6 +93,7 @@ export interface TrackData {
meta?: {
brainz?: BrainzMeta
spotify?: SpotifyMeta
}
}
@@ -98,11 +106,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 +165,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
* */
@@ -244,12 +268,29 @@ export interface DeadLetterScrobble<PlayType, RetryType = Dayjs> extends QueuedS
export type Second = number;
export type Millisecond = number;
export type TemporalAccuracy = 1 | 2 | 3 | false;
export type TemporalAccuracy = 1 | 2 | 3 | 4 | 99;
/** Timestamp diffs are close to exact (less than or equal to 1 second difference) */
export const TA_EXACT: TemporalAccuracy = 1;
/** Timestamp diffs are within source reporting granularity margin-of-error (see lowGranularitySources):
* normal granularity is 10 seconds
* low granularity (subsonic usually) is 60 seconds
*/
export const TA_CLOSE: TemporalAccuracy = 2;
/** Timestamp diffs are not CLOSE but Scrobble A's timestamp +/- duration is within fuzzyDiffThreshold seconds of Scrobble B's timestamp */
export const TA_FUZZY: TemporalAccuracy = 3;
export const TA_NONE: TemporalAccuracy = false;
/** Timestamp diffs are not FUZZY and Scrobble B's timestamp is within potential full play of Scrobble A (timestamp +/- duration) */
export const TA_DURING: TemporalAccuracy = 4;
/** No correlation between timestamps */
export const TA_NONE: TemporalAccuracy = 99;
export type AcceptableTemporaryAccuracy = TemporalAccuracy[]
export const TA_DEFAULT_ACCURACY: AcceptableTemporaryAccuracy = [TA_EXACT, TA_CLOSE];
export type TemporalDuringReference = 'range' | 'duration' | 'listenedFor';
export type AcceptableTemporalDuringReference = TemporalDuringReference[];
export interface TemporalPlayComparison {
match: TemporalAccuracy
@@ -258,8 +299,13 @@ export interface TemporalPlayComparison {
diff: number
fuzzyDurationDiff?: number
fuzzyListenedDiff?: number
fuzzyDiffThreshold?: number
}
range?: false | ListenRangeData
duringReferences: AcceptableTemporalDuringReference
range?: {
timestamps: [Dayjs, Dayjs]
type: TemporalDuringReference
} | { type: 'none' }
}
export type SOURCE_SOT_TYPES = 'player' | 'history';
+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})`;