mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7037d6f833 | ||
|
|
2deca322e7 | ||
|
|
ab84cb396d | ||
|
|
bb63c61ca8 | ||
|
|
f4256d1084 | ||
|
|
fc87b86f52 | ||
|
|
5dff617ed6 | ||
|
|
f2a171c596 | ||
|
|
336970c1f7 | ||
|
|
d04bc038d4 | ||
|
|
1ab12355f3 | ||
|
|
fb5d9fb7b0 | ||
|
|
9aae57f966 | ||
|
|
fefc82c521 | ||
|
|
43ebdcb10c | ||
|
|
adfb166320 | ||
|
|
1c54ae6465 | ||
|
|
9309f7d8cf | ||
|
|
8717fad08a | ||
|
|
983b4c7417 | ||
|
|
a663e9af14 | ||
|
|
249a606bf5 | ||
|
|
0ec8d8814d | ||
|
|
f0cee72f43 | ||
|
|
a82aafa6b8 | ||
|
|
3b624205ad | ||
|
|
c6d38e40d0 | ||
|
|
1ca7834feb | ||
|
|
77c9977d27 | ||
|
|
521130562d | ||
|
|
9512ca7e84 | ||
|
|
159901f79e | ||
|
|
9758568ded | ||
|
|
a953f83d07 | ||
|
|
2bbe2e6ba0 | ||
|
|
ec4ab8c078 | ||
|
|
7885c4ca9c | ||
|
|
8371ec3a01 | ||
|
|
43cbadcd2d | ||
|
|
6e1e2c3534 | ||
|
|
a46389e745 | ||
|
|
ce5f8d5fca | ||
|
|
bdbbce4cb0 | ||
|
|
034cb321ef | ||
|
|
ac2d453aa2 | ||
|
|
860d2d953f | ||
|
|
a486102747 | ||
|
|
735264cb2b | ||
|
|
7dece447df | ||
|
|
3fb5d6aae9 | ||
|
|
d5dfc33fce | ||
|
|
6dc40e50ad | ||
|
|
b8ebd8ca7e | ||
|
|
e9eb7d357f | ||
|
|
3f2cb8ac0c | ||
|
|
8e36c27ec9 | ||
|
|
fa90a3b821 | ||
|
|
2d2319fa27 | ||
|
|
ba161b7b92 | ||
|
|
d438c8b256 | ||
|
|
bbb4555c0e | ||
|
|
6e8ea96f32 | ||
|
|
b06de77f42 | ||
|
|
3aa8279914 | ||
|
|
a64724e114 | ||
|
|
052234a3d9 | ||
|
|
aa2c565afd | ||
|
|
d0fd225d99 | ||
|
|
c40acdd7c7 | ||
|
|
590610022d | ||
|
|
f7dae32f50 | ||
|
|
3cbbc0837f | ||
|
|
0197e70a1a | ||
|
|
40a7d9e398 | ||
|
|
c7b2dcbbbe | ||
|
|
a524e18820 | ||
|
|
81894d691b | ||
|
|
0fb192dd41 | ||
|
|
3f608c6c8e | ||
|
|
dcff9ee73d | ||
|
|
f1cd66d3e8 | ||
|
|
d95fef09a3 | ||
|
|
d1060e724e | ||
|
|
a7faaaac86 | ||
|
|
3762f95bae | ||
|
|
b9f04deb28 | ||
|
|
9cc820c76e | ||
|
|
f7195fb40c | ||
|
|
9c0f029992 | ||
|
|
f370b8ad1e | ||
|
|
7c962ec43a | ||
|
|
2013a6692e | ||
|
|
4a5eb91f16 | ||
|
|
a606801bb0 | ||
|
|
db395e40e8 | ||
|
|
1c498a2c71 | ||
|
|
e4a647d425 | ||
|
|
c1a91ab610 | ||
|
|
093007a72b | ||
|
|
cb5cdb09f7 | ||
|
|
8f2e1ad7e7 | ||
|
|
ed6bcd60b3 | ||
|
|
edbe131f1b | ||
|
|
881e537a89 | ||
|
|
f856f79e51 | ||
|
|
b03cbe7a0a | ||
|
|
b90ea03924 | ||
|
|
5e905eae17 | ||
|
|
7b0f5801a1 | ||
|
|
8b3f4c636c | ||
|
|
7a0a23b7fd | ||
|
|
28910a9332 | ||
|
|
e369f9eb21 | ||
|
|
0c58cc6687 | ||
|
|
4a0cdc0e87 | ||
|
|
a54bc99925 |
+6
-4
@@ -1,4 +1,4 @@
|
||||
FROM lsiobase/alpine:3.17 as base
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
@@ -24,7 +24,8 @@ ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
RUN npm install -g patch-package
|
||||
RUN npm install -g patch-package \
|
||||
&& chown -R root:root /usr/local/lib/node_modules/patch-package
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -39,7 +40,8 @@ COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
|
||||
RUN npm install
|
||||
RUN npm install \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
@@ -72,7 +74,7 @@ ENV IS_DOCKER=true
|
||||
|
||||
RUN npm install --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown abc:abc node_modules \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/ts-node \
|
||||
&& rm -rf node_modules/typescript \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
@@ -12,7 +12,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
|
||||
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
|
||||
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
|
||||
@@ -44,7 +44,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src="/assets/status-ui.jpg" width="800">
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
@@ -21,6 +21,7 @@
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
"enable": true,
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
@@ -33,6 +34,7 @@
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"enable": true,
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJriver",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "0.0.0.0",
|
||||
"username": "auser",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyKodi",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:8080/jsonrpc",
|
||||
"username": "myUser",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "brainz",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "myMaloja",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyMopidy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "localhost",
|
||||
"uriBlacklist": [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"whitelist": ["vlc", "mpd"],
|
||||
"blacklist": ["spotify"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MySpotify",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MySubsonic",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyTautuilli",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "MyYTMusic",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
|
||||
|
||||
@@ -13,6 +13,10 @@ services:
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- /path_on_host/multi-scrobbler-config:/config
|
||||
#networks:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
* [Connection Issues](#connection-issues)
|
||||
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
|
||||
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
|
||||
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
|
||||
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
|
||||
* [Configuration Issues](#configuration-issues)
|
||||
* [Config could not be parsed](#config-could-not-be-parsed)
|
||||
@@ -95,6 +96,22 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
## Jellyfin has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
```
|
||||
[API] Jellyfin is not sending a request with valid headers...
|
||||
```
|
||||
|
||||
A workaround that may fix this:
|
||||
|
||||
* In Webhook settings:
|
||||
* [In the webhook you have already configured...](configuration/configuration.md#jellyfin)
|
||||
* Add Request Header...
|
||||
* **Key:** `Content-Type`
|
||||
* **Value:** `application/json`
|
||||
* Then Save
|
||||
|
||||
## Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
|
||||
@@ -168,6 +168,21 @@ Example directory structure:
|
||||
|
||||
See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below.
|
||||
|
||||
# Application Options
|
||||
|
||||
These options affect multi-scrobbler's behavior and are not specific to any source/client.
|
||||
|
||||
#### Base URL
|
||||
|
||||
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints.
|
||||
|
||||
* Default => `http://localhost`
|
||||
* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration)
|
||||
|
||||
EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm))
|
||||
|
||||
Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration.
|
||||
|
||||
# Source Configurations
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
@@ -217,7 +232,7 @@ See [`tautulli.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/mas
|
||||
|
||||
## [Subsonic](http://www.subsonic.org/)
|
||||
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) and supports the [`getNowPlaying`](http://www.subsonic.org/pages/api.jsp#getNowPlaying) endpoint (such as [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
|
||||
**Known Issues:**
|
||||
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
|
||||
@@ -255,6 +270,8 @@ Must be using Jellyfin 10.7 or greater
|
||||
* Check `Send All Properties`
|
||||
* Save
|
||||
|
||||
If you see errors in the MS logs regarding `missing headers` when using Jellyfin [see this workaround.](../FAQ.md#jellyfin-has-warnings-about-missing-headers)
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
@@ -268,7 +285,7 @@ See [`jellyfin.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/mas
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
See the [Last.fm (Client)](#lastfm) setup for registration instructions.
|
||||
See the [Last.fm (Client)](#lastfm) setup for registration instructions. You may need to disable "Hide recent listening information" on your [privacy page](https://www.last.fm/settings/privacy) for this to work.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
|
||||
|
||||
```shell
|
||||
flatpak run io.github.multiscrobbler
|
||||
flatpak run io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
@@ -84,7 +84,7 @@ Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise these will be lost when the container is updated.
|
||||
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
|
||||
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
|
||||
@@ -98,6 +98,20 @@ The default container port is `9078`. To map container to host port:
|
||||
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
|
||||
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
|
||||
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
@@ -120,6 +134,8 @@ To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
## Docker Usage Examples
|
||||
|
||||
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
|
||||
|
||||
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,7 +15,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
@@ -47,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src={require('/img/status-ui.jpg').default} width="800"/>
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"baseUrl": "http://localhost",
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
+9
-2
@@ -24,7 +24,7 @@ First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpa
|
||||
|
||||
Then, from MS project root:
|
||||
|
||||
```
|
||||
```shell
|
||||
flatpak-node-generator npm package-lock.json
|
||||
```
|
||||
|
||||
@@ -34,8 +34,15 @@ Move `generated-sources.json` into [`/flatpak`](/flatpak)
|
||||
|
||||
From MS project root:
|
||||
|
||||
```console
|
||||
```shell
|
||||
cd flatpak
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
|
||||
```
|
||||
Add `--install --user` to have the app installed immediately.
|
||||
# 5. Run (Locally)
|
||||
|
||||
If built with `--install --user` you can now run MS using the command
|
||||
|
||||
```shell
|
||||
flatpak run -u io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"logging": {
|
||||
"file": false
|
||||
}
|
||||
},
|
||||
"port": 9078
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.5.2" date="2023-09-21"/>
|
||||
<release version="0.5.0" date="2023-08-29"/>
|
||||
<release version="0.4.6" date="2023-06-07"/>
|
||||
<release version="0.4.4" date="2023-03-20"/>
|
||||
</releases>
|
||||
|
||||
@@ -24,10 +24,10 @@ modules:
|
||||
- npm install --offline
|
||||
|
||||
# build app
|
||||
- npm run build
|
||||
- NODE_ENV=production npm run build
|
||||
|
||||
# makes node and npm available
|
||||
- mkdir -p /app/bin /app/lib /app/lib/src
|
||||
- mkdir -p /app/bin /app/lib /app/lib/build
|
||||
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
|
||||
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
|
||||
- rm -r /app/lib/node_modules/npm/{docs,man}
|
||||
@@ -38,11 +38,8 @@ modules:
|
||||
# copy node_modules needed to run app
|
||||
- cp -r node_modules/* /app/lib/node_modules
|
||||
|
||||
# delete typescript files (not needed for running app)
|
||||
- find src/ -name "*.ts" -type f -delete
|
||||
|
||||
# copy app files to runtime dir
|
||||
- cp -r src/* /app/lib/src
|
||||
- cp -r build/* /app/lib/build
|
||||
- cp -r config /app/lib/config
|
||||
- cp flatpak/defaultConfig.json /app/lib/config/config.json
|
||||
|
||||
@@ -56,8 +53,10 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/FoxxMD/multi-scrobbler
|
||||
tag: 0.4.6
|
||||
commit: b90ea039245491767baeefc53966b0350016aa4d
|
||||
branch: develop
|
||||
# in official builds use tag/commit instead of branch
|
||||
# tag: 0.4.6
|
||||
# commit: ed6bcd60b3448397424ed7c14075479452ff566f
|
||||
dest: main
|
||||
# Wrapper to launch the app
|
||||
- type: script
|
||||
@@ -73,6 +72,6 @@ modules:
|
||||
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
|
||||
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
|
||||
fi
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true node src/index.js
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node build/server.js
|
||||
|
||||
- generated-sources.json
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"ext": "js"
|
||||
"ext": "js",
|
||||
"ignore": ["tailwind.config.js"]
|
||||
}
|
||||
|
||||
Generated
+491
-240
File diff suppressed because it is too large
Load Diff
+26
-16
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.4.8",
|
||||
"version": "0.5.2",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
|
||||
"schema-client": "typescript-json-schema tsconfig.json ClientConfig --out src/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-source": "typescript-json-schema tsconfig.json SourceConfig --out src/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aio": "typescript-json-schema tsconfig.json AIOConfig --out src/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema tsconfig.json AIOSourceConfig --out src/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema tsconfig.json AIOClientConfig --out src/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"typedoc": "typedoc",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "react-scripts test",
|
||||
@@ -19,15 +19,15 @@
|
||||
"dev:server:webpack": "webpack --config webpack.config.server.js",
|
||||
"dev:server:nodemon": "rm -f build/server.js && wait-on -l build/server.js && nodemon build/server.js",
|
||||
"build": "npm run -s build:client && npm run -s build:server",
|
||||
"build:client": "react-scripts build",
|
||||
"build:server": "webpack --config webpack.config.server.js",
|
||||
"build:client": "NODE_ENV=production react-scripts build",
|
||||
"build:server": "webpack --env production --config webpack.config.server.js",
|
||||
"start": "node build/server.js",
|
||||
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/common/infrastructure/typings/lastfm-node-client.d.ts",
|
||||
"import": "./src/index.js"
|
||||
"types": "./src/backend/common/infrastructure/typings/lastfm-node-client.d.ts",
|
||||
"import": "./src/backend/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -47,14 +47,15 @@
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@foxxmd/string-sameness": "^0.1.3",
|
||||
"@foxxmd/winston": "3.3.31",
|
||||
"@foxxmd/youtube-music-ts-api": "1.5.1",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@tanstack/react-query": "^4.32.1",
|
||||
"@types/string-similarity": "^4.0.0",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^7.2.4",
|
||||
"better-sse": "^0.8.0",
|
||||
@@ -64,6 +65,7 @@
|
||||
"concat-stream": "^2.0.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"dbus-next": "0.10.2",
|
||||
"dotenv": "^10.0.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
@@ -73,11 +75,12 @@
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"kodi-api": "^0.2.1",
|
||||
"ky": "^0.33.3",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"nanoid": "^3.3.1",
|
||||
"normalize-url": "^6.1.0",
|
||||
"ntfy": "1.0.5",
|
||||
"object-hash": "^3.0.0",
|
||||
"p-event": "^4.2.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-deezer": "^0.2.0",
|
||||
@@ -87,9 +90,11 @@
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"sass": "^1.66.1",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"triple-beam": "^1.3.0",
|
||||
"wait-on": "^7.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
@@ -97,10 +102,12 @@
|
||||
"winston-duplex": "0.1.3",
|
||||
"winston-null": "^2.0.0",
|
||||
"winston-transport": "^4.4.0",
|
||||
"xml2js": "^0.4.23"
|
||||
"xml2js": "^0.4.23",
|
||||
"youtube-music-ts-api": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -108,6 +115,7 @@
|
||||
"@tsconfig/node18": "^1.0.1",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/clone": "^2.1.2",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
@@ -117,6 +125,7 @@
|
||||
"@types/jscodeshift": "^0.11.6",
|
||||
"@types/mocha": "^9.1.0",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/object-hash": "^3.0.0",
|
||||
"@types/passport": "^1.0.12",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
@@ -127,6 +136,7 @@
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"clone": "^2.1.2",
|
||||
"concurrently": "^8.2.0",
|
||||
"jscodeshift": "^0.15.0",
|
||||
"mocha": "^9.2.1",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
export class UpstreamError<T = undefined> extends ErrorWithCause<T> {
|
||||
|
||||
showStopper: boolean = false;
|
||||
|
||||
constructor(message: string, options?: { cause?: T, showStopper?: boolean } | undefined) {
|
||||
super(message, options);
|
||||
const {showStopper = false} = options;
|
||||
this.showStopper = showStopper;
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,21 @@ export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => {
|
||||
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
|
||||
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
|
||||
|
||||
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 30;
|
||||
/**
|
||||
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
* */
|
||||
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 240;
|
||||
/**
|
||||
* https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
* */
|
||||
export const DEFAULT_SCROBBLE_PERCENT_THRESHOLD: number = 50;
|
||||
|
||||
export const DEFAULT_POLLING_INTERVAL: number = 10;
|
||||
export const DEFAULT_POLLING_MAX_INTERVAL = 30;
|
||||
|
||||
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
|
||||
|
||||
export interface ScrobbleThresholdResult {
|
||||
passes: boolean
|
||||
@@ -192,3 +206,14 @@ export interface numberFormatOptions {
|
||||
}
|
||||
|
||||
export const DELIMITERS = [',','&','\/','\\'];
|
||||
|
||||
export const ARTIST_WEIGHT = 0.3;
|
||||
export const TITLE_WEIGHT = 0.4;
|
||||
export const TIME_WEIGHT = 0.5;
|
||||
export const REFERENCE_WEIGHT = 0.5;
|
||||
export const DUP_SCORE_THRESHOLD = 1;
|
||||
|
||||
export interface SourceScrobble {
|
||||
source: string
|
||||
play: PlayObject
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ export interface AIOConfig {
|
||||
* */
|
||||
port?: number
|
||||
|
||||
/**
|
||||
* Set the Base URL the application should assume the UI is served from.
|
||||
*
|
||||
* This will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.
|
||||
*
|
||||
* It will NOT set the actual interface/IP that the application is listening on.
|
||||
*
|
||||
* This can also be set using the BASE_URL environmental variable.
|
||||
*
|
||||
* @default "http://localhost"
|
||||
* @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"]
|
||||
* */
|
||||
baseUrl?: string
|
||||
|
||||
logging?: LogOptions
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,14 @@ export interface CommonClientData extends CommonData {
|
||||
|
||||
match?: MatchLoggingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of times MS should automatically retry scrobbles in dead letter queue
|
||||
*
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
deadLetterRetries?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
data?: CommonData
|
||||
/**
|
||||
* Should MS use this client/source? Defaults to true
|
||||
*
|
||||
* @default true
|
||||
* @examples [true]
|
||||
* */
|
||||
enable?: boolean
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
@@ -30,16 +37,16 @@ export interface PollingOptions {
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* @default 10
|
||||
* @examples [10]
|
||||
* */
|
||||
interval?: number
|
||||
|
||||
/**
|
||||
* When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
|
||||
@@ -12,18 +12,31 @@ export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
|
||||
export interface ScrobbleThresholds {
|
||||
/**
|
||||
* The number of seconds before a track should be considered scrobbled.
|
||||
* The number of seconds a track has been listened to before it should be considered scrobbled.
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* Set to null to disable.
|
||||
*
|
||||
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* @seehttps://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
*
|
||||
* @default 240
|
||||
* @examples [240]
|
||||
* */
|
||||
duration?: number
|
||||
duration?: number | null
|
||||
/**
|
||||
* The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
|
||||
* The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
|
||||
*
|
||||
* Set to null to disable.
|
||||
*
|
||||
* NOTE: This should be used with care when the Source is a "polling" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not "seen" the track for long enough from first discovery, even if you have been playing the track for longer.
|
||||
*
|
||||
* @see https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?)
|
||||
* @see https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline
|
||||
*
|
||||
* @default 50
|
||||
* @examples [50]
|
||||
* */
|
||||
percent?: number
|
||||
percent?: number | null
|
||||
}
|
||||
|
||||
export interface CommonSourceOptions {
|
||||
@@ -57,6 +70,19 @@ export interface CommonSourceOptions {
|
||||
* @examples [false]
|
||||
* */
|
||||
logPlayerState?: boolean
|
||||
|
||||
/**
|
||||
* If this source
|
||||
*
|
||||
* * supports fetching a listen history
|
||||
* * and this option is enabled
|
||||
*
|
||||
* then on startup MS will attempt to scrobble the recent listens from that history
|
||||
*
|
||||
* @default true
|
||||
* @examples [true, false]
|
||||
* */
|
||||
scrobbleBacklog?: boolean
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData, SourceRetryOptions {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LastfmData } from "../client/lastfm";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import {PollingOptions} from "../common";
|
||||
|
||||
export interface LastFmSourceData extends CommonSourceData, LastfmData{}
|
||||
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
|
||||
|
||||
export interface LastfmSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index";
|
||||
import { ListenBrainzData } from "../client/listenbrainz";
|
||||
import {PollingOptions} from "../common";
|
||||
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData {
|
||||
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
|
||||
}
|
||||
|
||||
export interface ListenBrainzSourceConfig extends CommonSourceConfig {
|
||||
|
||||
@@ -29,10 +29,12 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
* Reading:
|
||||
* * https://developer.spotify.com/documentation/web-api/guides/rate-limits/
|
||||
* * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e
|
||||
* * The rate limit is ~180 req/min
|
||||
* * Rate limit may ~180 req/min
|
||||
* * https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930
|
||||
* * Informally indicated as 20 req/sec? Probably for burstiness
|
||||
*
|
||||
* @default 30
|
||||
* @examples [30]
|
||||
* @default 10
|
||||
* @examples [10]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -101,6 +110,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -203,6 +221,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -249,6 +276,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -319,6 +355,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -373,6 +418,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -139,6 +149,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -166,19 +185,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -265,6 +284,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -417,6 +445,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -444,19 +481,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -545,6 +582,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -601,6 +647,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -635,6 +690,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -733,6 +806,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -759,6 +841,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -929,6 +1029,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1075,6 +1184,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1119,6 +1237,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1270,16 +1397,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -1384,6 +1515,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1427,19 +1567,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1604,6 +1744,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1648,6 +1797,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1688,19 +1846,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1768,6 +1926,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -106,6 +115,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -212,6 +231,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -288,19 +316,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -387,6 +415,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -539,6 +576,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -566,19 +612,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -667,6 +713,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -723,6 +778,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -757,6 +821,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -850,6 +932,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -952,6 +1043,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -998,6 +1098,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -1093,6 +1202,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1119,6 +1237,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -1334,6 +1470,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1365,6 +1510,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -1419,6 +1573,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -1624,6 +1787,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1724,6 +1896,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1931,16 +2112,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -2054,6 +2239,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2097,19 +2291,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -2274,6 +2468,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2318,6 +2521,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2369,19 +2581,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -2449,6 +2661,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -2475,6 +2696,17 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"default": "http://localhost",
|
||||
"description": "Set the Base URL the application should assume the UI is served from.\n\nThis will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.\n\nIt will NOT set the actual interface/IP that the application is listening on.\n\nThis can also be set using the BASE_URL environmental variable.",
|
||||
"examples": [
|
||||
"http://localhost",
|
||||
"http://192.168.0.101",
|
||||
"https://ms.myDomain.tld"
|
||||
],
|
||||
"title": "baseUrl",
|
||||
"type": "string"
|
||||
},
|
||||
"clientDefaults": {
|
||||
"$ref": "#/definitions/RequestRetryOptions",
|
||||
"title": "clientDefaults"
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -98,6 +107,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -192,6 +210,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -230,6 +257,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
@@ -300,6 +336,15 @@
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
@@ -346,6 +391,15 @@
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "CommonSourceOptions",
|
||||
@@ -180,6 +190,15 @@
|
||||
"$ref": "#/definitions/DeezerData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -199,19 +218,19 @@
|
||||
"JRiverData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -298,6 +317,15 @@
|
||||
"$ref": "#/definitions/JRiverData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -442,6 +470,15 @@
|
||||
"$ref": "#/definitions/JellyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -461,19 +498,19 @@
|
||||
"KodiData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -562,6 +599,15 @@
|
||||
"$ref": "#/definitions/KodiData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -588,6 +634,24 @@
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -686,6 +750,15 @@
|
||||
"$ref": "#/definitions/LastFmSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -734,6 +807,15 @@
|
||||
"$ref": "#/definitions/ListenBrainzSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -752,6 +834,24 @@
|
||||
},
|
||||
"ListenBrainzSourceData": {
|
||||
"properties": {
|
||||
"interval": {
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
@@ -922,6 +1022,15 @@
|
||||
"$ref": "#/definitions/MPRISData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1060,6 +1169,15 @@
|
||||
"$ref": "#/definitions/MopidyData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1096,6 +1214,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1239,16 +1366,20 @@
|
||||
"ScrobbleThresholds": {
|
||||
"properties": {
|
||||
"duration": {
|
||||
"default": 30,
|
||||
"description": "The number of seconds before a track should be considered scrobbled.",
|
||||
"default": 240,
|
||||
"description": "The number of seconds a track has been listened to before it should be considered scrobbled.\n\nSet to null to disable.",
|
||||
"examples": [
|
||||
30
|
||||
240
|
||||
],
|
||||
"title": "duration",
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"default": 50,
|
||||
"description": "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nSet to null to disable.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"title": "percent",
|
||||
"type": "number"
|
||||
}
|
||||
@@ -1276,6 +1407,15 @@
|
||||
"$ref": "#/definitions/SpotifySourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1311,19 +1451,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min",
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * Rate limit may ~180 req/min\n* https://community.spotify.com/t5/Spotify-for-Developers/Web-API-ratelimit/m-p/5503150/highlight/true#M7930\n * Informally indicated as 20 req/sec? Probably for burstiness",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1402,6 +1542,15 @@
|
||||
"$ref": "#/definitions/SubsonicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1524,6 +1673,15 @@
|
||||
"$ref": "#/definitions/PlexSourceData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
@@ -1556,19 +1714,19 @@
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"default": 30,
|
||||
"default": 10,
|
||||
"description": "How long to wait before polling the source API for new tracks (in seconds)",
|
||||
"examples": [
|
||||
30
|
||||
10
|
||||
],
|
||||
"title": "interval",
|
||||
"type": "number"
|
||||
},
|
||||
"maxInterval": {
|
||||
"default": 60,
|
||||
"default": 30,
|
||||
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
|
||||
"examples": [
|
||||
60
|
||||
30
|
||||
],
|
||||
"title": "maxInterval",
|
||||
"type": "number"
|
||||
@@ -1636,6 +1794,15 @@
|
||||
"$ref": "#/definitions/YTMusicData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { capitalize, mergeArr } from "../../utils";
|
||||
import { mergeArr } from "../../utils";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import winston from '@foxxmd/winston';
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import {capitalize} from "../../../core/StringUtils";
|
||||
|
||||
export default abstract class AbstractApiClient {
|
||||
name: string;
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import {JRiverData} from "../infrastructure/config/source/jriver";
|
||||
import request, {Request, Response} from 'superagent';
|
||||
import xml2js from 'xml2js';
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {DEFAULT_RETRY_MULTIPLIER} from "../infrastructure/Atomic";
|
||||
|
||||
const parser = new xml2js.Parser({'async': true});
|
||||
|
||||
@@ -107,7 +108,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
if (this.token !== undefined) {
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@ import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from "
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import dayjs from "dayjs";
|
||||
import { readJson, sleep, writeFile } from "../../utils";
|
||||
import { FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions} from "../infrastructure/Atomic";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
|
||||
@@ -25,10 +25,10 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
user?: string;
|
||||
declare config: LastfmData;
|
||||
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string}, options = {}) {
|
||||
constructor(name: any, config: Partial<LastfmData> & {configDir: string, localUrl: string}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config!");
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(func: any, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
@@ -109,8 +109,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
|
||||
}
|
||||
|
||||
authenticate = async (token: any) => {
|
||||
@@ -146,6 +145,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
testAuth = async () => {
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
||||
+36
-12
@@ -1,22 +1,20 @@
|
||||
import AbstractApiClient from "./AbstractApiClient";
|
||||
import request, {Request} from 'superagent';
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz";
|
||||
import { DELIMITERS, FormatPlayObjectOptions } from "../infrastructure/Atomic";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions} from "../infrastructure/Atomic";
|
||||
import dayjs from "dayjs";
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import {
|
||||
containsDelimiters,
|
||||
findDelimiters,
|
||||
normalizeStr,
|
||||
parseArtistCredits,
|
||||
parseCredits,
|
||||
parseStringList,
|
||||
parseTrackCredits,
|
||||
unique,
|
||||
uniqueNormalizedStrArr,
|
||||
combinePartsToString,
|
||||
} from "../../utils";
|
||||
import { PlayObject } from "../../../core/Atomic";
|
||||
import { slice } from "../../../core/StringUtils";
|
||||
import {
|
||||
findDelimiters,
|
||||
normalizeStr, parseArtistCredits, parseCredits,
|
||||
parseTrackCredits,
|
||||
uniqueNormalizedStrArr
|
||||
} from "../../utils/StringUtils";
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -130,7 +128,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
@@ -185,9 +183,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
deadline: 30000 // wait 30 seconds overall for request to complete
|
||||
}));
|
||||
const {body: {payload}} = resp as any;
|
||||
// const data = payload as ListensResponse;
|
||||
// if(data.listens.length > 0) {}
|
||||
// return data.listens[0];
|
||||
return payload as ListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
const now = await this.getPlayingNow(user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
@@ -464,6 +483,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
recording_mbid: aRecordingMbid,
|
||||
duration: aDuration,
|
||||
duration_ms: aDurationMs,
|
||||
music_service_name,
|
||||
music_service,
|
||||
submission_client,
|
||||
submission_client_version
|
||||
} = {},
|
||||
mbid_mapping: {
|
||||
recording_mbid: mRecordingMbid
|
||||
@@ -509,7 +532,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
meta: {
|
||||
source: 'listenbrainz',
|
||||
trackId,
|
||||
playId
|
||||
playId,
|
||||
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -41,19 +41,32 @@ export interface MalojaV3ScrobbleData {
|
||||
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
|
||||
|
||||
export interface MalojaScrobbleRequestData {
|
||||
/** The auth key used to scrobble */
|
||||
key: string
|
||||
/** name of the track */
|
||||
title: string
|
||||
album: string
|
||||
/** name of the album */
|
||||
album?: string
|
||||
/** unix timestamp of when the track was scrobbled*/
|
||||
time: number
|
||||
length: number
|
||||
/** length of the track in seconds */
|
||||
length?: number
|
||||
/** number of seconds the track was listened to */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
|
||||
/** comma-separated list of artists for this track */
|
||||
artist: string
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
|
||||
/** a list of artists for this track */
|
||||
artists: string[]
|
||||
/** a list of artists for the album the track is on */
|
||||
albumartists: string[]
|
||||
/** skip server-side metadata parsing */
|
||||
nofix?: boolean
|
||||
}
|
||||
|
||||
interface MalojaScrobbleWarning {
|
||||
|
||||
+22
-2
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
@@ -18,6 +19,9 @@ import { getRoot } from "./ioc";
|
||||
import {getLogger} from "./common/logging";
|
||||
import {LogInfo} from "../core/Atomic";
|
||||
import {initServer} from "./server/index";
|
||||
import {SimpleIntervalJob, ToadScheduler} from "toad-scheduler";
|
||||
import {createHeartbeatSourcesTask} from "./tasks/heartbeatSources";
|
||||
import {createHeartbeatClientsTask} from "./tasks/heartbeatClients";
|
||||
|
||||
|
||||
dayjs.extend(utc)
|
||||
@@ -28,6 +32,8 @@ dayjs.extend(timezone);
|
||||
|
||||
(async function () {
|
||||
|
||||
const scheduler = new ToadScheduler()
|
||||
|
||||
let output: LogInfo[] = []
|
||||
|
||||
const initLogger = getLogger({file: false}, 'init');
|
||||
@@ -53,7 +59,6 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
|
||||
const {
|
||||
webhooks = [],
|
||||
port,
|
||||
logging = {},
|
||||
debugMode,
|
||||
} = (config || {}) as AIOConfig;
|
||||
@@ -67,7 +72,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
process.env.DEBUG_MODE = b.toString();
|
||||
}
|
||||
|
||||
const root = getRoot(port);
|
||||
const root = getRoot(config);
|
||||
|
||||
logger = getLogger(logging, 'app');
|
||||
|
||||
@@ -131,10 +136,25 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for(const client of scrobbleClients.clients) {
|
||||
if((await client.isReady())) {
|
||||
client.initScrobbleMonitoring();
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
logger.info(`Some sources are not ready, open the dashboard to continue`);
|
||||
}
|
||||
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatClientsTask(scrobbleClients, logger)));
|
||||
logger.info('Scheduler started.');
|
||||
|
||||
} catch (e) {
|
||||
logger.error('Exited with uncaught error');
|
||||
logger.error(e);
|
||||
|
||||
+36
-26
@@ -1,53 +1,63 @@
|
||||
import {createContainer} from "iti";
|
||||
import path from "path";
|
||||
import {projectDir} from "./common/index";
|
||||
import {configDir, projectDir} from "./common/index";
|
||||
import ScrobbleClients from "./scrobblers/ScrobbleClients";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources";
|
||||
import {Notifiers} from "./notifier/Notifiers";
|
||||
import {EventEmitter} from "events";
|
||||
import {logPath} from "./common/logging";
|
||||
import {Container} from '@foxxmd/winston';
|
||||
import { WildcardEmitter } from "./common/WildcardEmitter";
|
||||
//import ScrobbleClients from "./clients/ScrobbleClients.js";
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
//const port = process.env.PORT ?? 9078;
|
||||
|
||||
/*let logPath = path.resolve(projectDir, `./logs`);
|
||||
if(typeof process.env.CONFIG_DIR === 'string') {
|
||||
logPath = path.resolve(process.env.CONFIG_DIR, './logs');
|
||||
}*/
|
||||
import normalizeUrl from 'normalize-url';
|
||||
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
const createRoot = (port: number | string | undefined) => {
|
||||
export interface RootOptions {
|
||||
baseUrl?: string,
|
||||
port?: string | number
|
||||
}
|
||||
|
||||
const createRoot = (options?: RootOptions) => {
|
||||
const {
|
||||
port,
|
||||
baseUrl = process.env.BASE_URL,
|
||||
} = options || {};
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
const envPort = process.env.PORT;
|
||||
return createContainer().add({
|
||||
configDir: configDir,
|
||||
logDir: logPath,
|
||||
localUrl: `http://localhost:${port}`,
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
configPort: port,
|
||||
apiPort: process.env.API_PORT ?? 9079,
|
||||
mainPort: process.env.PORT ?? 3000,
|
||||
mainPort: envPort ?? 3000,
|
||||
clientEmitter: () => new WildcardEmitter(),
|
||||
sourceEmitter: () => new WildcardEmitter(),
|
||||
notifierEmitter: () => new EventEmitter(),
|
||||
}).add((items) => ({
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
|
||||
port: () => {
|
||||
if(items.configPort !== undefined) {
|
||||
return items.configPort;
|
||||
}
|
||||
return items.isProd ? items.mainPort : items.apiPort;
|
||||
}).add((items) => {
|
||||
let usedPort = items.configPort ?? envPort;
|
||||
if (usedPort === undefined) {
|
||||
usedPort = items.isProd ? items.mainPort : items.apiPort;
|
||||
}
|
||||
}));
|
||||
const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true});
|
||||
const u = new URL(base);
|
||||
let localUrl = u.toString();
|
||||
if(u.port === '' && u.pathname === '/') {
|
||||
localUrl = `${u.origin}:${items.mainPort}`;
|
||||
}
|
||||
return {
|
||||
port: usedPort,
|
||||
clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir),
|
||||
sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir),
|
||||
notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter),
|
||||
localUrl,
|
||||
hasDefinedBaseUrl: baseUrl !== undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getRoot = (port?: number | string) => {
|
||||
export const getRoot = (options?: RootOptions) => {
|
||||
if(root === undefined) {
|
||||
root = createRoot(port);
|
||||
root = createRoot(options);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
capitalize,
|
||||
comparingMultipleArtists,
|
||||
isPlayTemporallyClose,
|
||||
mergeArr,
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
playObjDataMatch, pollingBackoff,
|
||||
setIntersection, sleep, sortByOldestPlayDate,
|
||||
} from "../utils";
|
||||
import {
|
||||
ClientType,
|
||||
ARTIST_WEIGHT,
|
||||
ClientType, DEFAULT_RETRY_MULTIPLIER, DUP_SCORE_THRESHOLD,
|
||||
FormatPlayObjectOptions,
|
||||
INITIALIZED,
|
||||
INITIALIZING,
|
||||
InitState,
|
||||
NOT_INITIALIZED,
|
||||
ScrobbledPlayObject,
|
||||
NOT_INITIALIZED, REFERENCE_WEIGHT,
|
||||
ScrobbledPlayObject, SourceScrobble, TIME_WEIGHT, TITLE_WEIGHT,
|
||||
} from "../common/infrastructure/Atomic";
|
||||
import winston, {Logger} from '@foxxmd/winston';
|
||||
import { CommonClientConfig } from "../common/infrastructure/config/client/index";
|
||||
@@ -21,13 +22,20 @@ import { ClientConfig } from "../common/infrastructure/config/client/clients";
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import {FixedSizeList} from 'fixed-size-list';
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils";
|
||||
import {buildTrackString, capitalize, truncateStringToLength} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {compareScrobbleArtists, compareScrobbleTracks, normalizeStr} from "../utils/StringUtils";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
import {nanoid} from "nanoid";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {de} from "@faker-js/faker";
|
||||
import {del} from "superagent";
|
||||
|
||||
export default abstract class AbstractScrobbleClient {
|
||||
|
||||
name: string;
|
||||
type: ClientType;
|
||||
identifier: string;
|
||||
|
||||
#initState: InitState = NOT_INITIALIZED;
|
||||
|
||||
@@ -37,17 +45,26 @@ export default abstract class AbstractScrobbleClient {
|
||||
requiresAuthInteraction: boolean = false;
|
||||
authed: boolean = false;
|
||||
|
||||
recentScrobbles: PlayObject[] = [];
|
||||
#recentScrobblesList: PlayObject[] = [];
|
||||
scrobbledPlayObjs: FixedSizeList<ScrobbledPlayObject>;
|
||||
newestScrobbleTime?: Dayjs
|
||||
oldestScrobbleTime?: Dayjs
|
||||
tracksScrobbled: number = 0;
|
||||
|
||||
lastScrobbleCheck: Dayjs = dayjs(0)
|
||||
lastScrobbleAttempt: Dayjs = dayjs(0)
|
||||
refreshEnabled: boolean;
|
||||
checkExistingScrobbles: boolean;
|
||||
verboseOptions;
|
||||
|
||||
scrobbleDelay: number = 1000;
|
||||
scrobbleSleep: number = 2000;
|
||||
scrobbleRetries: number = 0;
|
||||
scrobbling: boolean = false;
|
||||
userScrobblingStopSignal: undefined | any;
|
||||
queuedScrobbles: (SourceScrobble & {id: string})[] = [];
|
||||
deadLetterScrobbles: (SourceScrobble & {id: string, retries: number})[] = [];
|
||||
|
||||
config: CommonClientConfig;
|
||||
logger: Logger;
|
||||
|
||||
@@ -57,8 +74,8 @@ export default abstract class AbstractScrobbleClient {
|
||||
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = logger.child({labels: [identifier]}, mergeArr);
|
||||
this.identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = logger.child({labels: [this.identifier]}, mergeArr);
|
||||
this.notifier = notifier;
|
||||
this.emitter = emitter;
|
||||
|
||||
@@ -71,7 +88,7 @@ export default abstract class AbstractScrobbleClient {
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
},
|
||||
} = {},
|
||||
} = config;
|
||||
this.config = config;
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
@@ -98,6 +115,16 @@ export default abstract class AbstractScrobbleClient {
|
||||
};
|
||||
}
|
||||
|
||||
set recentScrobbles(scrobbles: PlayObject[]) {
|
||||
const sorted = [...scrobbles];
|
||||
sorted.sort(sortByOldestPlayDate);
|
||||
this.#recentScrobblesList = sorted;
|
||||
}
|
||||
|
||||
get recentScrobbles() {
|
||||
return this.#recentScrobblesList;
|
||||
}
|
||||
|
||||
get initialized() {
|
||||
return this.#initState === INITIALIZED;
|
||||
}
|
||||
@@ -137,11 +164,7 @@ export default abstract class AbstractScrobbleClient {
|
||||
this.logger.debug('Scrobbler does not have refresh function implemented!');
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => {
|
||||
this.logger.debug('Scrobbler does not have alreadyScrobbled check implemented!');
|
||||
return false;
|
||||
}
|
||||
|
||||
public abstract alreadyScrobbled(playObj: PlayObject, log?: boolean): Promise<boolean>;
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
@@ -175,21 +198,16 @@ export default abstract class AbstractScrobbleClient {
|
||||
|
||||
addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => {
|
||||
this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay});
|
||||
this.tracksScrobbled++;
|
||||
}
|
||||
|
||||
filterScrobbledTracks = () => {
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ;
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
return track.toLocaleLowerCase().trim();
|
||||
};
|
||||
getScrobbledPlays = () => {
|
||||
return this.scrobbledPlayObjs.data.map(x => x.scrobble);
|
||||
}
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
const {
|
||||
@@ -204,25 +222,12 @@ export default abstract class AbstractScrobbleClient {
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play));
|
||||
|
||||
if (dtInvariantMatches.length === 0) {
|
||||
return [undefined, undefined];
|
||||
return [undefined, []];
|
||||
}
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
|
||||
const {
|
||||
play: {
|
||||
data: {
|
||||
playDate: sPlayDate
|
||||
} = {},
|
||||
meta: {
|
||||
source: playSource
|
||||
} = {},
|
||||
} = {},
|
||||
} = x;
|
||||
// need to account for inaccurate DT from subsonic
|
||||
if(source === 'Subsonic' && playSource === 'Subsonic') {
|
||||
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
|
||||
}
|
||||
return playDate.isSame(sPlayDate);
|
||||
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x.play, playObj);
|
||||
return closeTime;
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
@@ -237,48 +242,30 @@ export default abstract class AbstractScrobbleClient {
|
||||
return [closeTime, fuzzyTime];
|
||||
}
|
||||
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
track: scrobbleTitle,
|
||||
} = {},
|
||||
} = existing;
|
||||
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(candidate);
|
||||
|
||||
let titleMatch;
|
||||
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
|
||||
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
|
||||
// and compare against length of scrobble title
|
||||
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== ''));
|
||||
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
|
||||
|
||||
titleMatch = commonTerms.size / sourceTitleTerms.size;
|
||||
return titleMatch;
|
||||
return Math.min(compareScrobbleTracks(existing, candidate)/100, 1);
|
||||
}
|
||||
|
||||
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): number => {
|
||||
protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): [number, number] => {
|
||||
const {
|
||||
data: {
|
||||
artists: sourceArtists = [],
|
||||
} = {},
|
||||
} = candidate;
|
||||
artists: existingArtists = [],
|
||||
} = {}
|
||||
} = existing;
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
} = {},
|
||||
artists: candidateArtists = [],
|
||||
} = {}
|
||||
} = candidate;
|
||||
let artistMatch;
|
||||
const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
|
||||
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
|
||||
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
|
||||
const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true}));
|
||||
const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true}));
|
||||
|
||||
return artistMatch;
|
||||
const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size;
|
||||
return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches]
|
||||
}
|
||||
|
||||
existingScrobble = async (playObj: PlayObject) => {
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}};
|
||||
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}};
|
||||
|
||||
// return early if we don't care about checking existing
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
@@ -289,7 +276,7 @@ export default abstract class AbstractScrobbleClient {
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']};
|
||||
let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'None'};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
@@ -300,7 +287,9 @@ export default abstract class AbstractScrobbleClient {
|
||||
|
||||
closestMatch = {
|
||||
score: 1,
|
||||
breakdowns: ['Exact Match found in previously successfully scrobbled']
|
||||
scrobble: existingScrobble,
|
||||
breakdowns: [],
|
||||
confidence: 'Exact Match found in previously successfully scrobbled plays'
|
||||
}
|
||||
}
|
||||
// if not though then we need to check recent scrobbles from scrobble api.
|
||||
@@ -317,66 +306,367 @@ export default abstract class AbstractScrobbleClient {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// we have have found an existing submission but without an exact date
|
||||
// we have found an existing submission but without an exact date
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
// clean source title so it matches title from the scrobble api response as closely as we can get it
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((x) => {
|
||||
|
||||
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
//const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
|
||||
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
|
||||
const timeMatch = (closeTime ? 1 : (fuzzyTime ? 0.6 : 0));
|
||||
|
||||
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
|
||||
|
||||
const artistMatch = this.compareExistingScrobbleArtist(x, playObj);
|
||||
const [artistMatch, wholeMatches] = this.compareExistingScrobbleArtist(x, playObj);
|
||||
|
||||
const artistScore = .2 * artistMatch;
|
||||
const titleScore = .3 * titleMatch;
|
||||
const timeScore = .5 * (closeTime ? 1 : (fuzzyTime ? 0.5 : 0));
|
||||
const referenceScore = .5 * (referenceMatch ? 1 : 0);
|
||||
const score = artistScore + titleScore + timeScore;
|
||||
let artistScore = ARTIST_WEIGHT * artistMatch;
|
||||
const titleScore = TITLE_WEIGHT * titleMatch;
|
||||
const timeScore = TIME_WEIGHT * timeMatch;
|
||||
//const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0);
|
||||
let score = artistScore + titleScore + timeScore;
|
||||
|
||||
let artistWholeMatchBonus = 0;
|
||||
let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`;
|
||||
|
||||
if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) {
|
||||
// address scenario where:
|
||||
// * title is very close
|
||||
// * time falls within plausible dup range
|
||||
// * artist is not totally different
|
||||
// * AND score is still not high enough for a dup
|
||||
//
|
||||
// if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score)
|
||||
// then bump artist score a little to see if it gets it over the fence
|
||||
//
|
||||
// EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match
|
||||
// one play is only returning primary artist, and timestamp is at beginning instead of end of play
|
||||
|
||||
const scoreBonus = artistMatch * 0.5;
|
||||
const scoreGapBonus = (1 - artistMatch) * 0.75;
|
||||
// use the smallest bump or 0.1
|
||||
artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1);
|
||||
artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus);
|
||||
score = artistScore + titleScore + timeScore;
|
||||
artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`;
|
||||
}
|
||||
|
||||
let scoreBreakdowns = [
|
||||
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
|
||||
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
|
||||
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
|
||||
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
//`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
|
||||
artistBreakdown,
|
||||
`Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
|
||||
`Time: ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
|
||||
];
|
||||
|
||||
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
|
||||
|
||||
const scoreInfo = {
|
||||
score,
|
||||
scrobble: x,
|
||||
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
|
||||
confidence,
|
||||
breakdowns: scoreBreakdowns
|
||||
}
|
||||
|
||||
if (closestMatch.score <= score && score > 0) {
|
||||
closestMatch = scoreInfo
|
||||
}
|
||||
|
||||
return score >= .7;
|
||||
return score >= DUP_SCORE_THRESHOLD;
|
||||
});
|
||||
}
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
|
||||
const closestScrobble = `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.confidence}`;
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
|
||||
if (this.verboseOptions.match.confidenceBreakdown === true) {
|
||||
this.logger.debug(`Breakdown:
|
||||
${closestMatch.breakdowns.join('\n')}`);
|
||||
}
|
||||
}
|
||||
return existingScrobble;
|
||||
}
|
||||
|
||||
public abstract scrobble(playObj: PlayObject): Promise<boolean>
|
||||
public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false }): Promise<PlayObject> => {
|
||||
const {delay} = opts || {};
|
||||
const scrobbleDelay = delay === undefined ? this.scrobbleDelay : (delay === false ? 0 : delay);
|
||||
if (scrobbleDelay !== 0) {
|
||||
const lastScrobbleDiff = dayjs().diff(this.lastScrobbleAttempt, 'ms');
|
||||
const remainingDelay = scrobbleDelay - lastScrobbleDiff;
|
||||
if (remainingDelay > 0) {
|
||||
this.logger.debug(`Waiting ${remainingDelay}ms to scrobble so time passed since previous scrobble is at least ${scrobbleDelay}ms`);
|
||||
await sleep(scrobbleDelay);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return this.doScrobble(playObj);
|
||||
} finally {
|
||||
this.lastScrobbleAttempt = dayjs();
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract doScrobble(playObj: PlayObject): Promise<PlayObject>
|
||||
|
||||
initScrobbleMonitoring = async () => {
|
||||
if(!this.initialized) {
|
||||
if(this.initializing) {
|
||||
this.logger.warn(`Cannot start scrobble processing because client is still initializing`);
|
||||
return;
|
||||
}
|
||||
if(!(await this.initialize())) {
|
||||
this.logger.warn(`Cannot start scrobble processing because client could not be initialized`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if(this.requiresAuth && !this.authed) {
|
||||
if (this.requiresAuthInteraction) {
|
||||
this.logger.warn(`Cannot start scrobble processing because user interaction is required for authentication`);
|
||||
return;
|
||||
} else if (!(await this.testAuth())) {
|
||||
this.logger.warn(`Cannot start scrobble processing because auth test failed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if(!(await this.isReady())) {
|
||||
this.logger.warn(`Cannot start scrobble processing because client is not ready`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScrobbling();
|
||||
}
|
||||
|
||||
startScrobbling = async () => {
|
||||
// reset poll attempts if already previously run
|
||||
this.scrobbleRetries = 0;
|
||||
|
||||
const {
|
||||
data: {
|
||||
maxPollRetries = 5,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER,
|
||||
} = {},
|
||||
} = this.config;
|
||||
|
||||
// can't have negative retries!
|
||||
const maxRetries = Math.max(0, maxPollRetries);
|
||||
|
||||
if(this.scrobbling === true) {
|
||||
this.logger.warn(`Already scrobble processing! Processing needs to be stopped before it can be started`);
|
||||
return;
|
||||
}
|
||||
|
||||
let pollRes: boolean | undefined = undefined;
|
||||
while (pollRes === undefined && this.scrobbleRetries <= maxRetries) {
|
||||
try {
|
||||
pollRes = await this.doProcessing();
|
||||
if(pollRes === true) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if(!this.initialized) {
|
||||
this.logger.warn('Stopping scrobble processing due to client no longer being initialized.');
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer initialized, stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
break;
|
||||
} else if (this.requiresAuth && !this.authed) {
|
||||
this.logger.warn('Stopping scrobble processing due to client no longer being authenticated.');
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and client is no longer authenticated, stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
break;
|
||||
} else if (this.scrobbleRetries < maxRetries) {
|
||||
const delayFor = pollingBackoff(this.scrobbleRetries + 1, retryMultiplier);
|
||||
this.logger.info(`Scrobble processing retries (${this.scrobbleRetries}) less than max processing retries (${maxRetries}), restarting processing after ${delayFor} second delay...`);
|
||||
await this.notifier.notify({title: `Client - ${this.name} - Processing Retry`, message: `Encountered error while polling but retries (${this.scrobbleRetries}) are less than max poll retries (${maxRetries}), restarting processing after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'});
|
||||
await sleep((delayFor) * 1000);
|
||||
} else {
|
||||
this.logger.warn(`Scrobble processing retries (${this.scrobbleRetries}) equal to max processing retries (${maxRetries}), stopping processing!`);
|
||||
await this.notifier.notify({title: `Client - ${this.identifier} - Processing Error`, message: `Encountered error while scrobble processing and retries (${this.scrobbleRetries}) are equal to max processing retries (${maxRetries}), stopping processing!. | Error: ${e.message}`, priority: 'error'});
|
||||
}
|
||||
this.scrobbleRetries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tryStopScrobbling = async () => {
|
||||
if(this.scrobbling === false) {
|
||||
this.logger.warn(`Polling is already stopped!`);
|
||||
return;
|
||||
}
|
||||
this.userScrobblingStopSignal = true;
|
||||
let secsPassed = 0;
|
||||
while(this.userScrobblingStopSignal !== undefined && secsPassed < 10) {
|
||||
await sleep(2000);
|
||||
secsPassed += 2;
|
||||
this.logger.verbose(`Waiting for scrobble processing stop signal to be acknowledged (waited ${secsPassed}s)`);
|
||||
}
|
||||
if(this.userScrobblingStopSignal !== undefined) {
|
||||
this.logger.warn('Could not stop scrobble processing! Or signal was lost :(');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected doStopScrobbling = (reason: string = 'system') => {
|
||||
this.scrobbling = false;
|
||||
this.userScrobblingStopSignal = undefined;
|
||||
this.logger.info(`Stopped scrobble processing due to: ${reason}`);
|
||||
}
|
||||
|
||||
protected shouldStopScrobbleProcessing = () => this.scrobbling === false || this.userScrobblingStopSignal !== undefined;
|
||||
|
||||
protected doProcessing = async (): Promise<true | undefined> => {
|
||||
if (this.scrobbling === true) {
|
||||
return true;
|
||||
}
|
||||
this.logger.info('Scrobble processing started');
|
||||
|
||||
try {
|
||||
this.scrobbling = true;
|
||||
while (!this.shouldStopScrobbleProcessing()) {
|
||||
while (this.queuedScrobbles.length > 0) {
|
||||
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
|
||||
await this.refreshScrobbles();
|
||||
}
|
||||
const currQueuedPlay = this.queuedScrobbles[0];
|
||||
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play);
|
||||
if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) {
|
||||
try {
|
||||
const scrobbledPlay = await this.scrobble(currQueuedPlay.play);
|
||||
this.emitEvent('scrobble', {play: currQueuedPlay.play});
|
||||
this.addScrobbledTrack(currQueuedPlay.play, scrobbledPlay);
|
||||
} catch (e) {
|
||||
if (e instanceof UpstreamError && e.showStopper === false) {
|
||||
this.addDeadLetterScrobble(currQueuedPlay);
|
||||
this.logger.warn(`Could not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`);
|
||||
} else {
|
||||
const processError = new ErrorWithCause('Error occurred while trying to scrobble', {cause: e});
|
||||
//this.logger.error(processError);
|
||||
throw processError;
|
||||
}
|
||||
}
|
||||
} else if (!timeFrameValid) {
|
||||
this.logger.debug(`Will not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' because it ${timeFrameValidLog}`);
|
||||
}
|
||||
// processing play may have changed index while we were scrobbling
|
||||
const pIndex = this.queuedScrobbles.findIndex(x => x.id === currQueuedPlay.id);
|
||||
if (pIndex !== -1) {
|
||||
this.queuedScrobbles.splice(pIndex, 1);
|
||||
}
|
||||
}
|
||||
await sleep(this.scrobbleSleep);
|
||||
}
|
||||
if (this.shouldStopScrobbleProcessing()) {
|
||||
this.doStopScrobbling(this.userScrobblingStopSignal !== undefined ? 'user input' : undefined);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Scrobble processing interrupted');
|
||||
this.logger.error(e);
|
||||
this.scrobbling = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
processDeadLetterQueue = async (attemptWithRetries?: number) => {
|
||||
|
||||
if (this.deadLetterScrobbles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
options: {
|
||||
deadLetterRetries = 1
|
||||
} = {}
|
||||
} = this.config.data;
|
||||
|
||||
const retries = attemptWithRetries ?? deadLetterRetries;
|
||||
|
||||
const processable = this.deadLetterScrobbles.filter(x => x.retries < retries);
|
||||
this.logger.info(`${processable.length} of ${this.deadLetterScrobbles.length} dead scrobbles have less than ${retries} retries, ${processable.length === 0 ? 'will skip processing.': 'processing now...'}`, {leaf: 'Dead Letter'});
|
||||
if (processable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idsToRemove = [];
|
||||
for (const deadScrobble of this.deadLetterScrobbles) {
|
||||
if (deadScrobble.retries < retries) {
|
||||
const scrobbled = await this.processDeadLetterScrobble(deadScrobble.id);
|
||||
if (scrobbled) {
|
||||
idsToRemove.push(deadScrobble.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (idsToRemove.length > 0) {
|
||||
this.deadLetterScrobbles = this.deadLetterScrobbles.filter(x => !idsToRemove.includes(x.id));
|
||||
this.logger.info(`Removed ${idsToRemove.length} scrobbles from dead letter queue`, {leaf: 'Dead Letter'});
|
||||
}
|
||||
}
|
||||
|
||||
processDeadLetterScrobble = async (id: string) => {
|
||||
if (!(await this.isReady())) {
|
||||
this.logger.warn('Cannot process dead letter scrobble because client is not ready.', {leaf: 'Dead Letter'});
|
||||
return;
|
||||
}
|
||||
const deadScrobbleIndex = this.deadLetterScrobbles.findIndex(x => x.id === id);
|
||||
const deadScrobble = this.deadLetterScrobbles[deadScrobbleIndex];
|
||||
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
|
||||
await this.refreshScrobbles();
|
||||
}
|
||||
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(deadScrobble.play);
|
||||
if (timeFrameValid && !(await this.alreadyScrobbled(deadScrobble.play))) {
|
||||
try {
|
||||
const scrobbledPlay = await this.scrobble(deadScrobble.play);
|
||||
this.emitEvent('scrobble', {play: deadScrobble.play});
|
||||
this.addScrobbledTrack(deadScrobble.play, scrobbledPlay);
|
||||
} catch (e) {
|
||||
deadScrobble.retries++;
|
||||
this.logger.error(`Could not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' due to error`, {leaf: 'Dead Letter'});
|
||||
this.logger.error(e);
|
||||
this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
|
||||
return false;
|
||||
} finally {
|
||||
await sleep(1000);
|
||||
}
|
||||
} else if (!timeFrameValid) {
|
||||
this.logger.debug(`Will not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' because it ${timeFrameValidLog}`, {leaf: 'Dead Letter'});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
removeDeadLetterScrobble = (id: string) => {
|
||||
const index = this.deadLetterScrobbles.findIndex(x => x.id === id);
|
||||
if (index === -1) {
|
||||
this.logger.warn(`No scrobble found with ID ${id}`, {leaf: 'Dead Letter'});
|
||||
}
|
||||
this.logger.debug(`Removed scrobble ${buildTrackString(this.deadLetterScrobbles[index].play)} from queue`, {leaf: 'Dead Letter'});
|
||||
this.deadLetterScrobbles.splice(index, 1);
|
||||
}
|
||||
|
||||
protected getLatestQueuePlayDate = () => {
|
||||
if (this.queuedScrobbles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return this.queuedScrobbles[this.queuedScrobbles.length - 1].play.data.playDate;
|
||||
}
|
||||
|
||||
queueScrobble = (data: PlayObject | PlayObject[], source: string) => {
|
||||
const plays = Array.isArray(data) ? data : [data];
|
||||
for(const p of plays) {
|
||||
this.queuedScrobbles.push({id: nanoid(), source, play: p});
|
||||
}
|
||||
this.queuedScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play));
|
||||
}
|
||||
|
||||
protected addDeadLetterScrobble = (data: SourceScrobble & { id?: string, retries?: number }) => {
|
||||
this.deadLetterScrobbles.push({id: nanoid(), retries: 0, ...data});
|
||||
this.deadLetterScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play));
|
||||
}
|
||||
|
||||
|
||||
public emitEvent = (eventName: string, payload: object) => {
|
||||
this.emitter.emit(eventName, {
|
||||
...payload,
|
||||
data: payload,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
from: 'client'
|
||||
|
||||
@@ -2,7 +2,6 @@ import AbstractScrobbleClient from "./AbstractScrobbleClient";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
capitalize,
|
||||
playObjDataMatch,
|
||||
removeUndefinedKeys,
|
||||
setIntersection,
|
||||
@@ -16,8 +15,9 @@ import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-cl
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
this.logger.debug(x);
|
||||
return acc;
|
||||
}
|
||||
}, []).sort(sortByOldestPlayDate);
|
||||
}, []);
|
||||
this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
@@ -119,7 +119,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
@@ -186,9 +186,8 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
} = response;
|
||||
if(code === 5) {
|
||||
this.initialized = false;
|
||||
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
|
||||
throw new UpstreamError('LastFM API reported daily scrobble limit exceeded! 😬 Disabling client', {showStopper: true});
|
||||
}
|
||||
this.addScrobbledTrack(playObj, this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName}));
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
@@ -197,18 +196,22 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
if(ignored > 0) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)}`, priority: 'warn'});
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/errorcodes for more information`, {payload: scrobblePayload});
|
||||
throw new UpstreamError('LastFM ignored scrobble', {showStopper: false});
|
||||
}
|
||||
|
||||
return this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName});
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
await sleep(1000);
|
||||
//await sleep(1000);
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobblePayload});
|
||||
throw e;
|
||||
if(!(e instanceof UpstreamError)) {
|
||||
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload: ', rawPayload);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import dayjs from 'dayjs';
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient";
|
||||
import {
|
||||
capitalize,
|
||||
playObjDataMatch,
|
||||
removeUndefinedKeys,
|
||||
setIntersection,
|
||||
@@ -16,8 +15,9 @@ import {Logger} from '@foxxmd/winston';
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz";
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient";
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES);
|
||||
this.logger.debug(`Found ${resp.length} recent scrobbles`);
|
||||
this.recentScrobbles = resp.sort(sortByOldestPlayDate);
|
||||
this.recentScrobbles = resp;
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
@@ -86,7 +86,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
source,
|
||||
@@ -99,15 +99,15 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
try {
|
||||
const resp = await this.api.submitListen(playObj);
|
||||
rawPayload = resp;
|
||||
this.addScrobbledTrack(playObj, playObj);
|
||||
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
await sleep(1000);
|
||||
//await sleep(1000);
|
||||
return playObj;
|
||||
} catch (e) {
|
||||
let message = e.message;
|
||||
if(e.response !== undefined) {
|
||||
@@ -119,11 +119,9 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'});
|
||||
this.logger.error(`Failed to scrobble => ${message}`, {payload: rawPayload});
|
||||
throw e;
|
||||
throw new UpstreamError(`Error received from Listenbrainz API: ${message}`, {cause: e, showStopper: true});
|
||||
} finally {
|
||||
this.logger.debug(`Raw Payload:`, {rawPayload});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
sleep,
|
||||
sortByOldestPlayDate,
|
||||
parseRetryAfterSecsFromObj,
|
||||
capitalize,
|
||||
isPlayTemporallyClose,
|
||||
} from "../utils";
|
||||
import { FormatPlayObjectOptions, INITIALIZING } from "../common/infrastructure/Atomic";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, INITIALIZING} from "../common/infrastructure/Atomic";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja";
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import {Logger} from '@foxxmd/winston';
|
||||
@@ -22,8 +21,10 @@ import {
|
||||
MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaV2ScrobbleData, MalojaV3ScrobbleData
|
||||
} from "../common/vendor/maloja/interfaces";
|
||||
import { PlayObject, TrackStringOptions } from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
import EventEmitter from "events";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {UpstreamError} from "../common/errors/UpstreamError";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
@@ -32,6 +33,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
requiresAuth = true;
|
||||
serverIsHealthy = false;
|
||||
serverVersion: any;
|
||||
webUrl: string;
|
||||
|
||||
declare config: MalojaClientConfig
|
||||
|
||||
@@ -44,6 +46,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
if (url === undefined) {
|
||||
throw new Error("Missing 'url' for Maloja config");
|
||||
}
|
||||
this.webUrl = normalizeUrl(url);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
@@ -53,7 +56,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
duration,
|
||||
time;
|
||||
|
||||
const {serverVersion} = options;
|
||||
const {serverVersion, url} = options;
|
||||
|
||||
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
|
||||
// scrobble data structure changed for v3
|
||||
@@ -107,6 +110,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
@@ -117,16 +121,19 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
url: {
|
||||
web: `${url}/track?${urlParams.toString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion});
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl});
|
||||
|
||||
callApi = async (req: any, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config.data;
|
||||
|
||||
try {
|
||||
@@ -318,7 +325,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} = {},
|
||||
} = resp;
|
||||
this.logger.debug(`Found ${list.length} recent scrobbles`);
|
||||
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate);
|
||||
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x));
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
@@ -358,7 +365,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject) => {
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
const {url, apiKey} = this.config.data;
|
||||
|
||||
const {
|
||||
@@ -367,7 +374,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
playDate,
|
||||
listenedFor
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
@@ -385,6 +393,9 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
if(listenedFor !== undefined && listenedFor > 0) {
|
||||
scrobbleData.duration = listenedFor;
|
||||
}
|
||||
|
||||
let responseBody: MalojaScrobbleV3ResponseData;
|
||||
|
||||
@@ -437,7 +448,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(buildErrorString(response));
|
||||
throw new UpstreamError(buildErrorString(response), {showStopper: true});
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
@@ -459,7 +470,6 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} else {
|
||||
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
|
||||
}
|
||||
this.addScrobbledTrack(playObj, scrobbledPlay);
|
||||
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
|
||||
if(warning !== '') {
|
||||
this.logger.warn(`${scrobbleInfo} | ${warning}`);
|
||||
@@ -467,6 +477,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} else {
|
||||
this.logger.info(scrobbleInfo);
|
||||
}
|
||||
return scrobbledPlay;
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
|
||||
@@ -477,8 +488,6 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload:', scrobbleData);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { LastfmClientConfig } from "../common/infrastructure/config/client/lastf
|
||||
import { Notifiers } from "../notifier/Notifiers";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient";
|
||||
import {EventEmitter} from "events";
|
||||
import winston from '@foxxmd/winston';
|
||||
import winston, {Logger} from '@foxxmd/winston';
|
||||
import ListenbrainzScrobbler from "./ListenbrainzScrobbler";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
@@ -35,17 +35,19 @@ export default class ScrobbleClients {
|
||||
|
||||
/** @type AbstractScrobbleClient[] */
|
||||
clients: (MalojaScrobbler | LastfmScrobbler)[] = [];
|
||||
logger;
|
||||
configDir;
|
||||
logger: Logger;
|
||||
configDir: string;
|
||||
localUrl: string;
|
||||
|
||||
emitter: WildcardEmitter;
|
||||
|
||||
sourceEmitter: WildcardEmitter;
|
||||
|
||||
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, configDir: any) {
|
||||
constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, localUrl: string, configDir: string) {
|
||||
this.emitter = emitter;
|
||||
this.sourceEmitter = sourceEmitter;
|
||||
this.configDir = configDir;
|
||||
this.localUrl = localUrl;
|
||||
this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
|
||||
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
|
||||
@@ -161,7 +163,7 @@ export default class ScrobbleClients {
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
// @ts-ignore
|
||||
data: lfm
|
||||
data: {...lfm, redirectUri: lfm.redirectUri ?? `${this.localUrl}/lastfm/callback`}
|
||||
})
|
||||
}
|
||||
break;
|
||||
@@ -294,7 +296,13 @@ ${sources.join('\n')}`);
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}*/
|
||||
const {type, name, data: d = {}} = clientConfig;
|
||||
const {type, name, enable = true, data: d = {}} = clientConfig;
|
||||
|
||||
if(enable === false) {
|
||||
this.logger.warn(`${type} (${name}) client was disabled by config`);
|
||||
return;
|
||||
}
|
||||
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
let newClient;
|
||||
@@ -356,8 +364,6 @@ ${sources.join('\n')}`);
|
||||
scrobbleFrom = 'source',
|
||||
} = options;
|
||||
|
||||
const tracksScrobbled: any = [];
|
||||
|
||||
if (this.clients.length === 0) {
|
||||
this.logger.warn('Cannot scrobble! No clients are configured.');
|
||||
}
|
||||
@@ -367,71 +373,9 @@ ${sources.join('\n')}`);
|
||||
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`);
|
||||
continue;
|
||||
}
|
||||
if(!client.initialized) {
|
||||
if(client.initializing) {
|
||||
client.logger.warn(`Cannot scrobble because it is still initializing`);
|
||||
continue;
|
||||
}
|
||||
if(!(await client.initialize())) {
|
||||
client.logger.warn(`Cannot scrobble because it could not be initialized`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(client.requiresAuth && !client.authed) {
|
||||
if (client.requiresAuthInteraction) {
|
||||
client.logger.warn(`Cannot scrobble because user interaction is required for authentication`);
|
||||
continue;
|
||||
} else if (!(await client.testAuth())) {
|
||||
client.logger.warn(`Cannot scrobble because auth test failed`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(!(await client.isReady())) {
|
||||
client.logger.warn(`Cannot scrobble because it is not ready`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
try {
|
||||
await client.refreshScrobbles();
|
||||
} catch(e) {
|
||||
client.logger.error(`Encountered error while refreshing scrobbles`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
try {
|
||||
const {
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj);
|
||||
if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) {
|
||||
client.emitEvent('scrobble', {play: playObj});
|
||||
await client.scrobble(playObj)
|
||||
client.tracksScrobbled++;
|
||||
// since this is what we return to the source only add to tracksScrobbled if not already in array
|
||||
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
|
||||
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
|
||||
tracksScrobbled.push(playObj);
|
||||
}
|
||||
} else {
|
||||
if(!timeFrameValid) {
|
||||
client.logger.debug(`Will not scrobble ${buildTrackString(playObj)} from Source '${scrobbleFrom}' because it ${timeFrameValidLog}`);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
client.logger.error(new ErrorWithCause(`Encountered error while in scrobble loop`, {cause: e}));
|
||||
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
|
||||
if(e.continueScrobbling !== true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
client.queueScrobble(playObj, scrobbleFrom);
|
||||
}
|
||||
}
|
||||
return tracksScrobbled;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-96
@@ -1,6 +1,5 @@
|
||||
import {ExpressWithAsync} from "@awaitjs/express";
|
||||
import {getRoot} from "../ioc";
|
||||
import { capitalize } from "../utils";
|
||||
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./middleware";
|
||||
import AbstractSource from "../sources/AbstractSource";
|
||||
import {
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
LogInfo,
|
||||
LogInfoJson,
|
||||
LogLevel,
|
||||
LogOutputConfig,
|
||||
LogOutputConfig, PlayObject,
|
||||
SourceStatusData,
|
||||
} from "../../core/Atomic";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
@@ -21,7 +20,12 @@ import {setupPlexRoutes} from "./plexRoutes";
|
||||
import {setupJellyfinRoutes} from "./jellyfinRoutes";
|
||||
import {setupDeezerRoutes} from "./deezerRoutes";
|
||||
import {setupAuthRoutes} from "./auth";
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic";
|
||||
import {ExpressHandler, ScrobbledPlayObject} from "../common/infrastructure/Atomic";
|
||||
import MemorySource from "../sources/MemorySource";
|
||||
import {capitalize} from "../../core/StringUtils";
|
||||
import {source} from "common-tags";
|
||||
import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient";
|
||||
import {sortByNewestPlayDate} from "../utils";
|
||||
|
||||
let output: LogInfo[] = []
|
||||
|
||||
@@ -64,8 +68,11 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
const scrobbleSources = root.get('sources');
|
||||
const scrobbleClients = root.get('clients');
|
||||
|
||||
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
|
||||
const clientMiddleFunc = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceMiddleFunc = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
const clientRequiredMiddle = clientMiddleFunc(true);
|
||||
const sourceRequiredMiddle = sourceMiddleFunc(true);
|
||||
|
||||
const setLogWebLevel: ExpressHandler = async (req, res, next) => {
|
||||
// @ts-ignore
|
||||
@@ -106,7 +113,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
const session = await createSession(req, res);
|
||||
scrobbleSources.emitter.on('*', (payload: any, eventName: string) => {
|
||||
if(payload.from !== undefined) {
|
||||
session.push({...payload.data, event: eventName}, payload.from);
|
||||
session.push({event: eventName, ...payload}, payload.from);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -115,7 +122,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
setupPlexRoutes(app, logger, scrobbleSources);
|
||||
setupJellyfinRoutes(app, logger, scrobbleSources);
|
||||
setupDeezerRoutes(app, logger, scrobbleSources);
|
||||
setupAuthRoutes(app, logger, sourceCheckMiddle, clientCheckMiddle, scrobbleSources, scrobbleClients);
|
||||
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
|
||||
|
||||
app.getAsync('/api/status', async (req, res, next) => {
|
||||
|
||||
@@ -143,15 +150,16 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
players: 'players' in x ? (x as MemorySource).playersToObject() : {}
|
||||
};
|
||||
if (!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else if (canPoll) {
|
||||
base.status = polling ? 'Running' : 'Idle';
|
||||
base.status = polling ? 'Polling' : 'Idle';
|
||||
} else {
|
||||
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
@@ -165,6 +173,7 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
scrobbling = false,
|
||||
} = x;
|
||||
const base: ClientStatusData = {
|
||||
status: '',
|
||||
@@ -173,35 +182,51 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
tracksDiscovered: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
initialized
|
||||
};
|
||||
if (!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
|
||||
base.status = scrobbling ? 'Running' : 'Idle';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
return res.json({sources: sourceData, clients: clientData});
|
||||
});
|
||||
|
||||
app.getAsync('/api/recent', sourceCheckMiddle, async (req, res, next) => {
|
||||
app.getAsync('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
scrobbleSource: source,
|
||||
} = req;
|
||||
if (!source.canPoll) {
|
||||
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
|
||||
|
||||
let result: PlayObject[] = [];
|
||||
if (source !== undefined) {
|
||||
result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
}
|
||||
|
||||
const result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
//const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat())));
|
||||
//const trackLength = longestString(result.map((x: any) => x.data.track))
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
app.use('/api/poll', sourceCheckMiddle);
|
||||
app.getAsync('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
|
||||
const {
|
||||
// @ts-ignore
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
let result: PlayObject[] = [];
|
||||
if (client !== undefined) {
|
||||
result = [...(client as AbstractScrobbleClient).getScrobbledPlays()].sort(sortByNewestPlayDate);
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
app.use('/api/poll', sourceRequiredMiddle);
|
||||
app.getAsync('/api/poll', async function (req, res) {
|
||||
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
|
||||
const source = req.scrobbleSource as AbstractSource;
|
||||
@@ -243,83 +268,4 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, initialLogOutput
|
||||
logger.debug(`Server received ${req.method} request from ${remote}${proxyRemote !== undefined ? ` (${proxyRemote})` : ''}${ua !== undefined ? ` (UA: ${ua})` : ''} to unknown route: ${req.url}`);
|
||||
return res.sendStatus(404);
|
||||
});
|
||||
|
||||
app.getAsync('/dashboard', async function (req, res) {
|
||||
let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE]));
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
// TODO links for re-trying auth and variables for signalling it (and API recently played)
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksDiscovered = 0,
|
||||
name,
|
||||
canPoll = false,
|
||||
polling = false,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else if(canPoll) {
|
||||
base.status = polling ? 'Running' : 'Idle';
|
||||
} else {
|
||||
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
}
|
||||
return base;
|
||||
});
|
||||
const clientData = scrobbleClients.clients.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: requiresAuth,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
return base;
|
||||
})
|
||||
res.render('status', {
|
||||
sources: sourceData,
|
||||
clients: clientData,
|
||||
logs: {
|
||||
output: slicedLog,
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" data-limit="${x}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" data-sort="${x}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize log-level log-${x} ${logConfig.level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const setupAuthRoutes = (app: ExpressWithAsync, logger: Logger, sourceMid
|
||||
try {
|
||||
await entity.api.authenticate(token);
|
||||
await entity.initialize();
|
||||
await entity.testAuth();
|
||||
return res.send('OK');
|
||||
} catch (e) {
|
||||
return res.send(e.message);
|
||||
|
||||
@@ -11,10 +11,10 @@ import passport from "passport";
|
||||
export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
// initialize deezer strategies
|
||||
const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
|
||||
for(const d of deezerSources) {
|
||||
passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
|
||||
}
|
||||
// const deezerSources = scrobbleSources.getByType('deezer') as DeezerSource[];
|
||||
// for(const d of deezerSources) {
|
||||
// passport.use(`deezer-${d.name}`, d.generatePassportStrategy());
|
||||
// }
|
||||
|
||||
// something about the deezer passport strategy makes express continue with the response even though it should wait for accesstoken callback and userprofile fetching
|
||||
// so to get around this add an additional middleware that loops/sleeps until we should have fetched everything ¯\_(ツ)_/¯
|
||||
@@ -34,6 +34,7 @@ export const setupDeezerRoutes = (app: ExpressWithAsync, logger: Logger, scrobbl
|
||||
return res.send('Error with deezer credentials storage');
|
||||
} else if(entity.config.data.accessToken !== undefined) {
|
||||
// start polling
|
||||
await entity.testAuth();
|
||||
entity.poll()
|
||||
return res.redirect('/');
|
||||
} else {
|
||||
|
||||
@@ -42,6 +42,8 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
const apiPort = root.get('apiPort');
|
||||
const mainPort = root.get('mainPort');
|
||||
const port = root.get('port');
|
||||
const local = root.get('localUrl');
|
||||
const localDefined = root.get('hasDefinedBaseUrl');
|
||||
|
||||
setupApi(app, logger, initialOutput);
|
||||
|
||||
@@ -52,7 +54,8 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
res.sendFile(path.join(buildDir, "index.html"));
|
||||
});
|
||||
|
||||
app.listen(port);
|
||||
const backendPort = isProd ? port : apiPort;
|
||||
app.listen(backendPort);
|
||||
|
||||
const addy = getAddress();
|
||||
const addresses: string[] = [];
|
||||
@@ -62,7 +65,7 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
--- HINT ---
|
||||
MS is likely being run in a container with BRIDGE networking which means the above addresses are not accessible from outside this container.
|
||||
To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://foxxmd.github.io/multi-scrobbler/docs/installation#networking
|
||||
The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT
|
||||
The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT${localDefined ? ` (or ${local} since you defined this!)` : ''}
|
||||
--- HINT ---
|
||||
`;
|
||||
}
|
||||
@@ -71,10 +74,10 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
switch (k) {
|
||||
case 'host':
|
||||
case 'v4':
|
||||
addresses.push(`---> ${k === 'host' ? 'Local'.padEnd(14, ' ') : 'Network'.padEnd(14, ' ')} http://${v}:${port}`);
|
||||
addresses.push(`---> ${k === 'host' ? 'Local'.padEnd(14, ' ') : 'Network'.padEnd(14, ' ')} http://${v}:${backendPort}`);
|
||||
break;
|
||||
case 'v6':
|
||||
addresses.push(`---> Network (IPv6) http://[${v}]:${port}`);
|
||||
addresses.push(`---> Network (IPv6) http://[${v}]:${backendPort}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +86,10 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[]
|
||||
${addresses.join('\n')}${dockerHint !== '' ? `\n${dockerHint}` : ''}`
|
||||
|
||||
logger.info(start);
|
||||
|
||||
if(localDefined) {
|
||||
logger.info(`User-defined base URL for UI and redirect URLs (spotify, deezer, lastfm): ${local}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(new ErrorWithCause('Server crashed with uncaught exception', {cause: e}));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ import JellyfinSource from "../sources/JellyfinSource";
|
||||
export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleSources: ScrobbleSources) => {
|
||||
|
||||
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
|
||||
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
|
||||
const jellyfinJsonParser = bodyParser.json({
|
||||
type: ['text/*', 'application/json'],
|
||||
// verify: function(req, res, buf, encoding) {
|
||||
// // get rawBody
|
||||
// // @ts-ignore
|
||||
// req.rawBody = buf.toString();
|
||||
// }
|
||||
});
|
||||
const jellyIngress = new JellyfinNotifier();
|
||||
app.postAsync('/jellyfin', async function(req, res) {
|
||||
res.redirect(307, 'api/jellyfin/ingress');
|
||||
res.redirect(307, '/api/jellyfin/ingress');
|
||||
});
|
||||
app.postAsync('/api/jellyfin/ingress',
|
||||
async function (req, res, next) {
|
||||
@@ -26,6 +33,22 @@ export const setupJellyfinRoutes = (app: ExpressWithAsync, logger: Logger, scrob
|
||||
|
||||
res.send('OK');
|
||||
|
||||
const bodyEmpty = req.body === undefined || req.body === null || (typeof req.body === 'object' && Object.keys(req.body).length === 0);
|
||||
if(bodyEmpty) {
|
||||
const length = req.header('content-length') !== undefined ? Number.parseInt(req.header('content-length')) : undefined;
|
||||
// can't think of a way a user would send an empty body for a webhook payload but if they meant to do it don't spam them with errors...
|
||||
if(length === 0) {
|
||||
return;
|
||||
}
|
||||
if(length === undefined) {
|
||||
logger.warn(`Jellyfin is not sending a well-formatted request. It does not have valid headers (application/json - text/*) OR it is missing content-length header: Content-Type => '${req.header('content-type')}' | Length => ${length}`);
|
||||
} else {
|
||||
logger.warn(`Jellyfin is not sending a request with valid headers. Content-Type must be either application/json or a text/* wildcard (like text/plain) -- given: Content-Type => '${req.header('content-type')}'`);
|
||||
}
|
||||
res.status(400).send('Invalid Content-Type. Must be either application/json or a text wildcard (like text/plain)');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = remoteHostIdentifiers(req);
|
||||
const connectionId = `${parts.host}-${parts.proxy ?? ''}`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpressHandler } from "../common/infrastructure/Atomic";
|
||||
|
||||
export const makeSourceCheckMiddle = (sources: any): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
@@ -10,22 +10,23 @@ export const makeSourceCheckMiddle = (sources: any): ExpressHandler => (req: any
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Source name must be defined');
|
||||
} else if(name !== undefined) {
|
||||
const source = sources.getByNameAndType(name, type);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
}
|
||||
|
||||
const source = sources.getByNameAndType(name, type);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
next();
|
||||
}
|
||||
|
||||
export const makeClientCheckMiddle = (clients: any): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
@@ -33,16 +34,17 @@ export const makeClientCheckMiddle = (clients: any): ExpressHandler => (req: any
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (name === undefined) {
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Client name must be defined');
|
||||
} else if (name !== undefined) {
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
}
|
||||
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
|
||||
res.send('OK');
|
||||
};
|
||||
app.postAsync('/plex', async function (req, res) {
|
||||
res.redirect(307, 'api/plex/ingress');
|
||||
res.redirect(307, '/api/plex/ingress');
|
||||
});
|
||||
app.postAsync('/api/plex/ingress', plexIngressMiddle, plexMiddle, plexIngressRoute);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const setupTautulliRoutes = (app: ExpressWithAsync, logger: Logger, scrob
|
||||
};
|
||||
|
||||
app.postAsync('/tautulli', async function(req, res) {
|
||||
res.redirect(307, 'api/plex/tautulli');
|
||||
res.redirect(307, '/api/tautulli/ingress');
|
||||
});
|
||||
app.postAsync('/api/tautulli/ingress', tautulliIngressRoute);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {
|
||||
capitalize,
|
||||
isPlayTemporallyClose,
|
||||
genGroupId,
|
||||
genGroupIdStrFromPlay,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils";
|
||||
import {
|
||||
DEFAULT_POLLING_INTERVAL, DEFAULT_POLLING_MAX_INTERVAL, DEFAULT_RETRY_MULTIPLIER,
|
||||
DeviceId,
|
||||
GroupedFixedPlays,
|
||||
GroupedPlays,
|
||||
@@ -30,7 +30,7 @@ import {EventEmitter} from "events";
|
||||
import {FixedSizeList} from "fixed-size-list";
|
||||
import TupleMap from "../common/TupleMap";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import {buildTrackString, capitalize} from "../../core/StringUtils";
|
||||
|
||||
export interface RecentlyPlayedOptions {
|
||||
limit?: number
|
||||
@@ -63,6 +63,7 @@ export default abstract class AbstractSource {
|
||||
|
||||
canPoll: boolean = false;
|
||||
polling: boolean = false;
|
||||
canBacklog: boolean = false;
|
||||
userPollingStopSignal: undefined | any;
|
||||
pollRetries: number = 0;
|
||||
tracksDiscovered: number = 0;
|
||||
@@ -92,6 +93,10 @@ export default abstract class AbstractSource {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
authGated = () => {
|
||||
return this.requiresAuth && !this.authed;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
return this.authed;
|
||||
@@ -165,9 +170,7 @@ export default abstract class AbstractSource {
|
||||
return existing !== undefined;
|
||||
}
|
||||
|
||||
|
||||
protected scrobble = (plays: PlayObject[], options: { forceRefresh?: boolean, checkAll?: boolean } = {}) => {
|
||||
|
||||
discover = (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): PlayObject[] => {
|
||||
const newDiscoveredPlays: PlayObject[] = [];
|
||||
|
||||
for(const play of plays) {
|
||||
@@ -177,9 +180,16 @@ export default abstract class AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
newDiscoveredPlays.sort(sortByOldestPlayDate);
|
||||
|
||||
return newDiscoveredPlays;
|
||||
}
|
||||
|
||||
|
||||
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any } = {}) => {
|
||||
|
||||
if(newDiscoveredPlays.length > 0) {
|
||||
newDiscoveredPlays.sort(sortByOldestPlayDate);
|
||||
|
||||
this.emitter.emit('discoveredToScrobble', {
|
||||
data: newDiscoveredPlays,
|
||||
options: {
|
||||
@@ -190,8 +200,37 @@ export default abstract class AbstractSource {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newDiscoveredPlays;
|
||||
protected processBacklog = async () => {
|
||||
if (this.canBacklog) {
|
||||
this.logger.info('Discovering backlogged tracks from recently played API...');
|
||||
const backlogPlays = await this.getBackloggedPlays();
|
||||
const discovered = this.discover(backlogPlays);
|
||||
|
||||
const {
|
||||
options: {
|
||||
scrobbleBacklog = true
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
if (scrobbleBacklog) {
|
||||
if (discovered.length > 0) {
|
||||
this.logger.info('Scrobbling backlogged tracks...');
|
||||
this.scrobble(discovered);
|
||||
this.logger.info('Backlog scrobbling complete.');
|
||||
} else {
|
||||
this.logger.info('All tracks already discovered!');
|
||||
}
|
||||
} else {
|
||||
this.logger.info('Backlog scrobbling is disabled by config, skipping...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (): Promise<PlayObject[]> => {
|
||||
this.logger.debug('Backlogging not implemented');
|
||||
return [];
|
||||
}
|
||||
|
||||
protected notify = (payload) => {
|
||||
@@ -210,7 +249,7 @@ export default abstract class AbstractSource {
|
||||
if(!(await this.onPollPreAuthCheck())) {
|
||||
return;
|
||||
}
|
||||
if(this.requiresAuth && !this.authed) {
|
||||
if(this.authGated()) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
this.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'});
|
||||
this.logger.error('Cannot start polling because user interaction is required for authentication');
|
||||
@@ -223,6 +262,8 @@ export default abstract class AbstractSource {
|
||||
if(!(await this.onPollPostAuthCheck())) {
|
||||
return;
|
||||
}
|
||||
await this.processBacklog();
|
||||
|
||||
await this.startPolling();
|
||||
}
|
||||
|
||||
@@ -233,7 +274,7 @@ export default abstract class AbstractSource {
|
||||
const {
|
||||
data: {
|
||||
maxPollRetries = 5,
|
||||
retryMultiplier = 1,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER,
|
||||
} = {},
|
||||
} = this.config;
|
||||
|
||||
@@ -245,10 +286,11 @@ export default abstract class AbstractSource {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.pollRetries <= maxRetries) {
|
||||
let pollRes: boolean | undefined = undefined;
|
||||
while (pollRes === undefined && this.pollRetries <= maxRetries) {
|
||||
try {
|
||||
const res = await this.doPolling();
|
||||
if(res === true) {
|
||||
pollRes = await this.doPolling();
|
||||
if(pollRes === true) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -285,12 +327,10 @@ export default abstract class AbstractSource {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected doStopPolling = (reason?: string) => {
|
||||
protected doStopPolling = (reason: string = 'system') => {
|
||||
this.polling = false;
|
||||
this.userPollingStopSignal = undefined;
|
||||
if(reason !== undefined) {
|
||||
this.logger.info(`Stopped polling due to: ${reason}`);
|
||||
}
|
||||
this.logger.info(`Stopped polling due to: ${reason}`);
|
||||
}
|
||||
|
||||
protected shouldStopPolling = () => this.polling === false || this.userPollingStopSignal !== undefined;
|
||||
@@ -305,7 +345,7 @@ export default abstract class AbstractSource {
|
||||
let checkCount = 0;
|
||||
let checksOverThreshold = 0;
|
||||
|
||||
const {interval = 30, checkActiveFor = 300, maxInterval = 60} = this.config.data;
|
||||
const {interval = DEFAULT_POLLING_INTERVAL, checkActiveFor = 300, maxInterval = DEFAULT_POLLING_MAX_INTERVAL} = this.config.data;
|
||||
const maxBackoff = maxInterval - interval;
|
||||
let sleepTime = interval;
|
||||
|
||||
@@ -327,7 +367,8 @@ export default abstract class AbstractSource {
|
||||
this.logger.info('Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
|
||||
await sleep(10 * 1000);
|
||||
}
|
||||
newDiscovered = this.scrobble(playObjs,
|
||||
newDiscovered = this.discover(playObjs);
|
||||
this.scrobble(newDiscovered,
|
||||
{
|
||||
forceRefresh: closeToInterval
|
||||
});
|
||||
@@ -370,6 +411,7 @@ export default abstract class AbstractSource {
|
||||
}
|
||||
if(this.shouldStopPolling()) {
|
||||
this.doStopPolling(this.userPollingStopSignal !== undefined ? 'user input' : undefined);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Error occurred while polling');
|
||||
@@ -379,12 +421,12 @@ export default abstract class AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
public emitEvent = (eventName: string, payload: object) => {
|
||||
public emitEvent = (eventName: string, payload: object = {}) => {
|
||||
this.emitter.emit(eventName, {
|
||||
...payload,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
from: 'source'
|
||||
from: 'source',
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from 'superagent';
|
||||
import passport from "passport";
|
||||
import {
|
||||
parseRetryAfterSecsFromObj,
|
||||
readJson,
|
||||
@@ -10,7 +11,7 @@ import {Strategy as DeezerStrategy} from 'passport-deezer';
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import dayjs from "dayjs";
|
||||
import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
|
||||
@@ -22,6 +23,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
baseUrl = 'https://api.deezer.com';
|
||||
redirectUri: string;
|
||||
|
||||
declare config: DeezerSourceConfig;
|
||||
|
||||
@@ -30,6 +32,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
const {
|
||||
data: {
|
||||
interval = 60,
|
||||
redirectUri,
|
||||
} = {},
|
||||
} = config;
|
||||
|
||||
@@ -38,9 +41,11 @@ export default class DeezerSource extends AbstractSource {
|
||||
}
|
||||
|
||||
this.config.data.interval = interval;
|
||||
this.redirectUri = redirectUri || `${this.localUrl}/deezer/callback`;
|
||||
|
||||
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
@@ -92,8 +97,11 @@ export default class DeezerSource extends AbstractSource {
|
||||
} else if(this.config.data.clientSecret === undefined) {
|
||||
throw new Error('clientSecret must be defined when accessToken is not present');
|
||||
}
|
||||
this.logger.info(`No access token is present. User interaction for authentication is required.`);
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
}
|
||||
this.initialized = true;
|
||||
passport.use(`deezer-${this.name}`, this.generatePassportStrategy());
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
@@ -116,7 +124,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
callApi = async (req: any, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config.data;
|
||||
|
||||
req.query({
|
||||
@@ -185,7 +193,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
return new DeezerStrategy({
|
||||
clientID: this.config.data.clientId,
|
||||
clientSecret: this.config.data.clientSecret,
|
||||
callbackURL: this.config.data.redirectUri || `${this.localUrl}/deezer/callback`,
|
||||
callbackURL: this.redirectUri,
|
||||
scope: ['listening_history','offline_access'],
|
||||
}, (accessToken: any, refreshToken: any, profile: any, done: any) => {
|
||||
// return done(null, {
|
||||
@@ -224,4 +232,8 @@ export default class DeezerSource extends AbstractSource {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async () => {
|
||||
return await this.getRecentlyPlayed({formatted: true});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,9 @@ export class JRiverSource extends MemorySource {
|
||||
data,
|
||||
} = config;
|
||||
const {
|
||||
interval = 10,
|
||||
maxInterval = 30,
|
||||
...rest
|
||||
} = data || {};
|
||||
super('jriver', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
|
||||
super('jriver', name, {...config, data: {...rest}}, internal, emitter);
|
||||
|
||||
const {
|
||||
data: {
|
||||
@@ -141,7 +139,7 @@ export class JRiverSource extends MemorySource {
|
||||
}
|
||||
}
|
||||
|
||||
return this.processRecentPlaysNew(play);
|
||||
return this.processRecentPlays(play);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -350,12 +350,13 @@ export default class JellyfinSource extends MemorySource {
|
||||
scrobbleOpts.checkAll = true;
|
||||
|
||||
} else {
|
||||
newPlays = this.processRecentPlaysNew([playObj]);
|
||||
newPlays = this.processRecentPlays([playObj]);
|
||||
}
|
||||
|
||||
if(newPlays.length > 0) {
|
||||
try {
|
||||
this.scrobble(newPlays, scrobbleOpts);
|
||||
const discovered = this.discover(newPlays, scrobbleOpts);
|
||||
this.scrobble(discovered);
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
|
||||
@@ -17,11 +17,9 @@ export class KodiSource extends MemorySource {
|
||||
data,
|
||||
} = config;
|
||||
const {
|
||||
interval = 10,
|
||||
maxInterval = 30,
|
||||
...rest
|
||||
} = data || {};
|
||||
super('kodi', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
|
||||
super('kodi', name, {...config, data: {...rest}}, internal, emitter);
|
||||
|
||||
this.requiresAuth = true;
|
||||
this.canPoll = true;
|
||||
@@ -70,7 +68,7 @@ export class KodiSource extends MemorySource {
|
||||
|
||||
let play = await this.client.getRecentlyPlayed(options);
|
||||
|
||||
return this.processRecentPlaysNew(play);
|
||||
return this.processRecentPlays(play);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient";
|
||||
import { sortByOldestPlayDate } from "../utils";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
|
||||
import {UserGetRecentTracksResponse} from "lastfm-node-client";
|
||||
import {TrackObject, UserGetRecentTracksResponse} from "lastfm-node-client";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import MemorySource from "./MemorySource";
|
||||
import {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default class LastfmSource extends AbstractSource {
|
||||
export default class LastfmSource extends MemorySource {
|
||||
|
||||
api: LastfmApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
declare config: LastfmClientConfig;
|
||||
declare config: LastfmSourceConfig;
|
||||
|
||||
constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('lastfm', name, config, internal, emitter);
|
||||
constructor(name: any, config: LastfmSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data: {
|
||||
interval = 15,
|
||||
maxInterval = 60,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
super('lastfm', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
|
||||
this.canPoll = true;
|
||||
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir});
|
||||
this.canBacklog = true;
|
||||
this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl});
|
||||
this.playerSourceOfTruth = false;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
@@ -42,7 +53,7 @@ export default class LastfmSource extends AbstractSource {
|
||||
}
|
||||
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
const {limit = 20} = options;
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
|
||||
const {
|
||||
@@ -51,7 +62,7 @@ export default class LastfmSource extends AbstractSource {
|
||||
}
|
||||
} = resp;
|
||||
|
||||
return list.reduce((acc: any, x: any) => {
|
||||
const plays = list.reduce((acc: PlayObject[], x: TrackObject) => {
|
||||
try {
|
||||
const formatted = LastfmApiClient.formatPlayObj(x);
|
||||
const {
|
||||
@@ -64,13 +75,11 @@ export default class LastfmSource extends AbstractSource {
|
||||
nowPlaying,
|
||||
}
|
||||
} = formatted;
|
||||
if(nowPlaying === true) {
|
||||
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
|
||||
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
|
||||
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
|
||||
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
|
||||
return acc;
|
||||
} else if(playDate === undefined) {
|
||||
if(playDate === undefined) {
|
||||
if(nowPlaying === true) {
|
||||
formatted.data.playDate = dayjs();
|
||||
return acc.concat(formatted);
|
||||
}
|
||||
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
|
||||
return acc;
|
||||
}
|
||||
@@ -82,5 +91,16 @@ export default class LastfmSource extends AbstractSource {
|
||||
return acc;
|
||||
}
|
||||
}, []).sort(sortByOldestPlayDate);
|
||||
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
|
||||
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
|
||||
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
|
||||
const history = plays.filter(x => x.meta.nowPlaying !== true);
|
||||
const now = plays.filter(x => x.meta.nowPlaying === true);
|
||||
this.processRecentPlays(now);
|
||||
return history;
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async () => {
|
||||
return await this.getRecentlyPlayed({formatted: true});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { FormatPlayObjectOptions, INITIALIZING, InternalConfig } from "../common
|
||||
import EventEmitter from "events";
|
||||
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz";
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient";
|
||||
import MemorySource from "./MemorySource";
|
||||
|
||||
export default class ListenbrainzSource extends AbstractSource {
|
||||
export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
@@ -13,9 +14,18 @@ export default class ListenbrainzSource extends AbstractSource {
|
||||
declare config: ListenBrainzSourceConfig;
|
||||
|
||||
constructor(name: any, config: ListenBrainzSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('listenbrainz', name, config, internal, emitter);
|
||||
const {
|
||||
data: {
|
||||
interval = 15,
|
||||
maxInterval = 60,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
super('listenbrainz', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
this.api = new ListenbrainzApiClient(name, config.data);
|
||||
this.playerSourceOfTruth = false;
|
||||
}
|
||||
|
||||
static formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
|
||||
@@ -52,6 +62,12 @@ export default class ListenbrainzSource extends AbstractSource {
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
const now = await this.api.getPlayingNow();
|
||||
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async () => {
|
||||
return await this.getRecentlyPlayed({formatted: true});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ export class MPRISSource extends MemorySource {
|
||||
if(options.display === true) {
|
||||
return deduped;
|
||||
}
|
||||
return this.processRecentPlaysNew(deduped);
|
||||
return this.processRecentPlays(deduped);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
sortByOldestPlayDate,
|
||||
toProgressAwarePlayObject,
|
||||
getProgress,
|
||||
genGroupIdStrFromPlay,
|
||||
playPassesScrobbleThreshold,
|
||||
timePassesScrobbleThreshold,
|
||||
thresholdResultSummary,
|
||||
@@ -16,21 +15,28 @@ import dayjs from "dayjs";
|
||||
import {
|
||||
asPlayerStateData,
|
||||
DeviceId,
|
||||
GroupedPlays,
|
||||
GroupedPlays, InternalConfig,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
PlayUserId,
|
||||
ProgressAwarePlayObject,
|
||||
ScrobbleThresholdResult,
|
||||
ScrobbleThresholdResult, SourceType,
|
||||
} from "../common/infrastructure/Atomic";
|
||||
import TupleMap from "../common/TupleMap";
|
||||
import { AbstractPlayerState, PlayerStateOptions } from "./PlayerState/AbstractPlayerState";
|
||||
import { GenericPlayerState } from "./PlayerState/GenericPlayerState";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
import {PlayObject, SourcePlayerObj} from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import {SimpleIntervalJob, Task, ToadScheduler} from "toad-scheduler";
|
||||
import {SourceConfig} from "../common/infrastructure/config/source/sources";
|
||||
import {EventEmitter} from "events";
|
||||
import objectHash from 'object-hash';
|
||||
|
||||
export default class MemorySource extends AbstractSource {
|
||||
|
||||
playerSourceOfTruth: boolean = true;
|
||||
|
||||
/*
|
||||
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track is valid.
|
||||
* This is necessary for any source that
|
||||
@@ -39,35 +45,75 @@ export default class MemorySource extends AbstractSource {
|
||||
* * where these timestamps don't have enough granularity (IE second accuracy)
|
||||
* such as subsonic and jellyfin */
|
||||
|
||||
/**
|
||||
* Tracks we are tracked that we are confident qualified as being played based on:
|
||||
*
|
||||
* - MS saw the track for the first time while it was running/polling
|
||||
* - Continued to see this same track through consecutive polling/ingress events for AT LEAST 30 seconds
|
||||
* - If the play info contains playback position data we also check if that has progressed at least 30 seconds
|
||||
*
|
||||
* These are tracks that are actually used by the source to scrobble to clients
|
||||
* */
|
||||
//statefulRecentlyPlayed: GroupedPlays = new Map();
|
||||
/**
|
||||
* Tracks that are actively being tracked (discovered from source recently) to see if they will "qualify" as being played.
|
||||
*
|
||||
* Once a track qualifies it is added to statefuls.
|
||||
* */
|
||||
candidateRecentlyPlayed: GroupedPlays = new TupleMap<DeviceId, PlayUserId, ProgressAwarePlayObject[]>
|
||||
|
||||
players: Map<string, AbstractPlayerState> = new Map();
|
||||
playerState: Map<string, string> = new Map();
|
||||
|
||||
getFlatCandidateRecentlyPlayed = (): PlayObject[] => {
|
||||
// TODO sort?
|
||||
return Array.from(this.candidateRecentlyPlayed.values()).flat();
|
||||
scheduler: ToadScheduler = new ToadScheduler();
|
||||
|
||||
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super(type, name, config, internal, emitter);
|
||||
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({seconds: 15}, new Task('Player Cleanup', () => {
|
||||
this.cleanupPlayers();
|
||||
})));
|
||||
}
|
||||
|
||||
cleanupPlayers = () => {
|
||||
const deadPlatformIds: string[] = [];
|
||||
for (const [key, player] of this.players.entries()) {
|
||||
// no communication from the source was received for this player
|
||||
const isStale = player.checkStale();
|
||||
if (isStale && player.checkOrphaned() && player.isDead()) {
|
||||
player.logger.debug(`Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
|
||||
deadPlatformIds.push(player.platformIdStr);
|
||||
this.emitEvent('playerDelete', {platformId: player.platformIdStr});
|
||||
} else if (isStale) {
|
||||
const state = player.getApiState();
|
||||
// @ts-ignore
|
||||
const stateHash = objectHash.sha1(state);
|
||||
if(stateHash !== this.playerState.get(key)) {
|
||||
this.playerState.set(key, stateHash);
|
||||
this.emitEvent('playerUpdate', state);
|
||||
}
|
||||
if(this.config.options?.logPlayerState === true) {
|
||||
player.logSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const deadId of deadPlatformIds) {
|
||||
this.deletePlayer(deadId);
|
||||
}
|
||||
}
|
||||
|
||||
playersToObject = (): Record<string, SourcePlayerObj> => {
|
||||
if(this.players.size === 0) {
|
||||
return {};
|
||||
}
|
||||
const record: Record<string, SourcePlayerObj> = {};
|
||||
for(const [k,v] of this.players.entries()) {
|
||||
record[k] = v.getApiState();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => {
|
||||
return new GenericPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
processRecentPlaysNew = (datas: (PlayObject | PlayerStateData)[]) => {
|
||||
setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
|
||||
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
|
||||
staleInterval: (this.config.data.interval ?? 30) * 3,
|
||||
orphanedInterval: (this.config.data.maxInterval ?? 60) * 5,
|
||||
...opts
|
||||
}));
|
||||
this.playerState.set(idStr, '');
|
||||
}
|
||||
|
||||
deletePlayer = (id: string) => {
|
||||
this.players.delete(id);
|
||||
this.playerState.delete(id);
|
||||
}
|
||||
|
||||
processRecentPlays = (datas: (PlayObject | PlayerStateData)[]) => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
@@ -78,20 +124,15 @@ export default class MemorySource extends AbstractSource {
|
||||
const newStatefulPlays: PlayObject[] = [];
|
||||
|
||||
// create any new players from incoming data
|
||||
//const incomingPlatformIds: PlayPlatformId[] = [];
|
||||
for (const data of datas) {
|
||||
const id = getPlatformIdFromData(data);
|
||||
const idStr = genGroupIdStr(id);
|
||||
if (!this.players.has(idStr)) {
|
||||
//incomingPlatformIds.push(id);
|
||||
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
|
||||
staleInterval: (this.config.data.interval ?? 30) * 3,
|
||||
orphanedInterval: (this.config.data.maxInterval ?? 60) * 5
|
||||
}));
|
||||
this.setNewPlayer(idStr, this.logger, id);
|
||||
}
|
||||
}
|
||||
|
||||
const deadPlatformIds: string[] = [];
|
||||
//const deadPlatformIds: string[] = [];
|
||||
|
||||
for (const [key, player] of this.players.entries()) {
|
||||
|
||||
@@ -113,15 +154,20 @@ export default class MemorySource extends AbstractSource {
|
||||
|
||||
const [currPlay, prevPlay] = asPlayerStateData(incomingData) ? player.setState(incomingData.status, incomingData.play) : player.setState(undefined, incomingData);
|
||||
const candidate = prevPlay !== undefined ? prevPlay : currPlay;
|
||||
const playChanged = prevPlay !== undefined;
|
||||
|
||||
if (candidate !== undefined) {
|
||||
// wait to discover play until it is stale or current play has changed
|
||||
// so that our discovered track has an accurate "listenedFor" count
|
||||
if (candidate !== undefined && (playChanged || player.isUpdateStale())) {
|
||||
let stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
|
||||
const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor, candidate.data.duration);
|
||||
|
||||
if (thresholdResults.passes) {
|
||||
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
|
||||
let stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
|
||||
if (matchingRecent === undefined) {
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
|
||||
if(this.playerSourceOfTruth) {
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
|
||||
}
|
||||
newStatefulPlays.push(candidate);
|
||||
} else {
|
||||
const {data: {playDate, duration}} = candidate;
|
||||
@@ -129,173 +175,41 @@ export default class MemorySource extends AbstractSource {
|
||||
if (!playDate.isSame(rplayDate)) {
|
||||
if (duration !== undefined) {
|
||||
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
|
||||
if(this.playerSourceOfTruth) {
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
|
||||
}
|
||||
newStatefulPlays.push(candidate);
|
||||
}
|
||||
} else {
|
||||
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate));
|
||||
if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
|
||||
// if most recent stateful play is not this track we'll add it
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
|
||||
if(this.playerSourceOfTruth) {
|
||||
player.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
|
||||
}
|
||||
newStatefulPlays.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if(playChanged) {
|
||||
player.logger.verbose(`${stPrefix} not added because ${thresholdResultSummary(thresholdResults)}.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no communication from the source was received for this player
|
||||
player.checkStale();
|
||||
if (player.checkOrphaned() && player.isDead()) {
|
||||
player.logger.debug(`Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
|
||||
deadPlatformIds.push(player.platformIdStr);
|
||||
|
||||
if(this.config.options?.logPlayerState === true) {
|
||||
player.logSummary();
|
||||
}
|
||||
const apiState = player.getApiState();
|
||||
// @ts-ignore
|
||||
this.playerState.set(key, objectHash.sha1(apiState))
|
||||
this.emitEvent('playerUpdate', apiState);
|
||||
}
|
||||
if(this.config.options?.logPlayerState === true) {
|
||||
player.logSummary();
|
||||
}
|
||||
}
|
||||
for (const deadId of deadPlatformIds) {
|
||||
this.players.delete(deadId);
|
||||
}
|
||||
|
||||
return newStatefulPlays;
|
||||
}
|
||||
|
||||
processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
scrobbleThresholds = {}
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
let newStatefulPlays: PlayObject[] = [];
|
||||
// if we can't trust existing play dates (like for subsonic where there is no timestamp) then
|
||||
// we format new plays with locked play date IE the first time we have "seen" this track
|
||||
|
||||
// -- otherwise, for sources like Spotify that accurately report when track started to play, we can use existing dates
|
||||
const flatLockedPlays = useExistingPlayDate ? plays : plays.map((p: any) => {
|
||||
const {data: {playDate, ...restData}, ...rest} = p;
|
||||
return {data: {...restData, playDate: dayjs()}, ...rest};
|
||||
});
|
||||
|
||||
// group by device-user
|
||||
const groupedLockedPlays = flatLockedPlays.reduce((acc: GroupedPlays, curr: ProgressAwarePlayObject) => {
|
||||
const id = genGroupId(curr);
|
||||
acc.set(id, (acc.get(id) ?? []).concat(curr));
|
||||
return acc;
|
||||
}, new Map());
|
||||
|
||||
for (const [groupId, lockedPlays] of groupedLockedPlays.entries()) {
|
||||
const groupIdStr = `${groupId[0]}-${groupId[1]}`;
|
||||
let cRecentlyPlayed = this.candidateRecentlyPlayed.get(groupId) ?? [];
|
||||
// if no candidates exist new plays are new candidates
|
||||
if (cRecentlyPlayed.length === 0) {
|
||||
this.logger.debug(`[Platform ${groupIdStr}] No prior candidate recent plays!`)
|
||||
// update activity date here so that polling interval decreases *before* we get a new valid play
|
||||
// so that we don't miss a play due to long polling interval
|
||||
this.lastActivityAt = dayjs();
|
||||
const progressAware: ProgressAwarePlayObject[] = [];
|
||||
for (const p of lockedPlays) {
|
||||
progressAware.push(toProgressAwarePlayObject(p));
|
||||
this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`);
|
||||
}
|
||||
this.candidateRecentlyPlayed.set(groupId, progressAware)
|
||||
} else {
|
||||
// otherwise determine new tracks (not found in prior candidates)
|
||||
const newTracks = lockedPlays.filter((x: any) => cRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
|
||||
const newProgressAwareTracks: ProgressAwarePlayObject[] = [];
|
||||
if (newTracks.length > 0) {
|
||||
// update activity date here so that polling interval decreases *before* we get a new valid play
|
||||
// so that we don't miss a play due to long polling interval
|
||||
this.lastActivityAt = dayjs();
|
||||
this.logger.debug(`[Platform ${groupIdStr}] New plays found that do not match existing candidates.`)
|
||||
for (const p of newTracks) {
|
||||
this.logger.debug(`[Platform ${groupIdStr}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`);
|
||||
newProgressAwareTracks.push(toProgressAwarePlayObject(p));
|
||||
}
|
||||
}
|
||||
// filter prior candidates based on new recently played
|
||||
cRecentlyPlayed = cRecentlyPlayed.filter(x => {
|
||||
const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y));
|
||||
if (!candidateMatchedLocked) {
|
||||
this.logger.debug(`[Platform ${groupIdStr}] Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`);
|
||||
}
|
||||
return candidateMatchedLocked;
|
||||
});
|
||||
// and then combine still playing with new tracks
|
||||
cRecentlyPlayed = cRecentlyPlayed.concat(newProgressAwareTracks);
|
||||
cRecentlyPlayed.sort(sortByOldestPlayDate);
|
||||
|
||||
this.candidateRecentlyPlayed.set(groupId, cRecentlyPlayed);
|
||||
|
||||
//const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? [];
|
||||
|
||||
// now we check if all candidates pass tests for having been tracked long enough:
|
||||
// * Has been tracked for at least [duration] seconds or [percentage] of track duration
|
||||
// * If it has playback position data then it must also have progressed at least [duration] seconds or [percentage] of track duration progress since our initial tracking data
|
||||
for (const candidate of cRecentlyPlayed) {
|
||||
let thresholdResults: ScrobbleThresholdResult;
|
||||
thresholdResults = playPassesScrobbleThreshold(candidate, scrobbleThresholds);
|
||||
const {passes: firstSeenValid} = thresholdResults;
|
||||
let progressValid = firstSeenValid;
|
||||
if (firstSeenValid) {
|
||||
// check if we can get progress as well
|
||||
const matchingLockedPlay = lockedPlays.find(x => playObjDataMatch(x, candidate));
|
||||
// this should always be found but checking just in case
|
||||
if (matchingLockedPlay !== undefined) {
|
||||
const progress = getProgress(candidate, matchingLockedPlay);
|
||||
if (progress !== undefined) {
|
||||
thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, progress, candidate.data.duration);
|
||||
const {passes: progressPasses} = thresholdResults;
|
||||
progressValid = progressPasses;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstSeenValid && progressValid) {
|
||||
// a prior candidate has been playing for more than 30 seconds and passed progress test, time to check statefuls
|
||||
|
||||
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
|
||||
let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
|
||||
if (matchingRecent === undefined) {
|
||||
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
|
||||
newStatefulPlays.push(candidate);
|
||||
//sRecentlyPlayed.push(candidate);
|
||||
} else {
|
||||
const {data: {playDate, duration}} = candidate;
|
||||
const {data: {playDate: rplayDate}} = matchingRecent;
|
||||
if (!playDate.isSame(rplayDate)) {
|
||||
if (duration !== undefined) {
|
||||
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
|
||||
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
|
||||
newStatefulPlays.push(candidate);
|
||||
//sRecentlyPlayed.push(candidate);
|
||||
}
|
||||
} else {
|
||||
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(genGroupId(candidate));
|
||||
if (discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
|
||||
// if most recent stateful play is not this track we'll add it
|
||||
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
|
||||
newStatefulPlays.push(candidate);
|
||||
}
|
||||
//sRecentlyPlayed.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//sRecentlyPlayed.sort(sortByPlayDate);
|
||||
//this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed);
|
||||
}
|
||||
}
|
||||
return newStatefulPlays;
|
||||
//return this.getFlatStatefulRecentlyPlayed();
|
||||
//return newStatefulPlays;
|
||||
}
|
||||
|
||||
recentlyPlayedTrackIsValid = (playObj: any) => {
|
||||
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
|
||||
}
|
||||
|
||||
@@ -39,11 +39,9 @@ export class MopidySource extends MemorySource {
|
||||
albumBlacklist = ['Soundcloud'],
|
||||
uriWhitelist = [],
|
||||
uriBlacklist = [],
|
||||
interval = 10,
|
||||
maxInterval = 30,
|
||||
...rest
|
||||
} = data;
|
||||
super('mopidy', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
|
||||
super('mopidy', name, {...config, data: {...rest}}, internal, emitter);
|
||||
|
||||
this.albumBlacklist = albumBlacklist.map(x => x.toLocaleLowerCase());
|
||||
this.uriWhitelist = uriWhitelist.map(x => x.toLocaleLowerCase());
|
||||
@@ -209,7 +207,7 @@ export class MopidySource extends MemorySource {
|
||||
play
|
||||
}
|
||||
|
||||
return this.processRecentPlaysNew([playerState]);
|
||||
return this.processRecentPlays([playerState]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import dayjs, {Dayjs} from "dayjs";
|
||||
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils";
|
||||
import {Logger} from "@foxxmd/winston";
|
||||
import { ListenProgress } from "./ListenProgress";
|
||||
import { ListenRange, PlayData, PlayObject } from "../../../core/Atomic";
|
||||
import {PlayObject, SourcePlayerObj} from "../../../core/Atomic";
|
||||
import { buildTrackString } from "../../../core/StringUtils";
|
||||
import {ListenRange} from "./ListenRange";
|
||||
import {start} from "repl";
|
||||
|
||||
export interface PlayerStateIntervals {
|
||||
staleInterval?: number
|
||||
@@ -29,8 +31,8 @@ export abstract class AbstractPlayerState {
|
||||
currentPlay?: PlayObject
|
||||
playFirstSeenAt?: Dayjs
|
||||
playLastUpdatedAt?: Dayjs
|
||||
currentListenRange?: [ListenProgress, ListenProgress]
|
||||
listenRanges: [ListenProgress, ListenProgress][] = [];
|
||||
currentListenRange?: ListenRange
|
||||
listenRanges: ListenRange[]
|
||||
createdAt: Dayjs = dayjs();
|
||||
stateLastUpdatedAt: Dayjs = dayjs();
|
||||
|
||||
@@ -62,7 +64,7 @@ export abstract class AbstractPlayerState {
|
||||
|
||||
checkStale() {
|
||||
const isStale = this.isUpdateStale();
|
||||
if (isStale && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.stale) {
|
||||
if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale;
|
||||
this.logger.debug(`Stale after no Play updates for ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`);
|
||||
// end current listening sessions
|
||||
@@ -76,7 +78,7 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
isDead() {
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'minutes') >= this.stateIntervalOptions.orphanedInterval * 2;
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2;
|
||||
}
|
||||
|
||||
checkOrphaned() {
|
||||
@@ -96,10 +98,10 @@ export abstract class AbstractPlayerState {
|
||||
return status !== 'paused' && status !== 'stopped';
|
||||
}
|
||||
|
||||
setState(status?: ReportedPlayerStatus, play?: PlayObject) {
|
||||
setState(status?: ReportedPlayerStatus, play?: PlayObject, reportedTS?: Dayjs) {
|
||||
this.stateLastUpdatedAt = dayjs();
|
||||
if (play !== undefined) {
|
||||
return this.setPlay(play, status);
|
||||
return this.setPlay(play, status, reportedTS);
|
||||
} else if (status !== undefined) {
|
||||
if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) {
|
||||
this.stopPlayer();
|
||||
@@ -114,7 +116,7 @@ export abstract class AbstractPlayerState {
|
||||
return [];
|
||||
}
|
||||
|
||||
setPlay(play: PlayObject, status?: ReportedPlayerStatus): [PlayObject, PlayObject?] {
|
||||
protected setPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
|
||||
this.playLastUpdatedAt = dayjs();
|
||||
if (status !== undefined) {
|
||||
this.reportedStatus = status;
|
||||
@@ -125,7 +127,7 @@ export abstract class AbstractPlayerState {
|
||||
this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`)
|
||||
this.currentListenSessionEnd();
|
||||
const played = this.getPlayedObject();
|
||||
this.setCurrentPlay(play);
|
||||
this.setCurrentPlay(play, undefined, reportedTS);
|
||||
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
@@ -133,8 +135,24 @@ export abstract class AbstractPlayerState {
|
||||
} else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) {
|
||||
this.currentListenSessionEnd();
|
||||
this.calculatedStatus = this.reportedStatus;
|
||||
} else if (this.isSessionRepeat(play.meta.trackProgressPosition, reportedTS)) {
|
||||
// if we detect the track has been restarted end listen session and treat as a new play
|
||||
this.currentListenSessionEnd();
|
||||
const played = this.getPlayedObject();
|
||||
play.data.playDate = dayjs();
|
||||
this.setCurrentPlay(play, undefined, reportedTS);
|
||||
return [this.getPlayedObject(), played];
|
||||
} else {
|
||||
this.currentListenSessionContinue(play.meta.trackProgressPosition);
|
||||
if(this.currentListenRange !== undefined) {
|
||||
const [isSeeked, seekedPos] = this.currentListenRange.seeked(play.meta.trackProgressPosition, reportedTS);
|
||||
if (isSeeked !== false) {
|
||||
this.logger.debug(`Detected player was seeked ${seekedPos.toFixed(2)}s, starting new listen range`);
|
||||
// if player has been seeked start a new listen range so our numbers don't get all screwy
|
||||
this.currentListenSessionEnd();
|
||||
}
|
||||
}
|
||||
|
||||
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
|
||||
}
|
||||
} else {
|
||||
this.setCurrentPlay(play);
|
||||
@@ -148,7 +166,7 @@ export abstract class AbstractPlayerState {
|
||||
return [this.getPlayedObject(), undefined];
|
||||
}
|
||||
|
||||
clearPlayer() {
|
||||
protected clearPlayer() {
|
||||
this.currentPlay = undefined;
|
||||
this.playLastUpdatedAt = undefined;
|
||||
this.playFirstSeenAt = undefined;
|
||||
@@ -156,7 +174,7 @@ export abstract class AbstractPlayerState {
|
||||
this.currentListenRange = undefined;
|
||||
}
|
||||
|
||||
stopPlayer() {
|
||||
protected stopPlayer() {
|
||||
this.reportedStatus = 'stopped';
|
||||
this.playLastUpdatedAt = dayjs();
|
||||
this.currentListenSessionEnd();
|
||||
@@ -181,27 +199,27 @@ export abstract class AbstractPlayerState {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getListenDuration() {
|
||||
getListenDuration(){
|
||||
let listenDur: number = 0;
|
||||
let ranges = [...this.listenRanges];
|
||||
if (this.currentListenRange !== undefined) {
|
||||
ranges.push(this.currentListenRange);
|
||||
}
|
||||
for (const [start, end] of ranges) {
|
||||
listenDur += start.getDuration(end);
|
||||
for (const range of ranges) {
|
||||
listenDur += range.getDuration();
|
||||
}
|
||||
return listenDur;
|
||||
}
|
||||
|
||||
currentListenSessionContinue(position?: number) {
|
||||
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
|
||||
const now = dayjs();
|
||||
if (this.currentListenRange === undefined) {
|
||||
this.logger.debug('Started new Player listen range.');
|
||||
this.currentListenRange = [new ListenProgress(now, position), new ListenProgress(now, position)];
|
||||
this.currentListenRange = new ListenRange(new ListenProgress(timestamp, position));
|
||||
} else {
|
||||
const newEndProgress = new ListenProgress(now, position);
|
||||
if (position !== undefined && this.currentListenRange[1].position !== undefined) {
|
||||
const oldEndProgress = this.currentListenRange[1];
|
||||
const oldEndProgress = this.currentListenRange.end;
|
||||
const newEndProgress = new ListenProgress(timestamp, position);
|
||||
if (position !== undefined && oldEndProgress !== undefined) {
|
||||
if (position === oldEndProgress.position && !['paused', 'stopped'].includes(this.calculatedStatus)) {
|
||||
this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused;
|
||||
if (this.reportedStatus !== this.calculatedStatus) {
|
||||
@@ -220,19 +238,73 @@ export abstract class AbstractPlayerState {
|
||||
} else {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
|
||||
}
|
||||
this.currentListenRange = [this.currentListenRange[0], newEndProgress];
|
||||
this.currentListenRange.setRangeEnd(newEndProgress);
|
||||
}
|
||||
}
|
||||
|
||||
currentListenSessionEnd() {
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange[0].getDuration(this.currentListenRange[1]) !== 0) {
|
||||
protected currentListenSessionEnd() {
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) {
|
||||
this.logger.debug('Ended current Player listen range.')
|
||||
this.listenRanges.push(this.currentListenRange);
|
||||
}
|
||||
this.currentListenRange = undefined;
|
||||
}
|
||||
|
||||
setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus) {
|
||||
protected isSessionRepeat(position?: number, reportedTS?: Dayjs) {
|
||||
if(this.currentListenRange === undefined) {
|
||||
return false;
|
||||
}
|
||||
const [isSeeked, seekPos] = this.currentListenRange.seeked(position, reportedTS);
|
||||
if (isSeeked === false || seekPos > 0) {
|
||||
return false;
|
||||
}
|
||||
let repeatHint = `New Position (${position})`;
|
||||
const trackDur = this.currentPlay.data.duration;
|
||||
// user is within 10 seconds or 10% of start of track
|
||||
const closeStartNum = position <= 12;
|
||||
if(closeStartNum) {
|
||||
repeatHint = `${repeatHint} is within 12 seconds of track start`;
|
||||
}
|
||||
const closeStartPer = (trackDur !== undefined && ((position / trackDur) <= 0.15));
|
||||
if(!closeStartNum && closeStartPer) {
|
||||
repeatHint = `${repeatHint} is within 15% of track start (${formatNumber((position/trackDur)*100)}%).`;
|
||||
}
|
||||
if (closeStartNum || closeStartPer) {
|
||||
// user has played at least 2 minutes or 50% of track
|
||||
const playerDur = this.getListenDuration();
|
||||
const closeDurNum = playerDur >= 120;
|
||||
if(closeDurNum) {
|
||||
repeatHint = `${repeatHint} and listened to more than 120s (${playerDur}s)`
|
||||
}
|
||||
const closeDurPer = (trackDur !== undefined && (playerDur / trackDur) >= 0.5);
|
||||
if(!closeDurNum && closeDurPer) {
|
||||
repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).`
|
||||
}
|
||||
if (closeDurNum || closeDurPer) {
|
||||
this.logger.debug(repeatHint);
|
||||
return true;
|
||||
}
|
||||
if (trackDur !== undefined) {
|
||||
const lastPos = this.currentListenRange.end.position;
|
||||
// or last position is within 10 seconds (or 10%) of end of track
|
||||
const nearEndNum = (trackDur - lastPos < 12);
|
||||
if(nearEndNum) {
|
||||
repeatHint = `${repeatHint} and previous position was within 12 seconds of track end.`;
|
||||
}
|
||||
const nearEndPos = ((lastPos / trackDur) > 0.85);
|
||||
if(!nearEndNum && nearEndPos) {
|
||||
repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`;
|
||||
}
|
||||
if(nearEndNum || nearEndPos) {
|
||||
this.logger.debug(repeatHint);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected setCurrentPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs) {
|
||||
this.currentPlay = play;
|
||||
this.playFirstSeenAt = dayjs();
|
||||
this.listenRanges = [];
|
||||
@@ -245,7 +317,7 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
if (!['stopped'].includes(this.reportedStatus)) {
|
||||
this.currentListenSessionContinue(play.meta.trackProgressPosition);
|
||||
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +329,8 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Last Update: ${this.stateLastUpdatedAt.toISOString()}`);
|
||||
let progress = '';
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange[1].position !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
progress = `${progressBar(this.currentListenRange[1].position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange[1].position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange.end.position !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
|
||||
}
|
||||
let listenedPercent = '';
|
||||
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
@@ -274,4 +346,38 @@ export abstract class AbstractPlayerState {
|
||||
logSummary() {
|
||||
this.logger.debug(this.textSummary());
|
||||
}
|
||||
|
||||
protected getPosition() {
|
||||
if(this.calculatedStatus === 'stopped') {
|
||||
return undefined;
|
||||
}
|
||||
let lastRange: ListenRange | undefined;
|
||||
if(this.currentListenRange !== undefined) {
|
||||
lastRange = this.currentListenRange;
|
||||
} else if(this.listenRanges.length > 0) {
|
||||
lastRange = this.listenRanges[this.listenRanges.length - 1];
|
||||
}
|
||||
if(lastRange === undefined || lastRange.end === undefined || lastRange.end.position === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return lastRange.end.position;
|
||||
}
|
||||
|
||||
getApiState(): SourcePlayerObj {
|
||||
return {
|
||||
platformId: this.platformIdStr,
|
||||
play: this.getPlayedObject(),
|
||||
playLastUpdatedAt: this.playLastUpdatedAt.toISOString(),
|
||||
playFirstSeenAt: this.playFirstSeenAt.toISOString(),
|
||||
playerLastUpdatedAt: this.stateLastUpdatedAt.toISOString(),
|
||||
position: this.getPosition(),
|
||||
listenedDuration: this.getListenDuration(),
|
||||
status: {
|
||||
reported: this.reportedStatus,
|
||||
calculated: this.calculatedStatus,
|
||||
stale: this.isUpdateStale(),
|
||||
orphaned: this.isOrphaned()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
|
||||
import { PlayProgress } from "../../../core/Atomic";
|
||||
import {PlayProgress} from "../../../core/Atomic";
|
||||
|
||||
export class ListenProgress implements PlayProgress {
|
||||
|
||||
constructor(
|
||||
public timestamp: Dayjs = dayjs(),
|
||||
public position?: number,
|
||||
public positionPercent?: number
|
||||
) {
|
||||
public timestamp: Dayjs;
|
||||
public position?: number;
|
||||
public positionPercent?: number;
|
||||
|
||||
constructor(timestamp?: Dayjs, position?: number, positionPercent?: number) {
|
||||
this.timestamp = timestamp ?? dayjs();
|
||||
this.position = position;
|
||||
this.positionPercent = positionPercent;
|
||||
}
|
||||
|
||||
getDuration(end: ListenProgress) {
|
||||
@@ -18,4 +21,12 @@ export class ListenProgress implements PlayProgress {
|
||||
return end.timestamp.diff(this.timestamp, 'seconds');
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
position: this.position,
|
||||
positionPercent: this.positionPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {ListenProgress} from "./ListenProgress";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {ListenRangeData} from "../../../core/Atomic";
|
||||
|
||||
export class ListenRange implements ListenRangeData {
|
||||
|
||||
public start: ListenProgress;
|
||||
public end: ListenProgress;
|
||||
|
||||
constructor(start?: ListenProgress, end?: ListenProgress) {
|
||||
const s = start ?? new ListenProgress();
|
||||
const e = end ?? s;
|
||||
|
||||
this.start = s;
|
||||
this.end = e;
|
||||
}
|
||||
|
||||
isPositional() {
|
||||
return this.start.position !== undefined && this.end.position !== undefined;
|
||||
}
|
||||
|
||||
isInitial() {
|
||||
if (this.isPositional()) {
|
||||
return this.start.position === this.end.position;
|
||||
}
|
||||
return this.start.timestamp.isSame(this.end.timestamp);
|
||||
}
|
||||
|
||||
seeked(position?: number, reportedTS: Dayjs = dayjs()): [boolean, number?] {
|
||||
if (position === undefined || this.isInitial() || !this.isPositional()) {
|
||||
return [false];
|
||||
}
|
||||
// if (new) position is earlier than last stored position then the user has seeked backwards on the player
|
||||
if (position < this.end.position) {
|
||||
return [true, position - this.end.position];
|
||||
}
|
||||
|
||||
// if (new) position is more than a reasonable number of ms ahead of real time than they have seeked forwards on the player
|
||||
const realTimeDiff = Math.max(0, reportedTS.diff(this.end.timestamp, 'ms')); // 0 max used so TS from testing doesn't cause "backward" diff
|
||||
const positionDiff = (position - this.end.position) * 1000;
|
||||
// if user is more than 2.5 seconds ahead of real time
|
||||
if (positionDiff - realTimeDiff > 2500) {
|
||||
return [true, position - this.end.position];
|
||||
}
|
||||
|
||||
return [false];
|
||||
}
|
||||
|
||||
setRangeStart(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
|
||||
if (data instanceof ListenProgress) {
|
||||
this.start = data;
|
||||
} else {
|
||||
const d = data || {};
|
||||
this.start = new ListenProgress(d.timestamp, d.position, d.positionPercent)
|
||||
}
|
||||
}
|
||||
|
||||
setRangeEnd(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
|
||||
if (data instanceof ListenProgress) {
|
||||
this.end = data;
|
||||
} else {
|
||||
const d = data || {};
|
||||
this.end = new ListenProgress(d.timestamp, d.position, d.positionPercent)
|
||||
}
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
return this.start.getDuration(this.end);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return [this.start, this.end];
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,8 @@ export default class PlexSource extends AbstractSource {
|
||||
}
|
||||
|
||||
try {
|
||||
this.scrobble([playObj]);
|
||||
const discovered = this.discover([playObj]);
|
||||
this.scrobble(discovered);
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
|
||||
@@ -230,8 +230,8 @@ export default class ScrobbleSources {
|
||||
break;
|
||||
case 'deezer':
|
||||
const d = {
|
||||
clientId: process.env.DEEZER_APP_ID,
|
||||
clientSecret: process.env.DEEZER_SECRET_KEY,
|
||||
clientId: process.env.DEEZER_CLIENT_ID,
|
||||
clientSecret: process.env.DEEZER_CLIENT_SECRET,
|
||||
redirectUri: process.env.DEEZER_REDIRECT_URI,
|
||||
accessToken: process.env.DEEZER_ACCESS_TOKEN,
|
||||
};
|
||||
@@ -410,7 +410,7 @@ export default class ScrobbleSources {
|
||||
}
|
||||
}
|
||||
|
||||
addSource = async (clientConfig: any, defaults: SourceDefaults = {}) => {
|
||||
addSource = async (clientConfig: ParsedConfig, defaults: SourceDefaults = {}) => {
|
||||
// const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
|
||||
// if (isValidConfig !== true) {
|
||||
// throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
@@ -422,7 +422,12 @@ export default class ScrobbleSources {
|
||||
logger: this.logger
|
||||
};
|
||||
|
||||
const {type, name, data: d = {}, options: clientOptions = {}} = clientConfig;
|
||||
const {type, name, data: d = {}, enable = true, options: clientOptions = {}} = clientConfig;
|
||||
|
||||
if(enable === false) {
|
||||
this.logger.warn(`${type} (${name}) source was disabled by config`);
|
||||
return;
|
||||
}
|
||||
|
||||
// add defaults
|
||||
const {options: defaultOptions = {}, ...restDefaults} = defaults;
|
||||
@@ -430,7 +435,7 @@ export default class ScrobbleSources {
|
||||
const compositeConfig: SourceConfig = {...clientConfig, data, options: {...defaultOptions, ...clientOptions}};
|
||||
|
||||
this.logger.debug(`(${name}) Constructing ${type} source`);
|
||||
let newSource;
|
||||
let newSource: AbstractSource;
|
||||
switch (type) {
|
||||
case 'spotify':
|
||||
newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, internal, this.emitter);
|
||||
@@ -448,7 +453,7 @@ export default class ScrobbleSources {
|
||||
newSource = await new JellyfinSource(name, compositeConfig as JellySourceConfig, internal, this.emitter);
|
||||
break;
|
||||
case 'lastfm':
|
||||
newSource = await new LastfmSource(name, compositeConfig as LastfmClientConfig, internal, this.emitter);
|
||||
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, internal, this.emitter);
|
||||
break;
|
||||
case 'deezer':
|
||||
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, internal, this.emitter);
|
||||
|
||||
@@ -11,6 +11,7 @@ import SpotifyWebApi from "spotify-web-api-node";
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify";
|
||||
import {
|
||||
DEFAULT_POLLING_INTERVAL,
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
@@ -51,18 +52,17 @@ export default class SpotifySource extends MemorySource {
|
||||
super('spotify', name, config, internal, emitter);
|
||||
const {
|
||||
data: {
|
||||
interval = 15,
|
||||
interval = DEFAULT_POLLING_INTERVAL,
|
||||
} = {}
|
||||
} = config;
|
||||
|
||||
if (interval < 5) {
|
||||
this.logger.warn('Interval should be 5 seconds or above...😬 preferably 15');
|
||||
this.logger.warn('Interval should probably be 5 seconds or above! Spotify may return 429 response (too many requests)');
|
||||
}
|
||||
|
||||
this.config.data.interval = interval;
|
||||
|
||||
this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`;
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: object, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
@@ -215,6 +215,11 @@ export default class SpotifySource extends MemorySource {
|
||||
throw new Error('Failed to initialize a Spotify source');
|
||||
}
|
||||
|
||||
if(accessToken === undefined || refreshToken === undefined) {
|
||||
this.logger.info(`No access or refresh token is present. User interaction for authentication is required.`);
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${rdUri}'`);
|
||||
}
|
||||
|
||||
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
||||
}
|
||||
|
||||
@@ -228,6 +233,10 @@ export default class SpotifySource extends MemorySource {
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
if(undefined === this.spotifyApi.getAccessToken()) {
|
||||
this.authed = false;
|
||||
return;
|
||||
}
|
||||
await this.callApi<ReturnType<typeof this.spotifyApi.getMe>>(((api: any) => api.getMe()));
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
@@ -283,7 +292,7 @@ export default class SpotifySource extends MemorySource {
|
||||
plays.push(currPlay);
|
||||
}
|
||||
}
|
||||
return this.processRecentPlaysNew(plays);
|
||||
return this.processRecentPlays(plays);
|
||||
}
|
||||
|
||||
getPlayHistory = async (options: RecentlyPlayedOptions = {}) => {
|
||||
@@ -428,13 +437,12 @@ export default class SpotifySource extends MemorySource {
|
||||
this.logger.warn('multi-scrobbler does not have sufficient permissions to access Spotify API "Get Playback State". MS will continue to work but accuracy for determining if/when a track played from a Spotify Connect device (smart device controlled through Spotify app) may be degraded. To fix this re-authenticate MS with Spotify and restart polling.');
|
||||
}
|
||||
|
||||
// and record backlogged tracks
|
||||
this.logger.info('Checking recently played API for tracks to backlog...');
|
||||
const backlogPlays = await this.getPlayHistory({formatted: true});
|
||||
this.scrobble(backlogPlays);
|
||||
this.logger.info('Backlog complete.');
|
||||
return true;
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async () => {
|
||||
return await this.getPlayHistory({formatted: true});
|
||||
}
|
||||
}
|
||||
|
||||
const asPlayHistoryObject = (obj: object): obj is PlayHistoryObject => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
|
||||
import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils";
|
||||
import MemorySource from "./MemorySource";
|
||||
import { SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
|
||||
import {DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
@@ -21,15 +21,12 @@ export class SubsonicSource extends MemorySource {
|
||||
declare config: SubSonicSourceConfig;
|
||||
|
||||
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
// default to quick interval so we can get a decently accurate nowPlaying
|
||||
const {
|
||||
data: {
|
||||
interval = 10,
|
||||
maxInterval = 30,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
const subsonicConfig = {...config, data: {...restData, internal, maxInterval}};
|
||||
const subsonicConfig = {...config, data: {...restData}};
|
||||
super('subsonic', name, subsonicConfig, internal,emitter);
|
||||
|
||||
const {data: {user, password, url} = {}} = this.config;
|
||||
@@ -84,7 +81,7 @@ export class SubsonicSource extends MemorySource {
|
||||
user,
|
||||
password,
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config.data;
|
||||
|
||||
|
||||
@@ -194,6 +191,6 @@ export class SubsonicSource extends MemorySource {
|
||||
} = resp;
|
||||
// sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays
|
||||
const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj));
|
||||
return this.processRecentPlaysNew(deduped);
|
||||
return this.processRecentPlays(deduped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// @ts-ignore
|
||||
import YouTubeMusic from "@foxxmd/youtube-music-ts-api";
|
||||
import YouTubeMusic from "youtube-music-ts-api";
|
||||
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic";
|
||||
// @ts-ignore
|
||||
import {IYouTubeMusicAuthenticated} from "@foxxmd/youtube-music-ts-api/interfaces-primary";
|
||||
import {IYouTubeMusicAuthenticated} from "youtube-music-ts-api/interfaces-primary";
|
||||
import dayjs from "dayjs";
|
||||
import { parseDurationFromTimestamp, playObjDataMatch } from "../utils";
|
||||
// @ts-ignore
|
||||
import {IPlaylistDetail, ITrackDetail} from "@foxxmd/youtube-music-ts-api/interfaces-supplementary";
|
||||
import {IPlaylistDetail, ITrackDetail} from "youtube-music-ts-api/interfaces-supplementary";
|
||||
import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {config, Logger} from "@foxxmd/winston";
|
||||
import {mergeArr} from "../utils";
|
||||
import {AsyncTask} from "toad-scheduler";
|
||||
import {PromisePool} from "@supercharge/promise-pool";
|
||||
import ScrobbleClients from "../scrobblers/ScrobbleClients";
|
||||
|
||||
export const createHeartbeatClientsTask = (clients: ScrobbleClients, parentLogger: Logger) => {
|
||||
const logger = parentLogger.child({labels: ['Heartbeat', 'Clients']}, mergeArr);
|
||||
|
||||
return new AsyncTask(
|
||||
'Heartbeat',
|
||||
(): Promise<any> => {
|
||||
return PromisePool
|
||||
.withConcurrency(1)
|
||||
.for(clients.clients)
|
||||
.process(async (client) => {
|
||||
if(await client.isReady()) {
|
||||
await client.processDeadLetterQueue();
|
||||
if(!client.scrobbling) {
|
||||
client.logger.info('Should be processing scrobbles! Attempting to restart scrobbling...', {leaf: 'Heartbeat'});
|
||||
client.initScrobbleMonitoring();
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}).then(({results, errors}) => {
|
||||
logger.info(`Checked Dead letter queue for ${clients.clients.length} clients.`);
|
||||
const restarted = results.reduce((acc, curr) => acc += curr, 0);
|
||||
if (restarted > 0) {
|
||||
logger.info(`Attempted to restart ${restarted} clients that were not processing scrobbles.`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
logger.error(`Encountered errors!`);
|
||||
for (const err of errors) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
(err: Error) => {
|
||||
logger.error(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {config, Logger} from "@foxxmd/winston";
|
||||
import {mergeArr} from "../utils";
|
||||
import {AsyncTask} from "toad-scheduler";
|
||||
import {PromisePool} from "@supercharge/promise-pool";
|
||||
import ScrobbleSources from "../sources/ScrobbleSources";
|
||||
|
||||
export const createHeartbeatSourcesTask = (sources: ScrobbleSources, parentLogger: Logger) => {
|
||||
const logger = parentLogger.child({labels: ['Heartbeat', 'Sources']}, mergeArr);
|
||||
|
||||
return new AsyncTask(
|
||||
'Heartbeat',
|
||||
(): Promise<any> => {
|
||||
return PromisePool
|
||||
.withConcurrency(1)
|
||||
.for(sources.sources)
|
||||
.process(async (source) => {
|
||||
if (source.initialized && source.canPoll && !source.polling && !source.authGated()) {
|
||||
source.logger.info('Should be polling! Attempting to restart polling...', {leaf: 'Heartbeat'});
|
||||
source.poll();
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}).then(({results, errors}) => {
|
||||
logger.info(`Checked ${sources.sources.length} sources for restart signals.`);
|
||||
const restarted = results.reduce((acc, curr) => acc += curr, 0);
|
||||
if (restarted > 0) {
|
||||
logger.info(`Attempted to restart ${restarted} sources that were not polling.`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
logger.error(`Encountered errors!`);
|
||||
for (const err of errors) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
(err: Error) => {
|
||||
logger.error(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
import samplePayload from './playbackProgressSample.json';
|
||||
|
||||
import JellyfinSource from "../../sources/JellyfinSource";
|
||||
import EventEmitter from "events";
|
||||
import {getLogger} from "../../common/logging";
|
||||
import {JsonPlayObject, PlayObject} from "../../../core/Atomic";
|
||||
|
||||
const dataAsFixture = (data: any): TestFixture => {
|
||||
return data as TestFixture;
|
||||
}
|
||||
|
||||
interface TestFixture {
|
||||
data: any
|
||||
expected: JsonPlayObject
|
||||
}
|
||||
|
||||
describe('Jellyfin Payload Parsing', function () {
|
||||
|
||||
it('Should parse PlayProgress payload as PlayObject', async function () {
|
||||
const fixture = dataAsFixture(samplePayload[0]);
|
||||
const play = JellyfinSource.formatPlayObj(fixture.data);
|
||||
|
||||
assert.equal(play.data.track, fixture.expected.data.track);
|
||||
assert.equal(play.meta.mediaType, 'Audio');
|
||||
});
|
||||
|
||||
describe('Correctly detects events as valid/invalid', function () {
|
||||
|
||||
const jfSource = new JellyfinSource('Test', {data: {}}, {localUrl: 'test', configDir: 'test', logger: getLogger({}, 'Test')}, new EventEmitter());
|
||||
|
||||
it('Should parse PlayProgress with Audio ItemType as valid event', async function () {
|
||||
const fixture = dataAsFixture(samplePayload[0]);
|
||||
const play = JellyfinSource.formatPlayObj(fixture.data);
|
||||
|
||||
assert.isTrue(jfSource.isValidEvent(play))
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"ServerId": "6c41d884a9c1490f8fd0863e04d75a69",
|
||||
"ServerName": "maine",
|
||||
"ServerVersion": "10.8.10",
|
||||
"ServerUrl": "<redacted>",
|
||||
"NotificationType": "PlaybackProgress",
|
||||
"Timestamp": "2023-09-26T11:18:17.8399513-03:00",
|
||||
"UtcTimestamp": "2023-09-26T14:18:17.8399533Z",
|
||||
"Name": "Brother Madonna",
|
||||
"Overview": "",
|
||||
"Tagline": "",
|
||||
"ItemId": "514941104216969e74a8d87fc7893ad5",
|
||||
"ItemType": "Audio",
|
||||
"RunTimeTicks": 926533312,
|
||||
"RunTime": "00:01:32",
|
||||
"Year": 2008,
|
||||
"Album": "I Kind of Really Want to Make Love to You",
|
||||
"Artist": "Kitsune\u00B2",
|
||||
"Provider_musicbrainzalbumartist": "6e659357-5963-4daf-a634-a3596893f3d7",
|
||||
"Provider_musicbrainzartist": "6e659357-5963-4daf-a634-a3596893f3d7",
|
||||
"Provider_musicbrainzalbum": "7c10f8a5-669f-4ff6-bb88-de59846c0e0c",
|
||||
"Provider_musicbrainzreleasegroup": "85c9ae8b-13e4-4fbf-9f09-5e405039772c",
|
||||
"Provider_musicbrainztrack": "3c2c1f40-b22e-360e-a29a-1b1610190a8c",
|
||||
"Audio_0_Title": "FLAC - Stereo",
|
||||
"Audio_0_Type": "Audio",
|
||||
"Audio_0_Language": null,
|
||||
"Audio_0_Codec": "flac",
|
||||
"Audio_0_Channels": 2,
|
||||
"Audio_0_Bitrate": 917349,
|
||||
"Audio_0_SampleRate": 44100,
|
||||
"Audio_0_Default": false,
|
||||
"PlaybackPositionTicks": 640000000,
|
||||
"PlaybackPosition": "00:01:04",
|
||||
"MediaSourceId": "514941104216969e74a8d87fc7893ad5",
|
||||
"IsPaused": false,
|
||||
"IsAutomated": true,
|
||||
"DeviceId": "BC9311B570ABE4BD71EEEC0856A448AD",
|
||||
"DeviceName": "samsung SM-S901E",
|
||||
"ClientName": "Android",
|
||||
"NotificationUsername": "Tabitha",
|
||||
"UserId": "6d23c30c5e9d4ec6a66db6b05b63e516"
|
||||
},
|
||||
"expected": {
|
||||
"data": {
|
||||
"track": "Brother Madonna",
|
||||
"artists": ["Kitsune²"],
|
||||
"album": "I Kind of Really Want to Make Love to You",
|
||||
"duration": 92
|
||||
},
|
||||
"meta": {
|
||||
"source": "Jellyfin",
|
||||
"event": "PlaybackProgress",
|
||||
"mediaType": "Audio",
|
||||
"trackId": "514941104216969e74a8d87fc7893ad5",
|
||||
"user": "Tabitha",
|
||||
"sourceVersion": "10.8.10",
|
||||
"deviceId": "BC9311B570-samsung SM-S901E",
|
||||
"trackProgressPosition": 64
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,55 @@
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"track_metadata": {
|
||||
"track_name": "Instant Crush (feat. Julian Casablancas)",
|
||||
"artist_name": "Daft Punk",
|
||||
"release_name": "Random Access Memories",
|
||||
"additional_info": {
|
||||
"duration": 338,
|
||||
"origin_url": "https://music.youtube.com/watch?v=khnokW3Mw24",
|
||||
"submission_client": "Web Scrobbler",
|
||||
"music_service_name": "YouTube Music",
|
||||
"submission_client_version": "3.1.0",
|
||||
"recording_msid": "878c787e-5dd4-42b4-9c2b-1b53baec7e8b"
|
||||
},
|
||||
"mbid_mapping": {
|
||||
"recording_mbid": "dbc0da50-4bc6-4230-9e7b-136b5dc2b31a",
|
||||
"recording_name": "Instant Crush",
|
||||
"release_mbid": "bbfc83ad-826f-4957-893d-a808105c828b",
|
||||
"artists": [
|
||||
{
|
||||
"artist_mbid": "056e4f3e-d505-4dad-8ec1-d04f521cbb56",
|
||||
"artist_credit_name": "Daft Punk",
|
||||
"join_phrase": " feat. "
|
||||
},
|
||||
{
|
||||
"artist_mbid": "c5ce487b-0462-444e-a184-4de0fef7e028",
|
||||
"artist_credit_name": "Julian Casablancas",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"artist_mbids": [
|
||||
"056e4f3e-d505-4dad-8ec1-d04f521cbb56",
|
||||
"c5ce487b-0462-444e-a184-4de0fef7e028"
|
||||
],
|
||||
"caa_id": 32554171842,
|
||||
"caa_release_mbid": "5000a285-b67e-4cfc-b54b-2b98f1810d2e"
|
||||
}
|
||||
},
|
||||
"listened_at": 1694479888,
|
||||
"recording_msid": "878c787e-5dd4-42b4-9c2b-1b53baec7e8b",
|
||||
"user_name": "kinescou",
|
||||
"inserted_at": 1694480058
|
||||
},
|
||||
"expected": {
|
||||
"artists": [
|
||||
"Daft Punk",
|
||||
"Julian Casablancas"
|
||||
],
|
||||
"track": "Instant Crush"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"inserted_at": 1692499763,
|
||||
|
||||
+10
-10
@@ -1,19 +1,19 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
// correct mappings
|
||||
import multiArtistInArtistName from './listenbrainz/correctlyMapped/multiArtistInArtistName.json';
|
||||
import multiArtistsInTrackName from './listenbrainz/correctlyMapped/multiArtistInTrackName.json';
|
||||
import noArtistMapping from './listenbrainz/correctlyMapped/noArtistMapping.json';
|
||||
import multiMappedArtistsWithSingleUserArtist from './listenbrainz/correctlyMapped/multiArtistMappingWithSingleRecordedArtist.json';
|
||||
import artistWithProperJoiner from './listenbrainz/correctlyMapped/artistProperHasJoinerInName.json';
|
||||
import normalizedValues from './listenbrainz/correctlyMapped/normalizedName.json';
|
||||
import slightlyDifferentNames from './listenbrainz/correctlyMapped/trackNameSlightlyDifferent.json';
|
||||
import multiArtistInArtistName from './correctlyMapped/multiArtistInArtistName.json';
|
||||
import multiArtistsInTrackName from './correctlyMapped/multiArtistInTrackName.json';
|
||||
import noArtistMapping from './correctlyMapped/noArtistMapping.json';
|
||||
import multiMappedArtistsWithSingleUserArtist from './correctlyMapped/multiArtistMappingWithSingleRecordedArtist.json';
|
||||
import artistWithProperJoiner from './correctlyMapped/artistProperHasJoinerInName.json';
|
||||
import normalizedValues from './correctlyMapped/normalizedName.json';
|
||||
import slightlyDifferentNames from './correctlyMapped/trackNameSlightlyDifferent.json';
|
||||
|
||||
// incorrect mappings
|
||||
import incorrectMultiArtistsTrackName from './listenbrainz/incorrectlyMapped/multiArtistsInTrackName.json';
|
||||
import veryWrong from './listenbrainz/incorrectlyMapped/veryWrong.json';
|
||||
import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTrackName.json';
|
||||
import veryWrong from './incorrectlyMapped/veryWrong.json';
|
||||
|
||||
import {ListenbrainzApiClient, ListenResponse} from "../common/vendor/ListenbrainzApiClient";
|
||||
import {ListenbrainzApiClient, ListenResponse} from "../../common/vendor/ListenbrainzApiClient";
|
||||
|
||||
interface ExpectedResults {
|
||||
artists: string[]
|
||||
@@ -0,0 +1,357 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
import {generatePlay} from "../utils/PlayTestUtils";
|
||||
import {GenericPlayerState} from "../../sources/PlayerState/GenericPlayerState";
|
||||
import {getLogger} from "../../common/logging";
|
||||
import {
|
||||
CALCULATED_PLAYER_STATUSES,
|
||||
NO_DEVICE,
|
||||
NO_USER,
|
||||
REPORTED_PLAYER_STATUSES
|
||||
} from "../../common/infrastructure/Atomic";
|
||||
import {playObjDataMatch} from "../../utils";
|
||||
import dayjs from "dayjs";
|
||||
import clone from "clone";
|
||||
|
||||
const logger = getLogger({});
|
||||
|
||||
const newPlay = generatePlay({duration: 300});
|
||||
|
||||
describe('Basic player state', function () {
|
||||
|
||||
it('Creates new play state when new', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
assert.isUndefined(player.currentListenRange);
|
||||
assert.isUndefined(player.currentPlay);
|
||||
|
||||
player.setState(undefined, newPlay);
|
||||
|
||||
assert.isDefined(player.currentListenRange);
|
||||
assert.isDefined(player.currentPlay);
|
||||
});
|
||||
|
||||
it('Creates new play state in unknown status', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
assert.isUndefined(player.currentListenRange);
|
||||
assert.isUndefined(player.currentPlay);
|
||||
|
||||
player.setState(undefined, newPlay);
|
||||
|
||||
assert.isDefined(player.currentListenRange);
|
||||
assert.isDefined(player.currentPlay);
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
|
||||
});
|
||||
|
||||
it('Creates new play state when incoming play is not the same as stored play', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(undefined, newPlay);
|
||||
|
||||
assert.isTrue(playObjDataMatch(player.currentPlay, newPlay));
|
||||
|
||||
const nextPlay = generatePlay({playDate: newPlay.data.playDate.add(2, 'seconds')});
|
||||
const [returnedPlay, prevPlay] = player.setState(undefined, nextPlay);
|
||||
|
||||
assert.isTrue(playObjDataMatch(prevPlay, newPlay));
|
||||
assert.isTrue(playObjDataMatch(player.currentPlay, nextPlay));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Player status', function () {
|
||||
|
||||
it('New player transitions from unknown to playing on n+1 states', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(undefined, newPlay);
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
|
||||
|
||||
player.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
|
||||
});
|
||||
|
||||
describe('When source provides reported status', function () {
|
||||
|
||||
it('Calculated state is playing when source reports playing', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
|
||||
});
|
||||
|
||||
|
||||
it('Calculated state is paused when source reports paused', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(20, 'seconds'));
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
|
||||
});
|
||||
|
||||
it('Calculated state is stopped when source reports stopped', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.stopped, newPlay, dayjs().add(20, 'seconds'));
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.stopped, player.calculatedStatus);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('When source provides playback position', function () {
|
||||
|
||||
it('Calculated state is playing when position moves forward', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 13;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
|
||||
});
|
||||
|
||||
it('Calculated state is paused when position does not change', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 13;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
|
||||
});
|
||||
|
||||
// TODO playback position reported and conflicts with player reported status
|
||||
});
|
||||
});
|
||||
|
||||
describe('Player listen ranges', function () {
|
||||
describe('When source does not provide playback position', function () {
|
||||
|
||||
it('Duration is timestamp based for unknown/playing reported players', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 20);
|
||||
|
||||
const uplayer = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
uplayer.setState(undefined, newPlay);
|
||||
uplayer.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
|
||||
uplayer.setState(undefined, newPlay, dayjs().add(20, 'seconds'));
|
||||
|
||||
assert.equal(uplayer.getListenDuration(), 20);
|
||||
});
|
||||
|
||||
it('Range ends if player reports paused', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 20);
|
||||
});
|
||||
|
||||
it('Listen duration continues when player resumes', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
|
||||
// For TS-only players the player must see two consecutive playing states to count the duration between them
|
||||
// so it does NOT count above paused ^^ to below playing -- only playing-to-playing
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(50, 'seconds'));
|
||||
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(60, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When source does provide playback position', function () {
|
||||
|
||||
it('Duration is position based', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 10;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 7);
|
||||
});
|
||||
|
||||
it('Range ends if position does not move', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
|
||||
it('Range continues when position continues moving forward', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 7;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 17;
|
||||
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 27;
|
||||
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 24);
|
||||
});
|
||||
|
||||
describe('Detects seeking', function () {
|
||||
|
||||
it('Detects seeking forward', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 3;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 13;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 30;
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
|
||||
|
||||
positioned.meta.trackProgressPosition = 40;
|
||||
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 20);
|
||||
});
|
||||
|
||||
it('Detects seeking backwards', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.meta.trackProgressPosition = 30;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 40;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 20;
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
|
||||
|
||||
positioned.meta.trackProgressPosition = 30;
|
||||
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.equal(player.getListenDuration(), 20);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Detects repeating', function () {
|
||||
it('Detects repeat when player was within 12 seconds of ending and seeked back to within 12 seconds of start', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 70;
|
||||
positioned.meta.trackProgressPosition = 45;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 55;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 65;
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 5;
|
||||
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
|
||||
it('Detects repeat when player was within 15% of ending and seeked back to within 15% of start', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 300;
|
||||
positioned.meta.trackProgressPosition = 351;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 361;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 371;
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 20;
|
||||
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
|
||||
it('Detects repeat when player is seeked to start and a heft chunk of the track has already been played', function () {
|
||||
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 70;
|
||||
positioned.meta.trackProgressPosition = 0;
|
||||
player.setState(undefined, positioned);
|
||||
|
||||
positioned.meta.trackProgressPosition = 10;
|
||||
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 20;
|
||||
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 30;
|
||||
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 40;
|
||||
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
|
||||
|
||||
positioned.meta.trackProgressPosition = 2;
|
||||
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(50, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Kaidi Tatham"
|
||||
],
|
||||
"track": "Fricassee",
|
||||
"duration": 305,
|
||||
"playDate": "2023-09-20T14:32:39.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Kuna Maze"
|
||||
],
|
||||
"track": "Jimbó",
|
||||
"playDate": "2023-09-20T14:46:00.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Norman Person",
|
||||
"Shamek Farrah"
|
||||
],
|
||||
"track": "Aisha",
|
||||
"duration": 559,
|
||||
"playDate": "2023-09-20T15:10:09.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Frédéric Chopin",
|
||||
"Krystian Zimerman"
|
||||
],
|
||||
"track": "Ballade No. 4 in F Minor, Op. 52",
|
||||
"duration": 575,
|
||||
"playDate": "2023-09-20T15:17:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Nidia Gongora",
|
||||
"The Bongo Hop"
|
||||
],
|
||||
"track": "Sonora",
|
||||
"duration": 327,
|
||||
"playDate": "2023-09-27T20:02:17.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Cheo Feliciano"
|
||||
],
|
||||
"track": "Aprieta (Oye Cómo Va)",
|
||||
"playDate": "2023-09-20T15:38:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Pink Floyd"
|
||||
],
|
||||
"track": "Another Brick in the Wall, Pt. 1",
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Frédéric Chopin",
|
||||
"Krystian Zimerman"
|
||||
],
|
||||
"track": "Ballade No. 1 in G Minor, Op. 23",
|
||||
"duration": 575,
|
||||
"playDate": "2023-09-20T15:41:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"A Collection of Boogie",
|
||||
"Jazz Funk",
|
||||
"Disco with Mehdi El"
|
||||
],
|
||||
"track": "Aquil",
|
||||
"playDate": "2023-09-20T17:55:05.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Outkast"
|
||||
],
|
||||
"track": "Da Art of Storytellin' (Pt. 1)",
|
||||
"duration": 422,
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Kaidi Tatham"
|
||||
],
|
||||
"track": "Fricassee",
|
||||
"duration": 305,
|
||||
"playDate": "2023-09-20T14:32:39.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Kuna Maze"
|
||||
],
|
||||
"track": "Jimbó",
|
||||
"duration": 255,
|
||||
"playDate": "2023-09-20T14:46:00.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Norman Person",
|
||||
"Shamek Farrah"
|
||||
],
|
||||
"track": "Aisha",
|
||||
"duration": 559,
|
||||
"playDate": "2023-09-20T15:10:09.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Frédéric Chopin",
|
||||
"Krystian Zimerman"
|
||||
],
|
||||
"track": "Ballade No. 4 in F Minor, Op. 52",
|
||||
"duration": 575,
|
||||
"playDate": "2023-09-20T15:17:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Nidia Gongora",
|
||||
"The Bongo Hop"
|
||||
],
|
||||
"track": "Sonora",
|
||||
"duration": 327,
|
||||
"playDate": "2023-09-27T20:02:17.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Cheo Feliciano"
|
||||
],
|
||||
"track": "Aprieta (Oye Cómo Va)",
|
||||
"duration": 195,
|
||||
"playDate": "2023-09-20T15:38:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Pink Floyd"
|
||||
],
|
||||
"track": "Another Brick in the Wall, Pt. 1",
|
||||
"duration": 192,
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Frédéric Chopin",
|
||||
"Krystian Zimerman"
|
||||
],
|
||||
"track": "Ballade No. 1 in G Minor, Op. 23",
|
||||
"duration": 575,
|
||||
"playDate": "2023-09-20T15:41:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"A Collection of Boogie",
|
||||
"Jazz Funk",
|
||||
"Disco with Mehdi El"
|
||||
],
|
||||
"track": "Aquil",
|
||||
"duration": 3072,
|
||||
"playDate": "2023-09-20T17:55:05.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"artists": [
|
||||
"Outkast"
|
||||
],
|
||||
"track": "Da Art of Storytellin' (Pt. 1)",
|
||||
"duration": 422,
|
||||
"playDate": "2023-09-20T15:39:12.000Z"
|
||||
},
|
||||
"meta": {
|
||||
"source": "Maloja"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient";
|
||||
import {PlayObject} from "../../../core/Atomic";
|
||||
import {getLogger} from "../../common/logging";
|
||||
import {Notifiers} from "../../notifier/Notifiers";
|
||||
import EventEmitter from "events";
|
||||
|
||||
export class TestScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor() {
|
||||
const logger = getLogger({});
|
||||
const notifier = new Notifiers(new EventEmitter(), new EventEmitter(), new EventEmitter());
|
||||
super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger);
|
||||
}
|
||||
|
||||
doScrobble(playObj: PlayObject): Promise<PlayObject> {
|
||||
return Promise.resolve(playObj);
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log?: boolean): Promise<boolean> => {
|
||||
return (await this.existingScrobble(playObj)) !== undefined;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
import clone from 'clone';
|
||||
import pEvent from 'p-event';
|
||||
|
||||
import withDuration from '../plays/withDuration.json';
|
||||
import mixedDuration from '../plays/mixedDuration.json';
|
||||
|
||||
import {TestScrobbler} from "./TestScrobbler";
|
||||
import {asPlays, generatePlay, normalizePlays} from "../utils/PlayTestUtils";
|
||||
import dayjs from "dayjs";
|
||||
import {sleep} from "../../utils";
|
||||
|
||||
const firstPlayDate = dayjs().subtract(1, 'hour');
|
||||
|
||||
const withDurPlays = asPlays(withDuration);
|
||||
const mixedDurPlays = asPlays(mixedDuration);
|
||||
const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate});
|
||||
const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate});
|
||||
|
||||
const testScrobbler = new TestScrobbler();
|
||||
testScrobbler.verboseOptions = {
|
||||
match: {
|
||||
onMatch: true,
|
||||
onNoMatch: true,
|
||||
confidenceBreakdown: true
|
||||
}
|
||||
};
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
|
||||
describe('Detects duplicate and unique scrobbles from client recent history', function () {
|
||||
|
||||
describe('When scrobble is unique', function () {
|
||||
|
||||
it('It is not detected as duplicate when play date is newer than most recent', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 1].data.playDate.add(70, 'seconds')
|
||||
});
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(newScrobble));
|
||||
});
|
||||
|
||||
it('It is not detected as duplicate when play date is close to an existing scrobble', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(newScrobble));
|
||||
});
|
||||
});
|
||||
|
||||
describe('When scrobble track/artist/album matches existing but is a new scrobble', function () {
|
||||
|
||||
it('Is not detected as duplicate when artist is same, time is similar, but track is different', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const diffPlay = clone(normalizedWithMixedDur[1]);
|
||||
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
|
||||
diffPlay.data.track = 'A Totally Different Track'
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
});
|
||||
|
||||
it('Is not detected as duplicate when track is same, time is similar, but artist is different', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const diffPlay = clone(normalizedWithMixedDur[1]);
|
||||
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
|
||||
diffPlay.data.artists = ['A Different Artist'];
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
});
|
||||
|
||||
|
||||
it('Is not detected as duplicate when play date is different by more than 10 seconds (high granularity source)', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffPos.data.playDate = timeOffPos.data.playDate.add(11, 's');
|
||||
|
||||
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(11, 's');
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffPos));
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffNeg));
|
||||
});
|
||||
|
||||
it('Is not detected as duplicate when play date is different by more than 60 seconds (low granularity source)', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizePlays(mixedDurPlays, {
|
||||
initialDate: firstPlayDate,
|
||||
defaultMeta: {source: 'subsonic'}
|
||||
});
|
||||
|
||||
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffPos.data.playDate = timeOffPos.data.playDate.add(61, 's');
|
||||
|
||||
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(61, 's');
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffPos));
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(timeOffNeg));
|
||||
});
|
||||
|
||||
describe('When existing has duration', function () {
|
||||
it('A track with continuity to the previous track is not detected as a duplicate', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithDur;
|
||||
|
||||
const brickPt1 = normalizedWithDur.find(x => x.data.track.includes('Another Brick'));
|
||||
const brickPt2 = clone(brickPt1);
|
||||
brickPt2.data.track = 'Another Brick in the Wall, Pt. 2';
|
||||
brickPt2.data.playDate = brickPt1.data.playDate.add(brickPt1.data.duration + 1, 'seconds');
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(brickPt2));
|
||||
|
||||
const story1 = normalizedWithDur.find(x => x.data.track.includes('Da Art of'));
|
||||
const story2 = clone(story1);
|
||||
story2.data.track = `Da Art of Storytellin' (Pt. 2)`;
|
||||
story2.data.playDate = story2.data.playDate.add(story1.data.duration + 1, 'seconds');
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(story2));
|
||||
|
||||
const ballad1 = normalizedWithDur.find(x => x.data.track.includes('Ballade No. 1'));
|
||||
const ballad2 = clone(ballad1);
|
||||
ballad2.data.track = `Ballade No. 2 in G Minor, Op. 27`;
|
||||
ballad2.data.playDate = ballad2.data.playDate.add(ballad1.data.duration + 1, 'seconds');
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(ballad2));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('When scrobble is a duplicate (title/artists/album)', function () {
|
||||
|
||||
it('Is detected as duplicate when an exact match', async function () {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when artist/title differences are whitespace or case', async function () {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
const ref = normalizedWithMixedDur[3];
|
||||
|
||||
const diffPlay = clone(ref);
|
||||
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
|
||||
|
||||
|
||||
diffPlay.data.track = ref.data.track.toUpperCase();
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
diffPlay.data.track = ` ${ref.data.track} `;
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
diffPlay.data.track = ref.data.track.replaceAll(' ', ' ');
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
diffPlay.data.artists = ref.data.artists.map(x => x.toUpperCase());
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
diffPlay.data.artists = ref.data.artists.map(x => x.replaceAll(' ', ' '));
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when artist/title differences are from unicode normalization', async function () {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
const ref = normalizedWithMixedDur.find(x => x.data.track === 'Jimbó');
|
||||
|
||||
const diffPlay = clone(ref);
|
||||
diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
|
||||
diffPlay.data.track = 'Jimbo';
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when play date is off by less than 10 seconds (high granularity source)', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
|
||||
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffPos.data.playDate = timeOffPos.data.playDate.add(10, 's');
|
||||
|
||||
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(10, 's');
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffPos));
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffNeg));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when play date is off by less than 60 seconds (low granularity source)', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizePlays(mixedDurPlays, {
|
||||
initialDate: firstPlayDate,
|
||||
defaultMeta: {source: 'subsonic'}
|
||||
});
|
||||
|
||||
const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffPos.data.playDate = timeOffPos.data.playDate.add(59, 's');
|
||||
|
||||
const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
|
||||
timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(59, 's');
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffPos));
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeOffNeg));
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when title is exact, artist is similar, and time is similar', async function () {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
const ref = normalizedWithMixedDur[3];
|
||||
|
||||
const diffPlay = clone(ref);
|
||||
diffPlay.data.playDate = diffPlay.data.playDate.add(3, 's');
|
||||
diffPlay.data.artists = [diffPlay.data.artists[0]]
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
diffPlay.data.artists = [ref.data.artists[1]]
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(diffPlay));
|
||||
|
||||
|
||||
const son = normalizedWithMixedDur.find(x => x.data.track === 'Sonora')
|
||||
|
||||
const sonDiffPlay = clone(son);
|
||||
sonDiffPlay.data.playDate = sonDiffPlay.data.playDate.subtract(son.data.duration + 1, 's');
|
||||
sonDiffPlay.data.artists = [sonDiffPlay.data.artists[1]]
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(sonDiffPlay));
|
||||
});
|
||||
|
||||
describe('When at least one play has duration', function () {
|
||||
|
||||
it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithDur;
|
||||
|
||||
const timeEnd = clone(normalizedWithDur[normalizedWithMixedDur.length - 1]);
|
||||
timeEnd.data.playDate = timeEnd.data.playDate.add(timeEnd.data.duration, 's');
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
|
||||
|
||||
// only one has duration
|
||||
timeEnd.data.duration = undefined;
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
|
||||
|
||||
// only one has duration
|
||||
timeEnd.data.duration = undefined;
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(timeEnd));
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detects duplicate and unique scrobbles using actively tracked scrobbles', function() {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
|
||||
it('Detects a unique play', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
|
||||
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble);
|
||||
|
||||
assert.isUndefined(matchedPlay);
|
||||
assert.isEmpty(matchedData);
|
||||
});
|
||||
|
||||
it('Detects an exact duplicate', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
|
||||
|
||||
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(newScrobble);
|
||||
|
||||
assert.isDefined(matchedPlay);
|
||||
assert.isNotEmpty(matchedData);
|
||||
});
|
||||
|
||||
it('Detects a duplicate with close time', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
testScrobbler.addScrobbledTrack(newScrobble, newScrobble);
|
||||
|
||||
const dupScrobble = clone(newScrobble);
|
||||
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
|
||||
|
||||
const [matchedPlay, matchedData] = testScrobbler.findExistingSubmittedPlayObj(dupScrobble);
|
||||
|
||||
assert.isDefined(matchedPlay);
|
||||
assert.isNotEmpty(matchedData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manages scrobble queue', function() {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.scrobbleSleep = 500;
|
||||
testScrobbler.scrobbleDelay = 0;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
testScrobbler.initScrobbleMonitoring();
|
||||
|
||||
it('Scrobbles a uniquely queued play', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(3000)]);
|
||||
|
||||
assert.isDefined(res);
|
||||
assert.isDefined(res.data);
|
||||
});
|
||||
|
||||
it('Does not Scrobble a duplicate play queued after original is scrobbled', async function() {
|
||||
this.timeout(3500);
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
const dupScrobble = clone(newScrobble);
|
||||
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
|
||||
|
||||
assert.isDefined(res);
|
||||
assert.isDefined(res.data);
|
||||
|
||||
testScrobbler.queueScrobble(dupScrobble, 'test');
|
||||
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
|
||||
|
||||
assert.isUndefined(resDup);
|
||||
});
|
||||
|
||||
it('Does not Scrobble a duplicate play queued before original is scrobbled', async function() {
|
||||
this.timeout(3500);
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
const dupScrobble = clone(newScrobble);
|
||||
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
testScrobbler.queueScrobble(dupScrobble, 'test');
|
||||
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
|
||||
|
||||
assert.isDefined(res);
|
||||
assert.isDefined(res.data);
|
||||
|
||||
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
|
||||
|
||||
assert.isUndefined(resDup);
|
||||
});
|
||||
|
||||
it('Delays scrobbles when many are queued', async function () {
|
||||
this.timeout(3500);
|
||||
|
||||
const newScrobble1 = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
});
|
||||
const newScrobble2 = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(80, 'seconds')
|
||||
});
|
||||
const newScrobble3 = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(160, 'seconds')
|
||||
});
|
||||
|
||||
testScrobbler.scrobbleDelay = 600;
|
||||
|
||||
testScrobbler.queueScrobble([newScrobble1, newScrobble2, newScrobble3], 'test');
|
||||
await pEvent(testScrobbler.emitter, 'scrobble');
|
||||
const initial = dayjs();
|
||||
await pEvent(testScrobbler.emitter, 'scrobble');
|
||||
await pEvent(testScrobbler.emitter, 'scrobble');
|
||||
|
||||
const end = dayjs();
|
||||
|
||||
// roughly...
|
||||
assert.closeTo(end.diff(initial, 'ms'), 1200, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {assert} from 'chai';
|
||||
import {compareNormalizedStrings} from "../utils/StringUtils";
|
||||
|
||||
|
||||
describe('String Comparisons', function () {
|
||||
|
||||
it('should ignore punctuation', async function () {
|
||||
const result = compareNormalizedStrings('this string! is the. same', 'this string is the same');
|
||||
assert.isAtLeast(result.highScore, 100);
|
||||
});
|
||||
|
||||
it('should ignore whitespace', async function () {
|
||||
const result = compareNormalizedStrings('this string is the same ', 'this string is the same');
|
||||
assert.isAtLeast(result.highScore, 100);
|
||||
});
|
||||
|
||||
it('should ignore case', async function () {
|
||||
const result = compareNormalizedStrings('ThIs STRING iS THe SAMe', 'this string is the same');
|
||||
assert.isAtLeast( result.highScore, 100);
|
||||
});
|
||||
|
||||
it('should normalize unicode', async function () {
|
||||
const tests = [
|
||||
['Enamorate Bailando','Enamórate Bailando'],
|
||||
['Dina Ogon', 'Dina Ögon'],
|
||||
['Nana', 'Nanã'],
|
||||
['Nana', 'Nanä']
|
||||
]
|
||||
|
||||
for(const test of tests) {
|
||||
const result = compareNormalizedStrings(test[0], test[1]);
|
||||
assert.equal(100, result.highScore);
|
||||
}
|
||||
});
|
||||
|
||||
it('should score small changes correctly', async function () {
|
||||
const tests = [
|
||||
['there is change', 'therr is change'],
|
||||
['laser cannon', 'lazer cannon'],
|
||||
['Monster', 'Manster'],
|
||||
['What are you doing', 'hwat are you doing']
|
||||
]
|
||||
|
||||
for(const test of tests) {
|
||||
const result = compareNormalizedStrings(test[0], test[1]);
|
||||
assert.isAtLeast( result.highScore, 75, `Comparing: '${test[0]}' | '${test[1]}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should score word bags correctly', async function () {
|
||||
const tests = [
|
||||
['there change is', 'there is change'],
|
||||
['laser cannon', 'cannon laser'],
|
||||
['One two three four', 'One four two threee'],
|
||||
['you, doin hwat are', 'hwat are you doing'],
|
||||
['My Track (feat. Tourist1, Artist2)', 'My Track (feat. Artist2, Tourist1)']
|
||||
]
|
||||
|
||||
for(const test of tests) {
|
||||
const result = compareNormalizedStrings(test[0], test[1]);
|
||||
assert.isAtLeast( result.highScore, 80, `Comparing: '${test[0]}' | '${test[1]}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not score very similar as identical', async function () {
|
||||
const tests = [
|
||||
['Another Brick in the Wall, Pt. 1', 'Another Brick in the Wall, Pt. 2'],
|
||||
]
|
||||
|
||||
for(const test of tests) {
|
||||
const result = compareNormalizedStrings(test[0], test[1]);
|
||||
assert.isAtMost( result.highScore, 99, `Comparing: '${test[0]}' | '${test[1]}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {JsonPlayObject, ObjectPlayData, PlayData, PlayMeta, PlayObject} from "../../../core/Atomic";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import utc from "dayjs/plugin/utc.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import relativeTime from "dayjs/plugin/relativeTime.js";
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import {faker} from '@faker-js/faker';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const asPlays = (data: object[]): PlayObject[] => {
|
||||
return data.map(x => {
|
||||
const y = x as JsonPlayObject;
|
||||
return {
|
||||
...y,
|
||||
data: {
|
||||
...y.data,
|
||||
playDate: dayjs(y.data.playDate)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const normalizePlays = (plays: PlayObject[],
|
||||
options?: {
|
||||
initialDate?: Dayjs,
|
||||
defaultDuration?: number,
|
||||
defaultData?: ObjectPlayData,
|
||||
defaultMeta?: PlayMeta
|
||||
}
|
||||
): PlayObject[] => {
|
||||
const {
|
||||
initialDate,
|
||||
defaultDuration = 180,
|
||||
defaultData = {},
|
||||
defaultMeta = {}
|
||||
} = options || {};
|
||||
let date = initialDate;
|
||||
if (date === undefined) {
|
||||
const firstDatedPlay = plays.find(x => x.data.playDate !== undefined);
|
||||
if (firstDatedPlay !== undefined) {
|
||||
date = firstDatedPlay.data.playDate;
|
||||
} else {
|
||||
throw new Error('No initial date specified and no play had a defined date');
|
||||
}
|
||||
}
|
||||
let lastTrackEndsAt: Dayjs | undefined = undefined;
|
||||
const normalizedPlays: PlayObject[] = [];
|
||||
|
||||
for (const play of plays) {
|
||||
|
||||
const cleanPlay = {...play};
|
||||
|
||||
if (lastTrackEndsAt === undefined) {
|
||||
// first track
|
||||
cleanPlay.data.playDate = date;
|
||||
} else {
|
||||
cleanPlay.data.playDate = lastTrackEndsAt.add(1, 'second');
|
||||
}
|
||||
cleanPlay.data = {
|
||||
...cleanPlay.data,
|
||||
...defaultData
|
||||
}
|
||||
cleanPlay.meta = {
|
||||
...cleanPlay.meta,
|
||||
...defaultMeta
|
||||
}
|
||||
lastTrackEndsAt = cleanPlay.data.playDate.add(cleanPlay.data.duration ?? defaultDuration, 'seconds');
|
||||
normalizedPlays.push(cleanPlay);
|
||||
}
|
||||
|
||||
return normalizedPlays;
|
||||
}
|
||||
|
||||
export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => {
|
||||
return {
|
||||
data: {
|
||||
track: faker.music.songName(),
|
||||
artists: faker.helpers.multiple(faker.person.fullName, {count: {min: 1, max: 2}}),
|
||||
duration: faker.number.int({min: 30, max: 300}),
|
||||
playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})),
|
||||
album: faker.music.songName(),
|
||||
...data
|
||||
},
|
||||
meta: {
|
||||
source: faker.word.noun(),
|
||||
...meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
"typeRoots": [
|
||||
"../../node_modules/@types",
|
||||
@@ -23,7 +24,7 @@
|
||||
"exclude": [
|
||||
"../../node_modules",
|
||||
"../../coverage",
|
||||
"../../tests/*.ts",
|
||||
"./tests/**/*",
|
||||
"../../_site",
|
||||
"../../docsite",
|
||||
"../../build",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user