mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd996eb46 | ||
|
|
a6ddc2b9f5 | ||
|
|
afb2e76b2d | ||
|
|
47812546e3 | ||
|
|
9709d6348a | ||
|
|
10fea9b23d | ||
|
|
051dd5d128 | ||
|
|
369e18b1c2 | ||
|
|
fd49d17de4 | ||
|
|
e0f034fa99 | ||
|
|
80bc629ac2 | ||
|
|
1a90c23e76 | ||
|
|
48ddd4272f | ||
|
|
ea408ac7a4 | ||
|
|
79c48af3e9 | ||
|
|
12c2c0a132 | ||
|
|
78abfc2168 | ||
|
|
0f721b94c2 | ||
|
|
f126bf0ec0 | ||
|
|
064a435c79 | ||
|
|
c0e5bb872a | ||
|
|
e9561b5783 | ||
|
|
77c3a2f0c7 | ||
|
|
db4e64dc2a | ||
|
|
fcc05a8b19 | ||
|
|
c37d94e71b | ||
|
|
aa59bc4607 | ||
|
|
a993df4f0b | ||
|
|
7e83e0f085 | ||
|
|
5b2449adb0 | ||
|
|
5811912745 | ||
|
|
b7d6e9e497 | ||
|
|
5fd0cc3fe4 | ||
|
|
6e2e6f4492 | ||
|
|
ed6f7cfe5f | ||
|
|
6b17babd7a | ||
|
|
c77e4355fc | ||
|
|
25de65f41f | ||
|
|
af037ee215 | ||
|
|
39c4764b40 |
@@ -0,0 +1,20 @@
|
||||
## Checklist before requesting a review
|
||||
|
||||
- [ ] **I am opening this PR for the [`develop` branch](https://github.com/FoxxMD/multi-scrobbler/tree/develop) and NOT `master`.**
|
||||
- [ ] I have read the [contributing guidelines.](../CONTRIBUTING.md)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Describe your changes
|
||||
|
||||
|
||||
|
||||
## Issue number and link, if applicable
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Creating a Pull Request
|
||||
|
||||
Please follow these guidelines when contributing code to this repository:
|
||||
|
||||
* The PR **must be for the [`develop` branch.](https://github.com/FoxxMD/multi-scrobbler/tree/develop)** The `master` branch is for releases only.
|
||||
* Use [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format when creating commits.
|
||||
* Preferably, please use a [feature branch](https://stackoverflow.com/a/39586780/1469797) instead of committing directly to `develop`.
|
||||
* Ensure that if your code is covered by [an existing test](./src/backend/tests) that you have updated the test accordingly
|
||||
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "MyWebScrobbler",
|
||||
"slug": null,
|
||||
"data": {
|
||||
"slug": null,
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ There is also a [**kitchensink example**](kitchensink.md) that provides examples
|
||||
|
||||
### All-in-One File Configuration
|
||||
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
[**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
Example directory structure:
|
||||
|
||||
@@ -203,7 +203,7 @@ Client ID/Secret. Make sure to also whitelist your redirect URI in the applicati
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`spotify.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/spotify.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSpotifySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Plex](https://plex.tv)
|
||||
|
||||
@@ -217,7 +217,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`plex.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/plex.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FPlexSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Tautulli](https://tautulli.com)
|
||||
|
||||
@@ -231,7 +231,7 @@ Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/tautulli.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FTautulliSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Subsonic](http://www.subsonic.org/)
|
||||
|
||||
@@ -254,7 +254,7 @@ Can use this source for any application that implements the [Subsonic API](http:
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`subsonic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/subsonic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubSonicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
@@ -284,7 +284,7 @@ If you see errors in the MS logs regarding `missing headers` when using Jellyfin
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jellyfin.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FJellySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
@@ -296,7 +296,7 @@ No support for ENV based for Last.fm as a client (only source)
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Listenbrainz (Source)](https://listenbrainz.org)
|
||||
|
||||
@@ -308,7 +308,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
**Change `configureAs` to `source`**
|
||||
|
||||
@@ -340,7 +340,7 @@ After starting multi-scrobbler with credentials in-place open the dashboard (`ht
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`deezer.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/deezer.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FDeezerSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Youtube Music](https://music.youtube.com)
|
||||
|
||||
@@ -366,7 +366,7 @@ NOTES:
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`ytmusic.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/)
|
||||
|
||||
@@ -390,7 +390,7 @@ multi-scrobbler can listen to this interface and scrobble tracks played by **any
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`mpris.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Mopidy](https://mopidy.com/)
|
||||
|
||||
@@ -400,7 +400,7 @@ multi-scrobbler can scrobble tracks played from any Mopidy backend source, regar
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`mopidy.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
Configuration Options:
|
||||
|
||||
@@ -557,7 +557,7 @@ MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`jriver.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [Kodi](https://kodi.tv/)
|
||||
|
||||
@@ -625,7 +625,7 @@ MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`kodi.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
## [WebScrobbler](https://web-scrobbler.com/)
|
||||
|
||||
@@ -678,6 +678,10 @@ In `webscrobbler.json`
|
||||
|
||||
Note: `http://localhost:9078/api/webscrobbler` is matched with the first source that _that does not have a slug defined._
|
||||
|
||||
##### Connectors Black/Whitelist
|
||||
|
||||
MS can be configured to only scrobble, or NOT scrobble, from some WS connectors. Use the name of the website from the [supported websites](https://web-scrobbler.com/) or from the **Connectors** tab in the extension. Note that this **only** affects MS's behavior and does not affect the general connector behavior you have configured within the WebScrobbler extension.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
@@ -688,7 +692,7 @@ Note: `http://localhost:9078/api/webscrobbler` is matched with the first source
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/webscrobbler.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FWebScrobblerSourceConfig/%23%2Fdefinitions%2FWebScrobblerData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
|
||||
See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/webscrobbler.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FWebScrobblerSourceConfig/%23%2Fdefinitions%2FWebScrobblerData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json)
|
||||
|
||||
# Client Configurations
|
||||
|
||||
@@ -703,7 +707,7 @@ See [`webscrobbler.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`maloja.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/maloja.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMalojaClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [Last.fm](https://www.last.fm)
|
||||
|
||||
@@ -726,7 +730,7 @@ or replace `localhost:9078` with your own base URL
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`lastfm.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
## [Listenbrainz](https://listenbrainz.org)
|
||||
|
||||
@@ -745,7 +749,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
|
||||
### File-Based
|
||||
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json)
|
||||
See [`listenbrainz.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json)
|
||||
|
||||
# Monitoring
|
||||
|
||||
@@ -794,7 +798,7 @@ Webhooks are configured in the main [config.json](#all-in-one-file-configuration
|
||||
|
||||
### [Gotify](https://gotify.net/)
|
||||
|
||||
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
@@ -816,7 +820,7 @@ EX
|
||||
|
||||
### [Ntfy](https://ntfy.sh/)
|
||||
|
||||
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Faio.json)
|
||||
Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json)
|
||||
|
||||
multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings.
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.6.3" date="2024-01-10"/>
|
||||
<release version="0.6.2" date="2023-11-29"/>
|
||||
<release version="0.6.1" date="2023-10-24"/>
|
||||
<release version="0.6.0" date="2023-10-04"/>
|
||||
<release version="0.5.2" date="2023-09-21"/>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"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",
|
||||
@@ -12,7 +12,7 @@
|
||||
"typedoc": "typedoc",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "react-scripts test",
|
||||
"test:backend": "mocha --extension ts --reporter spec --recursive src/backend/tests/scrobbler/**/*.test.ts",
|
||||
"test:backend": "mocha --extension ts --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"eject": "react-scripts eject",
|
||||
"dev": "concurrently -p name -c \"yellow,magenta,blue\" -n \"webpack-server,nodemon-server,CRA\" \"npm run dev:server:webpack\" \"npm run dev:server:nodemon\" \"npm run dev:client\"",
|
||||
"dev:client": "BROWSER=none REACT_APP_VERSION=$npm_package_version react-scripts start",
|
||||
|
||||
+3
-1
@@ -21,5 +21,7 @@ tsNode.register({
|
||||
project: './src/backend/tsconfig.json'
|
||||
});
|
||||
|
||||
process.env.CONSOLE_LEVEL = parseBool(process.env.DEBUG_MODE) ? undefined : 'false';
|
||||
if(!parseBool(process.env.DEBUG_MODE)) {
|
||||
process.env.CONSOLE_LEVEL = 'false';
|
||||
}
|
||||
process.env.FILE_LEVEL = 'false';
|
||||
|
||||
@@ -1335,8 +1335,15 @@
|
||||
"LogOptions": {
|
||||
"properties": {
|
||||
"console": {
|
||||
"$ref": "#/definitions/LogLevel",
|
||||
"description": "Specify the minimum log level streamed to the console (or docker container)",
|
||||
"enum": [
|
||||
"debug",
|
||||
"error",
|
||||
false,
|
||||
"info",
|
||||
"verbose",
|
||||
"warn"
|
||||
],
|
||||
"title": "console"
|
||||
},
|
||||
"file": {
|
||||
|
||||
+6
-4
@@ -6,6 +6,8 @@ import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions} from "../infrastructu
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import {isNodeNetworkException} from "../errors/NodeErrors";
|
||||
import {nonEmptyStringOrDefault, splitByFirstFound} from "../../../core/StringUtils";
|
||||
import {source} from "common-tags";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -61,7 +63,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
mbid,
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
|
||||
let artistStrings = splitByFirstFound(artists, [','], [artistName]);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
@@ -71,9 +73,9 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
playDate: time !== undefined ? dayjs.unix(time) : undefined,
|
||||
meta: {
|
||||
brainz: {
|
||||
album: albumMbid === '' ? undefined : albumMbid,
|
||||
artist: artistMbid === '' ? undefined : artistMbid,
|
||||
track: mbid === '' ? undefined : mbid
|
||||
album: nonEmptyStringOrDefault<undefined>(albumMbid),
|
||||
artist: splitByFirstFound<undefined>(artistMbid, [',',';'], undefined),
|
||||
track: nonEmptyStringOrDefault<undefined>(mbid)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+37
-2
@@ -15,6 +15,8 @@ import {
|
||||
parseTrackCredits,
|
||||
uniqueNormalizedStrArr
|
||||
} from "../../utils/StringUtils";
|
||||
import {UpstreamError} from "../errors/UpstreamError";
|
||||
import {getScrobbleTsSOCDate} from "../../utils/TimeUtils";
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -137,7 +139,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
err,
|
||||
status,
|
||||
response: {
|
||||
body = undefined,
|
||||
text = undefined,
|
||||
} = {}
|
||||
} = e;
|
||||
// TODO check err for network exception
|
||||
if(status !== undefined) {
|
||||
const msgParts = [`(HTTP Status ${status})`];
|
||||
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
|
||||
let showStopper = status !== 400;
|
||||
if(body !== undefined) {
|
||||
if(typeof body === 'object') {
|
||||
if('code' in body) {
|
||||
msgParts.push(`Code ${body.code}`);
|
||||
}
|
||||
if('error' in body) {
|
||||
msgParts.push(`Error => ${body.error}`);
|
||||
}
|
||||
if('message' in body) {
|
||||
msgParts.push(`Message => ${body.error}`);
|
||||
}
|
||||
// if('track_metadata' in body) {
|
||||
// msgParts.push(`Track Metadata => ${JSON.stringify(body.track_metadata)}`);
|
||||
// }
|
||||
} else if(typeof body === 'string') {
|
||||
msgParts.push(`Response => ${body}`);
|
||||
}
|
||||
} else if (text !== undefined) {
|
||||
msgParts.push(`Response => ${text}`);
|
||||
}
|
||||
throw new UpstreamError(`Listenbrainz API Request Failed => ${msgParts.join(' | ')}`, {cause: e, showStopper});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -241,7 +276,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
} = play;
|
||||
return {
|
||||
listened_at: (playDate ?? dayjs()).unix(),
|
||||
listened_at: getScrobbleTsSOCDate(play).unix(),
|
||||
track_metadata: {
|
||||
artist_name: artists[0],
|
||||
track_name: track,
|
||||
@@ -249,7 +284,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
additional_info: {
|
||||
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
|
||||
track_mbid: brainz.track,
|
||||
artist_mbids: brainz.artist !== undefined ? [brainz.artist] : undefined,
|
||||
artist_mbids: brainz.artist,
|
||||
release_mbid: brainz.album,
|
||||
release_group_mbid: brainz.releaseGroup
|
||||
}
|
||||
|
||||
+10
-25
@@ -80,7 +80,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
initServer(logger, output);
|
||||
|
||||
if(process.env.IS_LOCAL === 'true') {
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://github.com/FoxxMD/multi-scrobbler/blob/develop/docs/service.md');
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://foxxmd.github.io/multi-scrobbler/docs/installation/service');
|
||||
}
|
||||
|
||||
if(appConfigFail !== undefined) {
|
||||
@@ -98,6 +98,11 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
await scrobbleClients.buildClientsFromConfig(notifiers);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
} else {
|
||||
logger.info('Starting scrobble clients...');
|
||||
}
|
||||
for(const client of scrobbleClients.clients) {
|
||||
await client.initScrobbleMonitoring();
|
||||
}
|
||||
|
||||
const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir);
|
||||
@@ -116,30 +121,10 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
let anyNotReady = false;
|
||||
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
|
||||
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if ((source as SpotifySource).spotifyApi !== undefined) {
|
||||
if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) {
|
||||
anyNotReady = true;
|
||||
} else {
|
||||
(source as SpotifySource).poll();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
if(source.initialized === true) {
|
||||
source.poll();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (source.poll !== undefined) {
|
||||
source.poll();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(const client of scrobbleClients.clients) {
|
||||
if((await client.isReady())) {
|
||||
client.initScrobbleMonitoring();
|
||||
if(source.isReady()) {
|
||||
source.poll();
|
||||
} else {
|
||||
anyNotReady = true;
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
comparingMultipleArtists,
|
||||
isPlayTemporallyClose,
|
||||
mergeArr,
|
||||
playObjDataMatch, pollingBackoff,
|
||||
setIntersection, sleep, sortByOldestPlayDate,
|
||||
@@ -20,7 +19,13 @@ import {Logger} from '@foxxmd/winston';
|
||||
import { CommonClientConfig } from "../common/infrastructure/config/client/index";
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import {DeadLetterScrobble, PlayObject, QueuedScrobble, SourceScrobble, TrackStringOptions} from "../../core/Atomic";
|
||||
import {
|
||||
DeadLetterScrobble,
|
||||
PlayObject,
|
||||
QueuedScrobble,
|
||||
TA_CLOSE, TA_FUZZY,
|
||||
TrackStringOptions
|
||||
} from "../../core/Atomic";
|
||||
import {buildTrackString, capitalize, truncateStringToLength} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {compareScrobbleArtists, compareScrobbleTracks, normalizeStr} from "../utils/StringUtils";
|
||||
@@ -28,6 +33,12 @@ import {hasUpstreamError, UpstreamError} from "../common/errors/UpstreamError";
|
||||
import {nanoid} from "nanoid";
|
||||
import {ErrorWithCause, messageWithCauses} from "pony-cause";
|
||||
import {hasNodeNetworkException} from "../common/errors/NodeErrors";
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
temporalAccuracyToString,
|
||||
temporalPlayComparisonSummary
|
||||
} from "../utils/TimeUtils";
|
||||
|
||||
export default abstract class AbstractScrobbleClient implements Authenticatable {
|
||||
|
||||
@@ -147,6 +158,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
this.logger.info('Initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -171,8 +183,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
this.authed = false;
|
||||
this.logger.error(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`);
|
||||
this.logger.error(e);
|
||||
this.logger.error(new ErrorWithCause(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,23 +257,16 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
}
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
|
||||
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x.play, playObj);
|
||||
return closeTime;
|
||||
const temporalComparison = comparePlayTemporally(x.play, playObj);
|
||||
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
}
|
||||
|
||||
protected compareExistingScrobbleTime = (existing: PlayObject, candidate: PlayObject): [boolean, boolean?] => {
|
||||
let closeTime = isPlayTemporallyClose(existing, candidate);
|
||||
let fuzzyTime = false;
|
||||
if(!closeTime) {
|
||||
fuzzyTime = isPlayTemporallyClose(existing, candidate, {fuzzyDuration: true});
|
||||
}
|
||||
return [closeTime, fuzzyTime];
|
||||
}
|
||||
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
return Math.min(compareScrobbleTracks(existing, candidate)/100, 1);
|
||||
const result = compareScrobbleTracks(existing, candidate);
|
||||
return Math.min(result.highScore/100, 1);
|
||||
}
|
||||
|
||||
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => {
|
||||
@@ -290,7 +294,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
// return early if we don't care about checking existing
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
|
||||
this.logger.debug(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -321,7 +325,7 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
// (either user doesnt want to check history or there is no history to check!)
|
||||
if (this.recentScrobbles.length === 0) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
|
||||
this.logger.debug(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -335,8 +339,13 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
//const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
|
||||
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
|
||||
const timeMatch = (closeTime ? 1 : (fuzzyTime ? 0.6 : 0));
|
||||
const temporalComparison = comparePlayTemporally(x, playObj);
|
||||
let timeMatch = 0;
|
||||
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
|
||||
timeMatch = 1;
|
||||
} else if(temporalComparison.match === TA_FUZZY) {
|
||||
timeMatch = 0.6;
|
||||
}
|
||||
|
||||
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
|
||||
|
||||
@@ -377,7 +386,8 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
//`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)}`,
|
||||
`Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
|
||||
`Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
|
||||
];
|
||||
|
||||
@@ -400,10 +410,10 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.confidence}`;
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
|
||||
this.logger.debug(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`, {leaf: ['Dupe Check']});
|
||||
if (this.verboseOptions.match.confidenceBreakdown === true) {
|
||||
this.logger.debug(`Breakdown:
|
||||
${closestMatch.breakdowns.join('\n')}`);
|
||||
${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
}
|
||||
return existingScrobble;
|
||||
@@ -464,7 +474,10 @@ ${closestMatch.breakdowns.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScrobbling();
|
||||
this.startScrobbling().catch((e) => {
|
||||
// do nothing, should have already been caught and logged
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startScrobbling = async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
import {isNodeNetworkException} from "../common/errors/NodeErrors";
|
||||
import {getScrobbleTsSOCDate} from "../utils/TimeUtils";
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -39,7 +40,14 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
initialize = async () => {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
this.initialized = await this.api.initialize();
|
||||
const result = await this.api.initialize();
|
||||
this.initialized = result;
|
||||
if(result) {
|
||||
this.logger.info('Initialized');
|
||||
} else {
|
||||
this.logger.warn('Could not initialize');
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
@@ -156,7 +164,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: playDate.unix(),
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient";
|
||||
import {
|
||||
playObjDataMatch,
|
||||
removeUndefinedKeys,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient";
|
||||
import { FormatPlayObjectOptions, INITIALIZING } from "../common/infrastructure/Atomic";
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
@@ -19,6 +11,7 @@ import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
import {isNodeNetworkException} from "../common/errors/NodeErrors";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -39,14 +32,15 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
if(this.config.data.token === undefined) {
|
||||
this.logger.error('Must provide a User Token');
|
||||
this.logger.error('Could not initialize, must provide a User Token');
|
||||
this.initialized = false;
|
||||
} else {
|
||||
try {
|
||||
await this.api.testConnection();
|
||||
this.initialized = true;
|
||||
this.logger.info('Initialized');
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
this.logger.warn(new ErrorWithCause('Could not initialize', {cause: e}));
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -114,17 +108,13 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
//await sleep(1000);
|
||||
return playObj;
|
||||
} catch (e) {
|
||||
let message = e.message;
|
||||
if(e.response !== undefined) {
|
||||
if(e.response.body !== undefined) {
|
||||
message = e.response.body.messsage;
|
||||
} else if(e.response.text !== undefined) {
|
||||
message = e.response.text;
|
||||
}
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Failed to scrobble => ${e.message}`, {payload: rawPayload});
|
||||
if(e instanceof UpstreamError) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new UpstreamError(`Error occurred while making Listenbrainz API request: ${e.message}`, {cause: e, showStopper: true});
|
||||
}
|
||||
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 new UpstreamError(`Error received from Listenbrainz API: ${message}`, {cause: e, showStopper: true});
|
||||
} finally {
|
||||
this.logger.debug(`Raw Payload:`, {rawPayload});
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@ import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import compareVersions from 'compare-versions';
|
||||
import {
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
parseRetryAfterSecsFromObj,
|
||||
isPlayTemporallyClose,
|
||||
|
||||
} from "../utils";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, INITIALIZING} from "../common/infrastructure/Atomic";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja";
|
||||
@@ -25,7 +22,8 @@ import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
import {ar} from "@faker-js/faker";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext} from "../utils/TimeUtils";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
@@ -199,8 +197,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Communication test failed');
|
||||
this.logger.error(e);
|
||||
this.logger.error(new ErrorWithCause('Communication test failed', {cause: e}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -246,7 +243,13 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
// just checking that we can get a connection
|
||||
// @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'.
|
||||
this.initialized = INITIALIZING;
|
||||
this.initialized = await this.testConnection();
|
||||
const result = await this.testConnection();
|
||||
this.initialized = result;
|
||||
if(result) {
|
||||
this.logger.info('Initialized');
|
||||
} else {
|
||||
this.logger.warn('Could not initialize');
|
||||
}
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
@@ -375,16 +378,17 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
listenedFor
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
|
||||
|
||||
const scrobbleData: MalojaScrobbleRequestData = {
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
time: pd.unix(),
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
@@ -422,6 +426,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const pd = getScrobbleTsSOCDate(playObj);
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobbleData = this.playToClientPayload(playObj);
|
||||
@@ -446,7 +452,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
if(status === 'success') {
|
||||
if(track !== undefined) {
|
||||
scrobbleResponse = {
|
||||
time: playDate.unix(),
|
||||
time: pd.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
@@ -474,7 +480,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
const {
|
||||
body: {
|
||||
track: {
|
||||
time: mTime = playDate.unix(),
|
||||
time: mTime = pd.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
|
||||
@@ -325,29 +325,7 @@ ${sources.join('\n')}`);
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Client of type ${type} was not recognized??`);
|
||||
}
|
||||
if(newClient.initialized === false) {
|
||||
this.logger.debug(`Attempting ${type} (${name}) initialization...`);
|
||||
if ((await newClient.initialize()) === false) {
|
||||
this.logger.error(`${type} (${name}) client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
|
||||
} else {
|
||||
this.logger.info(`${type} (${name}) client initialized`);
|
||||
}
|
||||
}
|
||||
if(newClient.authGated()) {
|
||||
this.logger.debug(`Checking ${type} (${name}) client auth...`);
|
||||
let success;
|
||||
try {
|
||||
await newClient.testAuth();
|
||||
success = newClient.authed;
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`${type} (${name}) client auth failed.`);
|
||||
} else {
|
||||
this.logger.info(`${type} (${name}) client auth OK`);
|
||||
}
|
||||
}
|
||||
newClient.logger.info('Client Added');
|
||||
this.clients.push(newClient);
|
||||
}
|
||||
|
||||
|
||||
@@ -372,11 +372,28 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
source.logger.info('Source is already polling! Restarting polling...');
|
||||
const stopRes = await source.tryStopPolling();
|
||||
if(stopRes === true) {
|
||||
source.startPolling();
|
||||
source.poll();
|
||||
}
|
||||
} else {
|
||||
source.poll();
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/client/init', clientRequiredMiddle);
|
||||
app.postAsync('/api/client/init', 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 client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
|
||||
logger.info('Checking (and trying) to stop scrobbler if already running...');
|
||||
if(false === (await client.tryStopScrobbling())) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
logger.info('Trying to start scrobbler...');
|
||||
await client.initScrobbleMonitoring();
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.getAsync('/health', async function(req, res) {
|
||||
return res.redirect(307, `/api/${req.url.slice(1)}`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
isPlayTemporallyClose,
|
||||
genGroupId,
|
||||
genGroupIdStrFromPlay,
|
||||
mergeArr,
|
||||
@@ -30,10 +29,11 @@ import { SourceConfig } from "../common/infrastructure/config/source/sources";
|
||||
import {EventEmitter} from "events";
|
||||
import {FixedSizeList} from "fixed-size-list";
|
||||
import TupleMap from "../common/TupleMap";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import {PlayObject, TA_CLOSE} from "../../core/Atomic";
|
||||
import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import {isNodeNetworkException} from "../common/errors/NodeErrors";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {comparePlayTemporally, temporalAccuracyIsAtLeast} from "../utils/TimeUtils";
|
||||
|
||||
export interface RecentlyPlayedOptions {
|
||||
limit?: number
|
||||
@@ -123,6 +123,10 @@ export default abstract class AbstractSource implements Authenticatable {
|
||||
}
|
||||
}
|
||||
|
||||
public isReady() {
|
||||
return this.initialized && !this.authGated();
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
return [];
|
||||
}
|
||||
@@ -176,7 +180,7 @@ export default abstract class AbstractSource implements Authenticatable {
|
||||
});
|
||||
}
|
||||
for(const list of lists) {
|
||||
const existing = list.find(x => playObjDataMatch(x, play) && isPlayTemporallyClose(x, play));
|
||||
const existing = list.find(x => playObjDataMatch(x, play) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(x, play).match));
|
||||
if(existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import MemorySource from "./MemorySource";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
isPlayTemporallyClose,
|
||||
combinePartsToString,
|
||||
parseBool,
|
||||
parseDurationFromTimestamp,
|
||||
playObjDataMatch,
|
||||
comparePlayTemporally,
|
||||
temporalPlayComparisonSummary,
|
||||
doubleReturnNewline,
|
||||
} from "../utils";
|
||||
import { JellySourceConfig } from "../common/infrastructure/config/source/jellyfin";
|
||||
@@ -16,8 +13,14 @@ import EventEmitter from "events";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import { JellyfinPlayerState } from "./PlayerState/JellyfinPlayerState";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils";
|
||||
import {PlayObject, TA_CLOSE} from "../../core/Atomic";
|
||||
import {buildTrackString, splitByFirstFound, truncateStringToLength} from "../../core/StringUtils";
|
||||
import {source} from "common-tags";
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
temporalPlayComparisonSummary
|
||||
} from "../utils/TimeUtils";
|
||||
|
||||
const shortDeviceId = truncateStringToLength(10, '');
|
||||
|
||||
@@ -158,7 +161,7 @@ export default class JellyfinSource extends MemorySource {
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
artist: Provider_musicbrainzartist,
|
||||
artist: splitByFirstFound<undefined>(Provider_musicbrainzartist, [';'], undefined),
|
||||
album: Provider_musicbrainzalbum,
|
||||
albumArtist: Provider_musicbrainzalbumartist,
|
||||
track: Provider_musicbrainztrack,
|
||||
@@ -309,7 +312,7 @@ export default class JellyfinSource extends MemorySource {
|
||||
|
||||
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
|
||||
}
|
||||
if(temporalResult.close) {
|
||||
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
|
||||
existingTracked = currPlay;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
sortByOldestPlayDate,
|
||||
toProgressAwarePlayObject,
|
||||
getProgress,
|
||||
playPassesScrobbleThreshold,
|
||||
timePassesScrobbleThreshold,
|
||||
thresholdResultSummary,
|
||||
genGroupId,
|
||||
genGroupIdStr,
|
||||
@@ -32,6 +30,7 @@ import {SimpleIntervalJob, Task, ToadScheduler} from "toad-scheduler";
|
||||
import {SourceConfig} from "../common/infrastructure/config/source/sources";
|
||||
import {EventEmitter} from "events";
|
||||
import objectHash from 'object-hash';
|
||||
import {timePassesScrobbleThreshold} from "../utils/TimeUtils";
|
||||
|
||||
export default class MemorySource extends AbstractSource {
|
||||
|
||||
@@ -58,14 +57,12 @@ export default class MemorySource extends AbstractSource {
|
||||
}
|
||||
|
||||
cleanupPlayers = () => {
|
||||
const deadPlatformIds: string[] = [];
|
||||
const deadPlatformIds: [string, 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});
|
||||
deadPlatformIds.push([player.platformIdStr, `Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`]);
|
||||
} else if (isStale) {
|
||||
const state = player.getApiState();
|
||||
// @ts-ignore
|
||||
@@ -79,8 +76,8 @@ export default class MemorySource extends AbstractSource {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const deadId of deadPlatformIds) {
|
||||
this.deletePlayer(deadId);
|
||||
for (const [deadId, reason] of deadPlatformIds) {
|
||||
this.deletePlayer(deadId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +105,13 @@ export default class MemorySource extends AbstractSource {
|
||||
this.playerState.set(idStr, '');
|
||||
}
|
||||
|
||||
deletePlayer = (id: string) => {
|
||||
deletePlayer = (id: string, reason?: string) => {
|
||||
if(reason !== undefined) {
|
||||
this.players.get(id)?.logger.debug(reason);
|
||||
}
|
||||
this.players.delete(id);
|
||||
this.playerState.delete(id);
|
||||
this.emitEvent('playerDelete', {platformId: id});
|
||||
}
|
||||
|
||||
processRecentPlays = (datas: (PlayObject | PlayerStateData)[]) => {
|
||||
@@ -129,11 +130,17 @@ export default class MemorySource extends AbstractSource {
|
||||
const idStr = genGroupIdStr(id);
|
||||
if (!this.players.has(idStr)) {
|
||||
this.setNewPlayer(idStr, this.logger, id);
|
||||
|
||||
if(!this.multiPlatform && this.players.size > 1) {
|
||||
// new platform should have old platform data transferred
|
||||
const [id,firstPlayer] = Array.from(this.players.entries())[0];
|
||||
const newPlayer = this.players.get(idStr);
|
||||
firstPlayer.transferToNewPlayer(newPlayer);
|
||||
this.deletePlayer(id, 'Removed due to player transfer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//const deadPlatformIds: string[] = [];
|
||||
|
||||
for (const [key, player] of this.players.entries()) {
|
||||
|
||||
let incomingData: PlayObject | PlayerStateData;
|
||||
|
||||
@@ -133,7 +133,7 @@ export class MopidySource extends MemorySource {
|
||||
performers = []
|
||||
} = obj;
|
||||
|
||||
let artists = artistsVal === null ? [] : artistsVal;
|
||||
let artists: models.Artist[] = artistsVal === null ? [] : artistsVal;
|
||||
let album: models.Album = albumVal === null ? {} as models.Album : albumVal;
|
||||
if (this.albumBlacklist.length > 0 && album.name !== undefined && this.albumBlacklist.some(x => album.name.toLocaleLowerCase().includes(x))) {
|
||||
album = {} as models.Album;
|
||||
@@ -145,7 +145,7 @@ export class MopidySource extends MemorySource {
|
||||
artists: albumArtists = []
|
||||
} = album as models.Album;
|
||||
|
||||
let actualAlbumArtists = [];
|
||||
let actualAlbumArtists: models.Artist[] = [];
|
||||
if ((artists.length === 0 || artists.every(x => x.name.toLocaleLowerCase().includes('various'))) && albumArtists.length > 0) {
|
||||
artists = albumArtists;
|
||||
} else {
|
||||
@@ -162,7 +162,7 @@ export class MopidySource extends MemorySource {
|
||||
data: {
|
||||
track: name,
|
||||
album: albumName,
|
||||
albumArtists: actualAlbumArtists,
|
||||
albumArtists: actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : [],
|
||||
artists: artists.length > 0 ? artists.map(x => x.name) : [],
|
||||
duration: Math.round(length / 1000),
|
||||
playDate: dayjs()
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ListenProgress } from "./ListenProgress";
|
||||
import {PlayObject, Second, SourcePlayerObj} from "../../../core/Atomic";
|
||||
import { buildTrackString } from "../../../core/StringUtils";
|
||||
import {ListenRange} from "./ListenRange";
|
||||
import {id} from "common-tags";
|
||||
|
||||
export interface PlayerStateIntervals {
|
||||
staleInterval?: number
|
||||
@@ -31,7 +32,7 @@ export abstract class AbstractPlayerState {
|
||||
playFirstSeenAt?: Dayjs
|
||||
playLastUpdatedAt?: Dayjs
|
||||
currentListenRange?: ListenRange
|
||||
listenRanges: ListenRange[]
|
||||
listenRanges: ListenRange[] = [];
|
||||
createdAt: Dayjs = dayjs();
|
||||
stateLastUpdatedAt: Dayjs = dayjs();
|
||||
|
||||
@@ -104,7 +105,7 @@ export abstract class AbstractPlayerState {
|
||||
} else if (status !== undefined) {
|
||||
if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) {
|
||||
this.stopPlayer();
|
||||
const play = this.getPlayedObject();
|
||||
const play = this.getPlayedObject(true);
|
||||
this.clearPlayer();
|
||||
return [play, play];
|
||||
}
|
||||
@@ -122,11 +123,12 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
if (this.currentPlay !== undefined) {
|
||||
if (!playObjDataMatch(this.currentPlay, play)/* || (true !== false)*/) { // TODO check new play date and listen range to see if they intersect
|
||||
const currentPlayMatches = playObjDataMatch(this.currentPlay, play);
|
||||
if (!currentPlayMatches) { // TODO check new play date and listen range to see if they intersect
|
||||
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, undefined, reportedTS);
|
||||
const played = this.getPlayedObject(true);
|
||||
this.setCurrentPlay(play, {reportedTS});
|
||||
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
@@ -137,9 +139,9 @@ export abstract class AbstractPlayerState {
|
||||
} 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();
|
||||
const played = this.getPlayedObject(true);
|
||||
play.data.playDate = dayjs();
|
||||
this.setCurrentPlay(play, undefined, reportedTS);
|
||||
this.setCurrentPlay(play, {reportedTS});
|
||||
return [this.getPlayedObject(), played];
|
||||
} else {
|
||||
if(this.currentListenRange !== undefined) {
|
||||
@@ -179,18 +181,22 @@ export abstract class AbstractPlayerState {
|
||||
this.currentListenSessionEnd();
|
||||
}
|
||||
|
||||
getPlayedObject(): PlayObject | undefined {
|
||||
public getPlayedObject(completed: boolean = false): PlayObject | undefined {
|
||||
if(this.currentPlay !== undefined) {
|
||||
let ranges = [...this.listenRanges];
|
||||
if (this.currentListenRange !== undefined) {
|
||||
ranges.push(this.currentListenRange);
|
||||
}
|
||||
if(completed) {
|
||||
this.logger.debug('Generating play object with playDateCompleted');
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
...this.currentPlay.data,
|
||||
playDate: this.playFirstSeenAt,
|
||||
listenedFor: this.getListenDuration(),
|
||||
listenRanges: ranges
|
||||
listenRanges: ranges,
|
||||
playDateCompleted: completed ? dayjs() : undefined
|
||||
},
|
||||
meta: this.currentPlay.meta
|
||||
}
|
||||
@@ -198,7 +204,7 @@ export abstract class AbstractPlayerState {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getListenDuration(): Second{
|
||||
public getListenDuration(): Second{
|
||||
let listenDur: number = 0;
|
||||
let ranges = [...this.listenRanges];
|
||||
if (this.currentListenRange !== undefined) {
|
||||
@@ -322,7 +328,14 @@ export abstract class AbstractPlayerState {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs) {
|
||||
protected setCurrentPlay(play: PlayObject, options?: CurrentPlayOptions) {
|
||||
|
||||
const {
|
||||
status,
|
||||
reportedTS,
|
||||
listenSessionManaged = true
|
||||
} = options || {};
|
||||
|
||||
this.currentPlay = play;
|
||||
this.playFirstSeenAt = dayjs();
|
||||
this.listenRanges = [];
|
||||
@@ -334,12 +347,12 @@ export abstract class AbstractPlayerState {
|
||||
this.reportedStatus = status;
|
||||
}
|
||||
|
||||
if (!['stopped'].includes(this.reportedStatus)) {
|
||||
if (listenSessionManaged && !['stopped'].includes(this.reportedStatus)) {
|
||||
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
|
||||
}
|
||||
}
|
||||
|
||||
textSummary() {
|
||||
public textSummary() {
|
||||
let parts = [''];
|
||||
let play: string;
|
||||
if (this.currentPlay !== undefined) {
|
||||
@@ -361,7 +374,7 @@ export abstract class AbstractPlayerState {
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
logSummary() {
|
||||
public logSummary() {
|
||||
this.logger.debug(this.textSummary());
|
||||
}
|
||||
|
||||
@@ -381,7 +394,7 @@ export abstract class AbstractPlayerState {
|
||||
return lastRange.end.position;
|
||||
}
|
||||
|
||||
getApiState(): SourcePlayerObj {
|
||||
public getApiState(): SourcePlayerObj {
|
||||
return {
|
||||
platformId: this.platformIdStr,
|
||||
play: this.getPlayedObject(),
|
||||
@@ -398,4 +411,22 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public transferToNewPlayer(newPlayer: AbstractPlayerState) {
|
||||
this.logger.debug(`Transferring state to new Player (${newPlayer.platformIdStr})`);
|
||||
newPlayer.calculatedStatus = this.calculatedStatus;
|
||||
if(this.currentPlay !== undefined) {
|
||||
newPlayer.setCurrentPlay(this.currentPlay, {status: this.reportedStatus, listenSessionManaged: false});
|
||||
}
|
||||
newPlayer.currentListenRange = this.currentListenRange;
|
||||
newPlayer.listenRanges = this.listenRanges;
|
||||
newPlayer.playFirstSeenAt = this.playFirstSeenAt;
|
||||
newPlayer.playLastUpdatedAt = this.playLastUpdatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CurrentPlayOptions {
|
||||
status?: ReportedPlayerStatus,
|
||||
reportedTS?: Dayjs
|
||||
listenSessionManaged?: boolean
|
||||
}
|
||||
|
||||
@@ -304,10 +304,10 @@ export default class ScrobbleSources {
|
||||
}
|
||||
break;
|
||||
case 'webscrobbler':
|
||||
const wsShouldUse = parseBool(process.env.WEBSCROBBLER_ENABLE);
|
||||
const wsShouldUse = parseBool(process.env.WS_ENABLE);
|
||||
const ws = {
|
||||
blacklist: process.env.WEBSCROBBLER_BLACKLIST,
|
||||
whitelist: process.env.WEBSCROBBLER_WHITELIST
|
||||
blacklist: process.env.WS_BLACKLIST,
|
||||
whitelist: process.env.WS_WHITELIST
|
||||
}
|
||||
if (!Object.values(ws).every(x => x === undefined) || wsShouldUse) {
|
||||
configs.push({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
readJson,
|
||||
writeFile,
|
||||
@@ -28,7 +28,7 @@ import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified;
|
||||
import UserDevice = SpotifyApi.UserDevice;
|
||||
import MemorySource from "./MemorySource";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import {PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC} from "../../core/Atomic";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils";
|
||||
import {isNodeNetworkException} from "../common/errors/NodeErrors";
|
||||
import {hasUpstreamError, UpstreamError} from "../common/errors/UpstreamError";
|
||||
@@ -77,11 +77,13 @@ export default class SpotifySource extends MemorySource {
|
||||
let album: AlbumObjectSimplified;
|
||||
let name: string;
|
||||
let duration_ms: number;
|
||||
let played_at: string;
|
||||
let played_at: Dayjs;
|
||||
let playDateCompleted: Dayjs | undefined;
|
||||
let id: string;
|
||||
let url: string;
|
||||
let playbackPosition: number | undefined;
|
||||
let deviceId: string | undefined;
|
||||
let scrobbleTsSOC: ScrobbleTsSOC;
|
||||
|
||||
|
||||
if (asPlayHistoryObject(obj)) {
|
||||
@@ -100,7 +102,9 @@ export default class SpotifySource extends MemorySource {
|
||||
} = {}
|
||||
} = track;
|
||||
|
||||
played_at = pa;
|
||||
scrobbleTsSOC = SCROBBLE_TS_SOC_END;
|
||||
played_at = dayjs(pa);
|
||||
playDateCompleted = played_at;
|
||||
artists = art;
|
||||
name = n;
|
||||
id = i;
|
||||
@@ -130,7 +134,8 @@ export default class SpotifySource extends MemorySource {
|
||||
} = {}
|
||||
} = item as TrackObjectFull;
|
||||
|
||||
played_at = dayjs(timestamp).toISOString();
|
||||
scrobbleTsSOC = SCROBBLE_TS_SOC_START;
|
||||
played_at = dayjs(timestamp);
|
||||
artists = art;
|
||||
name = n;
|
||||
id = i;
|
||||
@@ -161,13 +166,15 @@ export default class SpotifySource extends MemorySource {
|
||||
album: albumName,
|
||||
track: name,
|
||||
duration: duration_ms / 1000,
|
||||
playDate: dayjs(played_at),
|
||||
playDate: played_at,
|
||||
playDateCompleted
|
||||
},
|
||||
meta: {
|
||||
deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`,
|
||||
source: 'Spotify',
|
||||
trackId: id,
|
||||
trackProgressPosition: playbackPosition,
|
||||
scrobbleTsSOC,
|
||||
newFromSource,
|
||||
url: {
|
||||
web: url
|
||||
@@ -295,7 +302,7 @@ export default class SpotifySource extends MemorySource {
|
||||
const state = await this.getCurrentPlaybackState();
|
||||
if(state.playerState !== undefined) {
|
||||
if(state.device.is_private_session) {
|
||||
this.logger.debug(`Will not track play on Device ${state.device.name} because it is a private session: ${buildTrackString(state.playerState.play)}`);
|
||||
this.logger.debug(`Will not track play on Device ${state.device.name} because it is in a private session.`);
|
||||
} else {
|
||||
plays.push(state.playerState);
|
||||
}
|
||||
@@ -306,7 +313,13 @@ export default class SpotifySource extends MemorySource {
|
||||
plays.push(currPlay);
|
||||
}
|
||||
}
|
||||
return this.processRecentPlays(plays);
|
||||
const newPlays = this.processRecentPlays(plays);
|
||||
// hint that scrobble timestamp source of truth should be when the track ended (player changed tracks)
|
||||
// rather than when we first saw the track
|
||||
//
|
||||
// this is because Spotify play history (getMyRecentlyPlayedTracks) timestamps based on end of play
|
||||
// and when we backlog we want timestamps to be as accurate as possible
|
||||
return newPlays.map(x => ({...x, meta: {...x.meta, scrobbleTsSOC: SCROBBLE_TS_SOC_END}}))
|
||||
}
|
||||
|
||||
getPlayHistory = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
@@ -39,6 +39,12 @@ export class WebScrobblerSource extends MemorySource {
|
||||
};
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.logger.info(`Accepting requests at ${this.localUrl}/api/webscrobbler${this.config.data.slug === undefined ? '' : `/${this.config.data.slug}`}`);
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
matchSlug(slug: string | undefined) {
|
||||
if (this.config.data.slug === undefined || this.config.data.slug === null) {
|
||||
return slug === undefined;
|
||||
|
||||
@@ -14,11 +14,13 @@ import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTr
|
||||
import veryWrong from './incorrectlyMapped/veryWrong.json';
|
||||
|
||||
import {ListenbrainzApiClient, ListenResponse} from "../../common/vendor/ListenbrainzApiClient";
|
||||
import {PlayObject} from "../../../core/Atomic";
|
||||
import dayjs from "dayjs";
|
||||
import {withRequestInterception} from "../utils/networking";
|
||||
import {http, HttpResponse} from "msw";
|
||||
import {UpstreamError} from "../../common/errors/UpstreamError";
|
||||
import {ExpectedResults} from "../utils/interfaces";
|
||||
|
||||
interface ExpectedResults {
|
||||
artists: string[]
|
||||
track: string
|
||||
}
|
||||
interface LZTestFixture {
|
||||
data: ListenResponse
|
||||
expected: ExpectedResults
|
||||
@@ -103,3 +105,48 @@ describe('Listenbrainz Listen Parsing', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Listenbrainz Response Behavior', function() {
|
||||
|
||||
const client = new ListenbrainzApiClient('test',
|
||||
{
|
||||
token: 'test',
|
||||
username: 'test'
|
||||
});
|
||||
|
||||
it('Should recognize bad requests as non-showstopping',withRequestInterception(
|
||||
[
|
||||
http.post('https://api.listenbrainz.org/1/submit-listens', () => {
|
||||
// @ts-expect-error
|
||||
return HttpResponse.json({code: 400, error: 'artist_mbids MBID format invalid'}, {status: 400});
|
||||
})
|
||||
],
|
||||
async function() {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
artists: ['Celldweller'],
|
||||
album: 'The Complete Cellout, Volume 01',
|
||||
track: 'Frozen',
|
||||
duration: 299,
|
||||
playDate: dayjs(),
|
||||
meta: {
|
||||
brainz: {
|
||||
// @ts-expect-error
|
||||
artist: 'fad8967c-a327-4af5-a64a-d4de66ece652;100846a7-06f6-4129-97ce-4409b9a9a311',
|
||||
album: '2eb6a8fb-14f6-436e-9bdf-2f9d0d8cbae0',
|
||||
track: '677862e0-3603-4120-8c44-ee9a70893647',
|
||||
releaseGroup: 'bd3bb964-6da7-4d59-b0aa-f8bf639cd419'
|
||||
}
|
||||
}
|
||||
},
|
||||
meta: {}
|
||||
}
|
||||
try {
|
||||
await client.submitListen(play);
|
||||
} catch (e) {
|
||||
assert.isTrue(e instanceof UpstreamError);
|
||||
assert.isTrue(e.showStopper === false);
|
||||
}
|
||||
}
|
||||
));
|
||||
});
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"track": "Fricassee",
|
||||
"duration": 305,
|
||||
"playDate": "2023-09-20T14:32:39.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -19,9 +16,6 @@
|
||||
],
|
||||
"track": "Jimbó",
|
||||
"playDate": "2023-09-20T14:46:00.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -33,9 +27,6 @@
|
||||
"track": "Aisha",
|
||||
"duration": 559,
|
||||
"playDate": "2023-09-20T15:10:09.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -47,9 +38,6 @@
|
||||
"track": "Ballade No. 4 in F Minor, Op. 52",
|
||||
"duration": 575,
|
||||
"playDate": "2023-09-20T15:17:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,9 +50,6 @@
|
||||
"duration": 327,
|
||||
"listenedFor": 327,
|
||||
"playDate": "2023-09-27T20:02:17.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -74,9 +59,6 @@
|
||||
],
|
||||
"track": "Aprieta (Oye Cómo Va)",
|
||||
"playDate": "2023-09-20T15:38:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,9 +68,6 @@
|
||||
],
|
||||
"track": "Another Brick in the Wall, Pt. 1",
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -101,9 +80,6 @@
|
||||
"duration": 575,
|
||||
"listenedFor": 570,
|
||||
"playDate": "2023-09-20T15:41:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -116,9 +92,6 @@
|
||||
"track": "Aquil",
|
||||
"listenedFor": 300,
|
||||
"playDate": "2023-09-20T17:55:05.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -129,9 +102,22 @@
|
||||
"track": "Da Art of Storytellin' (Pt. 1)",
|
||||
"duration": 422,
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"9th Wonder",
|
||||
"Cordae",
|
||||
"Dinner Party",
|
||||
"Kamasi Washington",
|
||||
"Phoelix",
|
||||
"Robert Glasper",
|
||||
"Terrace Martin"
|
||||
],
|
||||
"track": "Freeze Tag",
|
||||
"album": "Dinner Party: Dessert",
|
||||
"playDate": "2024-01-01T16:29:26.000Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,14 +12,18 @@ import {asPlays, generatePlay, normalizePlays} from "../utils/PlayTestUtils";
|
||||
import dayjs from "dayjs";
|
||||
import {sleep} from "../../utils";
|
||||
import {MockNetworkError, withRequestInterception} from "../utils/networking";
|
||||
import {PlayObject} from "../../../core/Atomic";
|
||||
|
||||
const firstPlayDate = dayjs().subtract(1, 'hour');
|
||||
const olderFirstPlayDate = dayjs().subtract(4, 'hour');
|
||||
|
||||
const withDurPlays = asPlays(withDuration);
|
||||
const mixedDurPlays = asPlays(mixedDuration);
|
||||
const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate});
|
||||
const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate});
|
||||
|
||||
const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate});
|
||||
|
||||
const testScrobbler = new TestScrobbler();
|
||||
testScrobbler.verboseOptions = {
|
||||
match: {
|
||||
@@ -237,7 +241,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
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 () {
|
||||
it('Is detected as duplicate when play date is off by 10 seconds or less (high granularity source)', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
@@ -249,6 +253,17 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffPos));
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffNeg));
|
||||
|
||||
// 10 seconds fuzzy diff inclusive
|
||||
const son = normalizedWithMixedDurOlder.find(x => x.data.track === 'Sonora')
|
||||
son.data.playDate = dayjs().subtract(1, 'hour').set('minute', 26).set('second', 20);
|
||||
son.data.duration = 267;
|
||||
son.data.listenedFor = undefined;
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDurOlder.concat(son);
|
||||
|
||||
const offSon = clone(son);
|
||||
offSon.data.playDate = dayjs().subtract(1, 'hour').set('minute', 30).set('second', 37);
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(offSon));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when play date is off by less than 60 seconds (low granularity source)', async function () {
|
||||
@@ -285,17 +300,46 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when artists are included in joiner', async function () {
|
||||
const ref = normalizedWithMixedDurOlder.find(x => x.data.track === 'Freeze Tag');
|
||||
ref.data.playDate = dayjs().subtract(1, 'hour').set('minute', 29).set('second', 26)
|
||||
|
||||
const spotifyPlay: PlayObject = {
|
||||
data: {
|
||||
artists: [
|
||||
"Terrace Martin",
|
||||
"Robert Glasper",
|
||||
"9th Wonder",
|
||||
"Kamasi Washington",
|
||||
"Dinner Party",
|
||||
"Cordae",
|
||||
"Phoelix"
|
||||
],
|
||||
album: "Dinner Party: Dessert",
|
||||
track: "Freeze Tag (feat. Cordae & Phoelix)",
|
||||
"duration": 191.375,
|
||||
"playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27)
|
||||
},
|
||||
meta: {
|
||||
source: 'Spotify'
|
||||
}
|
||||
}
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDurOlder.concat(ref);
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(spotifyPlay));
|
||||
});
|
||||
|
||||
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]);
|
||||
const timeEnd = clone(normalizedWithDur[normalizedWithMixedDur.length - 2]);
|
||||
timeEnd.data.playDate = timeEnd.data.playDate.add(timeEnd.data.duration, 's');
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
|
||||
|
||||
@@ -88,7 +88,7 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl
|
||||
...data
|
||||
},
|
||||
meta: {
|
||||
source: faker.word.noun(),
|
||||
source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})],
|
||||
...meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ExpectedResults {
|
||||
artists: string[]
|
||||
track: string
|
||||
album?: String
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
[
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Eugene Naumenko"
|
||||
],
|
||||
"track": "Take-Off feat. Diag",
|
||||
"album": "1000 Years"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Eugene Naumenko",
|
||||
"Diag"
|
||||
],
|
||||
"track": "Take-Off",
|
||||
"album": "1000 Years"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"remix"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Djfredse"
|
||||
],
|
||||
"track": "Anny Sky - Together With You (Djfredse Remix)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Djfredse",
|
||||
"Anny Sky"
|
||||
],
|
||||
"track": "Together With You (Djfredse Remix)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"remix",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Chriss"
|
||||
],
|
||||
"track": "Criminal mind Ft Akon (Remix Braquer vos têtes)",
|
||||
"album": "Music Is Life"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Chriss",
|
||||
"Akon"
|
||||
],
|
||||
"track": "Criminal mind (Remix Braquer vos têtes)",
|
||||
"album": "Music Is Life"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Luis Zunel"
|
||||
],
|
||||
"track": "Reset my Blues (Featuring Peergynt Lobogris)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Luis Zunel",
|
||||
"Peergynt Lobogris"
|
||||
],
|
||||
"track": "Reset my Blues"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Woochia"
|
||||
],
|
||||
"track": "La Flaque (feat.Ecstatik)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Woochia",
|
||||
"Ecstatik"
|
||||
],
|
||||
"track": "La Flaque"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner",
|
||||
"remix"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Alicia Keys",
|
||||
"Kaash Paige",
|
||||
"Diamond Platnumz"
|
||||
],
|
||||
"track": "Wasted Energy (feat. Kaash Paige & Diamond Platnumz) - Remix"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Alicia Keys",
|
||||
"Kaash Paige",
|
||||
"Diamond Platnumz"
|
||||
],
|
||||
"track": "Wasted Energy - Remix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Tyler, the Creator"
|
||||
],
|
||||
"track": "WUSYANAME (feat. Youngboy Never Broke Again & Ty Dolla $ign)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Tyler, the Creator",
|
||||
"Youngboy Never Broke Again",
|
||||
"Ty Dolla $ign"
|
||||
],
|
||||
"track": "WUSYANAME"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Tyler, the Creator"
|
||||
],
|
||||
"track": "Trashwang (feat. Na' kel, Jasper Dolphin, Lucas & L-Boy)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Tyler, the Creator",
|
||||
"Na' kel",
|
||||
"Jasper Dolphin",
|
||||
"L-Boy",
|
||||
"Lucas"
|
||||
],
|
||||
"track": "Trashwang"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caseHints": [
|
||||
"track",
|
||||
"joiner"
|
||||
],
|
||||
"data": {
|
||||
"artists": [
|
||||
"Childish Gambino"
|
||||
],
|
||||
"track": "12.38 (feat. 21 Savage, Ink & Kadhja Bonet)"
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Childish Gambino",
|
||||
"21 Savage",
|
||||
"Ink",
|
||||
"Kadhja Bonet"
|
||||
],
|
||||
"track": "12.38"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,19 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
import {compareNormalizedStrings} from "../utils/StringUtils";
|
||||
import {compareNormalizedStrings, parseTrackCredits, uniqueNormalizedStrArr} from "../../utils/StringUtils";
|
||||
import testData from './playTestData.json';
|
||||
import {ExpectedResults} from "./interfaces";
|
||||
import {intersect} from "../../utils";
|
||||
|
||||
interface PlayTestFixture {
|
||||
caseHints: string[]
|
||||
data: {
|
||||
track: string
|
||||
artists: string[]
|
||||
album?: string
|
||||
}
|
||||
expected: ExpectedResults
|
||||
}
|
||||
|
||||
describe('String Comparisons', function () {
|
||||
|
||||
@@ -73,4 +85,43 @@ describe('String Comparisons', function () {
|
||||
assert.isAtMost( result.highScore, 99, `Comparing: '${test[0]}' | '${test[1]}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle strings with different lengths', async function () {
|
||||
const tests = [
|
||||
['The Amazing Bongo Hop', 'The Bongo Hop'],
|
||||
]
|
||||
|
||||
for(const test of tests) {
|
||||
const result = compareNormalizedStrings(test[0], test[1]);
|
||||
assert.isAtMost( result.highScore, 53, `Comparing: '${test[0]}' | '${test[1]}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be string parameter order invariant', async function () {
|
||||
const longerString = 'Nidia Gongora TEST';
|
||||
const shorterString = 'Nidia Gongora'
|
||||
|
||||
const result1 = compareNormalizedStrings(longerString, shorterString);
|
||||
const result2 = compareNormalizedStrings(shorterString, longerString);
|
||||
|
||||
assert.equal( result1.highScore, result2.highScore, `Comparing: '${longerString}' | '${shorterString}'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play Strings',function () {
|
||||
|
||||
const testFixtures = testData as unknown as PlayTestFixture[];
|
||||
const joinerData = testFixtures.filter(x => intersect(['joiner','track'], x.caseHints).length === 2);
|
||||
|
||||
it('should parse joiners from track title', function() {
|
||||
for(const test of joinerData) {
|
||||
const res = parseTrackCredits(test.data.track);
|
||||
let artists: string[] = [...test.data.artists];
|
||||
if(res.secondary !== undefined) {
|
||||
artists = uniqueNormalizedStrArr([...artists, ...res.secondary]);
|
||||
}
|
||||
assert.equal(res.primaryComposite, test.expected.track);
|
||||
assert.sameDeepMembers(artists, test.expected.artists);
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-177
@@ -7,9 +7,6 @@ import {TimeoutError, WebapiError} from "spotify-web-api-node/src/response-error
|
||||
import Ajv, {Schema} from 'ajv';
|
||||
import {
|
||||
asPlayerStateData,
|
||||
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
|
||||
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
|
||||
lowGranularitySources,
|
||||
NO_DEVICE,
|
||||
NO_USER,
|
||||
numberFormatOptions,
|
||||
@@ -24,10 +21,9 @@ import {Request} from "express";
|
||||
import pathUtil from "path";
|
||||
import {ErrorWithCause, getErrorCause} from "pony-cause";
|
||||
import backoffStrategies from '@kenyip/backoff-strategies';
|
||||
import {ScrobbleThresholds} from "./common/infrastructure/config/source";
|
||||
import {replaceResultTransformer, stripIndentTransformer, TemplateTag, trimResultTransformer} from 'common-tags';
|
||||
import {Duration} from "dayjs/plugin/duration.js";
|
||||
import {ListenRangeData, PlayObject} from "../core/Atomic";
|
||||
import {PlayObject} from "../core/Atomic";
|
||||
import address from "address";
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -423,143 +419,6 @@ export const remoteHostStr = (req: Request): string => {
|
||||
return `${host}${proxy !== undefined ? ` (${proxy})` : ''}${agent !== undefined ? ` (UA: ${agent})` : ''}`;
|
||||
}
|
||||
|
||||
export const isPlayTemporallyClose = (existingPlay: PlayObject, candidatePlay: PlayObject, options: { diffThreshold?: number, fuzzyDuration?: boolean, useListRanges?: boolean} = {}): boolean => {
|
||||
return comparePlayTemporally(existingPlay, candidatePlay, options).close;
|
||||
}
|
||||
|
||||
export interface TemporalPlayComparison {
|
||||
close: boolean
|
||||
date?: {
|
||||
threshold: number
|
||||
diff: number
|
||||
fuzzyDurationDiff?: number
|
||||
fuzzyListenedDiff?: number
|
||||
}
|
||||
range?: false | ListenRangeData
|
||||
}
|
||||
|
||||
export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, existingPlay?: PlayObject, candidatePlay?: PlayObject) => {
|
||||
const parts: string[] = [];
|
||||
if (existingPlay !== undefined && candidatePlay !== undefined) {
|
||||
if (existingPlay.data.playDate.isSame(candidatePlay.data.playDate, 'day')) {
|
||||
parts.push(`Existing: ${existingPlay.data.playDate.format('HH:mm:ssZ')} - Candidate: ${candidatePlay.data.playDate.format('HH:mm:ssZ')}`);
|
||||
} else {
|
||||
parts.push(`Existing: ${existingPlay.data.playDate.toISOString()} - Candidate: ${candidatePlay.data.playDate.toISOString()}`);
|
||||
}
|
||||
}
|
||||
parts.push(`Close: ${data.close ? 'YES' : 'NO'}`);
|
||||
if (data.date !== undefined) {
|
||||
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
|
||||
}
|
||||
if (data.date.fuzzyDurationDiff !== undefined) {
|
||||
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <10s)`);
|
||||
}
|
||||
if (data.date.fuzzyListenedDiff !== undefined) {
|
||||
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <10s)`);
|
||||
}
|
||||
if (data.range !== undefined) {
|
||||
if (data.range === false) {
|
||||
parts.push('Candidate not played during Existing tracked listening');
|
||||
} else {
|
||||
parts.push(`Candidate played during tracked listening range from existing: ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
|
||||
}
|
||||
} else {
|
||||
parts.push('One or both Plays did not have have tracked listening to compare');
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: { diffThreshold?: number, fuzzyDuration?: boolean, useListRanges?: boolean} = {}): TemporalPlayComparison => {
|
||||
|
||||
const result: TemporalPlayComparison = {
|
||||
close: false
|
||||
};
|
||||
|
||||
const {
|
||||
meta:{
|
||||
source,
|
||||
},
|
||||
data: {
|
||||
playDate: existingPlayDate,
|
||||
duration: existingDuration,
|
||||
listenRanges: existingRanges,
|
||||
listenedFor: existingListenedFor,
|
||||
}
|
||||
} = existingPlay;
|
||||
|
||||
const {
|
||||
data: {
|
||||
playDate: newPlayDate,
|
||||
duration: newDuration,
|
||||
listenRanges: newRanges,
|
||||
listenedFor: newListenedFor,
|
||||
}
|
||||
} = candidatePlay;
|
||||
|
||||
const {
|
||||
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
|
||||
fuzzyDuration = false,
|
||||
useListRanges = true,
|
||||
} = options;
|
||||
|
||||
// cant compare!
|
||||
if(existingPlayDate === undefined || newPlayDate === undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const referenceDuration = newDuration ?? existingDuration;
|
||||
const referenceListenedFor = newListenedFor ?? existingListenedFor;
|
||||
|
||||
let playDiffThreshold = diffThreshold;
|
||||
|
||||
// check if existing play time is same as new play date
|
||||
let scrobblePlayDiff = Math.abs(existingPlayDate.unix() - newPlayDate.unix());
|
||||
result.date = {
|
||||
threshold: diffThreshold,
|
||||
diff: scrobblePlayDiff
|
||||
};
|
||||
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
result.close = true;
|
||||
}
|
||||
|
||||
if(useListRanges && existingRanges !== undefined) {
|
||||
// since we know when the existing track was listened to
|
||||
// we can check if the new track play date took place while the existing one was being listened to
|
||||
// which would indicate (assuming same source) the new track is a duplicate
|
||||
for(const range of existingRanges) {
|
||||
if(newPlayDate.isBetween(range.start.timestamp, range.end.timestamp)) {
|
||||
result.range = range;
|
||||
result.close = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(result.range === undefined) {
|
||||
result.range = false;
|
||||
}
|
||||
}
|
||||
|
||||
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
|
||||
// so check if the duration matches the diff between the two play dates
|
||||
if (result.close === false && referenceDuration !== undefined && fuzzyDuration) {
|
||||
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
|
||||
if(result.date.fuzzyDurationDiff < 10) { // TODO use finer comparison for this?
|
||||
result.close = true;
|
||||
}
|
||||
}
|
||||
// if the source has listened duration (maloja) it may differ from actual track duration
|
||||
// and its possible (spotify) the candidate play date is set at the end of this duration
|
||||
// so check if there is a close match between candidate play date and source + listened for
|
||||
if(result.close === false && referenceListenedFor !== undefined && fuzzyDuration) {
|
||||
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
|
||||
if(result.date.fuzzyListenedDiff < 10) { // TODO use finer comparison for this?
|
||||
result.close = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => {
|
||||
const cleanParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
@@ -614,41 +473,6 @@ export const getProgress = (initial: ProgressAwarePlayObject, curr: PlayObject):
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const playPassesScrobbleThreshold = (play: PlayObject, thresholds: ScrobbleThresholds): ScrobbleThresholdResult => {
|
||||
const progressed = Math.round(Math.abs(dayjs().diff(play.data.playDate, 's')));
|
||||
return timePassesScrobbleThreshold(thresholds, progressed, play.data.duration);
|
||||
}
|
||||
|
||||
export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, secondsTracked: number, playDuration?: number): ScrobbleThresholdResult => {
|
||||
let durationPasses = undefined,
|
||||
durationThreshold: number | null = thresholds.duration ?? DEFAULT_SCROBBLE_DURATION_THRESHOLD,
|
||||
percentPasses = undefined,
|
||||
percentThreshold: number | null = thresholds.percent ?? DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
|
||||
percent: number | undefined;
|
||||
|
||||
if (percentThreshold !== null && playDuration !== undefined && playDuration !== 0) {
|
||||
percent = Math.round(((secondsTracked / playDuration) * 100));
|
||||
percentPasses = percent >= percentThreshold;
|
||||
}
|
||||
if (durationThreshold !== null || percentPasses === undefined) {
|
||||
durationPasses = secondsTracked >= durationThreshold;
|
||||
}
|
||||
|
||||
return {
|
||||
passes: (durationPasses ?? false) || (percentPasses ?? false),
|
||||
duration: {
|
||||
passes: durationPasses,
|
||||
threshold: durationThreshold,
|
||||
value: secondsTracked
|
||||
},
|
||||
percent: {
|
||||
passes: percentPasses,
|
||||
value: percent,
|
||||
threshold: percentThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const thresholdResultSummary = (result: ScrobbleThresholdResult) => {
|
||||
const parts: string[] = [];
|
||||
if(result.duration.passes !== undefined) {
|
||||
|
||||
@@ -31,14 +31,59 @@ export const normalizeStr = (str: string, options?: {keepSingleWhitespace?: bool
|
||||
|
||||
export interface PlayCredits {
|
||||
primary: string
|
||||
primaryComposite: string
|
||||
secondary?: string[]
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches if the secondary string is wrapped in parenthesis-like symbols. Returns joiner, credits, and
|
||||
* suffix = if anything appears after end of wrapped string
|
||||
*
|
||||
* EX (feat. Kaash Paige & Diamond Platnumz) - Remix
|
||||
* !!!! ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ *******
|
||||
*
|
||||
*
|
||||
* */
|
||||
export const SECONDARY_CAPTURED_REGEX = new RegExp(/[(\[]\s*(?<joiner>ft\.?\W|feat\.?\W|featuring|vs\.?\W)\s*(?<credits>.*)[)\]](?<creditsSuffix>.*)/i);
|
||||
|
||||
|
||||
/**
|
||||
* Matches if the secondary string is NOT wrapped in parenthesis-like symbols. Returns joiner, credits, and
|
||||
* suffix = if anything appears wrapped or starting with " - " proceeding string appearing after joiner
|
||||
*
|
||||
* EX feat. Diag
|
||||
* !!!! ^^^^
|
||||
*
|
||||
* Ft Akon, Paige & Djfredse (Remix Braquer vos têtes)
|
||||
* !! ^^^^^^^^^^^^^^^^^^^^^^ *************************
|
||||
*
|
||||
* feat. Kaash Paige & Diamond Platnumz - Remix
|
||||
* !!!! ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ *******
|
||||
*
|
||||
* */
|
||||
export const SECONDARY_FREE_REGEX = new RegExp(/^\s*(?<joiner>ft\.?\W|feat\.?\W|featuring|vs\.?\W)\s*(?<credits>(?:.+?(?= - |\s*[(\[]))|(?:.*))(?<creditsSuffix>.*)/i);
|
||||
|
||||
const SECONDARY_REGEX_STRATS: RegExp[] = [SECONDARY_CAPTURED_REGEX, SECONDARY_FREE_REGEX];
|
||||
|
||||
/**
|
||||
* Matches JUST primary and secondary sections separated by a REQUIRED joiner that can optionally be wrapped in parenthesis-like symbols
|
||||
*
|
||||
* EX
|
||||
* Criminal mind Ft Akon (Remix Braquer vos têtes)
|
||||
* ^^^^^^^^^^^^^**********************************
|
||||
*
|
||||
* Wasted Energy (feat. Kaash Paige & Diamond Platnumz) - Remix
|
||||
* ^^^^^^^^^^^^^^**********************************************
|
||||
*
|
||||
* */
|
||||
export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?<primary>.+?)(?<secondary>(?:[(\[]?(?:\Wft\.?|\Wfeat\.?|featuring|\Wvs\.)).*)/i);
|
||||
|
||||
/**
|
||||
* For matching the most common track/artist pattern that has a joiner
|
||||
*
|
||||
* Primary ft. 2nd Artist, 3rd Artist
|
||||
* Primary (2nd Artist)
|
||||
* Primary (2nd Artist) - Suffix
|
||||
* Primary [featuring 2nd Artist]
|
||||
*
|
||||
* ____
|
||||
@@ -49,28 +94,44 @@ export interface PlayCredits {
|
||||
* => MUST begin with joiner ft. feat. featuring with vs.
|
||||
* => May have closing character ) ]
|
||||
* */
|
||||
export const SECONDARY_ARTISTS_SECTION_REGEX = new RegExp(/^(?<primary>[^(\[]*)?(?<secondarySection>[(\[]?(?<joiner>\Wft\.?|\Wfeat\.?|featuring|\Wvs\.?) (?<secondaryArtists>[^)\]]*)(?:[)\]]|\s*)$)/i);
|
||||
// export const SECONDARY_ARTISTS_REGEX = new RegExp(//ig);
|
||||
export const parseCredits = (str: string, delimiters?: boolean | string[]): PlayCredits => {
|
||||
if (str.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let primary: string | undefined;
|
||||
let secondary: string[] = [];
|
||||
const results = parseRegexSingleOrFail(SECONDARY_ARTISTS_SECTION_REGEX, str);
|
||||
if (results !== undefined) {
|
||||
primary = results.named.primary !== undefined ? results.named.primary.trim() : undefined;
|
||||
let suffix: string | undefined;
|
||||
const results = parseRegexSingleOrFail(PRIMARY_SECONDARY_SECTIONS_REGEX, str);
|
||||
if(results !== undefined) {
|
||||
|
||||
let delims: string[] | undefined;
|
||||
if (Array.isArray(delimiters)) {
|
||||
delims = delimiters;
|
||||
} else if (delimiters === false) {
|
||||
delims = [];
|
||||
}
|
||||
secondary = parseStringList(results.named.secondaryArtists as string, delims)
|
||||
|
||||
primary = results.named.primary.trim();
|
||||
for(const strat of SECONDARY_REGEX_STRATS) {
|
||||
const secCredits = parseRegexSingleOrFail(strat, results.named.secondary);
|
||||
if(secCredits !== undefined) {
|
||||
secondary = parseStringList(secCredits.named.credits as string, delims)
|
||||
suffix = secCredits.named.creditsSuffix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(secondary === undefined) {
|
||||
// uh oh, this shouldn't have happened! Return nothing since we don't know how to parse this
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
primary,
|
||||
secondary
|
||||
};
|
||||
primaryComposite: `${primary}${suffix ?? ''}`,
|
||||
secondary,
|
||||
suffix
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -92,6 +153,7 @@ export const parseArtistCredits = (str: string, delimiters?: boolean | string[])
|
||||
if (primaries.length > 1) {
|
||||
return {
|
||||
primary: primaries[0],
|
||||
primaryComposite: primaries[0],
|
||||
secondary: primaries.slice(1).concat(withJoiner.secondary)
|
||||
}
|
||||
}
|
||||
@@ -102,11 +164,13 @@ export const parseArtistCredits = (str: string, delimiters?: boolean | string[])
|
||||
if (artists.length > 1) {
|
||||
return {
|
||||
primary: artists[0],
|
||||
primaryComposite: artists[0],
|
||||
secondary: artists.slice(1)
|
||||
}
|
||||
}
|
||||
return {
|
||||
primary: artists[0]
|
||||
primary: artists[0],
|
||||
primaryComposite: artists[0],
|
||||
}
|
||||
}
|
||||
export const parseTrackCredits = (str: string, delimiters?: boolean | string[]): PlayCredits | undefined => parseCredits(str, delimiters);
|
||||
@@ -135,7 +199,7 @@ export const findDelimiters = (str: string) => {
|
||||
return found;
|
||||
}
|
||||
|
||||
export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObject): StringSamenessResult => {
|
||||
const {
|
||||
data: {
|
||||
track: existingTrack,
|
||||
@@ -148,7 +212,21 @@ export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObjec
|
||||
}
|
||||
} = candidate;
|
||||
|
||||
return compareNormalizedStrings(existingTrack, candidateTrack).highScore;
|
||||
// try to remove any joiners based on existing artists
|
||||
const existingCredits = parseTrackCredits(existingTrack);
|
||||
const existingPrimary = existingCredits !== undefined ? existingCredits.primaryComposite : existingTrack;
|
||||
|
||||
const candidateCredits = parseTrackCredits(candidateTrack);
|
||||
const candidatePrimary = candidateCredits !== undefined ? candidateCredits.primaryComposite : candidateTrack;
|
||||
|
||||
// take whichever score is higher
|
||||
const creditsCleanedTrackSameness = compareNormalizedStrings(existingPrimary, candidatePrimary);
|
||||
const naiveTrackSameness = compareNormalizedStrings(existingTrack, candidateTrack);
|
||||
|
||||
if(creditsCleanedTrackSameness.highScore > naiveTrackSameness.highScore) {
|
||||
return creditsCleanedTrackSameness;
|
||||
}
|
||||
return naiveTrackSameness;
|
||||
}
|
||||
|
||||
export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
@@ -167,23 +245,55 @@ export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObje
|
||||
return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr}`, '')).highScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the sameness of two strings after making them token-order independent
|
||||
*
|
||||
* Transform two strings before comparing in order to have as little difference between them as possible:
|
||||
*
|
||||
* * First, normalize (lower case, remove extraneous whitespace, remove punctuation, make all characters standard ANSI) strings and split into tokens
|
||||
* * Second, reorder tokens in the shorter list so that they mirror order of tokens in longer list as closely as possible
|
||||
* * Finally, concat back to strings and compare with sameness strategies
|
||||
*
|
||||
* */
|
||||
export const compareNormalizedStrings = (existing: string, candidate: string): StringSamenessResult => {
|
||||
|
||||
const normalExisting = normalizeStr(existing, {keepSingleWhitespace: true});
|
||||
const normalCandidate = normalizeStr(candidate, {keepSingleWhitespace: true});
|
||||
|
||||
// there may be scenarios where a track differs in *ordering* of ancillary information between sources
|
||||
// EX My Track (feat. Art1, Art2) -- My Track (feat. Art2 Art1)
|
||||
// so instead of naively comparing the entire track string against the candidate we
|
||||
// * first try to match up all white-space separated tokens
|
||||
// * recombine with closest tokens in order
|
||||
// * then check sameness
|
||||
|
||||
// first remove lower case, extraneous whitespace, punctuation, and replace non-ansi with ansi characters
|
||||
const normalExisting = normalizeStr(existing, {keepSingleWhitespace: true});
|
||||
const normalCandidate = normalizeStr(candidate, {keepSingleWhitespace: true});
|
||||
|
||||
// split by "token"
|
||||
const eTokens = normalExisting.split(' ');
|
||||
const cTokens = normalCandidate.split(' ');
|
||||
|
||||
const orderedCandidateTokens = eTokens.reduce((acc: { ordered: string[], remaining: string[] }, curr) => {
|
||||
|
||||
let longerTokens: string[],
|
||||
shorterTokens: string[];
|
||||
|
||||
if (eTokens.length > cTokens.length) {
|
||||
longerTokens = eTokens;
|
||||
shorterTokens = cTokens;
|
||||
} else {
|
||||
longerTokens = cTokens;
|
||||
shorterTokens = eTokens;
|
||||
}
|
||||
|
||||
// we will use longest string (token list) as the reducer and order the shorter list to match it
|
||||
// so we don't have to deal with undefined positions in the shorter list
|
||||
|
||||
const orderedCandidateTokens = longerTokens.reduce((acc: { ordered: string[], remaining: string[] }, curr) => {
|
||||
// if we've run out of tokens in the shorter list just return
|
||||
if (acc.remaining.length === 0) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// on each iteration of tokens in the long list
|
||||
// we iterate through remaining tokens from the shorter list and find the token with the most sameness
|
||||
|
||||
let highScore = 0;
|
||||
let highIndex = undefined;
|
||||
let highIndex = 0;
|
||||
let index = 0;
|
||||
for (const token of acc.remaining) {
|
||||
const result = stringSameness(curr, token);
|
||||
@@ -194,18 +304,28 @@ export const compareNormalizedStrings = (existing: string, candidate: string): S
|
||||
index++;
|
||||
}
|
||||
|
||||
// then remove the most same token from the remaining short list tokens
|
||||
const splicedRemaining = [...acc.remaining];
|
||||
splicedRemaining.splice(highIndex, 1);
|
||||
|
||||
return {ordered: acc.ordered.concat(acc.remaining[highIndex]), remaining: splicedRemaining};
|
||||
}, {ordered: [], remaining: cTokens});
|
||||
return {
|
||||
// finally add the most same token to the ordered short list
|
||||
ordered: acc.ordered.concat(acc.remaining[highIndex]),
|
||||
// and return the remaining short list tokens
|
||||
remaining: splicedRemaining
|
||||
};
|
||||
}, {
|
||||
// "ordered" is the result of ordering tokens in the shorter list to match longer token order
|
||||
ordered: [],
|
||||
// remaining is the initial shorter list
|
||||
remaining: shorterTokens
|
||||
});
|
||||
|
||||
const allOrderedCandidateTokens = orderedCandidateTokens.ordered.concat(orderedCandidateTokens.remaining);
|
||||
const orderedCandidateString = allOrderedCandidateTokens.join(' ');
|
||||
|
||||
// since we have already "matched" up words by order we don't want to use cosine strat
|
||||
// since we have already "matched" up tokens by order we don't want to use cosine strat
|
||||
// bc it only does comparisons between whole words in a sentence (instead of all letters in a string)
|
||||
// which makes it inaccurate for small-n sentences and typos
|
||||
|
||||
return stringSameness(normalExisting, orderedCandidateString, {transforms: [], strategies: [levenStrategy, diceStrategy]});
|
||||
return stringSameness(longerTokens.join(' '), orderedCandidateTokens.ordered.join(' '), {
|
||||
transforms: [],
|
||||
strategies: [levenStrategy, diceStrategy]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC,
|
||||
TA_CLOSE,
|
||||
TA_EXACT,
|
||||
TA_FUZZY,
|
||||
TA_NONE,
|
||||
TemporalAccuracy,
|
||||
TemporalPlayComparison
|
||||
} from "../../core/Atomic";
|
||||
import {
|
||||
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
|
||||
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
|
||||
lowGranularitySources,
|
||||
ScrobbleThresholdResult
|
||||
} from "../common/infrastructure/Atomic";
|
||||
import {formatNumber} from "../utils";
|
||||
import {ScrobbleThresholds} from "../common/infrastructure/config/source";
|
||||
import {capitalize} from "../../core/StringUtils";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
|
||||
export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, existingPlay?: PlayObject, candidatePlay?: PlayObject) => {
|
||||
const parts: string[] = [];
|
||||
if (existingPlay !== undefined && candidatePlay !== undefined) {
|
||||
if (existingPlay.data.playDate.isSame(candidatePlay.data.playDate, 'day')) {
|
||||
parts.push(`Existing: ${existingPlay.data.playDate.format('HH:mm:ssZ')} - Candidate: ${candidatePlay.data.playDate.format('HH:mm:ssZ')}`);
|
||||
} else {
|
||||
parts.push(`Existing: ${existingPlay.data.playDate.toISOString()} - Candidate: ${candidatePlay.data.playDate.toISOString()}`);
|
||||
}
|
||||
}
|
||||
parts.push(`Temporal Sameness: ${capitalize(temporalAccuracyToString(data.match))}`);
|
||||
if (data.date !== undefined) {
|
||||
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
|
||||
}
|
||||
if (data.date.fuzzyDurationDiff !== undefined) {
|
||||
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
|
||||
}
|
||||
if (data.date.fuzzyListenedDiff !== undefined) {
|
||||
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
|
||||
}
|
||||
if (data.range !== undefined) {
|
||||
if (data.range === false) {
|
||||
parts.push('Candidate not played during Existing tracked listening');
|
||||
} else {
|
||||
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
|
||||
}
|
||||
} else {
|
||||
parts.push('Range Comparison N/A');
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
|
||||
diffThreshold?: number,
|
||||
fuzzyDuration?: boolean,
|
||||
useListRanges?: boolean
|
||||
} = {}): TemporalPlayComparison => {
|
||||
|
||||
const result: TemporalPlayComparison = {
|
||||
match: TA_NONE
|
||||
};
|
||||
|
||||
const {
|
||||
meta: {
|
||||
source,
|
||||
//scrobbleTsSOC: existingScrobbleTsSOC = SCROBBLE_TS_SOC_START,
|
||||
},
|
||||
data: {
|
||||
// playDate: existingPlayDate,
|
||||
// playDateCompleted: existingPlayDateCompleted,
|
||||
duration: existingDuration,
|
||||
listenRanges: existingRanges,
|
||||
listenedFor: existingListenedFor,
|
||||
}
|
||||
} = existingPlay;
|
||||
|
||||
const [existingTsSOCDate, existingTsSOC] = getScrobbleTsSOCDateWithContext(existingPlay);
|
||||
|
||||
const {
|
||||
// meta: {
|
||||
// scrobbleTsSOC: candidateScrobbleTsSOC = SCROBBLE_TS_SOC_START,
|
||||
// },
|
||||
data: {
|
||||
// playDate: newPlayDate,
|
||||
// playDateCompleted: candidatePlayDateCompleted,
|
||||
duration: newDuration,
|
||||
listenRanges: newRanges,
|
||||
listenedFor: newListenedFor,
|
||||
}
|
||||
} = candidatePlay;
|
||||
|
||||
const [candidateTsSOCDate, candidateTsSOC] = getScrobbleTsSOCDateWithContext(candidatePlay);
|
||||
|
||||
const {
|
||||
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
|
||||
fuzzyDuration = false,
|
||||
useListRanges = true,
|
||||
} = options;
|
||||
|
||||
// cant compare!
|
||||
if (existingTsSOCDate === undefined || candidateTsSOCDate === undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const referenceDuration = newDuration ?? existingDuration;
|
||||
const referenceListenedFor = newListenedFor ?? existingListenedFor;
|
||||
|
||||
let playDiffThreshold = diffThreshold;
|
||||
|
||||
// check if existing play time is same as new play date
|
||||
let scrobblePlayDiff = Math.abs(existingTsSOCDate.unix() - candidateTsSOCDate.unix());
|
||||
result.date = {
|
||||
threshold: diffThreshold,
|
||||
diff: scrobblePlayDiff
|
||||
};
|
||||
|
||||
if(scrobblePlayDiff <= 1) {
|
||||
result.match = TA_EXACT;
|
||||
} else if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
result.match = TA_CLOSE;
|
||||
}
|
||||
|
||||
if (useListRanges && existingRanges !== undefined) {
|
||||
// since we know when the existing track was listened to
|
||||
// we can check if the new track play date took place while the existing one was being listened to
|
||||
// which would indicate (assuming same source) the new track is a duplicate
|
||||
for (const range of existingRanges) {
|
||||
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
|
||||
result.range = range;
|
||||
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
|
||||
result.match = TA_CLOSE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.range === undefined) {
|
||||
result.range = false;
|
||||
}
|
||||
}
|
||||
|
||||
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
|
||||
// so check if the duration matches the diff between the two play dates
|
||||
if (result.match === TA_NONE && referenceDuration !== undefined) {
|
||||
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
|
||||
if (result.date.fuzzyDurationDiff <= 10) { // TODO use finer comparison for this?
|
||||
result.match = TA_FUZZY;
|
||||
}
|
||||
}
|
||||
// if the source has listened duration (maloja) it may differ from actual track duration
|
||||
// and its possible (spotify) the candidate play date is set at the end of this duration
|
||||
// so check if there is a close match between candidate play date and source + listened for
|
||||
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
|
||||
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
|
||||
if (result.date.fuzzyListenedDiff <= 10) { // TODO use finer comparison for this?
|
||||
result.match = TA_FUZZY
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, secondsTracked: number, playDuration?: number): ScrobbleThresholdResult => {
|
||||
let durationPasses = undefined,
|
||||
durationThreshold: number | null = thresholds.duration ?? DEFAULT_SCROBBLE_DURATION_THRESHOLD,
|
||||
percentPasses = undefined,
|
||||
percentThreshold: number | null = thresholds.percent ?? DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
|
||||
percent: number | undefined;
|
||||
|
||||
if (percentThreshold !== null && playDuration !== undefined && playDuration !== 0) {
|
||||
percent = Math.round(((secondsTracked / playDuration) * 100));
|
||||
percentPasses = percent >= percentThreshold;
|
||||
}
|
||||
if (durationThreshold !== null || percentPasses === undefined) {
|
||||
durationPasses = secondsTracked >= durationThreshold;
|
||||
}
|
||||
|
||||
return {
|
||||
passes: (durationPasses ?? false) || (percentPasses ?? false),
|
||||
duration: {
|
||||
passes: durationPasses,
|
||||
threshold: durationThreshold,
|
||||
value: secondsTracked
|
||||
},
|
||||
percent: {
|
||||
passes: percentPasses,
|
||||
value: percent,
|
||||
threshold: percentThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
|
||||
if(typeof expected === 'number') {
|
||||
if(typeof found === 'number') {
|
||||
return found <= expected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return found === false;
|
||||
}
|
||||
|
||||
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
|
||||
switch(acc) {
|
||||
case 1:
|
||||
return 'exact';
|
||||
case 2:
|
||||
return 'close';
|
||||
case 3:
|
||||
return 'fuzzy';
|
||||
case false:
|
||||
return 'no correlation';
|
||||
}
|
||||
}
|
||||
|
||||
export const getScrobbleTsSOCDateWithContext = (data: PlayObject): [Dayjs, ScrobbleTsSOC] => {
|
||||
const {
|
||||
meta: {
|
||||
scrobbleTsSOC = SCROBBLE_TS_SOC_START,
|
||||
},
|
||||
data: {
|
||||
playDate = dayjs(),
|
||||
playDateCompleted
|
||||
}
|
||||
} = data;
|
||||
|
||||
if(scrobbleTsSOC === SCROBBLE_TS_SOC_END && playDateCompleted !== undefined) {
|
||||
return [playDateCompleted, SCROBBLE_TS_SOC_END];
|
||||
}
|
||||
return [playDate, SCROBBLE_TS_SOC_START];
|
||||
}
|
||||
|
||||
export const getScrobbleTsSOCDate = (data: PlayObject): Dayjs => {
|
||||
const [date, _] = getScrobbleTsSOCDateWithContext(data);
|
||||
return date;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import React, {Fragment, useCallback} from 'react';
|
||||
import StatusCardSkeleton, {StatusCardSkeletonData} from "./StatusCardSkeleton";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import {clientAdapter} from "../../status/ducks";
|
||||
import {RootState} from "../../store";
|
||||
import {connect, ConnectedProps} from "react-redux";
|
||||
import {Link} from "react-router-dom";
|
||||
import {useStartClientMutation} from "./clientDucks";
|
||||
|
||||
export interface ClientStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
|
||||
loading?: boolean
|
||||
@@ -36,11 +37,18 @@ const ClientStatusCard = (props: ClientStatusCardData) => {
|
||||
deadLetterScrobbles = 0
|
||||
} = {}
|
||||
} = props;
|
||||
|
||||
const [startClientPut, startResult] = useStartClientMutation();
|
||||
|
||||
const tryStart = useCallback((name: string) => startClientPut({name}), [startClientPut]);
|
||||
|
||||
let header: string | undefined = display;
|
||||
let body = <SkeletonParagraph/>;
|
||||
const startClientElement = <div onClick={() => tryStart(name)} className="capitalize underline cursor-pointer">{status === 'Running' ? 'Restart' : 'Start'}</div>
|
||||
if(data !== undefined) {
|
||||
const {
|
||||
hasAuth,
|
||||
hasAuthInteraction,
|
||||
name,
|
||||
type,
|
||||
authed,
|
||||
@@ -49,18 +57,24 @@ const ClientStatusCard = (props: ClientStatusCardData) => {
|
||||
if(type === 'lastfm' || type === 'listenbrainz')
|
||||
header = `${display} (Client)`;
|
||||
|
||||
const scrobbled = initialized && (!hasAuth || (hasAuth && authed)) ? <Link to={`/scrobbled?type=${type}&name=${name}`}>Tracks Scrobbled</Link> : <span>Tracks Scrobbled</span>
|
||||
const scrobbled = initialized && (!hasAuth || (hasAuth && authed)) ? <Link to={`/scrobbled?type=${type}&name=${name}`}>Tracks Scrobbled</Link> : <span>Tracks Scrobbled</span>;
|
||||
|
||||
// TODO links
|
||||
body = (<Fragment>
|
||||
<div>{scrobbled}: {scrobbledCount}</div>
|
||||
<div>Queued Scrobbles: {queued}</div>
|
||||
<div><Link to={`/dead?type=${type}&name=${name}`}>Failed Scrobbles</Link>: {deadLetterScrobbles}</div>
|
||||
{hasAuth ? <a target="_blank" href={`/api/client/auth?name=${name}&type=${type}`}>(Re)authenticate or initialize</a> : null}
|
||||
{hasAuthInteraction ? <a target="_blank" href={`/api/client/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
|
||||
</Fragment>);
|
||||
}
|
||||
return (
|
||||
<StatusCardSkeleton loading={loading} title={header} subtitle={name} status={status} statusType={statusToStatusType(status)}>
|
||||
<StatusCardSkeleton
|
||||
loading={loading}
|
||||
title={header}
|
||||
subtitle={name}
|
||||
status={status}
|
||||
subtitleRight={startClientElement}
|
||||
statusType={statusToStatusType(status)}>
|
||||
{body}
|
||||
</StatusCardSkeleton>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
method: 'GET',
|
||||
});
|
||||
},[data]);
|
||||
let startSourceElement = null;
|
||||
if(data !== undefined)
|
||||
{
|
||||
const {
|
||||
@@ -63,16 +64,25 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
|
||||
const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
|
||||
|
||||
if((!hasAuth || authed) && canPoll) {
|
||||
startSourceElement = <div onClick={poll} className="capitalize underline cursor-pointer">{status === 'Polling' ? 'Restart' : 'Start'}</div>
|
||||
}
|
||||
|
||||
// TODO links
|
||||
body = (<div className="statusCardBody">
|
||||
{platformIds.map(x => <Player key={x} data={players[x]}/>)}
|
||||
<div>{discovered}: {tracksDiscovered}</div>
|
||||
{canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate and (re)start polling</a> : null}
|
||||
{canPoll && (!hasAuth || authed) ? <div onClick={poll} className="cursor-pointer underline">Restart Polling</div> : null}
|
||||
{canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
|
||||
</div>);
|
||||
}
|
||||
return (
|
||||
<StatusCardSkeleton loading={loading} title={header} subtitle={name} status={status} statusType={statusToStatusType(status)}>
|
||||
<StatusCardSkeleton
|
||||
loading={loading}
|
||||
title={header}
|
||||
subtitle={name}
|
||||
status={status}
|
||||
subtitleRight={startSourceElement}
|
||||
statusType={statusToStatusType(status)}>
|
||||
{body}
|
||||
</StatusCardSkeleton>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {PropsWithChildren} from 'react';
|
||||
import React, {PropsWithChildren, ReactElement} from 'react';
|
||||
import SkeletonTitle from "../skeleton/SkeletonTitle";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import StatusIndicator, {StatusType} from "../StatusIndicator";
|
||||
@@ -8,6 +8,7 @@ export interface StatusCardSkeletonData {
|
||||
header?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
subtitleRight?: string | ReactElement
|
||||
status?: string
|
||||
statusType?: StatusType
|
||||
}
|
||||
@@ -20,6 +21,7 @@ const StatusCardSkeleton = (props: PropsWithChildren<StatusCardSkeletonData>) =>
|
||||
statusType,
|
||||
title,
|
||||
subtitle,
|
||||
subtitleRight,
|
||||
children
|
||||
} = props || {};
|
||||
|
||||
@@ -28,13 +30,16 @@ const StatusCardSkeleton = (props: PropsWithChildren<StatusCardSkeletonData>) =>
|
||||
<div className="shadow-md rounded bg-gray-500">
|
||||
<div className="p-3 bg-gray-700">
|
||||
<SkeletonTitle show={showLoading}/>
|
||||
<div className={`flex ${showLoading ? 'hidden' :''}`}>
|
||||
<div className={`flex ${showLoading ? 'hidden' : ''}`}>
|
||||
<div className="flex-auto text-left">
|
||||
{header ? <div className="font-semibold">{header}</div> : null}
|
||||
{title ? <div className="font-semibold">{title}</div> : null}
|
||||
{subtitle ? <div className="text-sm">{subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="flex items-center text-right">{status}<StatusIndicator type={statusType}/></div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center text-right">{status}<StatusIndicator type={statusType}/></div>
|
||||
{subtitleRight ? <div className="text-sm">{subtitleRight}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/dist/query/react";
|
||||
|
||||
export const scrobblerApi = createApi({
|
||||
reducerPath: 'scrobblerApi',
|
||||
baseQuery: fetchBaseQuery({baseUrl: '/api/'}),
|
||||
endpoints: (builder) => ({
|
||||
startClient: builder.mutation<undefined, {
|
||||
name: string
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/client/init',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const {useStartClientMutation} = scrobblerApi;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import {RootState} from "../store";
|
||||
import {connect, ConnectedProps} from "react-redux";
|
||||
import {id} from "common-tags";
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
|
||||
+3
-1
@@ -8,6 +8,7 @@ import {logsApi} from "./logs/logsApi";
|
||||
import {recentApi} from "./recent/recentDucks";
|
||||
import {scrobbledApi} from "./scrobbled/scrobbledDucks";
|
||||
import {deadApi, deadSlice} from "./deadLetter/deadLetterDucks";
|
||||
import {scrobblerApi} from './components/statusCard/clientDucks';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -17,6 +18,7 @@ export const store = configureStore({
|
||||
[recentApi.reducerPath]: recentApi.reducer,
|
||||
[deadApi.reducerPath]: deadApi.reducer,
|
||||
[scrobbledApi.reducerPath]: scrobbledApi.reducer,
|
||||
[scrobblerApi.reducerPath]: scrobblerApi.reducer,
|
||||
//parts: statusReducer
|
||||
clients: clientSlice.reducer,
|
||||
sources: sourceSlice.reducer,
|
||||
@@ -26,7 +28,7 @@ export const store = configureStore({
|
||||
// Adding the api middleware enables caching, invalidation, polling,
|
||||
// and other useful features of `rtk-query`.
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat([statusApi.middleware, logsApi.middleware, recentApi.middleware, scrobbledApi.middleware, deadApi.middleware]),
|
||||
getDefaultMiddleware().concat([statusApi.middleware, logsApi.middleware, recentApi.middleware, scrobbledApi.middleware, deadApi.middleware, scrobblerApi.middleware]),
|
||||
})
|
||||
|
||||
// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
|
||||
|
||||
+30
-2
@@ -37,7 +37,7 @@ export interface TrackStringOptions<T = string> {
|
||||
transformers?: {
|
||||
artists?: (a: string[]) => T | string
|
||||
track?: (t: string,data: AmbPlayObject, hasExistingParts?: boolean) => T | string
|
||||
time?: (t: Dayjs) => T | string
|
||||
time?: (t: Dayjs, i?: ScrobbleTsSOC) => T | string
|
||||
timeFromNow?: (t: Dayjs) => T | string
|
||||
reducer?: (arr: (T | string)[]) => T //(acc: T, curr: T | string) => T
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export interface TrackData {
|
||||
|
||||
meta?: {
|
||||
brainz?: {
|
||||
artist?: string
|
||||
artist?: string[]
|
||||
albumArtist?: string
|
||||
album?: string
|
||||
track?: string
|
||||
@@ -83,6 +83,7 @@ export interface PlayData extends TrackData {
|
||||
/** Number of seconds the track was listened to */
|
||||
listenedFor?: number
|
||||
listenRanges?: ListenRangeData[]
|
||||
playDateCompleted?: Dayjs | string
|
||||
}
|
||||
|
||||
export interface PlayMeta {
|
||||
@@ -122,9 +123,16 @@ export interface PlayMeta {
|
||||
|
||||
nowPlaying?: boolean
|
||||
|
||||
scrobbleTsSOC?: ScrobbleTsSOC
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type ScrobbleTsSOC = 1 | 2;
|
||||
|
||||
export const SCROBBLE_TS_SOC_START: ScrobbleTsSOC = 1;
|
||||
export const SCROBBLE_TS_SOC_END: ScrobbleTsSOC = 2;
|
||||
|
||||
export interface AmbPlayObject {
|
||||
data: PlayData,
|
||||
meta: PlayMeta
|
||||
@@ -140,10 +148,12 @@ export interface JsonPlayObject extends AmbPlayObject {
|
||||
|
||||
export interface ObjectPlayData extends PlayData {
|
||||
playDate?: Dayjs
|
||||
playDateCompleted?: Dayjs
|
||||
}
|
||||
|
||||
export interface JsonPlayData extends PlayData {
|
||||
playDate?: Dayjs
|
||||
playDateCompleted?: Dayjs
|
||||
}
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
|
||||
@@ -207,3 +217,21 @@ export interface DeadLetterScrobble<PlayType, RetryType = Dayjs> extends QueuedS
|
||||
|
||||
export type Second = number;
|
||||
export type Millisecond = number;
|
||||
|
||||
export type TemporalAccuracy = 1 | 2 | 3 | false;
|
||||
|
||||
export const TA_EXACT: TemporalAccuracy = 1;
|
||||
export const TA_CLOSE: TemporalAccuracy = 2;
|
||||
export const TA_FUZZY: TemporalAccuracy = 3;
|
||||
export const TA_NONE: TemporalAccuracy = false;
|
||||
|
||||
export interface TemporalPlayComparison {
|
||||
match: TemporalAccuracy
|
||||
date?: {
|
||||
threshold: number
|
||||
diff: number
|
||||
fuzzyDurationDiff?: number
|
||||
fuzzyListenedDiff?: number
|
||||
}
|
||||
range?: false | ListenRangeData
|
||||
}
|
||||
|
||||
+43
-6
@@ -4,7 +4,7 @@ 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 {AmbPlayObject, TrackStringOptions} from "./Atomic";
|
||||
import {AmbPlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, TrackStringOptions} from "./Atomic";
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
@@ -27,7 +27,7 @@ export const truncateStringToLength = (length: any, truncStr = '...') => (val: a
|
||||
export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input;
|
||||
export const defaultReducer = (acc, curr) => `${acc} ${curr}`;
|
||||
export const defaultArtistFunc = (a: string[]) => a.join(' / ');
|
||||
export const defaultTimeFunc = (t: Dayjs | undefined) => t === undefined ? '@ N/A' : `@ ${t.local().format()}`;
|
||||
export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t === undefined ? '@ N/A' : `@ ${t.local().format()} ${i === undefined ? '' : (i === SCROBBLE_TS_SOC_START ? '(S)' : '(C)')}`;
|
||||
export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;
|
||||
export const defaultBuildTrackStringTransformers = {
|
||||
artists: defaultArtistFunc,
|
||||
@@ -51,14 +51,23 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
playDate
|
||||
playDate,
|
||||
playDateCompleted
|
||||
} = {},
|
||||
meta: {
|
||||
trackId
|
||||
trackId,
|
||||
scrobbleTsSOC = SCROBBLE_TS_SOC_START
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
const pd = typeof playDate === 'string' ? dayjs(playDate) : playDate;
|
||||
let pd: Dayjs;
|
||||
let usedTsSOC: ScrobbleTsSOC = scrobbleTsSOC;
|
||||
if(scrobbleTsSOC === SCROBBLE_TS_SOC_END && playDateCompleted !== undefined) {
|
||||
pd = typeof playDateCompleted === 'string' ? dayjs(playDateCompleted) : playDateCompleted;
|
||||
} else {
|
||||
usedTsSOC = SCROBBLE_TS_SOC_START;
|
||||
pd = typeof playDate === 'string' ? dayjs(playDate) : playDate;
|
||||
}
|
||||
|
||||
const strParts: (T | string)[] = [];
|
||||
if (include.includes('trackId') && trackId !== undefined) {
|
||||
@@ -71,7 +80,7 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
|
||||
strParts.push(trackFunc(track, playObj, strParts.length > 0));
|
||||
}
|
||||
if (include.includes('time')) {
|
||||
strParts.push(timeFunc(pd));
|
||||
strParts.push(timeFunc(pd, usedTsSOC));
|
||||
}
|
||||
if (include.includes('timeFromNow')) {
|
||||
const tfn = timeFromNow(pd);
|
||||
@@ -98,3 +107,31 @@ export const slice = (str: string, index: number, count: number, add?: string):
|
||||
export const capitalize = (str: any) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a string-ish variable by a list of deliminators and return the first actually split array or default to returning the string as the first element.
|
||||
*
|
||||
* Returns empty array, or user defined value, if variable is undefined / null / not a string / or an empty string.
|
||||
* */
|
||||
export const splitByFirstFound = <T>(str: any, delims = [','], onNotAStringVal: T): string[] | T => {
|
||||
if(str === undefined || str === null || typeof str !== 'string' || str.trim() === '') {
|
||||
return onNotAStringVal;
|
||||
}
|
||||
for(const d of delims) {
|
||||
const split = d.split(d);
|
||||
if(split.length > 1) {
|
||||
return split;
|
||||
}
|
||||
}
|
||||
return [str];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns value if it is a non-empty string or returns default value
|
||||
* */
|
||||
export const nonEmptyStringOrDefault = <T>(str: any, defaultVal: T = undefined): string | T => {
|
||||
if (str === undefined || str === null || typeof str !== 'string' || str.trim() === '') {
|
||||
return defaultVal;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user