Compare commits

...
116 Commits
Author SHA1 Message Date
FoxxMD 7037d6f833 Merge branch 'develop' 2023-10-04 09:13:21 -04:00
FoxxMD 2deca322e7 fix(ui): For non-polling sources use last activity date for status
So that status changes if player is updated instead of waiting for track to be discovered
2023-10-03 14:16:07 -04:00
FoxxMD ab84cb396d docs(jellyfin): Add request header workaround for jellyfin #101 2023-10-03 14:08:07 -04:00
FoxxMD bb63c61ca8 docs: Clarify subsonic api compatibility 2023-10-03 14:00:57 -04:00
FoxxMD f4256d1084 docs: Update dashboard screenshot 2023-10-03 13:56:27 -04:00
FoxxMD fc87b86f52 test(player): Add player tests 2023-10-03 12:36:00 -04:00
FoxxMD 5dff617ed6 fix(player): Fix some bugs and add param for easier testing
* Fix repeat end position percent test (should be 85% not 15%)
* Fix current play/range not being reset correctly on repeat
* Add reportedTS param to facilitate testing
2023-10-03 12:35:48 -04:00
FoxxMD f2a171c596 refactor(player): Move listen progress/range functionality into own class (SRP) 2023-10-03 11:06:44 -04:00
FoxxMD 336970c1f7 feat(ui): Improve status display
* Use running/idle for scrobble client to indicate processing status
* Change polling sources status to polling/idle to better reflect behavior
* Add status indicator color to statuses
2023-10-03 10:15:18 -04:00
FoxxMD d04bc038d4 feat(ui): Implement status indicator 2023-10-03 10:13:45 -04:00
FoxxMD 1ab12355f3 feat(player): Implement seek and repeat detection
* Start new listening ranges if player has seeked forward/backwards
* Treat track repeats as new plays
2023-10-02 17:22:59 -04:00
FoxxMD fb5d9fb7b0 feat(player): Log when play fails discovery on player change 2023-10-02 13:54:55 -04:00
FoxxMD 9aae57f966 test(scrobble): Add tests for scrobble processing queue and existing comparisons 2023-10-02 13:39:33 -04:00
FoxxMD fefc82c521 feat(scrobble): Improve existing scrobble comparison logic
* Use existing time comparison function with "close" matching
* Fix wording and return value
2023-10-02 13:39:14 -04:00
FoxxMD 43ebdcb10c refactor(scrobble): Refactor scrobble delay and scrobble processing sleep behavior
* Delay scrobble based on last scrobble attempt TS
  * rename abstract scrobble func to doScrobble
  * Implement scrobble() in abstract client and track last scrobble attempt time, delay based on arg or client property
    * This way first scrobble isn't delayed and we only delay if subsequent scrobble attempts are close in time
* Make scrobble processing sleep time on empty queue configurable
2023-10-02 13:38:08 -04:00
FoxxMD adfb166320 feat(scrobble): Implement heartbeat task for scrobblers
Task does scrobble processing restart and dead letter queue processing
2023-09-29 15:07:44 -04:00
FoxxMD 1c54ae6465 feat(scrobble): Implement dead letter queue processing
* Implemented logic for processing and removing scrobbles from dead letter queue
  * Only retry scrobbles under X retries based on config or argument
  * Add removal-only function (for later use with UI)
2023-09-29 15:07:08 -04:00
FoxxMD 9309f7d8cf feat(scrobble): Initial scrobble queue implementation
* Move scrobble checking into client (out of client manager) for single responsibility
* Implement scrobble queue/deadletter
  * Less chance of duplicate processing since sources can't scrobble concurrently
  * Better throttling since multiple sources can't scrobble concurrently
  * When scrobbling fails we can retry on delay just like polling and *keep* the failed scrobbles for later retry rather than dropping them
  * Deadletter (to be fully implemented) for scrobbles that fail multiple times or are signalled as not upstream api issues by client
2023-09-29 14:15:32 -04:00
FoxxMD 8717fad08a feat(source): Control backlogging via config #100 2023-09-29 11:28:32 -04:00
FoxxMD 983b4c7417 feat(maloja): Add listened duration to scrobble payload 2023-09-28 15:23:14 -04:00
FoxxMD a663e9af14 feat(player): Defer play discovery so listenedFor is accurate
Wait until player gets a new play or becomes stale before trying to discover the (previous) play so that we store a more accurate amount of time the user actually listened to that play
2023-09-28 15:22:22 -04:00
FoxxMD 249a606bf5 feat(scrobble): Implement artist dup bonus scoring heuristic
When looking for dups and finding a fuzzy timestamp match, close title, and mismatch (but some matched) multiple artist -- we give a bonus to artist score since its likely a dup.
2023-09-28 12:19:11 -04:00
FoxxMD 0ec8d8814d fix(scrobble): Fix not using reference duration for fuzzy comparison 2023-09-28 11:25:33 -04:00
FoxxMD f0cee72f43 test(scrobble): Improve test durability by using mixed duration sources 2023-09-28 11:01:07 -04:00
FoxxMD a82aafa6b8 test(scrobble): Add test for duplicate when exact title/time 2023-09-28 10:47:07 -04:00
FoxxMD 3b624205ad fix: Fix missing destructure default value 2023-09-28 10:35:38 -04:00
FoxxMD c6d38e40d0 refactor(scrobble): Adjust dup score weights 2023-09-28 10:31:45 -04:00
FoxxMD 1ca7834feb test(scrobble): Add more tests for similar-but-unique scrobble detection 2023-09-28 10:31:06 -04:00
FoxxMD 77c9977d27 Use string highScore instead of weighted since cosine isn't messing things up anymore 2023-09-28 10:29:34 -04:00
FoxxMD 521130562d feat(scrobble): Use new string approach for artists 2023-09-28 09:55:09 -04:00
FoxxMD 9512ca7e84 feat(scrobble): Use new string approach for track
And move weights to constants
2023-09-27 16:01:09 -04:00
FoxxMD 159901f79e refactor(tests): Move jellyfin/listenbrainz test into own folders 2023-09-27 15:44:22 -04:00
FoxxMD 9758568ded feat: Implement new string comparison logic 2023-09-27 15:43:44 -04:00
FoxxMD a953f83d07 test(scrobble): Add more tests 2023-09-27 14:14:49 -04:00
FoxxMD 2bbe2e6ba0 refactor(scrobble): Print score breakdown in different statement
Is more readable
2023-09-27 14:14:34 -04:00
FoxxMD ec4ab8c078 test(scrobble): Implement basic scrobble tests 2023-09-27 13:20:03 -04:00
FoxxMD 7885c4ca9c fix(scrobble): Fix wrong artists used for comparison 2023-09-27 13:19:25 -04:00
FoxxMD 8371ec3a01 fix(scrobble): Fix config destructing default value 2023-09-27 13:18:58 -04:00
FoxxMD 43cbadcd2d refactor(scrobble): Clean up base design a little
* Make alreadyScrobbled abstract function
* Make sorting recentScrobbles automatic by refactoring into a getter/setter
2023-09-27 13:18:20 -04:00
FoxxMD 6e1e2c3534 Add verify example for body parser 2023-09-26 15:38:04 -04:00
FoxxMD a46389e745 Remove unused import 2023-09-26 14:08:02 -04:00
FoxxMD ce5f8d5fca fix(jellyfin): Enable parsing requests with more content-types and add logging on invalid types
* Add text/* wildcard so any text-prefixed content-type is valid (Jellyfin should always be sending json anyway)
* Add logging WARN when body is empty

Fixes #101
2023-09-26 14:03:03 -04:00
FoxxMD bdbbce4cb0 feat(jellyfin): Initial testing suite 2023-09-26 11:39:34 -04:00
FoxxMD 034cb321ef fix(flatpak): Add missing release node 2023-09-21 09:12:07 -04:00
FoxxMD ac2d453aa2 feat(config): Implement property to control whether a source/client is used
* QoL improvement -- Instead of needing to rename a config file so it is not read by MS a user can now use the 'enable' property in all source/client configs to enable or disable loading the config
* Added property to all config examples
2023-09-20 11:31:15 -04:00
FoxxMD 860d2d953f fix(youtube): Use updated yt music library #98 2023-09-19 09:22:22 -04:00
FoxxMD a486102747 feat(lastfm): Add player support
* Use lastfm 'nowPlaying' property in user listens for MS player implementation
* Do not use MS player as source of truth -- continue to use lfm listen history
2023-09-18 15:21:25 -04:00
FoxxMD 735264cb2b feat(listenbrainz): Add player support #74
* Use listenbrainz 'playing-now' endpoint for MS player implementation
* Do not use MS player as source of truth -- continue to use LZ listen history
2023-09-18 14:58:18 -04:00
FoxxMD 7dece447df feat(player): Add sourceOfTruth variable to control logging and future use
Will not be used by listenbrainz source for actual scrobbling so make logs less noisy by suppressing what would normally be logging on newely added tracks from player
2023-09-18 14:56:23 -04:00
FoxxMD 3fb5d6aae9 fix(ui): Remove duration percent display when duration is zero 2023-09-18 14:40:22 -04:00
FoxxMD d5dfc33fce fix(deezer): Fix deezer passport generation 2023-09-14 12:51:16 -04:00
FoxxMD 6dc40e50ad feat: Implement Base URL
* Allows users to define a base URL to be used for default redirect URI generation
  * Convenience mostly for docker users so they do not have to explicitly define redirect uri for each source/client
* Use in UI start up logging hints
2023-09-14 12:38:20 -04:00
FoxxMD b8ebd8ca7e chore: Ensure production mode for webpack without relying on env 2023-09-14 10:34:07 -04:00
FoxxMD e9eb7d357f fix*(scrobblers): Test auth when client completes auth callback 2023-09-14 10:07:44 -04:00
FoxxMD 3f2cb8ac0c fix: Pass localUrl to scrobble clients for use by lastfm 2023-09-14 10:07:12 -04:00
FoxxMD 8e36c27ec9 fix(backend): Fix how port is determined and used for localUrl 2023-09-14 10:06:42 -04:00
FoxxMD fa90a3b821 fix: Load variables from .env for backend
Was being used for frontend (cra) but not applied to backend
2023-09-14 10:05:24 -04:00
FoxxMD 2d2319fa27 fix(ui): Fix wrong route for client auth #97 2023-09-14 09:08:01 -04:00
FoxxMD ba161b7b92 chore: Remove unused dashboard backend endpoint 2023-09-14 09:07:36 -04:00
FoxxMD d438c8b256 fix(docker): Normalize ownership of node_modules files
files in node_module can have wierd ownership based on how a package owner published the package. If this occurs a rootless container build system could potentially have issues with subui/gid mapping for non-standard (high number) ids.

During image build recursively chown all node_modules files to normalize ownership and avoid this problem.

Fixes #96
2023-09-13 14:55:04 -04:00
FoxxMD bbb4555c0e chore(docker)!: Bump lsio base image version to 3.18
* Updates alpine 3.17 -> 3.18
  * Main reason is to potentially fix DNS issues related to musl #88
    * https://github.com/nodejs/node/issues/15780#issuecomment-334496907 and https://bell-sw.com/blog/how-to-deal-with-alpine-dns-issues/
    * Resolved in musl 1.2.4
      * https://gitlab.alpinelinux.org/alpine/tsc/-/issues/43
      * https://www.openwall.com/lists/musl/2023/05/02/1
  * Bumps npm 9.1.2 -> 9.6.6

New/changed ENVs used by LSIO may require removing "stored" ENVs if using portainer
2023-09-13 13:21:44 -04:00
FoxxMD 6e8ea96f32 fix: Fix js endings 2023-09-13 12:19:58 -04:00
FoxxMD b06de77f42 Merge branch 'player' into develop 2023-09-13 12:13:17 -04:00
FoxxMD 3aa8279914 Merge branch 'develop' 2023-09-13 12:02:51 -04:00
FoxxMD a64724e114 chore: Bump version for release 2023-09-13 12:02:38 -04:00
FoxxMD 052234a3d9 Merge branch 'develop' 2023-09-13 12:02:04 -04:00
FoxxMD aa2c565afd fix(ui): Log line parsing missing line terminator capture 2023-09-13 11:59:27 -04:00
FoxxMD d0fd225d99 chore: Use constant for default retry multiplier 2023-09-13 11:40:29 -04:00
FoxxMD c40acdd7c7 feat!: Decrease default polling interval
* Due to increased scrobbled thresholds in 590610022d we need to pol more frequently to make sure more of the track is accurately monitored
* Decreased default from 30 to 10 seconds
* Decreased max interval from 60 to 30 seconds
2023-09-13 11:36:22 -04:00
FoxxMD 590610022d feat!: Set default scrobble thresholds to standard values #73
https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
https://www.last.fm/api/scrobbling

The defaults are 50% of the track or 4 minutes.
2023-09-13 10:49:54 -04:00
FoxxMD f7dae32f50 feat(maloja): Generate web links for scrobbled tracks 2023-09-12 16:01:32 -04:00
FoxxMD 3cbbc0837f feat(ui): Expand displayed plays
* Implement API for fetching client recently scrobbled
* Reduce text noise by combining track stats and link for recent
2023-09-12 15:46:49 -04:00
FoxxMD 0197e70a1a feat(ui): Remove unnecessary wording for track stats 2023-09-12 14:58:20 -04:00
FoxxMD 40a7d9e398 feat(ui): Make player dates human friendly 2023-09-12 14:57:41 -04:00
FoxxMD c7b2dcbbbe fix(ui): Use normal match parameters
groups are undefined probably due to minification?
2023-09-12 12:11:39 -04:00
FoxxMD a524e18820 feat(ui): More ui compaction
* Better use of header
  * Show name on separate line
  * Only show client/source if ambiguous
  * Move status into right side of header
* Reduce padding/margin on card grid
2023-09-12 12:03:49 -04:00
FoxxMD 81894d691b feat(ui): Compact ui a bit
* Reduce padding on card headers and bodies
* Improve log experience
  * Reduce log font size and convert to mono
  * Use proper log message parsing to remove dangerous html set
2023-09-12 11:07:39 -04:00
FoxxMD 0fb192dd41 refactor(ui): Prevent nodemon from restarting on tailwind config change 2023-09-12 11:05:56 -04:00
FoxxMD 3f608c6c8e fix(listenbrainz): Artist parsing joiner phrases must begin with non-word character
Prevents artists with joiner phrases in their name (EX 'Daft Punk' contains 'ft') from erroneously being split

Fixes #94
2023-09-12 09:29:04 -04:00
FoxxMD dcff9ee73d feat(player state): Implement player status and listened duration in UI 2023-09-11 13:21:24 -04:00
FoxxMD f1cd66d3e8 feat(player state): Preserve position when player is not in stopped status
Makes for a nicer UI experience to see where the player was paused
2023-09-11 13:20:58 -04:00
FoxxMD d95fef09a3 refactor(player state): Remove old play logic
Recent plays are dead. Long live recent plays.
2023-09-11 12:38:10 -04:00
FoxxMD d1060e724e feat(ui): Remove player when dead 2023-09-11 12:36:10 -04:00
FoxxMD a7faaaac86 refactor(player state): Refactor handling of non-updated players
* Check for stale/orphaned/dead in independent loop so they are not dependent on getting source updates to update
* Only emit events if player state has updated
2023-09-11 12:35:41 -04:00
FoxxMD 3762f95bae fix(player state): Fix stale/dead logic checks
* Stale should not reset to stale when status is orphaned
* Dead check should be in seconds, not minutes
2023-09-11 12:34:09 -04:00
FoxxMD b9f04deb28 Always emit player state 2023-09-08 16:35:20 -04:00
FoxxMD 9cc820c76e feat: More player polish and additional info pane 2023-09-08 16:33:23 -04:00
FoxxMD f7195fb40c feat: Rework source api to include player states
* Create and implement api interface for player state data
* Serialize memory source players to object indexed by platform id
* Refactor event emitters to separate event data better from event metadata
* Update source slice when player update event is broadcast
2023-09-08 14:47:53 -04:00
FoxxMD 9c0f029992 feat: POC player
Based on https://codepen.io/keithpickering/pen/wjYJKM
2023-09-08 13:40:43 -04:00
FoxxMD f370b8ad1e fix: Update heartbeat job to 20 minutes
1 minute was for debugging...
2023-09-05 11:44:22 -04:00
FoxxMD 7c962ec43a fix: Fix more import endings 2023-09-05 11:19:13 -04:00
FoxxMD 2013a6692e fix: Fix import ending 2023-09-05 11:18:15 -04:00
FoxxMD 4a5eb91f16 feat: Schedule heartbeat task to restart sources that should be polling
Addresses #5 and #65 -- in default config use-cases where:

 * request/polling retry attempts are low
 * source was initially OK (auth OK, initial communication OK)
 * and there is a non-auth related *upstream* communication issue

 there is no fallback path for restarting source polling after upstream has normalized.

 This feat implements a scheduled heartbeat task that attempts to restart polling every 20 minutes if this scenario is valid so that a good source config will recover without user intervention.
2023-09-05 11:11:10 -04:00
FoxxMD a606801bb0 Merge branch 'develop' 2023-08-31 10:58:48 -04:00
FoxxMD db395e40e8 chore: Bump for release 2023-08-31 10:58:30 -04:00
FoxxMD 1c498a2c71 Merge branch 'develop' 2023-08-31 10:55:10 -04:00
FoxxMD e4a647d425 fix(jellyfin): Attempt to fix route redirect #92
Should not be appending to route but replacing...see if leading slash fixes things
2023-08-31 09:29:11 -04:00
FoxxMD c1a91ab610 fix: Fix route redirect 2023-08-31 09:28:19 -04:00
FoxxMD 093007a72b Merge branch 'develop' 2023-08-29 09:46:25 -04:00
FoxxMD cb5cdb09f7 chore: Bump versions for release 2023-08-29 09:45:54 -04:00
FoxxMD 8f2e1ad7e7 refactor: Update flatpak build
* Update flatpak build instructions to use build
* Add run command to readme
* Update default config to keep web port backwards compatible
* Fix flatpak application name in run command in installation docs
2023-08-29 09:35:49 -04:00
FoxxMD ed6bcd60b3 refactor: Remove unused react packages and fix CRA babel plugin warning 2023-08-28 10:39:03 -04:00
FoxxMD edbe131f1b fix: Fix tsconfig and out locations for generated schema 2023-08-24 12:10:49 -04:00
FoxxMD 881e537a89 fix: ts libcheck prevents mocha from causing errors while generating schema
https://stackoverflow.com/a/65568463/1469797
2023-08-24 12:10:31 -04:00
FoxxMD f856f79e51 Merge branch 'develop' 2023-07-17 15:41:00 -04:00
FoxxMD b03cbe7a0a Merge branch 'develop' 2023-06-12 14:20:09 -04:00
FoxxMD b90ea03924 Merge branch 'develop' 2023-06-07 09:23:51 -04:00
FoxxMD 5e905eae17 Merge branch 'develop' 2023-03-27 09:40:42 -04:00
FoxxMD 7b0f5801a1 Merge branch 'develop' 2023-03-20 11:06:38 -04:00
FoxxMD 8b3f4c636c Merge branch 'develop' 2023-03-15 09:13:20 -04:00
FoxxMD 7a0a23b7fd Merge branch 'develop' 2023-03-10 15:12:53 -05:00
FoxxMD 28910a9332 Merge branch 'develop' 2023-02-27 11:13:28 -05:00
FoxxMD e369f9eb21 Merge branch 'develop' 2023-02-22 12:55:23 -05:00
FoxxMD 0c58cc6687 Merge branch 'develop' 2023-02-22 12:09:40 -05:00
Matt Foxx 4a0cdc0e87 Merge pull request #62 from samdoshi/lastfm-docs 2023-01-21 17:48:27 -05:00
Sam Doshi a54bc99925 add note about privacy setting for Last.fm sources 2023-01-21 15:28:21 +00:00
135 changed files with 5352 additions and 1252 deletions
+6 -4
View File
@@ -1,4 +1,4 @@
FROM lsiobase/alpine:3.17 as base
FROM lsiobase/alpine:3.18 as base
ENV TZ=Etc/GMT
@@ -24,7 +24,8 @@ ENV CONFIG_DIR=$data_dir
COPY docker/root/ /
RUN npm install -g patch-package
RUN npm install -g patch-package \
&& chown -R root:root /usr/local/lib/node_modules/patch-package
WORKDIR /app
@@ -39,7 +40,8 @@ COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
RUN npm install
RUN npm install \
&& chown -R root:root node_modules
#RUN yarn install
COPY --chown=abc:abc . /app
@@ -72,7 +74,7 @@ ENV IS_DOCKER=true
RUN npm install --omit=dev \
&& npm cache clean --force \
&& chown abc:abc node_modules \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/ts-node \
&& rm -rf node_modules/typescript \
&& rm -rf node_modules/@types
+2 -2
View File
@@ -12,7 +12,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Supports scrobbling from many **Sources**
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
@@ -44,7 +44,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src="/assets/status-ui.jpg" width="800">
<img src="/assets/status-ui.png" width="800">
## How Does multi-scrobbler (MS) Work?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+2
View File
@@ -21,6 +21,7 @@
"sources": [
{
"type": "spotify",
"enable": true,
"clients": ["myConfig"],
"name": "mySpotifySource",
"data": {
@@ -33,6 +34,7 @@
"clients": [
{
"type": "maloja",
"enable": true,
"name": "myConfig",
"data": {
"url": "http://localhost:42010",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "FoxxMDeezer",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyJellyfin",
"enable": true,
"clients": [],
"data": {
"users": ["FoxxMD"],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyJriver",
"enable": true,
"data": {
"url": "0.0.0.0",
"username": "auser",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyKodi",
"enable": true,
"data": {
"url": "http://localhost:8080/jsonrpc",
"username": "myUser",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "myLastFm",
"enable": true,
"configureAs": "client",
"data": {
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "brainz",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "myMaloja",
"enable": true,
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyMopidy",
"enable": true,
"data": {
"url": "localhost",
"uriBlacklist": [],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "ubuntu",
"enable": true,
"data": {
"whitelist": ["vlc", "mpd"],
"blacklist": ["spotify"]
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyPlex",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MySpotify",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MySubsonic",
"enable": true,
"data": {
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyTautuilli",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
+1
View File
@@ -1,6 +1,7 @@
[
{
"name": "MyYTMusic",
"enable": true,
"clients": [],
"data": {
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
+4
View File
@@ -13,6 +13,10 @@ services:
#- MALOJA_API_KEY=
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
#- BASE_URL=http://MyHostIP:9078
volumes:
- /path_on_host/multi-scrobbler-config:/config
#networks:
+17
View File
@@ -1,6 +1,7 @@
* [Connection Issues](#connection-issues)
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
* [Configuration Issues](#configuration-issues)
* [Config could not be parsed](#config-could-not-be-parsed)
@@ -95,6 +96,22 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
## Jellyfin has warnings about missing headers
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.md#jellyfin)
* Add Request Header...
* **Key:** `Content-Type`
* **Value:** `application/json`
* Then Save
## Spotify/Deezer/LastFM won't authenticate
+19 -2
View File
@@ -168,6 +168,21 @@ Example directory structure:
See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
# Application Options
These options affect multi-scrobbler's behavior and are not specific to any source/client.
#### Base URL
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
* Default => `http://localhost`
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
# Source Configurations
## [Spotify](https://www.spotify.com)
@@ -217,7 +232,7 @@ See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/mas
## [Subsonic](http://www.subsonic.org/)
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
**Known Issues:**
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
@@ -255,6 +270,8 @@ Must be using Jellyfin 10.7 or greater
* Check `Send All Properties`
* Save
If you see errors in the MS logs regarding `missing headers` when using Jellyfin [see this workaround.](../FAQ.md#jellyfin-has-warnings-about-missing-headers)
### ENV-Based
| Environmental Variable | Required? | Default | Description |
@@ -268,7 +285,7 @@ See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/mas
## [Last.fm (Source)](https://www.last.fm)
See the [Last.fm (Client)](#lastfm) setup for registration instructions.
See the [Last.fm (Client)](#lastfm) setup for registration instructions. You may need to disable "Hide recent listening information" on your [privacy page](https://www.last.fm/settings/privacy) for this to work.
### ENV-Based
+18 -2
View File
@@ -53,7 +53,7 @@ flatpak install flathub io.github.foxxmd.multiscrobbler
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.multiscrobbler
flatpak run io.github.foxxmd.multiscrobbler
```
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
@@ -84,7 +84,7 @@ Recommended configuration steps for docker or docker-compose usage:
#### Storage
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise these will be lost when the container is updated.
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
@@ -98,6 +98,20 @@ The default container port is `9078`. To map container to host port:
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
##### Base URL
Optionally, when
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
* and
* using a bridge network or
* installing MS on a different machine than the one used to view the dashboard
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
#### Other
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
@@ -120,6 +134,8 @@ To get the UID and GID for the current user run these commands from a terminal:
## Docker Usage Examples
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
```bash
+2 -2
View File
@@ -15,7 +15,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* 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/))
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](docs/configuration#jellyfin)
* [Youtube Music](docs/configuration#youtube-music)
* [Last.fm](docs/configuration#lastfm-source)
@@ -47,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
<img src={require('/img/status-ui.jpg').default} width="800"/>
<img src={require('/img/status-ui.png').default} width="800"/>
## How Does multi-scrobbler (MS) Work?
+1
View File
@@ -8,6 +8,7 @@
"maxRequestRetries": 1,
"retryMultiplier": 1.5
},
"baseUrl": "http://localhost",
"sources": [
{
"type": "spotify",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+9 -2
View File
@@ -24,7 +24,7 @@ First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpa
Then, from MS project root:
```
```shell
flatpak-node-generator npm package-lock.json
```
@@ -34,8 +34,15 @@ Move `generated-sources.json` into [`/flatpak`](/flatpak)
From MS project root:
```console
```shell
cd flatpak
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
```
Add `--install --user` to have the app installed immediately.
# 5. Run (Locally)
If built with `--install --user` you can now run MS using the command
```shell
flatpak run -u io.github.foxxmd.multiscrobbler
```
+2 -1
View File
@@ -1,5 +1,6 @@
{
"logging": {
"file": false
}
},
"port": 9078
}
@@ -41,6 +41,8 @@
</screenshot>
</screenshots>
<releases>
<release version="0.5.2" date="2023-09-21"/>
<release version="0.5.0" date="2023-08-29"/>
<release version="0.4.6" date="2023-06-07"/>
<release version="0.4.4" date="2023-03-20"/>
</releases>
+8 -9
View File
@@ -24,10 +24,10 @@ modules:
- npm install --offline
# build app
- npm run build
- NODE_ENV=production npm run build
# makes node and npm available
- mkdir -p /app/bin /app/lib /app/lib/src
- mkdir -p /app/bin /app/lib /app/lib/build
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
- rm -r /app/lib/node_modules/npm/{docs,man}
@@ -38,11 +38,8 @@ modules:
# copy node_modules needed to run app
- cp -r node_modules/* /app/lib/node_modules
# delete typescript files (not needed for running app)
- find src/ -name "*.ts" -type f -delete
# copy app files to runtime dir
- cp -r src/* /app/lib/src
- cp -r build/* /app/lib/build
- cp -r config /app/lib/config
- cp flatpak/defaultConfig.json /app/lib/config/config.json
@@ -56,8 +53,10 @@ modules:
sources:
- type: git
url: https://github.com/FoxxMD/multi-scrobbler
tag: 0.4.6
commit: b90ea039245491767baeefc53966b0350016aa4d
branch: develop
# in official builds use tag/commit instead of branch
# tag: 0.4.6
# commit: ed6bcd60b3448397424ed7c14075479452ff566f
dest: main
# Wrapper to launch the app
- type: script
@@ -73,6 +72,6 @@ modules:
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
fi
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true node src/index.js
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node build/server.js
- generated-sources.json
+2 -1
View File
@@ -1,3 +1,4 @@
{
"ext": "js"
"ext": "js",
"ignore": ["tailwind.config.js"]
}
+491 -240
View File
File diff suppressed because it is too large Load Diff
+26 -16
View File
@@ -1,14 +1,14 @@
{
"name": "multi-scrobbler",
"version": "0.4.8",
"version": "0.5.2",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
"schema-client": "typescript-json-schema tsconfig.json ClientConfig --out src/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-source": "typescript-json-schema tsconfig.json SourceConfig --out src/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aio": "typescript-json-schema tsconfig.json AIOConfig --out src/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aiosource": "typescript-json-schema tsconfig.json AIOSourceConfig --out src/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aioclient": "typescript-json-schema tsconfig.json AIOClientConfig --out src/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"typedoc": "typedoc",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "react-scripts test",
@@ -19,15 +19,15 @@
"dev:server:webpack": "webpack --config webpack.config.server.js",
"dev:server:nodemon": "rm -f build/server.js && wait-on -l build/server.js && nodemon build/server.js",
"build": "npm run -s build:client && npm run -s build:server",
"build:client": "react-scripts build",
"build:server": "webpack --config webpack.config.server.js",
"build:client": "NODE_ENV=production react-scripts build",
"build:server": "webpack --env production --config webpack.config.server.js",
"start": "node build/server.js",
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend"
},
"exports": {
".": {
"types": "./src/common/infrastructure/typings/lastfm-node-client.d.ts",
"import": "./src/index.js"
"types": "./src/backend/common/infrastructure/typings/lastfm-node-client.d.ts",
"import": "./src/backend/index.js"
}
},
"engines": {
@@ -47,14 +47,15 @@
"homepage": ".",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/string-sameness": "^0.1.3",
"@foxxmd/winston": "3.3.31",
"@foxxmd/youtube-music-ts-api": "1.5.1",
"@kenyip/backoff-strategies": "^1.0.4",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@tanstack/react-query": "^4.32.1",
"@types/string-similarity": "^4.0.0",
"@supercharge/promise-pool": "^3.0.0",
"address": "^1.2.2",
"ajv": "^7.2.4",
"better-sse": "^0.8.0",
@@ -64,6 +65,7 @@
"concat-stream": "^2.0.0",
"dayjs": "^1.10.4",
"dbus-next": "0.10.2",
"dotenv": "^10.0.0",
"es6-error": "^4.1.1",
"express": "^4.17.1",
"express-session": "^1.17.2",
@@ -73,11 +75,12 @@
"iti": "^0.6.0",
"json5": "^2.2.3",
"kodi-api": "^0.2.1",
"ky": "^0.33.3",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
"nanoid": "^3.3.1",
"normalize-url": "^6.1.0",
"ntfy": "1.0.5",
"object-hash": "^3.0.0",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
@@ -87,9 +90,11 @@
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"safe-stable-stringify": "^1.1.1",
"sass": "^1.66.1",
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"tailwindcss": "^3.3.3",
"toad-scheduler": "^3.0.0",
"triple-beam": "^1.3.0",
"wait-on": "^7.0.1",
"web-vitals": "^2.1.4",
@@ -97,10 +102,12 @@
"winston-duplex": "0.1.3",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"xml2js": "^0.4.23"
"xml2js": "^0.4.23",
"youtube-music-ts-api": "^1.7.0"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@faker-js/faker": "^8.1.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@@ -108,6 +115,7 @@
"@tsconfig/node18": "^1.0.1",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.5",
"@types/clone": "^2.1.2",
"@types/common-tags": "^1.8.1",
"@types/concat-stream": "^2.0.0",
"@types/express": "^4.17.13",
@@ -117,6 +125,7 @@
"@types/jscodeshift": "^0.11.6",
"@types/mocha": "^9.1.0",
"@types/node": "^18.0.0",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
@@ -127,6 +136,7 @@
"@types/xml2js": "^0.4.11",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"clone": "^2.1.2",
"concurrently": "^8.2.0",
"jscodeshift": "^0.15.0",
"mocha": "^9.2.1",
@@ -0,0 +1,12 @@
import {ErrorWithCause} from "pony-cause";
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
showStopper: boolean = false;
constructor(message: string, options?: { cause?: T, showStopper?: boolean } | undefined) {
super(message, options);
const {showStopper = false} = options;
this.showStopper = showStopper;
}
}
+26 -1
View File
@@ -147,7 +147,21 @@ export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 30;
/**
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
* */
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 240;
/**
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
* */
export const DEFAULT_SCROBBLE_PERCENT_THRESHOLD: number = 50;
export const DEFAULT_POLLING_INTERVAL: number = 10;
export const DEFAULT_POLLING_MAX_INTERVAL = 30;
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
export interface ScrobbleThresholdResult {
passes: boolean
@@ -192,3 +206,14 @@ export interface numberFormatOptions {
}
export const DELIMITERS = [',','&','\/','\\'];
export const ARTIST_WEIGHT = 0.3;
export const TITLE_WEIGHT = 0.4;
export const TIME_WEIGHT = 0.5;
export const REFERENCE_WEIGHT = 0.5;
export const DUP_SCORE_THRESHOLD = 1;
export interface SourceScrobble {
source: string
play: PlayObject
}
@@ -28,6 +28,20 @@ export interface AIOConfig {
* */
port?: number
/**
* Set the Base URL the application should assume the UI is served from.
*
* This will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.
*
* It will NOT set the actual interface/IP that the application is listening on.
*
* This can also be set using the BASE_URL environmental variable.
*
* @default "http://localhost"
* @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"]
* */
baseUrl?: string
logging?: LogOptions
/**
@@ -63,6 +63,14 @@ export interface CommonClientData extends CommonData {
match?: MatchLoggingOptions
}
/**
* Number of times MS should automatically retry scrobbles in dead letter queue
*
* @default 1
* @examples [1]
* */
deadLetterRetries?: boolean
}
}
@@ -1,6 +1,13 @@
export interface CommonConfig {
name?: string
data?: CommonData
/**
* Should MS use this client/source? Defaults to true
*
* @default true
* @examples [true]
* */
enable?: boolean
}
export interface CommonData {
@@ -30,16 +37,16 @@ export interface PollingOptions {
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
* @default 30
* @examples [30]
* @default 10
* @examples [10]
* */
interval?: number
/**
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
*
* @default 60
* @examples [60]
* @default 30
* @examples [30]
* */
maxInterval?: number
}
@@ -12,18 +12,31 @@ export interface SourceRetryOptions extends RequestRetryOptions {
export interface ScrobbleThresholds {
/**
* The number of seconds before a track should be considered scrobbled.
* The number of seconds a track has been listened to before it should be considered scrobbled.
*
* @default 30
* @examples [30]
* Set to null to disable.
*
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* @seehttps://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
*
* @default 240
* @examples [240]
* */
duration?: number
duration?: number | null
/**
* The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
* The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
*
* Set to null to disable.
*
* NOTE: This should be used with care when the Source is a "polling" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not "seen" the track for long enough from first discovery, even if you have been playing the track for longer.
*
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
* @see https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
*
* @default 50
* @examples [50]
* */
percent?: number
percent?: number | null
}
export interface CommonSourceOptions {
@@ -57,6 +70,19 @@ export interface CommonSourceOptions {
* @examples [false]
* */
logPlayerState?: boolean
/**
* If this source
*
* * supports fetching a listen history
* * and this option is enabled
*
* then on startup MS will attempt to scrobble the recent listens from that history
*
* @default true
* @examples [true, false]
* */
scrobbleBacklog?: boolean
}
export interface CommonSourceData extends CommonData, SourceRetryOptions {
@@ -1,7 +1,8 @@
import { LastfmData } from "../client/lastfm";
import { CommonSourceConfig, CommonSourceData } from "./index";
import {PollingOptions} from "../common";
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
export interface LastfmSourceConfig extends CommonSourceConfig {
/**
@@ -1,7 +1,8 @@
import { CommonSourceConfig, CommonSourceData } from "./index";
import { ListenBrainzData } from "../client/listenbrainz";
import {PollingOptions} from "../common";
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
}
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
@@ -29,10 +29,12 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
* Reading:
* * https://developer.spotify.com/documentation/web-api/guides/rate-limits/
* * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
* * The rate limit is ~180 req/min
* * Rate limit may ~180 req/min
* * https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930
* * Informally indicated as 20 req/sec? Probably for burstiness
*
* @default 30
* @examples [30]
* @default 10
* @examples [10]
* */
interval?: number
}
+54
View File
@@ -37,6 +37,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -101,6 +110,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -203,6 +221,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -249,6 +276,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -319,6 +355,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -373,6 +418,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
+188 -21
View File
@@ -33,6 +33,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -139,6 +149,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -166,19 +185,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -265,6 +284,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -417,6 +445,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -444,19 +481,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -545,6 +582,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -601,6 +647,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -635,6 +690,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -733,6 +806,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -759,6 +841,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -929,6 +1029,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1075,6 +1184,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1119,6 +1237,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1270,16 +1397,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -1384,6 +1515,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1427,19 +1567,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1604,6 +1744,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1648,6 +1797,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1688,19 +1846,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1768,6 +1926,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
+253 -21
View File
@@ -37,6 +37,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -106,6 +115,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -212,6 +231,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -288,19 +316,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -387,6 +415,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -539,6 +576,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -566,19 +612,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -667,6 +713,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -723,6 +778,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -757,6 +821,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -850,6 +932,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -952,6 +1043,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -998,6 +1098,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -1093,6 +1202,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1119,6 +1237,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -1334,6 +1470,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1365,6 +1510,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -1419,6 +1573,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -1624,6 +1787,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1724,6 +1896,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1931,16 +2112,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -2054,6 +2239,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2097,19 +2291,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -2274,6 +2468,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2318,6 +2521,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2369,19 +2581,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -2449,6 +2661,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -2475,6 +2696,17 @@
}
},
"properties": {
"baseUrl": {
"default": "http://localhost",
"description": "Set the Base URL the application should assume the UI is served from.\n\nThis will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.\n\nIt will NOT set the actual interface/IP that the application is listening on.\n\nThis can also be set using the BASE_URL environmental variable.",
"examples": [
"http://localhost",
"http://192.168.0.101",
"https://ms.myDomain.tld"
],
"title": "baseUrl",
"type": "string"
},
"clientDefaults": {
"$ref": "#/definitions/RequestRetryOptions",
"title": "clientDefaults"
+54
View File
@@ -34,6 +34,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -98,6 +107,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -192,6 +210,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -230,6 +257,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
@@ -300,6 +336,15 @@
"description": "Specific data required to configure this client",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
"examples": [
@@ -346,6 +391,15 @@
"title": "checkExistingScrobbles",
"type": "boolean"
},
"deadLetterRetries": {
"default": 1,
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
"examples": [
1
],
"title": "deadLetterRetries",
"type": "boolean"
},
"refreshEnabled": {
"default": true,
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
+188 -21
View File
@@ -74,6 +74,16 @@
],
"title": "logPlayerState",
"type": "boolean"
},
"scrobbleBacklog": {
"default": true,
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
"examples": [
true,
false
],
"title": "scrobbleBacklog",
"type": "boolean"
}
},
"title": "CommonSourceOptions",
@@ -180,6 +190,15 @@
"$ref": "#/definitions/DeezerData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -199,19 +218,19 @@
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -298,6 +317,15 @@
"$ref": "#/definitions/JRiverData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -442,6 +470,15 @@
"$ref": "#/definitions/JellyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -461,19 +498,19 @@
"KodiData": {
"properties": {
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -562,6 +599,15 @@
"$ref": "#/definitions/KodiData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -588,6 +634,24 @@
"title": "apiKey",
"type": "string"
},
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -686,6 +750,15 @@
"$ref": "#/definitions/LastFmSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -734,6 +807,15 @@
"$ref": "#/definitions/ListenBrainzSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -752,6 +834,24 @@
},
"ListenBrainzSourceData": {
"properties": {
"interval": {
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
30
],
"title": "maxInterval",
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
@@ -922,6 +1022,15 @@
"$ref": "#/definitions/MPRISData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1060,6 +1169,15 @@
"$ref": "#/definitions/MopidyData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1096,6 +1214,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1239,16 +1366,20 @@
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"default": 240,
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
"examples": [
30
240
],
"title": "duration",
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"default": 50,
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"examples": [
50
],
"title": "percent",
"type": "number"
}
@@ -1276,6 +1407,15 @@
"$ref": "#/definitions/SpotifySourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1311,19 +1451,19 @@
"type": "string"
},
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1402,6 +1542,15 @@
"$ref": "#/definitions/SubsonicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1524,6 +1673,15 @@
"$ref": "#/definitions/PlexSourceData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
@@ -1556,19 +1714,19 @@
"type": "string"
},
"interval": {
"default": 30,
"default": 10,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
10
],
"title": "interval",
"type": "number"
},
"maxInterval": {
"default": 60,
"default": 30,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
30
],
"title": "maxInterval",
"type": "number"
@@ -1636,6 +1794,15 @@
"$ref": "#/definitions/YTMusicData",
"title": "data"
},
"enable": {
"default": true,
"description": "Should MS use this client/source? Defaults to true",
"examples": [
true
],
"title": "enable",
"type": "boolean"
},
"name": {
"description": "Unique identifier for this source.",
"title": "name",
+2 -1
View File
@@ -1,8 +1,9 @@
import { capitalize, mergeArr } from "../../utils";
import { mergeArr } from "../../utils";
import {Logger} from '@foxxmd/winston';
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
import winston from '@foxxmd/winston';
import { PlayObject } from "../../../core/Atomic";
import {capitalize} from "../../../core/StringUtils";
export default abstract class AbstractApiClient {
name: string;
+2 -1
View File
@@ -3,6 +3,7 @@ import {JRiverData} from "../infrastructure/config/source/jriver";
import request, {Request, Response} from 'superagent';
import xml2js from 'xml2js';
import {ErrorWithCause} from "pony-cause";
import {DEFAULT_RETRY_MULTIPLIER} from "../infrastructure/Atomic";
const parser = new xml2js.Parser({'async': true});
@@ -107,7 +108,7 @@ export class JRiverApiClient extends AbstractApiClient {
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
if (this.token !== undefined) {
+6 -6
View File
@@ -2,7 +2,7 @@ import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "
import AbstractApiClient from "./AbstractApiClient";
import dayjs from "dayjs";
import { readJson, sleep, writeFile } from "../../utils";
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions} from "../infrastructure/Atomic";
import { LastfmData } from "../infrastructure/config/client/lastfm";
import { PlayObject } from "../../../core/Atomic";
@@ -25,10 +25,10 @@ export default class LastfmApiClient extends AbstractApiClient {
user?: string;
declare config: LastfmData;
constructor(name: any, config: Partial<LastfmData> & {configDir: string}, options = {}) {
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: string}, options = {}) {
super('lastfm', name, config, options);
const {redirectUri, apiKey, secret, session, configDir} = config;
this.redirectUri = `${redirectUri}?state=${name}`;
this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`;
if (apiKey === undefined) {
this.logger.warn("'apiKey' not found in config!");
}
@@ -81,7 +81,7 @@ export default class LastfmApiClient extends AbstractApiClient {
callApi = async <T>(func: any, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
@@ -109,8 +109,7 @@ export default class LastfmApiClient extends AbstractApiClient {
}
getAuthUrl = () => {
const redir = `${this.config.redirectUri}?state=${this.name}`;
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
}
authenticate = async (token: any) => {
@@ -146,6 +145,7 @@ export default class LastfmApiClient extends AbstractApiClient {
testAuth = async () => {
if (this.client.sessionKey === undefined) {
this.logger.info('No session key found. User interaction for authentication required.');
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
return false;
}
try {
+36 -12
View File
@@ -1,22 +1,20 @@
import AbstractApiClient from "./AbstractApiClient";
import request, {Request} from 'superagent';
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz";
import { DELIMITERS, FormatPlayObjectOptions } from "../infrastructure/Atomic";
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions} from "../infrastructure/Atomic";
import dayjs from "dayjs";
import { stringSameness } from '@foxxmd/string-sameness';
import {
containsDelimiters,
findDelimiters,
normalizeStr,
parseArtistCredits,
parseCredits,
parseStringList,
parseTrackCredits,
unique,
uniqueNormalizedStrArr,
combinePartsToString,
} from "../../utils";
import { PlayObject } from "../../../core/Atomic";
import { slice } from "../../../core/StringUtils";
import {
findDelimiters,
normalizeStr, parseArtistCredits, parseCredits,
parseTrackCredits,
uniqueNormalizedStrArr
} from "../../utils/StringUtils";
export interface ArtistMBIDMapping {
@@ -130,7 +128,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config;
try {
@@ -185,9 +183,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.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
deadline: 30000 // wait 30 seconds overall for request to complete
}));
const {body: {payload}} = resp as any;
// const data = payload as ListensResponse;
// if(data.listens.length > 0) {}
// return data.listens[0];
return payload as ListensResponse;
} catch (e) {
throw e;
}
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
try {
const resp = await this.getUserListens(maxTracks, user);
const now = await this.getPlayingNow(user);
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
} catch (e) {
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
@@ -464,6 +483,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
recording_mbid: aRecordingMbid,
duration: aDuration,
duration_ms: aDurationMs,
music_service_name,
music_service,
submission_client,
submission_client_version
} = {},
mbid_mapping: {
recording_mbid: mRecordingMbid
@@ -509,7 +532,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
meta: {
source: 'listenbrainz',
trackId,
playId
playId,
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
}
}
}
+15 -2
View File
@@ -41,19 +41,32 @@ export interface MalojaV3ScrobbleData {
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
/** The auth key used to scrobble */
key: string
/** name of the track */
title: string
album: string
/** name of the album */
album?: string
/** unix timestamp of when the track was scrobbled*/
time: number
length: number
/** length of the track in seconds */
length?: number
/** number of seconds the track was listened to */
duration?: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
/** comma-separated list of artists for this track */
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
/** a list of artists for this track */
artists: string[]
/** a list of artists for the album the track is on */
albumartists: string[]
/** skip server-side metadata parsing */
nofix?: boolean
}
interface MalojaScrobbleWarning {
+22 -2
View File
@@ -1,3 +1,4 @@
import 'dotenv/config';
import {Logger} from '@foxxmd/winston';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
@@ -18,6 +19,9 @@ import { getRoot } from "./ioc";
import {getLogger} from "./common/logging";
import {LogInfo} from "../core/Atomic";
import {initServer} from "./server/index";
import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
import {createHeartbeatSourcesTask} from "./tasks/heartbeatSources";
import {createHeartbeatClientsTask} from "./tasks/heartbeatClients";
dayjs.extend(utc)
@@ -28,6 +32,8 @@ dayjs.extend(timezone);
(async function () {
const scheduler = new ToadScheduler()
let output: LogInfo[] = []
const initLogger = getLogger({file: false}, 'init');
@@ -53,7 +59,6 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
const {
webhooks = [],
port,
logging = {},
debugMode,
} = (config || {}) as AIOConfig;
@@ -67,7 +72,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
process.env.DEBUG_MODE = b.toString();
}
const root = getRoot(port);
const root = getRoot(config);
logger = getLogger(logging, 'app');
@@ -131,10 +136,25 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
}
}
}
for(const client of scrobbleClients.clients) {
if((await client.isReady())) {
client.initScrobbleMonitoring();
}
}
if (anyNotReady) {
logger.info(`Some sources are not ready, open the dashboard to continue`);
}
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatClientsTask(scrobbleClients, logger)));
logger.info('Scheduler started.');
} catch (e) {
logger.error('Exited with uncaught error');
logger.error(e);
+36 -26
View File
@@ -1,53 +1,63 @@
import {createContainer} from "iti";
import path from "path";
import {projectDir} from "./common/index";
import {configDir, projectDir} from "./common/index";
import ScrobbleClients from "./scrobblers/ScrobbleClients";
import ScrobbleSources from "./sources/ScrobbleSources";
import {Notifiers} from "./notifier/Notifiers";
import {EventEmitter} from "events";
import {logPath} from "./common/logging";
import {Container} from '@foxxmd/winston';
import { WildcardEmitter } from "./common/WildcardEmitter";
//import ScrobbleClients from "./clients/ScrobbleClients.js";
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
//const port = process.env.PORT ?? 9078;
/*let logPath = path.resolve(projectDir, `./logs`);
if(typeof process.env.CONFIG_DIR === 'string') {
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
}*/
import normalizeUrl from 'normalize-url';
let root: ReturnType<typeof createRoot>;
const createRoot = (port: number | string | undefined) => {
export interface RootOptions {
baseUrl?: string,
port?: string | number
}
const createRoot = (options?: RootOptions) => {
const {
port,
baseUrl = process.env.BASE_URL,
} = options || {};
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
const envPort = process.env.PORT;
return createContainer().add({
configDir: configDir,
logDir: logPath,
localUrl: `http://localhost:${port}`,
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
configPort: port,
apiPort: process.env.API_PORT ?? 9079,
mainPort: process.env.PORT ?? 3000,
mainPort: envPort ?? 3000,
clientEmitter: () => new WildcardEmitter(),
sourceEmitter: () => new WildcardEmitter(),
notifierEmitter: () => new EventEmitter(),
}).add((items) => ({
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir),
sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir),
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
port: () => {
if(items.configPort !== undefined) {
return items.configPort;
}
return items.isProd ? items.mainPort : items.apiPort;
}).add((items) => {
let usedPort = items.configPort ?? envPort;
if (usedPort === undefined) {
usedPort = items.isProd ? items.mainPort : items.apiPort;
}
}));
const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true});
const u = new URL(base);
let localUrl = u.toString();
if(u.port === '' && u.pathname === '/') {
localUrl = `${u.origin}:${items.mainPort}`;
}
return {
port: usedPort,
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir),
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir),
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
localUrl,
hasDefinedBaseUrl: baseUrl !== undefined
}
});
}
export const getRoot = (port?: number | string) => {
export const getRoot = (options?: RootOptions) => {
if(root === undefined) {
root = createRoot(port);
root = createRoot(options);
}
return root;
}
+385 -95
View File
@@ -1,19 +1,20 @@
import dayjs, {Dayjs} from "dayjs";
import {
capitalize,
comparingMultipleArtists,
isPlayTemporallyClose,
mergeArr,
playObjDataMatch,
setIntersection,
playObjDataMatch, pollingBackoff,
setIntersection, sleep, sortByOldestPlayDate,
} from "../utils";
import {
ClientType,
ARTIST_WEIGHT,
ClientType, DEFAULT_RETRY_MULTIPLIER, DUP_SCORE_THRESHOLD,
FormatPlayObjectOptions,
INITIALIZED,
INITIALIZING,
InitState,
NOT_INITIALIZED,
ScrobbledPlayObject,
NOT_INITIALIZED, REFERENCE_WEIGHT,
ScrobbledPlayObject, SourceScrobble, TIME_WEIGHT, TITLE_WEIGHT,
} from "../common/infrastructure/Atomic";
import winston, {Logger} from '@foxxmd/winston';
import { CommonClientConfig } from "../common/infrastructure/config/client/index";
@@ -21,13 +22,20 @@ import { ClientConfig } from "../common/infrastructure/config/client/clients";
import { Notifiers } from "../notifier/Notifiers";
import {FixedSizeList} from 'fixed-size-list';
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils";
import {buildTrackString, capitalize, truncateStringToLength} from "../../core/StringUtils";
import EventEmitter from "events";
import {compareScrobbleArtists, compareScrobbleTracks, normalizeStr} from "../utils/StringUtils";
import {UpstreamError} from "../common/errors/UpstreamError";
import {nanoid} from "nanoid";
import {ErrorWithCause} from "pony-cause";
import {de} from "@faker-js/faker";
import {del} from "superagent";
export default abstract class AbstractScrobbleClient {
name: string;
type: ClientType;
identifier: string;
#initState: InitState = NOT_INITIALIZED;
@@ -37,17 +45,26 @@ export default abstract class AbstractScrobbleClient {
requiresAuthInteraction: boolean = false;
authed: boolean = false;
recentScrobbles: PlayObject[] = [];
#recentScrobblesList: PlayObject[] = [];
scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
newestScrobbleTime?: Dayjs
oldestScrobbleTime?: Dayjs
tracksScrobbled: number = 0;
lastScrobbleCheck: Dayjs = dayjs(0)
lastScrobbleAttempt: Dayjs = dayjs(0)
refreshEnabled: boolean;
checkExistingScrobbles: boolean;
verboseOptions;
scrobbleDelay: number = 1000;
scrobbleSleep: number = 2000;
scrobbleRetries: number = 0;
scrobbling: boolean = false;
userScrobblingStopSignal: undefined | any;
queuedScrobbles: (SourceScrobble & {id: string})[] = [];
deadLetterScrobbles: (SourceScrobble & {id: string, retries: number})[] = [];
config: CommonClientConfig;
logger: Logger;
@@ -57,8 +74,8 @@ export default abstract class AbstractScrobbleClient {
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
this.type = type;
this.name = name;
const identifier = `${capitalize(this.type)} - ${name}`;
this.logger = logger.child({labels: [identifier]}, mergeArr);
this.identifier = `${capitalize(this.type)} - ${name}`;
this.logger = logger.child({labels: [this.identifier]}, mergeArr);
this.notifier = notifier;
this.emitter = emitter;
@@ -71,7 +88,7 @@ export default abstract class AbstractScrobbleClient {
checkExistingScrobbles = true,
verbose = {},
} = {},
},
} = {},
} = config;
this.config = config;
this.refreshEnabled = refreshEnabled;
@@ -98,6 +115,16 @@ export default abstract class AbstractScrobbleClient {
};
}
set recentScrobbles(scrobbles: PlayObject[]) {
const sorted = [...scrobbles];
sorted.sort(sortByOldestPlayDate);
this.#recentScrobblesList = sorted;
}
get recentScrobbles() {
return this.#recentScrobblesList;
}
get initialized() {
return this.#initState === INITIALIZED;
}
@@ -137,11 +164,7 @@ export default abstract class AbstractScrobbleClient {
this.logger.debug('Scrobbler does not have refresh function implemented!');
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
this.logger.debug('Scrobbler does not have alreadyScrobbled check implemented!');
return false;
}
public abstract alreadyScrobbled(playObj: PlayObject, log?: boolean): Promise<boolean>;
scrobblesLastCheckedAt = () => {
return this.lastScrobbleCheck;
}
@@ -175,21 +198,16 @@ export default abstract class AbstractScrobbleClient {
addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => {
this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay});
this.tracksScrobbled++;
}
filterScrobbledTracks = () => {
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
}
cleanSourceSearchTitle = (playObj: PlayObject) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
};
getScrobbledPlays = () => {
return this.scrobbledPlayObjs.data.map(x => x.scrobble);
}
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
const {
@@ -204,25 +222,12 @@ export default abstract class AbstractScrobbleClient {
const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play));
if (dtInvariantMatches.length === 0) {
return [undefined, undefined];
return [undefined, []];
}
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
const {
play: {
data: {
playDate: sPlayDate
} = {},
meta: {
source: playSource
} = {},
} = {},
} = x;
// need to account for inaccurate DT from subsonic
if(source === 'Subsonic' && playSource === 'Subsonic') {
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
}
return playDate.isSame(sPlayDate);
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x.play, playObj);
return closeTime;
});
return [matchPlayDate, dtInvariantMatches];
@@ -237,48 +242,30 @@ export default abstract class AbstractScrobbleClient {
return [closeTime, fuzzyTime];
}
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
const {
data: {
track: scrobbleTitle,
} = {},
} = existing;
let cleanSourceTitle = this.cleanSourceSearchTitle(candidate);
let titleMatch;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
// and compare against length of scrobble title
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
return titleMatch;
return Math.min(compareScrobbleTracks(existing, candidate)/100, 1);
}
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): number => {
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => {
const {
data: {
artists: sourceArtists = [],
} = {},
} = candidate;
artists: existingArtists = [],
} = {}
} = existing;
const {
data: {
artists = [],
} = {},
artists: candidateArtists = [],
} = {}
} = candidate;
let artistMatch;
const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true}));
const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true}));
return artistMatch;
const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size;
return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches]
}
existingScrobble = async (playObj: PlayObject) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}};
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}};
// return early if we don't care about checking existing
if (false === this.checkExistingScrobbles) {
@@ -289,7 +276,7 @@ export default abstract class AbstractScrobbleClient {
}
let existingScrobble;
let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']};
let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'None'};
// then check if we have already recorded this
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
@@ -300,7 +287,9 @@ export default abstract class AbstractScrobbleClient {
closestMatch = {
score: 1,
breakdowns: ['Exact Match found in previously successfully scrobbled']
scrobble: existingScrobble,
breakdowns: [],
confidence: 'Exact Match found in previously successfully scrobbled plays'
}
}
// if not though then we need to check recent scrobbles from scrobble api.
@@ -317,66 +306,367 @@ export default abstract class AbstractScrobbleClient {
return undefined;
}
// we have have found an existing submission but without an exact date
// we have found an existing submission but without an exact date
// 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;
// clean source title so it matches title from the scrobble api response as closely as we can get it
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
existingScrobble = this.recentScrobbles.find((x) => {
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
//const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
const timeMatch = (closeTime ? 1 : (fuzzyTime ? 0.6 : 0));
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
const artistMatch = this.compareExistingScrobbleArtist(x, playObj);
const [artistMatch, wholeMatches] = this.compareExistingScrobbleArtist(x, playObj);
const artistScore = .2 * artistMatch;
const titleScore = .3 * titleMatch;
const timeScore = .5 * (closeTime ? 1 : (fuzzyTime ? 0.5 : 0));
const referenceScore = .5 * (referenceMatch ? 1 : 0);
const score = artistScore + titleScore + timeScore;
let artistScore = ARTIST_WEIGHT * artistMatch;
const titleScore = TITLE_WEIGHT * titleMatch;
const timeScore = TIME_WEIGHT * timeMatch;
//const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0);
let score = artistScore + titleScore + timeScore;
let artistWholeMatchBonus = 0;
let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`;
if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) {
// address scenario where:
// * title is very close
// * time falls within plausible dup range
// * artist is not totally different
// * AND score is still not high enough for a dup
//
// if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score)
// then bump artist score a little to see if it gets it over the fence
//
// EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match
// one play is only returning primary artist, and timestamp is at beginning instead of end of play
const scoreBonus = artistMatch * 0.5;
const scoreGapBonus = (1 - artistMatch) * 0.75;
// use the smallest bump or 0.1
artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1);
artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus);
score = artistScore + titleScore + timeScore;
artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`;
}
let scoreBreakdowns = [
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
//`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
artistBreakdown,
`Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
`Time: ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
];
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
const scoreInfo = {
score,
scrobble: x,
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
confidence,
breakdowns: scoreBreakdowns
}
if (closestMatch.score <= score && score > 0) {
closestMatch = scoreInfo
}
return score >= .7;
return score >= DUP_SCORE_THRESHOLD;
});
}
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
const closestScrobble = `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.confidence}`;
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
if (this.verboseOptions.match.confidenceBreakdown === true) {
this.logger.debug(`Breakdown:
${closestMatch.breakdowns.join('\n')}`);
}
}
return existingScrobble;
}
public abstract scrobble(playObj: PlayObject): Promise<boolean>
public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false }): Promise<PlayObject> => {
const {delay} = opts || {};
const scrobbleDelay = delay === undefined ? this.scrobbleDelay : (delay === false ? 0 : delay);
if (scrobbleDelay !== 0) {
const lastScrobbleDiff = dayjs().diff(this.lastScrobbleAttempt, 'ms');
const remainingDelay = scrobbleDelay - lastScrobbleDiff;
if (remainingDelay > 0) {
this.logger.debug(`Waiting ${remainingDelay}ms to scrobble so time passed since previous scrobble is at least ${scrobbleDelay}ms`);
await sleep(scrobbleDelay);
}
}
try {
return this.doScrobble(playObj);
} finally {
this.lastScrobbleAttempt = dayjs();
}
}
protected abstract doScrobble(playObj: PlayObject): Promise<PlayObject>
initScrobbleMonitoring = async () => {
if(!this.initialized) {
if(this.initializing) {
this.logger.warn(`Cannot start scrobble processing because client is still initializing`);
return;
}
if(!(await this.initialize())) {
this.logger.warn(`Cannot start scrobble processing because client could not be initialized`);
return;
}
}
if(this.requiresAuth && !this.authed) {
if (this.requiresAuthInteraction) {
this.logger.warn(`Cannot start scrobble processing because user interaction is required for authentication`);
return;
} else if (!(await this.testAuth())) {
this.logger.warn(`Cannot start scrobble processing because auth test failed`);
return;
}
}
if(!(await this.isReady())) {
this.logger.warn(`Cannot start scrobble processing because client is not ready`);
return;
}
await this.startScrobbling();
}
startScrobbling = async () => {
// reset poll attempts if already previously run
this.scrobbleRetries = 0;
const {
data: {
maxPollRetries = 5,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER,
} = {},
} = this.config;
// can't have negative retries!
const maxRetries = Math.max(0, maxPollRetries);
if(this.scrobbling === true) {
this.logger.warn(`Already scrobble processing! Processing needs to be stopped before it can be started`);
return;
}
let pollRes: boolean | undefined = undefined;
while (pollRes === undefined && this.scrobbleRetries <= maxRetries) {
try {
pollRes = await this.doProcessing();
if(pollRes === true) {
break;
}
} catch (e) {
if(!this.initialized) {
this.logger.warn('Stopping scrobble processing due to client no longer being initialized.');
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer initialized, stopping processing!. | Error: ${e.message}`, priority: 'error'});
break;
} else if (this.requiresAuth && !this.authed) {
this.logger.warn('Stopping scrobble processing due to client no longer being authenticated.');
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer authenticated, stopping processing!. | Error: ${e.message}`, priority: 'error'});
break;
} else if (this.scrobbleRetries < maxRetries) {
const delayFor = pollingBackoff(this.scrobbleRetries + 1, retryMultiplier);
this.logger.info(`Scrobble processing retries (${this.scrobbleRetries}) less than max processing retries (${maxRetries}), restarting processing after ${delayFor} second delay...`);
await this.notifier.notify({title: `Client - ${this.name} - Processing Retry`, message: `Encountered error while polling but retries (${this.scrobbleRetries}) are less than max poll retries (${maxRetries}), restarting processing after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'});
await sleep((delayFor) * 1000);
} else {
this.logger.warn(`Scrobble processing retries (${this.scrobbleRetries}) equal to max processing retries (${maxRetries}), stopping processing!`);
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and retries (${this.scrobbleRetries}) are equal to max processing retries (${maxRetries}), stopping processing!. | Error: ${e.message}`, priority: 'error'});
}
this.scrobbleRetries++;
}
}
}
tryStopScrobbling = async () => {
if(this.scrobbling === false) {
this.logger.warn(`Polling is already stopped!`);
return;
}
this.userScrobblingStopSignal = true;
let secsPassed = 0;
while(this.userScrobblingStopSignal !== undefined && secsPassed < 10) {
await sleep(2000);
secsPassed += 2;
this.logger.verbose(`Waiting for scrobble processing stop signal to be acknowledged (waited ${secsPassed}s)`);
}
if(this.userScrobblingStopSignal !== undefined) {
this.logger.warn('Could not stop scrobble processing! Or signal was lost :(');
return false;
}
return true;
}
protected doStopScrobbling = (reason: string = 'system') => {
this.scrobbling = false;
this.userScrobblingStopSignal = undefined;
this.logger.info(`Stopped scrobble processing due to: ${reason}`);
}
protected shouldStopScrobbleProcessing = () => this.scrobbling === false || this.userScrobblingStopSignal !== undefined;
protected doProcessing = async (): Promise<true | undefined> => {
if (this.scrobbling === true) {
return true;
}
this.logger.info('Scrobble processing started');
try {
this.scrobbling = true;
while (!this.shouldStopScrobbleProcessing()) {
while (this.queuedScrobbles.length > 0) {
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
await this.refreshScrobbles();
}
const currQueuedPlay = this.queuedScrobbles[0];
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play);
if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) {
try {
const scrobbledPlay = await this.scrobble(currQueuedPlay.play);
this.emitEvent('scrobble', {play: currQueuedPlay.play});
this.addScrobbledTrack(currQueuedPlay.play, scrobbledPlay);
} catch (e) {
if (e instanceof UpstreamError && e.showStopper === false) {
this.addDeadLetterScrobble(currQueuedPlay);
this.logger.warn(`Could not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`);
} else {
const processError = new ErrorWithCause('Error occurred while trying to scrobble', {cause: e});
//this.logger.error(processError);
throw processError;
}
}
} else if (!timeFrameValid) {
this.logger.debug(`Will not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' because it ${timeFrameValidLog}`);
}
// processing play may have changed index while we were scrobbling
const pIndex = this.queuedScrobbles.findIndex(x => x.id === currQueuedPlay.id);
if (pIndex !== -1) {
this.queuedScrobbles.splice(pIndex, 1);
}
}
await sleep(this.scrobbleSleep);
}
if (this.shouldStopScrobbleProcessing()) {
this.doStopScrobbling(this.userScrobblingStopSignal !== undefined ? 'user input' : undefined);
return true;
}
} catch (e) {
this.logger.error('Scrobble processing interrupted');
this.logger.error(e);
this.scrobbling = false;
throw e;
}
}
processDeadLetterQueue = async (attemptWithRetries?: number) => {
if (this.deadLetterScrobbles.length === 0) {
return;
}
const {
options: {
deadLetterRetries = 1
} = {}
} = this.config.data;
const retries = attemptWithRetries ?? deadLetterRetries;
const processable = this.deadLetterScrobbles.filter(x => x.retries < retries);
this.logger.info(`${processable.length} of ${this.deadLetterScrobbles.length} dead scrobbles have less than ${retries} retries, ${processable.length === 0 ? 'will skip processing.': 'processing now...'}`, {leaf: 'Dead Letter'});
if (processable.length === 0) {
return;
}
const idsToRemove = [];
for (const deadScrobble of this.deadLetterScrobbles) {
if (deadScrobble.retries < retries) {
const scrobbled = await this.processDeadLetterScrobble(deadScrobble.id);
if (scrobbled) {
idsToRemove.push(deadScrobble.id);
}
}
}
if (idsToRemove.length > 0) {
this.deadLetterScrobbles = this.deadLetterScrobbles.filter(x => !idsToRemove.includes(x.id));
this.logger.info(`Removed ${idsToRemove.length} scrobbles from dead letter queue`, {leaf: 'Dead Letter'});
}
}
processDeadLetterScrobble = async (id: string) => {
if (!(await this.isReady())) {
this.logger.warn('Cannot process dead letter scrobble because client is not ready.', {leaf: 'Dead Letter'});
return;
}
const deadScrobbleIndex = this.deadLetterScrobbles.findIndex(x => x.id === id);
const deadScrobble = this.deadLetterScrobbles[deadScrobbleIndex];
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
await this.refreshScrobbles();
}
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(deadScrobble.play);
if (timeFrameValid && !(await this.alreadyScrobbled(deadScrobble.play))) {
try {
const scrobbledPlay = await this.scrobble(deadScrobble.play);
this.emitEvent('scrobble', {play: deadScrobble.play});
this.addScrobbledTrack(deadScrobble.play, scrobbledPlay);
} catch (e) {
deadScrobble.retries++;
this.logger.error(`Could not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' due to error`, {leaf: 'Dead Letter'});
this.logger.error(e);
this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
return false;
} finally {
await sleep(1000);
}
} else if (!timeFrameValid) {
this.logger.debug(`Will not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' because it ${timeFrameValidLog}`, {leaf: 'Dead Letter'});
}
return true;
}
removeDeadLetterScrobble = (id: string) => {
const index = this.deadLetterScrobbles.findIndex(x => x.id === id);
if (index === -1) {
this.logger.warn(`No scrobble found with ID ${id}`, {leaf: 'Dead Letter'});
}
this.logger.debug(`Removed scrobble ${buildTrackString(this.deadLetterScrobbles[index].play)} from queue`, {leaf: 'Dead Letter'});
this.deadLetterScrobbles.splice(index, 1);
}
protected getLatestQueuePlayDate = () => {
if (this.queuedScrobbles.length === 0) {
return undefined;
}
return this.queuedScrobbles[this.queuedScrobbles.length - 1].play.data.playDate;
}
queueScrobble = (data: PlayObject | PlayObject[], source: string) => {
const plays = Array.isArray(data) ? data : [data];
for(const p of plays) {
this.queuedScrobbles.push({id: nanoid(), source, play: p});
}
this.queuedScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play));
}
protected addDeadLetterScrobble = (data: SourceScrobble & { id?: string, retries?: number }) => {
this.deadLetterScrobbles.push({id: nanoid(), retries: 0, ...data});
this.deadLetterScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play));
}
public emitEvent = (eventName: string, payload: object) => {
this.emitter.emit(eventName, {
...payload,
data: payload,
type: this.type,
name: this.name,
from: 'client'
+13 -10
View File
@@ -2,7 +2,6 @@ import AbstractScrobbleClient from "./AbstractScrobbleClient";
import dayjs from 'dayjs';
import {
capitalize,
playObjDataMatch,
removeUndefinedKeys,
setIntersection,
@@ -16,8 +15,9 @@ import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-cl
import { Notifiers } from "../notifier/Notifiers";
import {Logger} from '@foxxmd/winston';
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
import { buildTrackString } from "../../core/StringUtils";
import {buildTrackString, capitalize} from "../../core/StringUtils";
import EventEmitter from "events";
import {UpstreamError} from "../common/errors/UpstreamError";
export default class LastfmScrobbler extends AbstractScrobbleClient {
@@ -92,7 +92,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
this.logger.debug(x);
return acc;
}
}, []).sort(sortByOldestPlayDate);
}, []);
this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
@@ -119,7 +119,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
return (await this.existingScrobble(playObj)) !== undefined;
}
scrobble = async (playObj: PlayObject) => {
doScrobble = async (playObj: PlayObject) => {
const {
data: {
artists,
@@ -186,9 +186,8 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
} = response;
if(code === 5) {
this.initialized = false;
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
throw new UpstreamError('LastFM API reported daily scrobble limit exceeded! 😬 Disabling client', {showStopper: true});
}
this.addScrobbledTrack(playObj, this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName}));
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
@@ -197,18 +196,22 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
if(ignored > 0) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)}`, priority: 'warn'});
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/errorcodes for more information`, {payload: scrobblePayload});
throw new UpstreamError('LastFM ignored scrobble', {showStopper: false});
}
return this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName});
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
//await sleep(1000);
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobblePayload});
throw e;
if(!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
} else {
throw e;
}
} finally {
this.logger.debug('Raw Payload: ', rawPayload);
}
return true;
}
}
@@ -2,7 +2,6 @@ import dayjs from 'dayjs';
import AbstractScrobbleClient from "./AbstractScrobbleClient";
import {
capitalize,
playObjDataMatch,
removeUndefinedKeys,
setIntersection,
@@ -16,8 +15,9 @@ import {Logger} from '@foxxmd/winston';
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient";
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
import { buildTrackString } from "../../core/StringUtils";
import {buildTrackString, capitalize} from "../../core/StringUtils";
import EventEmitter from "events";
import {UpstreamError} from "../common/errors/UpstreamError";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
@@ -69,7 +69,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES);
this.logger.debug(`Found ${resp.length} recent scrobbles`);
this.recentScrobbles = resp.sort(sortByOldestPlayDate);
this.recentScrobbles = resp;
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
@@ -86,7 +86,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
return (await this.existingScrobble(playObj)) !== undefined;
}
scrobble = async (playObj: PlayObject) => {
doScrobble = async (playObj: PlayObject) => {
const {
meta: {
source,
@@ -99,15 +99,15 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
try {
const resp = await this.api.submitListen(playObj);
rawPayload = resp;
this.addScrobbledTrack(playObj, playObj);
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
//await sleep(1000);
return playObj;
} catch (e) {
let message = e.message;
if(e.response !== undefined) {
@@ -119,11 +119,9 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
}
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'});
this.logger.error(`Failed to scrobble => ${message}`, {payload: rawPayload});
throw e;
throw new UpstreamError(`Error received from Listenbrainz API: ${message}`, {cause: e, showStopper: true});
} finally {
this.logger.debug(`Raw Payload:`, {rawPayload});
}
return true;
}
}
+22 -13
View File
@@ -8,10 +8,9 @@ import {
sleep,
sortByOldestPlayDate,
parseRetryAfterSecsFromObj,
capitalize,
isPlayTemporallyClose,
} from "../utils";
import { FormatPlayObjectOptions, INITIALIZING } from "../common/infrastructure/Atomic";
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, INITIALIZING} from "../common/infrastructure/Atomic";
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja";
import { Notifiers } from "../notifier/Notifiers";
import {Logger} from '@foxxmd/winston';
@@ -22,8 +21,10 @@ import {
MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaV2ScrobbleData, MalojaV3ScrobbleData
} from "../common/vendor/maloja/interfaces";
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
import { buildTrackString } from "../../core/StringUtils";
import {buildTrackString, capitalize} from "../../core/StringUtils";
import EventEmitter from "events";
import normalizeUrl from "normalize-url";
import {UpstreamError} from "../common/errors/UpstreamError";
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
@@ -32,6 +33,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
requiresAuth = true;
serverIsHealthy = false;
serverVersion: any;
webUrl: string;
declare config: MalojaClientConfig
@@ -44,6 +46,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
if (url === undefined) {
throw new Error("Missing 'url' for Maloja config");
}
this.webUrl = normalizeUrl(url);
}
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
@@ -53,7 +56,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
duration,
time;
const {serverVersion} = options;
const {serverVersion, url} = options;
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
// scrobble data structure changed for v3
@@ -107,6 +110,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
const aStrings = aString.split(',');
return [...acc, ...aStrings];
}, []);
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
return {
data: {
artists: [...new Set(artistStrings)] as string[],
@@ -117,16 +121,19 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
},
meta: {
source: 'Maloja',
url: {
web: `${url}/track?${urlParams.toString()}`
}
}
}
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion});
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl});
callApi = async (req: any, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.data;
try {
@@ -318,7 +325,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} = {},
} = resp;
this.logger.debug(`Found ${list.length} recent scrobbles`);
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate);
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x));
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
@@ -358,7 +365,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
return (await this.existingScrobble(playObj)) !== undefined;
}
scrobble = async (playObj: PlayObject) => {
doScrobble = async (playObj: PlayObject) => {
const {url, apiKey} = this.config.data;
const {
@@ -367,7 +374,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
album,
track,
duration,
playDate
playDate,
listenedFor
} = {},
meta: {
source,
@@ -385,6 +393,9 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
length: duration,
};
if(listenedFor !== undefined && listenedFor > 0) {
scrobbleData.duration = listenedFor;
}
let responseBody: MalojaScrobbleV3ResponseData;
@@ -437,7 +448,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
}
}
} else {
throw new Error(buildErrorString(response));
throw new UpstreamError(buildErrorString(response), {showStopper: true});
}
} else {
const {
@@ -459,7 +470,6 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} else {
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
}
this.addScrobbledTrack(playObj, scrobbledPlay);
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
if(warning !== '') {
this.logger.warn(`${scrobbleInfo} | ${warning}`);
@@ -467,6 +477,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} else {
this.logger.info(scrobbleInfo);
}
return scrobbledPlay;
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
@@ -477,8 +488,6 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} finally {
this.logger.debug('Raw Payload:', scrobbleData);
}
return true;
}
}
+15 -71
View File
@@ -19,7 +19,7 @@ import { LastfmClientConfig } from "../common/infrastructure/config/client/lastf
import { Notifiers } from "../notifier/Notifiers";
import AbstractScrobbleClient from "./AbstractScrobbleClient";
import {EventEmitter} from "events";
import winston from '@foxxmd/winston';
import winston, {Logger} from '@foxxmd/winston';
import ListenbrainzScrobbler from "./ListenbrainzScrobbler";
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz";
import {ErrorWithCause} from "pony-cause";
@@ -35,17 +35,19 @@ export default class ScrobbleClients {
/** @type AbstractScrobbleClient[] */
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
logger;
configDir;
logger: Logger;
configDir: string;
localUrl: string;
emitter: WildcardEmitter;
sourceEmitter: WildcardEmitter;
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, configDir: any) {
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, localUrl: string, configDir: string) {
this.emitter = emitter;
this.sourceEmitter = sourceEmitter;
this.configDir = configDir;
this.localUrl = localUrl;
this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
@@ -161,7 +163,7 @@ export default class ScrobbleClients {
mode: 'single',
configureAs: 'client',
// @ts-ignore
data: lfm
data: {...lfm, redirectUri: lfm.redirectUri ?? `${this.localUrl}/lastfm/callback`}
})
}
break;
@@ -294,7 +296,13 @@ ${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, data: d = {}} = clientConfig;
const {type, name, enable = true, data: d = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) client was disabled by config`);
return;
}
// add defaults
const data = {...defaults, ...d};
let newClient;
@@ -356,8 +364,6 @@ ${sources.join('\n')}`);
scrobbleFrom = 'source',
} = options;
const tracksScrobbled: any = [];
if (this.clients.length === 0) {
this.logger.warn('Cannot scrobble! No clients are configured.');
}
@@ -367,71 +373,9 @@ ${sources.join('\n')}`);
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`);
continue;
}
if(!client.initialized) {
if(client.initializing) {
client.logger.warn(`Cannot scrobble because it is still initializing`);
continue;
}
if(!(await client.initialize())) {
client.logger.warn(`Cannot scrobble because it could not be initialized`);
continue;
}
}
if(client.requiresAuth && !client.authed) {
if (client.requiresAuthInteraction) {
client.logger.warn(`Cannot scrobble because user interaction is required for authentication`);
continue;
} else if (!(await client.testAuth())) {
client.logger.warn(`Cannot scrobble because auth test failed`);
continue;
}
}
if(!(await client.isReady())) {
client.logger.warn(`Cannot scrobble because it is not ready`);
continue;
}
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
try {
await client.refreshScrobbles();
} catch(e) {
client.logger.error(`Encountered error while refreshing scrobbles`);
this.logger.error(e);
}
}
for (const playObj of playObjs) {
try {
const {
meta: {
newFromSource = false,
} = {}
} = playObj;
const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj);
if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) {
client.emitEvent('scrobble', {play: playObj});
await client.scrobble(playObj)
client.tracksScrobbled++;
// since this is what we return to the source only add to tracksScrobbled if not already in array
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
tracksScrobbled.push(playObj);
}
} else {
if(!timeFrameValid) {
client.logger.debug(`Will not scrobble ${buildTrackString(playObj)} from Source '${scrobbleFrom}' because it ${timeFrameValidLog}`);
}
}
} catch(e) {
client.logger.error(new ErrorWithCause(`Encountered error while in scrobble loop`, {cause: e}));
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
if(e.continueScrobbling !== true) {
break;
}
}
client.queueScrobble(playObj, scrobbleFrom);
}
}
return tracksScrobbled;
}
}
+42 -96
View File
@@ -1,6 +1,5 @@
import {ExpressWithAsync} from "@awaitjs/express";
import {getRoot} from "../ioc";
import { capitalize } from "../utils";
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./middleware";
import AbstractSource from "../sources/AbstractSource";
import {
@@ -8,7 +7,7 @@ import {
LogInfo,
LogInfoJson,
LogLevel,
LogOutputConfig,
LogOutputConfig, PlayObject,
SourceStatusData,
} from "../../core/Atomic";
import {Logger} from "@foxxmd/winston";
@@ -21,7 +20,12 @@ import {setupPlexRoutes} from "./plexRoutes";
import {setupJellyfinRoutes} from "./jellyfinRoutes";
import {setupDeezerRoutes} from "./deezerRoutes";
import {setupAuthRoutes} from "./auth";
import { ExpressHandler } from "../common/infrastructure/Atomic";
import {ExpressHandler, ScrobbledPlayObject} from "../common/infrastructure/Atomic";
import MemorySource from "../sources/MemorySource";
import {capitalize} from "../../core/StringUtils";
import {source} from "common-tags";
import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient";
import {sortByNewestPlayDate} from "../utils";
let output: LogInfo[] = []
@@ -64,8 +68,11 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
const scrobbleSources = root.get('sources');
const scrobbleClients = root.get('clients');
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
const clientMiddleFunc = makeClientCheckMiddle(scrobbleClients);
const sourceMiddleFunc = makeSourceCheckMiddle(scrobbleSources);
const clientRequiredMiddle = clientMiddleFunc(true);
const sourceRequiredMiddle = sourceMiddleFunc(true);
const setLogWebLevel: ExpressHandler = async (req, res, next) => {
// @ts-ignore
@@ -106,7 +113,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
const session = await createSession(req, res);
scrobbleSources.emitter.on('*', (payload: any, eventName: string) => {
if(payload.from !== undefined) {
session.push({...payload.data, event: eventName}, payload.from);
session.push({event: eventName, ...payload}, payload.from);
}
});
});
@@ -115,7 +122,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
setupPlexRoutes(app, logger, scrobbleSources);
setupJellyfinRoutes(app, logger, scrobbleSources);
setupDeezerRoutes(app, logger, scrobbleSources);
setupAuthRoutes(app, logger, sourceCheckMiddle, clientCheckMiddle, scrobbleSources, scrobbleClients);
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
app.getAsync('/api/status', async (req, res, next) => {
@@ -143,15 +150,16 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
hasAuth: requiresAuth,
hasAuthInteraction: requiresAuthInteraction,
authed,
players: 'players' in x ? (x as MemorySource).playersToObject() : {}
};
if (!initialized) {
base.status = 'Not Initialized';
} else if (requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else if (canPoll) {
base.status = polling ? 'Running' : 'Idle';
base.status = polling ? 'Polling' : 'Idle';
} else {
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data';
}
return base;
});
@@ -165,6 +173,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
requiresAuth = false,
requiresAuthInteraction = false,
authed = false,
scrobbling = false,
} = x;
const base: ClientStatusData = {
status: '',
@@ -173,35 +182,51 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
tracksDiscovered: tracksScrobbled,
name,
hasAuth: requiresAuth,
hasAuthInteraction: requiresAuthInteraction,
authed,
initialized
};
if (!initialized) {
base.status = 'Not Initialized';
} else if (requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else {
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
base.status = scrobbling ? 'Running' : 'Idle';
}
return base;
});
return res.json({sources: sourceData, clients: clientData});
});
app.getAsync('/api/recent', sourceCheckMiddle, async (req, res, next) => {
app.getAsync('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
} = req;
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
let result: PlayObject[] = [];
if (source !== undefined) {
result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
}
const result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
//const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat())));
//const trackLength = longestString(result.map((x: any) => x.data.track))
return res.json(result);
});
app.use('/api/poll', sourceCheckMiddle);
app.getAsync('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
const {
// @ts-ignore
scrobbleClient: client,
} = req;
let result: PlayObject[] = [];
if (client !== undefined) {
result = [...(client as AbstractScrobbleClient).getScrobbledPlays()].sort(sortByNewestPlayDate);
}
return res.json(result);
});
app.use('/api/poll', sourceRequiredMiddle);
app.getAsync('/api/poll', async function (req, res) {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const source = req.scrobbleSource as AbstractSource;
@@ -243,83 +268,4 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.url}`);
return res.sendStatus(404);
});
app.getAsync('/dashboard', async function (req, res) {
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
if (logConfig.sort === 'ascending') {
slicedLog.reverse();
}
// TODO links for re-trying auth and variables for signalling it (and API recently played)
const sourceData = scrobbleSources.sources.map((x) => {
const {
type,
tracksDiscovered = 0,
name,
canPoll = false,
polling = false,
initialized = false,
requiresAuth = false,
requiresAuthInteraction = false,
authed = false,
} = x;
const base = {
status: '',
type,
display: capitalize(type),
tracksDiscovered,
name,
canPoll,
hasAuth: requiresAuth,
hasAuthInteraction: requiresAuthInteraction,
authed,
};
if(!initialized) {
base.status = 'Not Initialized';
} else if(requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else if(canPoll) {
base.status = polling ? 'Running' : 'Idle';
} else {
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
}
return base;
});
const clientData = scrobbleClients.clients.map((x) => {
const {
type,
tracksScrobbled = 0,
name,
initialized = false,
requiresAuth = false,
requiresAuthInteraction = false,
authed = false,
} = x;
const base = {
status: '',
type,
display: capitalize(type),
tracksDiscovered: tracksScrobbled,
name,
hasAuth: requiresAuth,
};
if(!initialized) {
base.status = 'Not Initialized';
} else if(requiresAuth && !authed) {
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
} else {
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
}
return base;
})
res.render('status', {
sources: sourceData,
clients: clientData,
logs: {
output: slicedLog,
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" data-limit="${x}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" data-sort="${x}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
level: availableLevels.map(x => `<a class="capitalize log-level log-${x} ${logConfig.level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
}
});
})
}
+1
View File
@@ -76,6 +76,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
try {
await entity.api.authenticate(token);
await entity.initialize();
await entity.testAuth();
return res.send('OK');
} catch (e) {
return res.send(e.message);
+5 -4
View File
@@ -11,10 +11,10 @@ import passport from "passport";
export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
// initialize deezer strategies
const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
for(const d of deezerSources) {
passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
}
// const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
// for(const d of deezerSources) {
// passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
// }
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
@@ -34,6 +34,7 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
return res.send('Error with deezer credentials storage');
} else if(entity.config.data.accessToken !== undefined) {
// start polling
await entity.testAuth();
entity.poll()
return res.redirect('/');
} else {
+11 -4
View File
@@ -42,6 +42,8 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
const apiPort = root.get('apiPort');
const mainPort = root.get('mainPort');
const port = root.get('port');
const local = root.get('localUrl');
const localDefined = root.get('hasDefinedBaseUrl');
setupApi(app, logger, initialOutput);
@@ -52,7 +54,8 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
res.sendFile(path.join(buildDir, "index.html"));
});
app.listen(port);
const backendPort = isProd ? port : apiPort;
app.listen(backendPort);
const addy = getAddress();
const addresses: string[] = [];
@@ -62,7 +65,7 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
--- HINT ---
MS is likely being run in a container with BRIDGE networking which means the above addresses are not accessible from outside this container.
To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://foxxmd.github.io/multi-scrobbler/docs/installation#networking
The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT
The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT${localDefined ? ` (or ${local} since you defined this!)` : ''}
--- HINT ---
`;
}
@@ -71,10 +74,10 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
switch (k) {
case 'host':
case 'v4':
addresses.push(`---> ${k === 'host' ? 'Local'.padEnd(14, ' ') : 'Network'.padEnd(14, ' ')} http://${v}:${port}`);
addresses.push(`---> ${k === 'host' ? 'Local'.padEnd(14, ' ') : 'Network'.padEnd(14, ' ')} http://${v}:${backendPort}`);
break;
case 'v6':
addresses.push(`---> Network (IPv6) http://[${v}]:${port}`);
addresses.push(`---> Network (IPv6) http://[${v}]:${backendPort}`);
}
}
}
@@ -83,6 +86,10 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
${addresses.join('\n')}${dockerHint !== '' ? `\n${dockerHint}` : ''}`
logger.info(start);
if(localDefined) {
logger.info(`User-defined base URL for UI and redirect URLs (spotify, deezer, lastfm): ${local}`)
}
} catch (e) {
logger.error(new ErrorWithCause('Server crashed with uncaught exception', {cause: e}));
}
+25 -2
View File
@@ -9,10 +9,17 @@ import JellyfinSource from "../sources/JellyfinSource";
export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
const jellyfinJsonParser = bodyParser.json({
type: ['text/*', 'application/json'],
// verify: function(req, res, buf, encoding) {
// // get rawBody
// // @ts-ignore
// req.rawBody = buf.toString();
// }
});
const jellyIngress = new JellyfinNotifier();
app.postAsync('/jellyfin', async function(req, res) {
res.redirect(307, 'api/jellyfin/ingress');
res.redirect(307, '/api/jellyfin/ingress');
});
app.postAsync('/api/jellyfin/ingress',
async function (req, res, next) {
@@ -26,6 +33,22 @@ export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrob
res.send('OK');
const bodyEmpty = req.body === undefined || req.body === null || (typeof req.body === 'object' && Object.keys(req.body).length === 0);
if(bodyEmpty) {
const length = req.header('content-length') !== undefined ? Number.parseInt(req.header('content-length')) : undefined;
// can't think of a way a user would send an empty body for a webhook payload but if they meant to do it don't spam them with errors...
if(length === 0) {
return;
}
if(length === undefined) {
logger.warn(`Jellyfin is not sending a well-formatted request. It does not have valid headers (application/json - text/*) OR it is missing content-length header: Content-Type => '${req.header('content-type')}' | Length => ${length}`);
} else {
logger.warn(`Jellyfin is not sending a request with valid headers. Content-Type must be either application/json or a text/* wildcard (like text/plain) -- given: Content-Type => '${req.header('content-type')}'`);
}
res.status(400).send('Invalid Content-Type. Must be either application/json or a text wildcard (like text/plain)');
return;
}
const parts = remoteHostIdentifiers(req);
const connectionId = `${parts.host}-${parts.proxy ?? ''}`;
+21 -19
View File
@@ -1,6 +1,6 @@
import { ExpressHandler } from "../common/infrastructure/Atomic";
export const makeSourceCheckMiddle = (sources: any): ExpressHandler => (req: any, res: any, next: any) => {
export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => {
const {
query: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
@@ -10,22 +10,23 @@ export const makeSourceCheckMiddle = (sources: any): ExpressHandler => (req: any
} = {}
} = req;
if (name === undefined) {
if (required && name === undefined) {
return res.status(404).send('Source name must be defined');
} else if(name !== undefined) {
const source = sources.getByNameAndType(name, type);
if (source === undefined) {
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
}
req.sourceName = name;
req.scrobbleSource = source;
}
const source = sources.getByNameAndType(name, type);
if (source === undefined) {
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
}
req.sourceName = name;
req.scrobbleSource = source;
next();
}
export const makeClientCheckMiddle = (clients: any): ExpressHandler => (req: any, res: any, next: any) => {
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => {
const {
query: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
@@ -33,16 +34,17 @@ export const makeClientCheckMiddle = (clients: any): ExpressHandler => (req: any
} = {}
} = req;
if (name === undefined) {
if (required && name === undefined) {
return res.status(404).send('Client name must be defined');
} else if (name !== undefined) {
const client = clients.getByName(name);
if (client === undefined) {
return res.status(404).send(`No client with the name: ${name}`);
}
req.scrobbleClient = client;
}
const client = clients.getByName(name);
if (client === undefined) {
return res.status(404).send(`No client with the name: ${name}`);
}
req.scrobbleClient = client;
next();
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
res.send('OK');
};
app.postAsync('/plex', async function (req, res) {
res.redirect(307, 'api/plex/ingress');
res.redirect(307, '/api/plex/ingress');
});
app.postAsync('/api/plex/ingress', plexIngressMiddle, plexMiddle, plexIngressRoute);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const setupTautulliRoutes = (app: ExpressWithAsync, logger: Logger, scrob
};
app.postAsync('/tautulli', async function(req, res) {
res.redirect(307, 'api/plex/tautulli');
res.redirect(307, '/api/tautulli/ingress');
});
app.postAsync('/api/tautulli/ingress', tautulliIngressRoute);
}
+63 -21
View File
@@ -1,6 +1,5 @@
import dayjs, {Dayjs} from "dayjs";
import {
capitalize,
isPlayTemporallyClose,
genGroupId,
genGroupIdStrFromPlay,
@@ -12,6 +11,7 @@ import {
sortByOldestPlayDate,
} from "../utils";
import {
DEFAULT_POLLING_INTERVAL, DEFAULT_POLLING_MAX_INTERVAL, DEFAULT_RETRY_MULTIPLIER,
DeviceId,
GroupedFixedPlays,
GroupedPlays,
@@ -30,7 +30,7 @@ import {EventEmitter} from "events";
import {FixedSizeList} from "fixed-size-list";
import TupleMap from "../common/TupleMap";
import { PlayObject } from "../../core/Atomic";
import { buildTrackString } from "../../core/StringUtils";
import {buildTrackString, capitalize} from "../../core/StringUtils";
export interface RecentlyPlayedOptions {
limit?: number
@@ -63,6 +63,7 @@ export default abstract class AbstractSource {
canPoll: boolean = false;
polling: boolean = false;
canBacklog: boolean = false;
userPollingStopSignal: undefined | any;
pollRetries: number = 0;
tracksDiscovered: number = 0;
@@ -92,6 +93,10 @@ export default abstract class AbstractSource {
return this.initialized;
}
authGated = () => {
return this.requiresAuth && !this.authed;
}
// default init function, should be overridden if auth stage is required
testAuth = async () => {
return this.authed;
@@ -165,9 +170,7 @@ export default abstract class AbstractSource {
return existing !== undefined;
}
protected scrobble = (plays: PlayObject[], options: { forceRefresh?: boolean, checkAll?: boolean } = {}) => {
discover = (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): PlayObject[] => {
const newDiscoveredPlays: PlayObject[] = [];
for(const play of plays) {
@@ -177,9 +180,16 @@ export default abstract class AbstractSource {
}
}
newDiscoveredPlays.sort(sortByOldestPlayDate);
return newDiscoveredPlays;
}
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any } = {}) => {
if(newDiscoveredPlays.length > 0) {
newDiscoveredPlays.sort(sortByOldestPlayDate);
this.emitter.emit('discoveredToScrobble', {
data: newDiscoveredPlays,
options: {
@@ -190,8 +200,37 @@ export default abstract class AbstractSource {
}
});
}
}
return newDiscoveredPlays;
protected processBacklog = async () => {
if (this.canBacklog) {
this.logger.info('Discovering backlogged tracks from recently played API...');
const backlogPlays = await this.getBackloggedPlays();
const discovered = this.discover(backlogPlays);
const {
options: {
scrobbleBacklog = true
} = {}
} = this.config;
if (scrobbleBacklog) {
if (discovered.length > 0) {
this.logger.info('Scrobbling backlogged tracks...');
this.scrobble(discovered);
this.logger.info('Backlog scrobbling complete.');
} else {
this.logger.info('All tracks already discovered!');
}
} else {
this.logger.info('Backlog scrobbling is disabled by config, skipping...');
}
}
}
protected getBackloggedPlays = async (): Promise<PlayObject[]> => {
this.logger.debug('Backlogging not implemented');
return [];
}
protected notify = (payload) => {
@@ -210,7 +249,7 @@ export default abstract class AbstractSource {
if(!(await this.onPollPreAuthCheck())) {
return;
}
if(this.requiresAuth && !this.authed) {
if(this.authGated()) {
if(this.requiresAuthInteraction) {
this.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'});
this.logger.error('Cannot start polling because user interaction is required for authentication');
@@ -223,6 +262,8 @@ export default abstract class AbstractSource {
if(!(await this.onPollPostAuthCheck())) {
return;
}
await this.processBacklog();
await this.startPolling();
}
@@ -233,7 +274,7 @@ export default abstract class AbstractSource {
const {
data: {
maxPollRetries = 5,
retryMultiplier = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER,
} = {},
} = this.config;
@@ -245,10 +286,11 @@ export default abstract class AbstractSource {
return;
}
while (this.pollRetries <= maxRetries) {
let pollRes: boolean | undefined = undefined;
while (pollRes === undefined && this.pollRetries <= maxRetries) {
try {
const res = await this.doPolling();
if(res === true) {
pollRes = await this.doPolling();
if(pollRes === true) {
break;
}
} catch (e) {
@@ -285,12 +327,10 @@ export default abstract class AbstractSource {
return true;
}
protected doStopPolling = (reason?: string) => {
protected doStopPolling = (reason: string = 'system') => {
this.polling = false;
this.userPollingStopSignal = undefined;
if(reason !== undefined) {
this.logger.info(`Stopped polling due to: ${reason}`);
}
this.logger.info(`Stopped polling due to: ${reason}`);
}
protected shouldStopPolling = () => this.polling === false || this.userPollingStopSignal !== undefined;
@@ -305,7 +345,7 @@ export default abstract class AbstractSource {
let checkCount = 0;
let checksOverThreshold = 0;
const {interval = 30, checkActiveFor = 300, maxInterval = 60} = this.config.data;
const {interval = DEFAULT_POLLING_INTERVAL, checkActiveFor = 300, maxInterval = DEFAULT_POLLING_MAX_INTERVAL} = this.config.data;
const maxBackoff = maxInterval - interval;
let sleepTime = interval;
@@ -327,7 +367,8 @@ export default abstract class AbstractSource {
this.logger.info('Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
await sleep(10 * 1000);
}
newDiscovered = this.scrobble(playObjs,
newDiscovered = this.discover(playObjs);
this.scrobble(newDiscovered,
{
forceRefresh: closeToInterval
});
@@ -370,6 +411,7 @@ export default abstract class AbstractSource {
}
if(this.shouldStopPolling()) {
this.doStopPolling(this.userPollingStopSignal !== undefined ? 'user input' : undefined);
return true;
}
} catch (e) {
this.logger.error('Error occurred while polling');
@@ -379,12 +421,12 @@ export default abstract class AbstractSource {
}
}
public emitEvent = (eventName: string, payload: object) => {
public emitEvent = (eventName: string, payload: object = {}) => {
this.emitter.emit(eventName, {
...payload,
type: this.type,
name: this.name,
from: 'source'
from: 'source',
data: payload,
});
}
}
+15 -3
View File
@@ -1,4 +1,5 @@
import request from 'superagent';
import passport from "passport";
import {
parseRetryAfterSecsFromObj,
readJson,
@@ -10,7 +11,7 @@ import {Strategy as DeezerStrategy} from 'passport-deezer';
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
import dayjs from "dayjs";
import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic";
@@ -22,6 +23,7 @@ export default class DeezerSource extends AbstractSource {
requiresAuthInteraction = true;
baseUrl = 'https://api.deezer.com';
redirectUri: string;
declare config: DeezerSourceConfig;
@@ -30,6 +32,7 @@ export default class DeezerSource extends AbstractSource {
const {
data: {
interval = 60,
redirectUri,
} = {},
} = config;
@@ -38,9 +41,11 @@ export default class DeezerSource extends AbstractSource {
}
this.config.data.interval = interval;
this.redirectUri = redirectUri || `${this.localUrl}/deezer/callback`;
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
this.canPoll = true;
this.canBacklog = true;
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
@@ -92,8 +97,11 @@ export default class DeezerSource extends AbstractSource {
} else if(this.config.data.clientSecret === undefined) {
throw new Error('clientSecret must be defined when accessToken is not present');
}
this.logger.info(`No access token is present. User interaction for authentication is required.`);
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
}
this.initialized = true;
passport.use(`deezer-${this.name}`, this.generatePassportStrategy());
return this.initialized;
}
@@ -116,7 +124,7 @@ export default class DeezerSource extends AbstractSource {
callApi = async (req: any, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.data;
req.query({
@@ -185,7 +193,7 @@ export default class DeezerSource extends AbstractSource {
return new DeezerStrategy({
clientID: this.config.data.clientId,
clientSecret: this.config.data.clientSecret,
callbackURL: this.config.data.redirectUri || `${this.localUrl}/deezer/callback`,
callbackURL: this.redirectUri,
scope: ['listening_history','offline_access'],
}, (accessToken: any, refreshToken: any, profile: any, done: any) => {
// return done(null, {
@@ -224,4 +232,8 @@ export default class DeezerSource extends AbstractSource {
return error;
}
}
protected getBackloggedPlays = async () => {
return await this.getRecentlyPlayed({formatted: true});
}
}
+2 -4
View File
@@ -22,11 +22,9 @@ export class JRiverSource extends MemorySource {
data,
} = config;
const {
interval = 10,
maxInterval = 30,
...rest
} = data || {};
super('jriver', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
super('jriver', name, {...config, data: {...rest}}, internal, emitter);
const {
data: {
@@ -141,7 +139,7 @@ export class JRiverSource extends MemorySource {
}
}
return this.processRecentPlaysNew(play);
return this.processRecentPlays(play);
}
}
+3 -2
View File
@@ -350,12 +350,13 @@ export default class JellyfinSource extends MemorySource {
scrobbleOpts.checkAll = true;
} else {
newPlays = this.processRecentPlaysNew([playObj]);
newPlays = this.processRecentPlays([playObj]);
}
if(newPlays.length > 0) {
try {
this.scrobble(newPlays, scrobbleOpts);
const discovered = this.discover(newPlays, scrobbleOpts);
this.scrobble(discovered);
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
+2 -4
View File
@@ -17,11 +17,9 @@ export class KodiSource extends MemorySource {
data,
} = config;
const {
interval = 10,
maxInterval = 30,
...rest
} = data || {};
super('kodi', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
super('kodi', name, {...config, data: {...rest}}, internal, emitter);
this.requiresAuth = true;
this.canPoll = true;
@@ -70,7 +68,7 @@ export class KodiSource extends MemorySource {
let play = await this.client.getRecentlyPlayed(options);
return this.processRecentPlaysNew(play);
return this.processRecentPlays(play);
}
}
+37 -17
View File
@@ -1,24 +1,35 @@
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
import { RecentlyPlayedOptions } from "./AbstractSource";
import LastfmApiClient from "../common/vendor/LastfmApiClient";
import { sortByOldestPlayDate } from "../utils";
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
import {UserGetRecentTracksResponse} from "lastfm-node-client";
import {TrackObject, UserGetRecentTracksResponse} from "lastfm-node-client";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic";
import MemorySource from "./MemorySource";
import {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm";
import dayjs from "dayjs";
export default class LastfmSource extends AbstractSource {
export default class LastfmSource extends MemorySource {
api: LastfmApiClient;
requiresAuth = true;
requiresAuthInteraction = true;
declare config: LastfmClientConfig;
declare config: LastfmSourceConfig;
constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, emitter: EventEmitter) {
super('lastfm', name, config, internal, emitter);
constructor(name: any, config: LastfmSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
interval = 15,
maxInterval = 60,
...restData
} = {}
} = config;
super('lastfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
this.canPoll = true;
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir});
this.canBacklog = true;
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl});
this.playerSourceOfTruth = false;
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
@@ -42,7 +53,7 @@ export default class LastfmSource extends AbstractSource {
}
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
const {limit = 20} = options;
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
const {
@@ -51,7 +62,7 @@ export default class LastfmSource extends AbstractSource {
}
} = resp;
return list.reduce((acc: any, x: any) => {
const plays = list.reduce((acc: PlayObject[], x: TrackObject) => {
try {
const formatted = LastfmApiClient.formatPlayObj(x);
const {
@@ -64,13 +75,11 @@ export default class LastfmSource extends AbstractSource {
nowPlaying,
}
} = formatted;
if(nowPlaying === true) {
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
return acc;
} else if(playDate === undefined) {
if(playDate === undefined) {
if(nowPlaying === true) {
formatted.data.playDate = dayjs();
return acc.concat(formatted);
}
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
return acc;
}
@@ -82,5 +91,16 @@ export default class LastfmSource extends AbstractSource {
return acc;
}
}, []).sort(sortByOldestPlayDate);
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
const history = plays.filter(x => x.meta.nowPlaying !== true);
const now = plays.filter(x => x.meta.nowPlaying === true);
this.processRecentPlays(now);
return history;
}
protected getBackloggedPlays = async () => {
return await this.getRecentlyPlayed({formatted: true});
}
}
+18 -2
View File
@@ -3,8 +3,9 @@ import { FormatPlayObjectOptions, INITIALIZING, InternalConfig } from "../common
import EventEmitter from "events";
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient";
import MemorySource from "./MemorySource";
export default class ListenbrainzSource extends AbstractSource {
export default class ListenbrainzSource extends MemorySource {
api: ListenbrainzApiClient;
requiresAuth = true;
@@ -13,9 +14,18 @@ export default class ListenbrainzSource extends AbstractSource {
declare config: ListenBrainzSourceConfig;
constructor(name: any, config: ListenBrainzSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('listenbrainz', name, config, internal, emitter);
const {
data: {
interval = 15,
maxInterval = 60,
...restData
} = {}
} = config;
super('listenbrainz', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
this.canPoll = true;
this.canBacklog = true;
this.api = new ListenbrainzApiClient(name, config.data);
this.playerSourceOfTruth = false;
}
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
@@ -52,6 +62,12 @@ export default class ListenbrainzSource extends AbstractSource {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
const now = await this.api.getPlayingNow();
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
return await this.api.getRecentlyPlayed(limit);
}
protected getBackloggedPlays = async () => {
return await this.getRecentlyPlayed({formatted: true});
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ export class MPRISSource extends MemorySource {
if(options.display === true) {
return deduped;
}
return this.processRecentPlaysNew(deduped);
return this.processRecentPlays(deduped);
}
}
+96 -182
View File
@@ -4,7 +4,6 @@ import {
sortByOldestPlayDate,
toProgressAwarePlayObject,
getProgress,
genGroupIdStrFromPlay,
playPassesScrobbleThreshold,
timePassesScrobbleThreshold,
thresholdResultSummary,
@@ -16,21 +15,28 @@ import dayjs from "dayjs";
import {
asPlayerStateData,
DeviceId,
GroupedPlays,
GroupedPlays, InternalConfig,
PlayerStateData,
PlayPlatformId,
PlayUserId,
ProgressAwarePlayObject,
ScrobbleThresholdResult,
ScrobbleThresholdResult, SourceType,
} from "../common/infrastructure/Atomic";
import TupleMap from "../common/TupleMap";
import { AbstractPlayerState, PlayerStateOptions } from "./PlayerState/AbstractPlayerState";
import { GenericPlayerState } from "./PlayerState/GenericPlayerState";
import {Logger} from "@foxxmd/winston";
import { PlayObject } from "../../core/Atomic";
import {PlayObject, SourcePlayerObj} from "../../core/Atomic";
import { buildTrackString } from "../../core/StringUtils";
import {SimpleIntervalJob, Task, ToadScheduler} from "toad-scheduler";
import {SourceConfig} from "../common/infrastructure/config/source/sources";
import {EventEmitter} from "events";
import objectHash from 'object-hash';
export default class MemorySource extends AbstractSource {
playerSourceOfTruth: boolean = true;
/*
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid.
* This is necessary for any source that
@@ -39,35 +45,75 @@ export default class MemorySource extends AbstractSource {
* * where these timestamps don't have enough granularity (IE second accuracy)
* such as subsonic and jellyfin */
/**
* Tracks we are tracked that we are confident qualified as being played based on:
*
* - MS saw the track for the first time while it was running/polling
* - Continued to see this same track through consecutive polling/ingress events for AT LEAST 30 seconds
* - If the play info contains playback position data we also check if that has progressed at least 30 seconds
*
* These are tracks that are actually used by the source to scrobble to clients
* */
//statefulRecentlyPlayed: GroupedPlays = new Map();
/**
* Tracks that are actively being tracked (discovered from source recently) to see if they will "qualify" as being played.
*
* Once a track qualifies it is added to statefuls.
* */
candidateRecentlyPlayed: GroupedPlays = new TupleMap<DeviceId, PlayUserId, ProgressAwarePlayObject[]>
players: Map<string, AbstractPlayerState> = new Map();
playerState: Map<string, string> = new Map();
getFlatCandidateRecentlyPlayed = (): PlayObject[] => {
// TODO sort?
return Array.from(this.candidateRecentlyPlayed.values()).flat();
scheduler: ToadScheduler = new ToadScheduler();
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super(type, name, config, internal, emitter);
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({seconds: 15}, new Task('Player Cleanup', () => {
this.cleanupPlayers();
})));
}
cleanupPlayers = () => {
const deadPlatformIds: string[] = [];
for (const [key, player] of this.players.entries()) {
// no communication from the source was received for this player
const isStale = player.checkStale();
if (isStale && player.checkOrphaned() && player.isDead()) {
player.logger.debug(`Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
deadPlatformIds.push(player.platformIdStr);
this.emitEvent('playerDelete', {platformId: player.platformIdStr});
} else if (isStale) {
const state = player.getApiState();
// @ts-ignore
const stateHash = objectHash.sha1(state);
if(stateHash !== this.playerState.get(key)) {
this.playerState.set(key, stateHash);
this.emitEvent('playerUpdate', state);
}
if(this.config.options?.logPlayerState === true) {
player.logSummary();
}
}
}
for (const deadId of deadPlatformIds) {
this.deletePlayer(deadId);
}
}
playersToObject = (): Record<string, SourcePlayerObj> => {
if(this.players.size === 0) {
return {};
}
const record: Record<string, SourcePlayerObj> = {};
for(const [k,v] of this.players.entries()) {
record[k] = v.getApiState();
}
return record;
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => {
return new GenericPlayerState(logger, id, opts);
}
processRecentPlaysNew = (datas: (PlayObject | PlayerStateData)[]) => {
setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
staleInterval: (this.config.data.interval ?? 30) * 3,
orphanedInterval: (this.config.data.maxInterval ?? 60) * 5,
...opts
}));
this.playerState.set(idStr, '');
}
deletePlayer = (id: string) => {
this.players.delete(id);
this.playerState.delete(id);
}
processRecentPlays = (datas: (PlayObject | PlayerStateData)[]) => {
const {
data: {
@@ -78,20 +124,15 @@ export default class MemorySource extends AbstractSource {
const newStatefulPlays: PlayObject[] = [];
// create any new players from incoming data
//const incomingPlatformIds: PlayPlatformId[] = [];
for (const data of datas) {
const id = getPlatformIdFromData(data);
const idStr = genGroupIdStr(id);
if (!this.players.has(idStr)) {
//incomingPlatformIds.push(id);
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
staleInterval: (this.config.data.interval ?? 30) * 3,
orphanedInterval: (this.config.data.maxInterval ?? 60) * 5
}));
this.setNewPlayer(idStr, this.logger, id);
}
}
const deadPlatformIds: string[] = [];
//const deadPlatformIds: string[] = [];
for (const [key, player] of this.players.entries()) {
@@ -113,15 +154,20 @@ export default class MemorySource extends AbstractSource {
const [currPlay, prevPlay] = asPlayerStateData(incomingData) ? player.setState(incomingData.status, incomingData.play) : player.setState(undefined, incomingData);
const candidate = prevPlay !== undefined ? prevPlay : currPlay;
const playChanged = prevPlay !== undefined;
if (candidate !== undefined) {
// wait to discover play until it is stale or current play has changed
// so that our discovered track has an accurate "listenedFor" count
if (candidate !== undefined && (playChanged || player.isUpdateStale())) {
let stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor, candidate.data.duration);
if (thresholdResults.passes) {
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
let stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
if (matchingRecent === undefined) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
if(this.playerSourceOfTruth) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
}
newStatefulPlays.push(candidate);
} else {
const {data: {playDate, duration}} = candidate;
@@ -129,173 +175,41 @@ export default class MemorySource extends AbstractSource {
if (!playDate.isSame(rplayDate)) {
if (duration !== undefined) {
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
if(this.playerSourceOfTruth) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
}
newStatefulPlays.push(candidate);
}
} else {
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate));
if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
// if most recent stateful play is not this track we'll add it
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
if(this.playerSourceOfTruth) {
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
}
newStatefulPlays.push(candidate);
}
}
}
}
} else if(playChanged) {
player.logger.verbose(`${stPrefix} not added because ${thresholdResultSummary(thresholdResults)}.`);
}
}
} else {
// no communication from the source was received for this player
player.checkStale();
if (player.checkOrphaned() && player.isDead()) {
player.logger.debug(`Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
deadPlatformIds.push(player.platformIdStr);
if(this.config.options?.logPlayerState === true) {
player.logSummary();
}
const apiState = player.getApiState();
// @ts-ignore
this.playerState.set(key, objectHash.sha1(apiState))
this.emitEvent('playerUpdate', apiState);
}
if(this.config.options?.logPlayerState === true) {
player.logSummary();
}
}
for (const deadId of deadPlatformIds) {
this.players.delete(deadId);
}
return newStatefulPlays;
}
processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => {
const {
data: {
scrobbleThresholds = {}
} = {}
} = this.config;
let newStatefulPlays: PlayObject[] = [];
// if we can't trust existing play dates (like for subsonic where there is no timestamp) then
// we format new plays with locked play date IE the first time we have "seen" this track
// -- otherwise, for sources like Spotify that accurately report when track started to play, we can use existing dates
const flatLockedPlays = useExistingPlayDate ? plays : plays.map((p: any) => {
const {data: {playDate, ...restData}, ...rest} = p;
return {data: {...restData, playDate: dayjs()}, ...rest};
});
// group by device-user
const groupedLockedPlays = flatLockedPlays.reduce((acc: GroupedPlays, curr: ProgressAwarePlayObject) => {
const id = genGroupId(curr);
acc.set(id, (acc.get(id) ?? []).concat(curr));
return acc;
}, new Map());
for (const [groupId, lockedPlays] of groupedLockedPlays.entries()) {
const groupIdStr = `${groupId[0]}-${groupId[1]}`;
let cRecentlyPlayed = this.candidateRecentlyPlayed.get(groupId) ?? [];
// if no candidates exist new plays are new candidates
if (cRecentlyPlayed.length === 0) {
this.logger.debug(`[Platform ${groupIdStr}] No prior candidate recent plays!`)
// update activity date here so that polling interval decreases *before* we get a new valid play
// so that we don't miss a play due to long polling interval
this.lastActivityAt = dayjs();
const progressAware: ProgressAwarePlayObject[] = [];
for (const p of lockedPlays) {
progressAware.push(toProgressAwarePlayObject(p));
this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`);
}
this.candidateRecentlyPlayed.set(groupId, progressAware)
} else {
// otherwise determine new tracks (not found in prior candidates)
const newTracks = lockedPlays.filter((x: any) => cRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
const newProgressAwareTracks: ProgressAwarePlayObject[] = [];
if (newTracks.length > 0) {
// update activity date here so that polling interval decreases *before* we get a new valid play
// so that we don't miss a play due to long polling interval
this.lastActivityAt = dayjs();
this.logger.debug(`[Platform ${groupIdStr}] New plays found that do not match existing candidates.`)
for (const p of newTracks) {
this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`);
newProgressAwareTracks.push(toProgressAwarePlayObject(p));
}
}
// filter prior candidates based on new recently played
cRecentlyPlayed = cRecentlyPlayed.filter(x => {
const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y));
if (!candidateMatchedLocked) {
this.logger.debug(`[Platform ${groupIdStr}] Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`);
}
return candidateMatchedLocked;
});
// and then combine still playing with new tracks
cRecentlyPlayed = cRecentlyPlayed.concat(newProgressAwareTracks);
cRecentlyPlayed.sort(sortByOldestPlayDate);
this.candidateRecentlyPlayed.set(groupId, cRecentlyPlayed);
//const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? [];
// now we check if all candidates pass tests for having been tracked long enough:
// * Has been tracked for at least [duration] seconds or [percentage] of track duration
// * If it has playback position data then it must also have progressed at least [duration] seconds or [percentage] of track duration progress since our initial tracking data
for (const candidate of cRecentlyPlayed) {
let thresholdResults: ScrobbleThresholdResult;
thresholdResults = playPassesScrobbleThreshold(candidate, scrobbleThresholds);
const {passes: firstSeenValid} = thresholdResults;
let progressValid = firstSeenValid;
if (firstSeenValid) {
// check if we can get progress as well
const matchingLockedPlay = lockedPlays.find(x => playObjDataMatch(x, candidate));
// this should always be found but checking just in case
if (matchingLockedPlay !== undefined) {
const progress = getProgress(candidate, matchingLockedPlay);
if (progress !== undefined) {
thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, progress, candidate.data.duration);
const {passes: progressPasses} = thresholdResults;
progressValid = progressPasses;
}
}
}
if (firstSeenValid && progressValid) {
// a prior candidate has been playing for more than 30 seconds and passed progress test, time to check statefuls
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
if (matchingRecent === undefined) {
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
newStatefulPlays.push(candidate);
//sRecentlyPlayed.push(candidate);
} else {
const {data: {playDate, duration}} = candidate;
const {data: {playDate: rplayDate}} = matchingRecent;
if (!playDate.isSame(rplayDate)) {
if (duration !== undefined) {
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
newStatefulPlays.push(candidate);
//sRecentlyPlayed.push(candidate);
}
} else {
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate));
if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
// if most recent stateful play is not this track we'll add it
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
newStatefulPlays.push(candidate);
}
//sRecentlyPlayed.push(candidate);
}
}
}
}
}
//sRecentlyPlayed.sort(sortByPlayDate);
//this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed);
}
}
return newStatefulPlays;
//return this.getFlatStatefulRecentlyPlayed();
//return newStatefulPlays;
}
recentlyPlayedTrackIsValid = (playObj: any) => {
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
}
+2 -4
View File
@@ -39,11 +39,9 @@ export class MopidySource extends MemorySource {
albumBlacklist = ['Soundcloud'],
uriWhitelist = [],
uriBlacklist = [],
interval = 10,
maxInterval = 30,
...rest
} = data;
super('mopidy', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
super('mopidy', name, {...config, data: {...rest}}, internal, emitter);
this.albumBlacklist = albumBlacklist.map(x => x.toLocaleLowerCase());
this.uriWhitelist = uriWhitelist.map(x => x.toLocaleLowerCase());
@@ -209,7 +207,7 @@ export class MopidySource extends MemorySource {
play
}
return this.processRecentPlaysNew([playerState]);
return this.processRecentPlays([playerState]);
}
}
@@ -9,8 +9,10 @@ import dayjs, {Dayjs} from "dayjs";
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils";
import {Logger} from "@foxxmd/winston";
import { ListenProgress } from "./ListenProgress";
import { ListenRange, PlayData, PlayObject } from "../../../core/Atomic";
import {PlayObject, SourcePlayerObj} from "../../../core/Atomic";
import { buildTrackString } from "../../../core/StringUtils";
import {ListenRange} from "./ListenRange";
import {start} from "repl";
export interface PlayerStateIntervals {
staleInterval?: number
@@ -29,8 +31,8 @@ export abstract class AbstractPlayerState {
currentPlay?: PlayObject
playFirstSeenAt?: Dayjs
playLastUpdatedAt?: Dayjs
currentListenRange?: [ListenProgress, ListenProgress]
listenRanges: [ListenProgress, ListenProgress][] = [];
currentListenRange?: ListenRange
listenRanges: ListenRange[]
createdAt: Dayjs = dayjs();
stateLastUpdatedAt: Dayjs = dayjs();
@@ -62,7 +64,7 @@ export abstract class AbstractPlayerState {
checkStale() {
const isStale = this.isUpdateStale();
if (isStale && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.stale) {
if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale;
this.logger.debug(`Stale after no Play updates for ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`);
// end current listening sessions
@@ -76,7 +78,7 @@ export abstract class AbstractPlayerState {
}
isDead() {
return dayjs().diff(this.stateLastUpdatedAt, 'minutes') >= this.stateIntervalOptions.orphanedInterval * 2;
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2;
}
checkOrphaned() {
@@ -96,10 +98,10 @@ export abstract class AbstractPlayerState {
return status !== 'paused' && status !== 'stopped';
}
setState(status?: ReportedPlayerStatus, play?: PlayObject) {
setState(status?: ReportedPlayerStatus, play?: PlayObject, reportedTS?: Dayjs) {
this.stateLastUpdatedAt = dayjs();
if (play !== undefined) {
return this.setPlay(play, status);
return this.setPlay(play, status, reportedTS);
} else if (status !== undefined) {
if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) {
this.stopPlayer();
@@ -114,7 +116,7 @@ export abstract class AbstractPlayerState {
return [];
}
setPlay(play: PlayObject, status?: ReportedPlayerStatus): [PlayObject, PlayObject?] {
protected setPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
this.playLastUpdatedAt = dayjs();
if (status !== undefined) {
this.reportedStatus = status;
@@ -125,7 +127,7 @@ 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();
this.setCurrentPlay(play);
this.setCurrentPlay(play, undefined, reportedTS);
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
}
@@ -133,8 +135,24 @@ export abstract class AbstractPlayerState {
} else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) {
this.currentListenSessionEnd();
this.calculatedStatus = this.reportedStatus;
} else if (this.isSessionRepeat(play.meta.trackProgressPosition, reportedTS)) {
// if we detect the track has been restarted end listen session and treat as a new play
this.currentListenSessionEnd();
const played = this.getPlayedObject();
play.data.playDate = dayjs();
this.setCurrentPlay(play, undefined, reportedTS);
return [this.getPlayedObject(), played];
} else {
this.currentListenSessionContinue(play.meta.trackProgressPosition);
if(this.currentListenRange !== undefined) {
const [isSeeked, seekedPos] = this.currentListenRange.seeked(play.meta.trackProgressPosition, reportedTS);
if (isSeeked !== false) {
this.logger.debug(`Detected player was seeked ${seekedPos.toFixed(2)}s, starting new listen range`);
// if player has been seeked start a new listen range so our numbers don't get all screwy
this.currentListenSessionEnd();
}
}
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
}
} else {
this.setCurrentPlay(play);
@@ -148,7 +166,7 @@ export abstract class AbstractPlayerState {
return [this.getPlayedObject(), undefined];
}
clearPlayer() {
protected clearPlayer() {
this.currentPlay = undefined;
this.playLastUpdatedAt = undefined;
this.playFirstSeenAt = undefined;
@@ -156,7 +174,7 @@ export abstract class AbstractPlayerState {
this.currentListenRange = undefined;
}
stopPlayer() {
protected stopPlayer() {
this.reportedStatus = 'stopped';
this.playLastUpdatedAt = dayjs();
this.currentListenSessionEnd();
@@ -181,27 +199,27 @@ export abstract class AbstractPlayerState {
return undefined;
}
getListenDuration() {
getListenDuration(){
let listenDur: number = 0;
let ranges = [...this.listenRanges];
if (this.currentListenRange !== undefined) {
ranges.push(this.currentListenRange);
}
for (const [start, end] of ranges) {
listenDur += start.getDuration(end);
for (const range of ranges) {
listenDur += range.getDuration();
}
return listenDur;
}
currentListenSessionContinue(position?: number) {
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
const now = dayjs();
if (this.currentListenRange === undefined) {
this.logger.debug('Started new Player listen range.');
this.currentListenRange = [new ListenProgress(now, position), new ListenProgress(now, position)];
this.currentListenRange = new ListenRange(new ListenProgress(timestamp, position));
} else {
const newEndProgress = new ListenProgress(now, position);
if (position !== undefined && this.currentListenRange[1].position !== undefined) {
const oldEndProgress = this.currentListenRange[1];
const oldEndProgress = this.currentListenRange.end;
const newEndProgress = new ListenProgress(timestamp, position);
if (position !== undefined && oldEndProgress !== undefined) {
if (position === oldEndProgress.position && !['paused', 'stopped'].includes(this.calculatedStatus)) {
this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused;
if (this.reportedStatus !== this.calculatedStatus) {
@@ -220,19 +238,73 @@ export abstract class AbstractPlayerState {
} else {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
}
this.currentListenRange = [this.currentListenRange[0], newEndProgress];
this.currentListenRange.setRangeEnd(newEndProgress);
}
}
currentListenSessionEnd() {
if (this.currentListenRange !== undefined && this.currentListenRange[0].getDuration(this.currentListenRange[1]) !== 0) {
protected currentListenSessionEnd() {
if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) {
this.logger.debug('Ended current Player listen range.')
this.listenRanges.push(this.currentListenRange);
}
this.currentListenRange = undefined;
}
setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus) {
protected isSessionRepeat(position?: number, reportedTS?: Dayjs) {
if(this.currentListenRange === undefined) {
return false;
}
const [isSeeked, seekPos] = this.currentListenRange.seeked(position, reportedTS);
if (isSeeked === false || seekPos > 0) {
return false;
}
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
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.debug(repeatHint);
return true;
}
if (trackDur !== undefined) {
const lastPos = this.currentListenRange.end.position;
// 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.debug(repeatHint);
return true;
}
}
}
return false;
}
protected setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs) {
this.currentPlay = play;
this.playFirstSeenAt = dayjs();
this.listenRanges = [];
@@ -245,7 +317,7 @@ export abstract class AbstractPlayerState {
}
if (!['stopped'].includes(this.reportedStatus)) {
this.currentListenSessionContinue(play.meta.trackProgressPosition);
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
}
}
@@ -257,8 +329,8 @@ export abstract class AbstractPlayerState {
}
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Last Update: ${this.stateLastUpdatedAt.toISOString()}`);
let progress = '';
if (this.currentListenRange !== undefined && this.currentListenRange[1].position !== undefined && this.currentPlay.data.duration !== undefined) {
progress = `${progressBar(this.currentListenRange[1].position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange[1].position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
if (this.currentListenRange !== undefined && this.currentListenRange.end.position !== undefined && this.currentPlay.data.duration !== undefined) {
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 | `;
}
let listenedPercent = '';
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
@@ -274,4 +346,38 @@ export abstract class AbstractPlayerState {
logSummary() {
this.logger.debug(this.textSummary());
}
protected getPosition() {
if(this.calculatedStatus === 'stopped') {
return undefined;
}
let lastRange: ListenRange | undefined;
if(this.currentListenRange !== undefined) {
lastRange = this.currentListenRange;
} else if(this.listenRanges.length > 0) {
lastRange = this.listenRanges[this.listenRanges.length - 1];
}
if(lastRange === undefined || lastRange.end === undefined || lastRange.end.position === undefined) {
return undefined;
}
return lastRange.end.position;
}
getApiState(): SourcePlayerObj {
return {
platformId: this.platformIdStr,
play: this.getPlayedObject(),
playLastUpdatedAt: this.playLastUpdatedAt.toISOString(),
playFirstSeenAt: this.playFirstSeenAt.toISOString(),
playerLastUpdatedAt: this.stateLastUpdatedAt.toISOString(),
position: this.getPosition(),
listenedDuration: this.getListenDuration(),
status: {
reported: this.reportedStatus,
calculated: this.calculatedStatus,
stale: this.isUpdateStale(),
orphaned: this.isOrphaned()
}
}
}
}
@@ -1,14 +1,17 @@
import dayjs, {Dayjs} from "dayjs";
import { PlayProgress } from "../../../core/Atomic";
import {PlayProgress} from "../../../core/Atomic";
export class ListenProgress implements PlayProgress {
constructor(
public timestamp: Dayjs = dayjs(),
public position?: number,
public positionPercent?: number
) {
public timestamp: Dayjs;
public position?: number;
public positionPercent?: number;
constructor(timestamp?: Dayjs, position?: number, positionPercent?: number) {
this.timestamp = timestamp ?? dayjs();
this.position = position;
this.positionPercent = positionPercent;
}
getDuration(end: ListenProgress) {
@@ -18,4 +21,12 @@ export class ListenProgress implements PlayProgress {
return end.timestamp.diff(this.timestamp, 'seconds');
}
}
toJSON() {
return {
timestamp: this.timestamp.toISOString(),
position: this.position,
positionPercent: this.positionPercent
}
}
}
@@ -0,0 +1,74 @@
import {ListenProgress} from "./ListenProgress";
import dayjs, {Dayjs} from "dayjs";
import {ListenRangeData} from "../../../core/Atomic";
export class ListenRange implements ListenRangeData {
public start: ListenProgress;
public end: ListenProgress;
constructor(start?: ListenProgress, end?: ListenProgress) {
const s = start ?? new ListenProgress();
const e = end ?? s;
this.start = s;
this.end = e;
}
isPositional() {
return this.start.position !== undefined && this.end.position !== undefined;
}
isInitial() {
if (this.isPositional()) {
return this.start.position === this.end.position;
}
return this.start.timestamp.isSame(this.end.timestamp);
}
seeked(position?: number, reportedTS: Dayjs = dayjs()): [boolean, number?] {
if (position === undefined || this.isInitial() || !this.isPositional()) {
return [false];
}
// if (new) position is earlier than last stored position then the user has seeked backwards on the player
if (position < this.end.position) {
return [true, position - this.end.position];
}
// if (new) position is more than a reasonable number of ms ahead of real time than they have seeked forwards on the player
const realTimeDiff = Math.max(0, reportedTS.diff(this.end.timestamp, 'ms')); // 0 max used so TS from testing doesn't cause "backward" diff
const positionDiff = (position - this.end.position) * 1000;
// if user is more than 2.5 seconds ahead of real time
if (positionDiff - realTimeDiff > 2500) {
return [true, position - this.end.position];
}
return [false];
}
setRangeStart(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
if (data instanceof ListenProgress) {
this.start = data;
} else {
const d = data || {};
this.start = new ListenProgress(d.timestamp, d.position, d.positionPercent)
}
}
setRangeEnd(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
if (data instanceof ListenProgress) {
this.end = data;
} else {
const d = data || {};
this.end = new ListenProgress(d.timestamp, d.position, d.positionPercent)
}
}
getDuration() {
return this.start.getDuration(this.end);
}
toJSON() {
return [this.start, this.end];
}
}
+2 -1
View File
@@ -210,7 +210,8 @@ export default class PlexSource extends AbstractSource {
}
try {
this.scrobble([playObj]);
const discovered = this.discover([playObj]);
this.scrobble(discovered);
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
+11 -6
View File
@@ -230,8 +230,8 @@ export default class ScrobbleSources {
break;
case 'deezer':
const d = {
clientId: process.env.DEEZER_APP_ID,
clientSecret: process.env.DEEZER_SECRET_KEY,
clientId: process.env.DEEZER_CLIENT_ID,
clientSecret: process.env.DEEZER_CLIENT_SECRET,
redirectUri: process.env.DEEZER_REDIRECT_URI,
accessToken: process.env.DEEZER_ACCESS_TOKEN,
};
@@ -410,7 +410,7 @@ export default class ScrobbleSources {
}
}
addSource = async (clientConfig: any, defaults: SourceDefaults = {}) => {
addSource = async (clientConfig: ParsedConfig, defaults: SourceDefaults = {}) => {
// const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
// 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(' | ')}`)
@@ -422,7 +422,12 @@ export default class ScrobbleSources {
logger: this.logger
};
const {type, name, data: d = {}, options: clientOptions = {}} = clientConfig;
const {type, name, data: d = {}, enable = true, options: clientOptions = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) source was disabled by config`);
return;
}
// add defaults
const {options: defaultOptions = {}, ...restDefaults} = defaults;
@@ -430,7 +435,7 @@ export default class ScrobbleSources {
const compositeConfig: SourceConfig = {...clientConfig, data, options: {...defaultOptions, ...clientOptions}};
this.logger.debug(`(${name}) Constructing ${type} source`);
let newSource;
let newSource: AbstractSource;
switch (type) {
case 'spotify':
newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, internal, this.emitter);
@@ -448,7 +453,7 @@ export default class ScrobbleSources {
newSource = await new JellyfinSource(name, compositeConfig as JellySourceConfig, internal, this.emitter);
break;
case 'lastfm':
newSource = await new LastfmSource(name, compositeConfig as LastfmClientConfig, internal, this.emitter);
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, internal, this.emitter);
break;
case 'deezer':
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, internal, this.emitter);
+18 -10
View File
@@ -11,6 +11,7 @@ import SpotifyWebApi from "spotify-web-api-node";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify";
import {
DEFAULT_POLLING_INTERVAL,
FormatPlayObjectOptions,
InternalConfig,
NO_USER,
@@ -51,18 +52,17 @@ export default class SpotifySource extends MemorySource {
super('spotify', name, config, internal, emitter);
const {
data: {
interval = 15,
interval = DEFAULT_POLLING_INTERVAL,
} = {}
} = config;
if (interval < 5) {
this.logger.warn('Interval should be 5 seconds or above...😬 preferably 15');
this.logger.warn('Interval should probably be 5 seconds or above! Spotify may return 429 response (too many requests)');
}
this.config.data.interval = interval;
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
this.canPoll = true;
this.canBacklog = true;
}
static formatPlayObj(obj: object, options: FormatPlayObjectOptions = {}): PlayObject {
@@ -215,6 +215,11 @@ export default class SpotifySource extends MemorySource {
throw new Error('Failed to initialize a Spotify source');
}
if(accessToken === undefined || refreshToken === undefined) {
this.logger.info(`No access or refresh token is present. User interaction for authentication is required.`);
this.logger.info(`Redirect URL that will be used on auth callback: '${rdUri}'`);
}
this.spotifyApi = new SpotifyWebApi(apiConfig);
}
@@ -228,6 +233,10 @@ export default class SpotifySource extends MemorySource {
testAuth = async () => {
try {
if(undefined === this.spotifyApi.getAccessToken()) {
this.authed = false;
return;
}
await this.callApi<ReturnType<typeof this.spotifyApi.getMe>>(((api: any) => api.getMe()));
this.authed = true;
} catch (e) {
@@ -283,7 +292,7 @@ export default class SpotifySource extends MemorySource {
plays.push(currPlay);
}
}
return this.processRecentPlaysNew(plays);
return this.processRecentPlays(plays);
}
getPlayHistory = async (options: RecentlyPlayedOptions = {}) => {
@@ -428,13 +437,12 @@ export default class SpotifySource extends MemorySource {
this.logger.warn('multi-scrobbler does not have sufficient permissions to access Spotify API "Get Playback State". MS will continue to work but accuracy for determining if/when a track played from a Spotify Connect device (smart device controlled through Spotify app) may be degraded. To fix this re-authenticate MS with Spotify and restart polling.');
}
// and record backlogged tracks
this.logger.info('Checking recently played API for tracks to backlog...');
const backlogPlays = await this.getPlayHistory({formatted: true});
this.scrobble(backlogPlays);
this.logger.info('Backlog complete.');
return true;
}
protected getBackloggedPlays = async () => {
return await this.getPlayHistory({formatted: true});
}
}
const asPlayHistoryObject = (obj: object): obj is PlayHistoryObject => {
+4 -7
View File
@@ -5,7 +5,7 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils";
import MemorySource from "./MemorySource";
import { SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic";
import { RecentlyPlayedOptions } from "./AbstractSource";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic";
@@ -21,15 +21,12 @@ export class SubsonicSource extends MemorySource {
declare config: SubSonicSourceConfig;
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
// default to quick interval so we can get a decently accurate nowPlaying
const {
data: {
interval = 10,
maxInterval = 30,
...restData
} = {}
} = config;
const subsonicConfig = {...config, data: {...restData, internal, maxInterval}};
const subsonicConfig = {...config, data: {...restData}};
super('subsonic', name, subsonicConfig, internal,emitter);
const {data: {user, password, url} = {}} = this.config;
@@ -84,7 +81,7 @@ export class SubsonicSource extends MemorySource {
user,
password,
maxRequestRetries = 1,
retryMultiplier = 1.5
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.data;
@@ -194,6 +191,6 @@ 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.processRecentPlaysNew(deduped);
return this.processRecentPlays(deduped);
}
}
+3 -4
View File
@@ -1,14 +1,13 @@
// @ts-ignore
import YouTubeMusic from "@foxxmd/youtube-music-ts-api";
import YouTubeMusic from "youtube-music-ts-api";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
// @ts-ignore
import {IYouTubeMusicAuthenticated} from "@foxxmd/youtube-music-ts-api/interfaces-primary";
import {IYouTubeMusicAuthenticated} from "youtube-music-ts-api/interfaces-primary";
import dayjs from "dayjs";
import { parseDurationFromTimestamp, playObjDataMatch } from "../utils";
// @ts-ignore
import {IPlaylistDetail, ITrackDetail} from "@foxxmd/youtube-music-ts-api/interfaces-supplementary";
import {IPlaylistDetail, ITrackDetail} from "youtube-music-ts-api/interfaces-supplementary";
import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic";
+44
View File
@@ -0,0 +1,44 @@
import {config, Logger} from "@foxxmd/winston";
import {mergeArr} from "../utils";
import {AsyncTask} from "toad-scheduler";
import {PromisePool} from "@supercharge/promise-pool";
import ScrobbleClients from "../scrobblers/ScrobbleClients";
export const createHeartbeatClientsTask = (clients: ScrobbleClients, parentLogger: Logger) => {
const logger = parentLogger.child({labels: ['Heartbeat', 'Clients']}, mergeArr);
return new AsyncTask(
'Heartbeat',
(): Promise<any> => {
return PromisePool
.withConcurrency(1)
.for(clients.clients)
.process(async (client) => {
if(await client.isReady()) {
await client.processDeadLetterQueue();
if(!client.scrobbling) {
client.logger.info('Should be processing scrobbles! Attempting to restart scrobbling...', {leaf: 'Heartbeat'});
client.initScrobbleMonitoring();
return 1;
}
return 0;
}
}).then(({results, errors}) => {
logger.info(`Checked Dead letter queue for ${clients.clients.length} clients.`);
const restarted = results.reduce((acc, curr) => acc += curr, 0);
if (restarted > 0) {
logger.info(`Attempted to restart ${restarted} clients that were not processing scrobbles.`);
}
if (errors.length > 0) {
logger.error(`Encountered errors!`);
for (const err of errors) {
logger.error(err);
}
}
});
},
(err: Error) => {
logger.error(err);
}
);
}
+41
View File
@@ -0,0 +1,41 @@
import {config, Logger} from "@foxxmd/winston";
import {mergeArr} from "../utils";
import {AsyncTask} from "toad-scheduler";
import {PromisePool} from "@supercharge/promise-pool";
import ScrobbleSources from "../sources/ScrobbleSources";
export const createHeartbeatSourcesTask = (sources: ScrobbleSources, parentLogger: Logger) => {
const logger = parentLogger.child({labels: ['Heartbeat', 'Sources']}, mergeArr);
return new AsyncTask(
'Heartbeat',
(): Promise<any> => {
return PromisePool
.withConcurrency(1)
.for(sources.sources)
.process(async (source) => {
if (source.initialized && source.canPoll && !source.polling && !source.authGated()) {
source.logger.info('Should be polling! Attempting to restart polling...', {leaf: 'Heartbeat'});
source.poll();
return 1;
}
return 0;
}).then(({results, errors}) => {
logger.info(`Checked ${sources.sources.length} sources for restart signals.`);
const restarted = results.reduce((acc, curr) => acc += curr, 0);
if (restarted > 0) {
logger.info(`Attempted to restart ${restarted} sources that were not polling.`);
}
if (errors.length > 0) {
logger.error(`Encountered errors!`);
for (const err of errors) {
logger.error(err);
}
}
});
},
(err: Error) => {
logger.error(err);
}
);
}
@@ -0,0 +1,41 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import samplePayload from './playbackProgressSample.json';
import JellyfinSource from "../../sources/JellyfinSource";
import EventEmitter from "events";
import {getLogger} from "../../common/logging";
import {JsonPlayObject, PlayObject} from "../../../core/Atomic";
const dataAsFixture = (data: any): TestFixture => {
return data as TestFixture;
}
interface TestFixture {
data: any
expected: JsonPlayObject
}
describe('Jellyfin Payload Parsing', function () {
it('Should parse PlayProgress payload as PlayObject', async function () {
const fixture = dataAsFixture(samplePayload[0]);
const play = JellyfinSource.formatPlayObj(fixture.data);
assert.equal(play.data.track, fixture.expected.data.track);
assert.equal(play.meta.mediaType, 'Audio');
});
describe('Correctly detects events as valid/invalid', function () {
const jfSource = new JellyfinSource('Test', {data: {}}, {localUrl: 'test', configDir: 'test', logger: getLogger({}, 'Test')}, new EventEmitter());
it('Should parse PlayProgress with Audio ItemType as valid event', async function () {
const fixture = dataAsFixture(samplePayload[0]);
const play = JellyfinSource.formatPlayObj(fixture.data);
assert.isTrue(jfSource.isValidEvent(play))
});
});
});
@@ -0,0 +1,64 @@
[
{
"data": {
"ServerId": "6c41d884a9c1490f8fd0863e04d75a69",
"ServerName": "maine",
"ServerVersion": "10.8.10",
"ServerUrl": "<redacted>",
"NotificationType": "PlaybackProgress",
"Timestamp": "2023-09-26T11:18:17.8399513-03:00",
"UtcTimestamp": "2023-09-26T14:18:17.8399533Z",
"Name": "Brother Madonna",
"Overview": "",
"Tagline": "",
"ItemId": "514941104216969e74a8d87fc7893ad5",
"ItemType": "Audio",
"RunTimeTicks": 926533312,
"RunTime": "00:01:32",
"Year": 2008,
"Album": "I Kind of Really Want to Make Love to You",
"Artist": "Kitsune\u00B2",
"Provider_musicbrainzalbumartist": "6e659357-5963-4daf-a634-a3596893f3d7",
"Provider_musicbrainzartist": "6e659357-5963-4daf-a634-a3596893f3d7",
"Provider_musicbrainzalbum": "7c10f8a5-669f-4ff6-bb88-de59846c0e0c",
"Provider_musicbrainzreleasegroup": "85c9ae8b-13e4-4fbf-9f09-5e405039772c",
"Provider_musicbrainztrack": "3c2c1f40-b22e-360e-a29a-1b1610190a8c",
"Audio_0_Title": "FLAC - Stereo",
"Audio_0_Type": "Audio",
"Audio_0_Language": null,
"Audio_0_Codec": "flac",
"Audio_0_Channels": 2,
"Audio_0_Bitrate": 917349,
"Audio_0_SampleRate": 44100,
"Audio_0_Default": false,
"PlaybackPositionTicks": 640000000,
"PlaybackPosition": "00:01:04",
"MediaSourceId": "514941104216969e74a8d87fc7893ad5",
"IsPaused": false,
"IsAutomated": true,
"DeviceId": "BC9311B570ABE4BD71EEEC0856A448AD",
"DeviceName": "samsung SM-S901E",
"ClientName": "Android",
"NotificationUsername": "Tabitha",
"UserId": "6d23c30c5e9d4ec6a66db6b05b63e516"
},
"expected": {
"data": {
"track": "Brother Madonna",
"artists": ["Kitsune²"],
"album": "I Kind of Really Want to Make Love to You",
"duration": 92
},
"meta": {
"source": "Jellyfin",
"event": "PlaybackProgress",
"mediaType": "Audio",
"trackId": "514941104216969e74a8d87fc7893ad5",
"user": "Tabitha",
"sourceVersion": "10.8.10",
"deviceId": "BC9311B570-samsung SM-S901E",
"trackProgressPosition": 64
}
}
}
]
@@ -1,4 +1,55 @@
[
{
"data": {
"track_metadata": {
"track_name": "Instant Crush (feat. Julian Casablancas)",
"artist_name": "Daft Punk",
"release_name": "Random Access Memories",
"additional_info": {
"duration": 338,
"origin_url": "https://music.youtube.com/watch?v=khnokW3Mw24",
"submission_client": "Web Scrobbler",
"music_service_name": "YouTube Music",
"submission_client_version": "3.1.0",
"recording_msid": "878c787e-5dd4-42b4-9c2b-1b53baec7e8b"
},
"mbid_mapping": {
"recording_mbid": "dbc0da50-4bc6-4230-9e7b-136b5dc2b31a",
"recording_name": "Instant Crush",
"release_mbid": "bbfc83ad-826f-4957-893d-a808105c828b",
"artists": [
{
"artist_mbid": "056e4f3e-d505-4dad-8ec1-d04f521cbb56",
"artist_credit_name": "Daft Punk",
"join_phrase": " feat. "
},
{
"artist_mbid": "c5ce487b-0462-444e-a184-4de0fef7e028",
"artist_credit_name": "Julian Casablancas",
"join_phrase": ""
}
],
"artist_mbids": [
"056e4f3e-d505-4dad-8ec1-d04f521cbb56",
"c5ce487b-0462-444e-a184-4de0fef7e028"
],
"caa_id": 32554171842,
"caa_release_mbid": "5000a285-b67e-4cfc-b54b-2b98f1810d2e"
}
},
"listened_at": 1694479888,
"recording_msid": "878c787e-5dd4-42b4-9c2b-1b53baec7e8b",
"user_name": "kinescou",
"inserted_at": 1694480058
},
"expected": {
"artists": [
"Daft Punk",
"Julian Casablancas"
],
"track": "Instant Crush"
}
},
{
"data": {
"inserted_at": 1692499763,
@@ -1,19 +1,19 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
// correct mappings
import multiArtistInArtistName from './listenbrainz/correctlyMapped/multiArtistInArtistName.json';
import multiArtistsInTrackName from './listenbrainz/correctlyMapped/multiArtistInTrackName.json';
import noArtistMapping from './listenbrainz/correctlyMapped/noArtistMapping.json';
import multiMappedArtistsWithSingleUserArtist from './listenbrainz/correctlyMapped/multiArtistMappingWithSingleRecordedArtist.json';
import artistWithProperJoiner from './listenbrainz/correctlyMapped/artistProperHasJoinerInName.json';
import normalizedValues from './listenbrainz/correctlyMapped/normalizedName.json';
import slightlyDifferentNames from './listenbrainz/correctlyMapped/trackNameSlightlyDifferent.json';
import multiArtistInArtistName from './correctlyMapped/multiArtistInArtistName.json';
import multiArtistsInTrackName from './correctlyMapped/multiArtistInTrackName.json';
import noArtistMapping from './correctlyMapped/noArtistMapping.json';
import multiMappedArtistsWithSingleUserArtist from './correctlyMapped/multiArtistMappingWithSingleRecordedArtist.json';
import artistWithProperJoiner from './correctlyMapped/artistProperHasJoinerInName.json';
import normalizedValues from './correctlyMapped/normalizedName.json';
import slightlyDifferentNames from './correctlyMapped/trackNameSlightlyDifferent.json';
// incorrect mappings
import incorrectMultiArtistsTrackName from './listenbrainz/incorrectlyMapped/multiArtistsInTrackName.json';
import veryWrong from './listenbrainz/incorrectlyMapped/veryWrong.json';
import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTrackName.json';
import veryWrong from './incorrectlyMapped/veryWrong.json';
import {ListenbrainzApiClient, ListenResponse} from "../common/vendor/ListenbrainzApiClient";
import {ListenbrainzApiClient, ListenResponse} from "../../common/vendor/ListenbrainzApiClient";
interface ExpectedResults {
artists: string[]
+357
View File
@@ -0,0 +1,357 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import {generatePlay} from "../utils/PlayTestUtils";
import {GenericPlayerState} from "../../sources/PlayerState/GenericPlayerState";
import {getLogger} from "../../common/logging";
import {
CALCULATED_PLAYER_STATUSES,
NO_DEVICE,
NO_USER,
REPORTED_PLAYER_STATUSES
} from "../../common/infrastructure/Atomic";
import {playObjDataMatch} from "../../utils";
import dayjs from "dayjs";
import clone from "clone";
const logger = getLogger({});
const newPlay = generatePlay({duration: 300});
describe('Basic player state', function () {
it('Creates new play state when new', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
assert.isUndefined(player.currentListenRange);
assert.isUndefined(player.currentPlay);
player.setState(undefined, newPlay);
assert.isDefined(player.currentListenRange);
assert.isDefined(player.currentPlay);
});
it('Creates new play state in unknown status', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
assert.isUndefined(player.currentListenRange);
assert.isUndefined(player.currentPlay);
player.setState(undefined, newPlay);
assert.isDefined(player.currentListenRange);
assert.isDefined(player.currentPlay);
assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
});
it('Creates new play state when incoming play is not the same as stored play', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(undefined, newPlay);
assert.isTrue(playObjDataMatch(player.currentPlay, newPlay));
const nextPlay = generatePlay({playDate: newPlay.data.playDate.add(2, 'seconds')});
const [returnedPlay, prevPlay] = player.setState(undefined, nextPlay);
assert.isTrue(playObjDataMatch(prevPlay, newPlay));
assert.isTrue(playObjDataMatch(player.currentPlay, nextPlay));
});
});
describe('Player status', function () {
it('New player transitions from unknown to playing on n+1 states', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(undefined, newPlay);
assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
player.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
describe('When source provides reported status', function () {
it('Calculated state is playing when source reports playing', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
it('Calculated state is paused when source reports paused', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
});
it('Calculated state is stopped when source reports stopped', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.stopped, newPlay, dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.stopped, player.calculatedStatus);
});
});
describe('When source provides playback position', function () {
it('Calculated state is playing when position moves forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
it('Calculated state is paused when position does not change', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
});
// TODO playback position reported and conflicts with player reported status
});
});
describe('Player listen ranges', function () {
describe('When source does not provide playback position', function () {
it('Duration is timestamp based for unknown/playing reported players', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
assert.equal(player.getListenDuration(), 20);
const uplayer = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
uplayer.setState(undefined, newPlay);
uplayer.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
uplayer.setState(undefined, newPlay, dayjs().add(20, 'seconds'));
assert.equal(uplayer.getListenDuration(), 20);
});
it('Range ends if player reports paused', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 20);
});
it('Listen duration continues when player resumes', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
// For TS-only players the player must see two consecutive playing states to count the duration between them
// so it does NOT count above paused ^^ to below playing -- only playing-to-playing
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(50, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(60, 'seconds'));
assert.equal(player.getListenDuration(), 30);
});
});
describe('When source does provide playback position', function () {
it('Duration is position based', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 10;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 7);
});
it('Range ends if position does not move', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 0);
});
it('Range continues when position continues moving forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 7;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 17;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
positioned.meta.trackProgressPosition = 27;
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 24);
});
describe('Detects seeking', function () {
it('Detects seeking forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.equal(player.getListenDuration(), 20);
});
it('Detects seeking backwards', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 20;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.equal(player.getListenDuration(), 20);
});
});
describe('Detects repeating', function () {
it('Detects repeat when player was within 12 seconds of ending and seeked back to within 12 seconds of start', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 70;
positioned.meta.trackProgressPosition = 45;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 55;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 65;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 5;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
it('Detects repeat when player was within 15% of ending and seeked back to within 15% of start', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 300;
positioned.meta.trackProgressPosition = 351;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 361;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 371;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 20;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
it('Detects repeat when player is seeked to start and a heft chunk of the track has already been played', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 70;
positioned.meta.trackProgressPosition = 0;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 10;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 20;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
positioned.meta.trackProgressPosition = 2;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(50, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
});
});
});
+134
View File
@@ -0,0 +1,134 @@
[
{
"data": {
"artists": [
"Kaidi Tatham"
],
"track": "Fricassee",
"duration": 305,
"playDate": "2023-09-20T14:32:39.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Kuna Maze"
],
"track": "Jimbó",
"playDate": "2023-09-20T14:46:00.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Norman Person",
"Shamek Farrah"
],
"track": "Aisha",
"duration": 559,
"playDate": "2023-09-20T15:10:09.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Frédéric Chopin",
"Krystian Zimerman"
],
"track": "Ballade No. 4 in F Minor, Op. 52",
"duration": 575,
"playDate": "2023-09-20T15:17:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Nidia Gongora",
"The Bongo Hop"
],
"track": "Sonora",
"duration": 327,
"playDate": "2023-09-27T20:02:17.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Cheo Feliciano"
],
"track": "Aprieta (Oye Cómo Va)",
"playDate": "2023-09-20T15:38:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Pink Floyd"
],
"track": "Another Brick in the Wall, Pt. 1",
"playDate": "2023-09-20T15:39:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Frédéric Chopin",
"Krystian Zimerman"
],
"track": "Ballade No. 1 in G Minor, Op. 23",
"duration": 575,
"playDate": "2023-09-20T15:41:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"A Collection of Boogie",
"Jazz Funk",
"Disco with Mehdi El"
],
"track": "Aquil",
"playDate": "2023-09-20T17:55:05.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Outkast"
],
"track": "Da Art of Storytellin' (Pt. 1)",
"duration": 422,
"playDate": "2023-09-20T15:39:12.000Z"
},
"meta": {
"source": "Maloja"
}
}
]
+138
View File
@@ -0,0 +1,138 @@
[
{
"data": {
"artists": [
"Kaidi Tatham"
],
"track": "Fricassee",
"duration": 305,
"playDate": "2023-09-20T14:32:39.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Kuna Maze"
],
"track": "Jimbó",
"duration": 255,
"playDate": "2023-09-20T14:46:00.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Norman Person",
"Shamek Farrah"
],
"track": "Aisha",
"duration": 559,
"playDate": "2023-09-20T15:10:09.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Frédéric Chopin",
"Krystian Zimerman"
],
"track": "Ballade No. 4 in F Minor, Op. 52",
"duration": 575,
"playDate": "2023-09-20T15:17:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Nidia Gongora",
"The Bongo Hop"
],
"track": "Sonora",
"duration": 327,
"playDate": "2023-09-27T20:02:17.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Cheo Feliciano"
],
"track": "Aprieta (Oye Cómo Va)",
"duration": 195,
"playDate": "2023-09-20T15:38:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Pink Floyd"
],
"track": "Another Brick in the Wall, Pt. 1",
"duration": 192,
"playDate": "2023-09-20T15:39:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Frédéric Chopin",
"Krystian Zimerman"
],
"track": "Ballade No. 1 in G Minor, Op. 23",
"duration": 575,
"playDate": "2023-09-20T15:41:12.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"A Collection of Boogie",
"Jazz Funk",
"Disco with Mehdi El"
],
"track": "Aquil",
"duration": 3072,
"playDate": "2023-09-20T17:55:05.000Z"
},
"meta": {
"source": "Maloja"
}
},
{
"data": {
"artists": [
"Outkast"
],
"track": "Da Art of Storytellin' (Pt. 1)",
"duration": 422,
"playDate": "2023-09-20T15:39:12.000Z"
},
"meta": {
"source": "Maloja"
}
}
]
@@ -0,0 +1,23 @@
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient";
import {PlayObject} from "../../../core/Atomic";
import {getLogger} from "../../common/logging";
import {Notifiers} from "../../notifier/Notifiers";
import EventEmitter from "events";
export class TestScrobbler extends AbstractScrobbleClient {
constructor() {
const logger = getLogger({});
const notifier = new Notifiers(new EventEmitter(), new EventEmitter(), new EventEmitter());
super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger);
}
doScrobble(playObj: PlayObject): Promise<PlayObject> {
return Promise.resolve(playObj);
}
alreadyScrobbled = async (playObj: PlayObject, log?: boolean): Promise<boolean> => {
return (await this.existingScrobble(playObj)) !== undefined;
}
}
@@ -0,0 +1,392 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import clone from 'clone';
import pEvent from 'p-event';
import withDuration from '../plays/withDuration.json';
import mixedDuration from '../plays/mixedDuration.json';
import {TestScrobbler} from "./TestScrobbler";
import {asPlays, generatePlay, normalizePlays} from "../utils/PlayTestUtils";
import dayjs from "dayjs";
import {sleep} from "../../utils";
const firstPlayDate = dayjs().subtract(1, 'hour');
const withDurPlays = asPlays(withDuration);
const mixedDurPlays = asPlays(mixedDuration);
const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate});
const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate});
const testScrobbler = new TestScrobbler();
testScrobbler.verboseOptions = {
match: {
onMatch: true,
onNoMatch: true,
confidenceBreakdown: true
}
};
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
describe('Detects duplicate and unique scrobbles from client recent history', function () {
describe('When scrobble is unique', function () {
it('It is not detected as duplicate when play date is newer than most recent', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 1].data.playDate.add(70, 'seconds')
});
assert.isFalse(await testScrobbler.alreadyScrobbled(newScrobble));
});
it('It is not detected as duplicate when play date is close to an existing scrobble', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
assert.isFalse(await testScrobbler.alreadyScrobbled(newScrobble));
});
});
describe('When scrobble track/artist/album matches existing but is a new scrobble', function () {
it('Is not detected as duplicate when artist is same, time is similar, but track is different', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const diffPlay = clone(normalizedWithMixedDur[1]);
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
diffPlay.data.track = 'A Totally Different Track'
assert.isFalse(await testScrobbler.alreadyScrobbled(diffPlay));
});
it('Is not detected as duplicate when track is same, time is similar, but artist is different', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const diffPlay = clone(normalizedWithMixedDur[1]);
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
diffPlay.data.artists = ['A Different Artist'];
assert.isFalse(await testScrobbler.alreadyScrobbled(diffPlay));
});
it('Is not detected as duplicate when play date is different by more than 10 seconds (high granularity source)', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffPos.data.playDate = timeOffPos.data.playDate.add(11, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(11, 's');
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffPos));
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffNeg));
});
it('Is not detected as duplicate when play date is different by more than 60 seconds (low granularity source)', async function () {
testScrobbler.recentScrobbles = normalizePlays(mixedDurPlays, {
initialDate: firstPlayDate,
defaultMeta: {source: 'subsonic'}
});
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffPos.data.playDate = timeOffPos.data.playDate.add(61, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(61, 's');
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffPos));
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffNeg));
});
describe('When existing has duration', function () {
it('A track with continuity to the previous track is not detected as a duplicate', async function () {
testScrobbler.recentScrobbles = normalizedWithDur;
const brickPt1 = normalizedWithDur.find(x => x.data.track.includes('Another Brick'));
const brickPt2 = clone(brickPt1);
brickPt2.data.track = 'Another Brick in the Wall, Pt. 2';
brickPt2.data.playDate = brickPt1.data.playDate.add(brickPt1.data.duration + 1, 'seconds');
assert.isFalse(await testScrobbler.alreadyScrobbled(brickPt2));
const story1 = normalizedWithDur.find(x => x.data.track.includes('Da Art of'));
const story2 = clone(story1);
story2.data.track = `Da Art of Storytellin' (Pt. 2)`;
story2.data.playDate = story2.data.playDate.add(story1.data.duration + 1, 'seconds');
assert.isFalse(await testScrobbler.alreadyScrobbled(story2));
const ballad1 = normalizedWithDur.find(x => x.data.track.includes('Ballade No. 1'));
const ballad2 = clone(ballad1);
ballad2.data.track = `Ballade No. 2 in G Minor, Op. 27`;
ballad2.data.playDate = ballad2.data.playDate.add(ballad1.data.duration + 1, 'seconds');
assert.isFalse(await testScrobbler.alreadyScrobbled(ballad2));
});
});
});
describe('When scrobble is a duplicate (title/artists/album)', function () {
it('Is detected as duplicate when an exact match', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
assert.isTrue(await testScrobbler.alreadyScrobbled(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]));
});
it('Is detected as duplicate when artist/title differences are whitespace or case', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const ref = normalizedWithMixedDur[3];
const diffPlay = clone(ref);
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
diffPlay.data.track = ref.data.track.toUpperCase();
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
diffPlay.data.track = ` ${ref.data.track} `;
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
diffPlay.data.track = ref.data.track.replaceAll(' ', ' ');
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
diffPlay.data.artists = ref.data.artists.map(x => x.toUpperCase());
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
diffPlay.data.artists = ref.data.artists.map(x => x.replaceAll(' ', ' '));
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
});
it('Is detected as duplicate when artist/title differences are from unicode normalization', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const ref = normalizedWithMixedDur.find(x => x.data.track === 'Jimbó');
const diffPlay = clone(ref);
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
diffPlay.data.track = 'Jimbo';
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
});
it('Is detected as duplicate when play date is off by less than 10 seconds (high granularity source)', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffPos.data.playDate = timeOffPos.data.playDate.add(10, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(10, 's');
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffPos));
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffNeg));
});
it('Is detected as duplicate when play date is off by less than 60 seconds (low granularity source)', async function () {
testScrobbler.recentScrobbles = normalizePlays(mixedDurPlays, {
initialDate: firstPlayDate,
defaultMeta: {source: 'subsonic'}
});
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffPos.data.playDate = timeOffPos.data.playDate.add(59, 's');
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(59, 's');
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffPos));
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffNeg));
});
it('Is detected as duplicate when title is exact, artist is similar, and time is similar', async function () {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
const ref = normalizedWithMixedDur[3];
const diffPlay = clone(ref);
diffPlay.data.playDate = diffPlay.data.playDate.add(3, 's');
diffPlay.data.artists = [diffPlay.data.artists[0]]
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
diffPlay.data.artists = [ref.data.artists[1]]
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
const son = normalizedWithMixedDur.find(x => x.data.track === 'Sonora')
const sonDiffPlay = clone(son);
sonDiffPlay.data.playDate = sonDiffPlay.data.playDate.subtract(son.data.duration + 1, 's');
sonDiffPlay.data.artists = [sonDiffPlay.data.artists[1]]
assert.isTrue(await testScrobbler.alreadyScrobbled(sonDiffPlay));
});
describe('When at least one play has duration', function () {
it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () {
testScrobbler.recentScrobbles = normalizedWithDur;
const timeEnd = clone(normalizedWithDur[normalizedWithMixedDur.length - 1]);
timeEnd.data.playDate = timeEnd.data.playDate.add(timeEnd.data.duration, 's');
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
// only one has duration
timeEnd.data.duration = undefined;
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
// only one has duration
timeEnd.data.duration = undefined;
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
});
});
});
});
describe('Detects duplicate and unique scrobbles using actively tracked scrobbles', function() {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
it('Detects a unique play', async function() {
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble);
assert.isUndefined(matchedPlay);
assert.isEmpty(matchedData);
});
it('Detects an exact duplicate', async function() {
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble);
assert.isDefined(matchedPlay);
assert.isNotEmpty(matchedData);
});
it('Detects a duplicate with close time', async function() {
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
const dupScrobble = clone(newScrobble);
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(dupScrobble);
assert.isDefined(matchedPlay);
assert.isNotEmpty(matchedData);
});
});
describe('Manages scrobble queue', function() {
testScrobbler.recentScrobbles = normalizedWithMixedDur;
testScrobbler.scrobbleSleep = 500;
testScrobbler.scrobbleDelay = 0;
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
testScrobbler.initScrobbleMonitoring();
it('Scrobbles a uniquely queued play', async function() {
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
testScrobbler.queueScrobble(newScrobble, 'test');
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(3000)]);
assert.isDefined(res);
assert.isDefined(res.data);
});
it('Does not Scrobble a duplicate play queued after original is scrobbled', async function() {
this.timeout(3500);
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
const dupScrobble = clone(newScrobble);
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
testScrobbler.queueScrobble(newScrobble, 'test');
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res);
assert.isDefined(res.data);
testScrobbler.queueScrobble(dupScrobble, 'test');
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
assert.isUndefined(resDup);
});
it('Does not Scrobble a duplicate play queued before original is scrobbled', async function() {
this.timeout(3500);
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
const dupScrobble = clone(newScrobble);
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
testScrobbler.queueScrobble(newScrobble, 'test');
testScrobbler.queueScrobble(dupScrobble, 'test');
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res);
assert.isDefined(res.data);
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
assert.isUndefined(resDup);
});
it('Delays scrobbles when many are queued', async function () {
this.timeout(3500);
const newScrobble1 = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
const newScrobble2 = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(80, 'seconds')
});
const newScrobble3 = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(160, 'seconds')
});
testScrobbler.scrobbleDelay = 600;
testScrobbler.queueScrobble([newScrobble1, newScrobble2, newScrobble3], 'test');
await pEvent(testScrobbler.emitter, 'scrobble');
const initial = dayjs();
await pEvent(testScrobbler.emitter, 'scrobble');
await pEvent(testScrobbler.emitter, 'scrobble');
const end = dayjs();
// roughly...
assert.closeTo(end.diff(initial, 'ms'), 1200, 200);
});
});
+76
View File
@@ -0,0 +1,76 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import {compareNormalizedStrings} from "../utils/StringUtils";
describe('String Comparisons', function () {
it('should ignore punctuation', async function () {
const result = compareNormalizedStrings('this string! is the. same', 'this string is the same');
assert.isAtLeast(result.highScore, 100);
});
it('should ignore whitespace', async function () {
const result = compareNormalizedStrings('this string is the same ', 'this string is the same');
assert.isAtLeast(result.highScore, 100);
});
it('should ignore case', async function () {
const result = compareNormalizedStrings('ThIs STRING iS THe SAMe', 'this string is the same');
assert.isAtLeast( result.highScore, 100);
});
it('should normalize unicode', async function () {
const tests = [
['Enamorate Bailando','Enamórate Bailando'],
['Dina Ogon', 'Dina Ögon'],
['Nana', 'Nanã'],
['Nana', 'Nanä']
]
for(const test of tests) {
const result = compareNormalizedStrings(test[0], test[1]);
assert.equal(100, result.highScore);
}
});
it('should score small changes correctly', async function () {
const tests = [
['there is change', 'therr is change'],
['laser cannon', 'lazer cannon'],
['Monster', 'Manster'],
['What are you doing', 'hwat are you doing']
]
for(const test of tests) {
const result = compareNormalizedStrings(test[0], test[1]);
assert.isAtLeast( result.highScore, 75, `Comparing: '${test[0]}' | '${test[1]}'`);
}
});
it('should score word bags correctly', async function () {
const tests = [
['there change is', 'there is change'],
['laser cannon', 'cannon laser'],
['One two three four', 'One four two threee'],
['you, doin hwat are', 'hwat are you doing'],
['My Track (feat. Tourist1, Artist2)', 'My Track (feat. Artist2, Tourist1)']
]
for(const test of tests) {
const result = compareNormalizedStrings(test[0], test[1]);
assert.isAtLeast( result.highScore, 80, `Comparing: '${test[0]}' | '${test[1]}'`);
}
});
it('should not score very similar as identical', async function () {
const tests = [
['Another Brick in the Wall, Pt. 1', 'Another Brick in the Wall, Pt. 2'],
]
for(const test of tests) {
const result = compareNormalizedStrings(test[0], test[1]);
assert.isAtMost( result.highScore, 99, `Comparing: '${test[0]}' | '${test[1]}'`);
}
});
});
+95
View File
@@ -0,0 +1,95 @@
import {JsonPlayObject, ObjectPlayData, PlayData, PlayMeta, PlayObject} from "../../../core/Atomic";
import dayjs, {Dayjs} from "dayjs";
import utc from "dayjs/plugin/utc.js";
import isBetween from "dayjs/plugin/isBetween.js";
import relativeTime from "dayjs/plugin/relativeTime.js";
import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {faker} from '@faker-js/faker';
dayjs.extend(utc)
dayjs.extend(isBetween);
dayjs.extend(relativeTime);
dayjs.extend(duration);
dayjs.extend(timezone);
export const asPlays = (data: object[]): PlayObject[] => {
return data.map(x => {
const y = x as JsonPlayObject;
return {
...y,
data: {
...y.data,
playDate: dayjs(y.data.playDate)
}
}
});
}
export const normalizePlays = (plays: PlayObject[],
options?: {
initialDate?: Dayjs,
defaultDuration?: number,
defaultData?: ObjectPlayData,
defaultMeta?: PlayMeta
}
): PlayObject[] => {
const {
initialDate,
defaultDuration = 180,
defaultData = {},
defaultMeta = {}
} = options || {};
let date = initialDate;
if (date === undefined) {
const firstDatedPlay = plays.find(x => x.data.playDate !== undefined);
if (firstDatedPlay !== undefined) {
date = firstDatedPlay.data.playDate;
} else {
throw new Error('No initial date specified and no play had a defined date');
}
}
let lastTrackEndsAt: Dayjs | undefined = undefined;
const normalizedPlays: PlayObject[] = [];
for (const play of plays) {
const cleanPlay = {...play};
if (lastTrackEndsAt === undefined) {
// first track
cleanPlay.data.playDate = date;
} else {
cleanPlay.data.playDate = lastTrackEndsAt.add(1, 'second');
}
cleanPlay.data = {
...cleanPlay.data,
...defaultData
}
cleanPlay.meta = {
...cleanPlay.meta,
...defaultMeta
}
lastTrackEndsAt = cleanPlay.data.playDate.add(cleanPlay.data.duration ?? defaultDuration, 'seconds');
normalizedPlays.push(cleanPlay);
}
return normalizedPlays;
}
export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => {
return {
data: {
track: faker.music.songName(),
artists: faker.helpers.multiple(faker.person.fullName, {count: {min: 1, max: 2}}),
duration: faker.number.int({min: 30, max: 300}),
playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})),
album: faker.music.songName(),
...data
},
meta: {
source: faker.word.noun(),
...meta,
}
}
}
+2 -1
View File
@@ -8,6 +8,7 @@
"module": "commonjs",
"target": "es2020",
"esModuleInterop": true,
"skipLibCheck": true,
"typeRoots": [
"../../node_modules/@types",
@@ -23,7 +24,7 @@
"exclude": [
"../../node_modules",
"../../coverage",
"../../tests/*.ts",
"./tests/**/*",
"../../_site",
"../../docsite",
"../../build",

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