mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f78b02fb | ||
|
|
6f5a8a7bc7 | ||
|
|
0d5dd34597 | ||
|
|
a93ec8dd7e | ||
|
|
1e488b576c | ||
|
|
d3d8eb40b5 | ||
|
|
db54761422 | ||
|
|
aa9f20f544 | ||
|
|
60371f52f8 | ||
|
|
87394fbce7 | ||
|
|
72341ff1de | ||
|
|
63cebf3784 | ||
|
|
60f3f05f67 | ||
|
|
ce39065c80 | ||
|
|
3f39e9efa1 | ||
|
|
f37dbd75c6 | ||
|
|
47ad7a3b74 | ||
|
|
91747c0682 | ||
|
|
80f8495f8e | ||
|
|
c6bb258bf4 | ||
|
|
e70abc593c | ||
|
|
22d73c6a7f | ||
|
|
82afe4195c | ||
|
|
5e9409ab66 | ||
|
|
e64854eb14 | ||
|
|
333f70bd7e | ||
|
|
ace7d5efc9 | ||
|
|
cd14f82753 | ||
|
|
aba718aef1 | ||
|
|
c87497071d | ||
|
|
2a35e7cf5c | ||
|
|
3b98036687 | ||
|
|
3fc935b96d | ||
|
|
7586f226ba | ||
|
|
1a0e600db5 | ||
|
|
1dba5a6c6f | ||
|
|
3ec0652fbb | ||
|
|
e71d5983a4 | ||
|
|
bb9b5fc5cd | ||
|
|
89fde5a6fe | ||
|
|
24ff8f2b0f | ||
|
|
f103563cb7 | ||
|
|
1767268a48 | ||
|
|
8ab2d61742 | ||
|
|
132580af6f | ||
|
|
375c0123c6 | ||
|
|
769002412d | ||
|
|
7fa567c1e4 | ||
|
|
8dfc9a6d55 | ||
|
|
0efc85c516 | ||
|
|
dbb8e3c5e0 | ||
|
|
f4f1eed645 | ||
|
|
0a31f51567 | ||
|
|
c255138a6e | ||
|
|
856a05a357 | ||
|
|
18915167f4 | ||
|
|
34b0995d6b | ||
|
|
4e5f38378e | ||
|
|
5d2c9261ae | ||
|
|
e039d4512c | ||
|
|
51509a3531 | ||
|
|
8f032f2379 | ||
|
|
ff11e26219 | ||
|
|
f004aa4cfd | ||
|
|
2104f2a99d | ||
|
|
389129aadf | ||
|
|
f885726ed1 | ||
|
|
3cc452323d | ||
|
|
780acb32d7 | ||
|
|
bf5fdb95f4 | ||
|
|
7d7ccf53bb | ||
|
|
94837b7e03 | ||
|
|
a9c1c7083d | ||
|
|
8dd6fc15cc | ||
|
|
32ca57fc65 | ||
|
|
9d748f26c0 | ||
|
|
5af1454f36 | ||
|
|
bf7a660633 | ||
|
|
f0723f23e8 | ||
|
|
36357135cb | ||
|
|
8be08a5c29 | ||
|
|
9fe5b9b671 | ||
|
|
720c07b008 | ||
|
|
7fdf93c5d3 | ||
|
|
a0fd466bcf | ||
|
|
35d62c913a | ||
|
|
ede2d9f71a | ||
|
|
acccc32813 | ||
|
|
69f73ff09f | ||
|
|
305f3c42ae | ||
|
|
d6ac4c34c5 | ||
|
|
9c6eeaafdb | ||
|
|
6bd841fea2 | ||
|
|
e05c46e9ce | ||
|
|
e94109dc75 | ||
|
|
a68dea5193 | ||
|
|
b5d56b75d7 | ||
|
|
c8daffb935 | ||
|
|
18a7f26c54 | ||
|
|
e7395e79a5 | ||
|
|
08ee6d379f | ||
|
|
9c692539ce | ||
|
|
5cd5061f20 |
@@ -30,6 +30,9 @@ jobs:
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
RY_ANALYTICS: ${{ vars.RY_ANALYTICS }}
|
||||
RY_ANALYTICS_SITEID: ${{ vars.RY_ANALYTICS_SITEID }}
|
||||
RY_ANALYTICS_REPLAY: ${{ vars.RY_ANALYTICS_REPLAY }}
|
||||
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
|
||||
DOCS_BASE: '/multi-scrobbler'
|
||||
run: npm run build
|
||||
|
||||
Vendored
+19
@@ -16,6 +16,25 @@
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "dev (ENV Only)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// Debug app in VSCode
|
||||
"program": "${workspaceFolder}/src/backend/index.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"CONFIG_DIR": "./not-real"
|
||||
},
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "tsx",
|
||||
"type": "node",
|
||||
|
||||
@@ -12,32 +12,34 @@ alt="multi-scrobbler logo" width="180" height="180">
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
|
||||
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito-source)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#koito)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "DeezerOfficalDeprecated",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,13 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"name": "DeezerARL",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
"arl": "UOsRPjT3U5Dhaaup3xQ30DWFoSgqugdSYzL3QE743waaVy2eur2ckyJcZ4RV123Q3PsbKXkLwA6ahc9XmEfev6BtfSP8LujE3JswWRQogZCDUOwVzWO5sAig",
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "koito-client",
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "admin",
|
||||
"url": "http://192.168.0.100:4110"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "koito-source",
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "admin",
|
||||
"url": "http://192.168.0.100:4110"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -16,5 +16,15 @@
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "koitoClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD",
|
||||
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
"password": "yourPassword"
|
||||
"password": "yourPassword",
|
||||
"usersAllow": ["yourUser"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+8
-62
@@ -5,22 +5,21 @@ toc_max_heading_level: 5
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### Plex (Webhook)/Tautulli/Jellyfin(Webhook)/Webscrobbler don't connect
|
||||
### Webscrobbler/Listenbrainz Endpoint doesn't connect {#ingress-connection}
|
||||
|
||||
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
|
||||
These sources are **ingress-based** which means that multi-scrobbler waits for the Webscrobbler extension or app using Listenbrainz Endpoint to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
|
||||
|
||||
multi-scrobbler will log information about any server that connects to it for these services. In the logs it looks something like this:
|
||||
|
||||
```
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) Received valid data from server examplePlex for the first time.
|
||||
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
|
||||
[2025-07-10 12:32:21.417 -0400] INFO : [App] [API] [Ingress] [WebScrobbler] [Ingress] [WebScrobbler] Received request from a new remote address: ::ffff:127.0.0.1 (192.168.0.100) (UA: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0)
|
||||
[2025-07-10 12:32:21.418 -0400] INFO : [App] [API] [Ingress] [WebScrobbler] [Ingress] [WebScrobbler] ::ffff:127.0.0.1 (192.168.0.100) (UA: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0) Received data for API URL slug '(none)' for the first time.
|
||||
```
|
||||
It also logs if a server tries to connect to a URL that it does not recognize:
|
||||
```
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.100 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
```
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin/Webscrobbler is not able to connect to your multi-scrobbler instance at all.**
|
||||
**So, if you do not see either of these in your logs then the Source is not able to connect to your multi-scrobbler instance at all.**
|
||||
|
||||
This is not something multi-scrobbler can fix and means you have an issue in your network.
|
||||
|
||||
@@ -34,9 +33,9 @@ First, turn on **debug** logging for multi-scrobbler by setting the environmenta
|
||||
|
||||
##### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Plex/Tautulli/Jellyfin/Webscrobbler on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Webscrobbler/LFM Endpoint on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
|
||||
EX `http://localhost:9078/api/webscrobbler` -> `http://192.168.0.140:9078/api/webscrobbler`
|
||||
|
||||
##### Check Firewall and Port Forwarding
|
||||
|
||||
@@ -44,53 +43,10 @@ If the machine multi-scrobbler is running on has a firewall ensure that port **9
|
||||
|
||||
##### Check Source Service Logs
|
||||
|
||||
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
|
||||
|
||||
###### Plex
|
||||
|
||||
Settings -> Manage -> Console
|
||||
|
||||
###### Tautulli
|
||||
|
||||
Check the command-line output of the application or docker logs.
|
||||
|
||||
###### Jellyfin
|
||||
|
||||
Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
###### Webscrobbler
|
||||
|
||||
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
|
||||
|
||||
### Jellyfin (Webhook) has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
|
||||
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
|
||||
You can verify the payload sent from the webhook by enabling Debug Mode with the env `DEBUG_MODE=true` which will output the raw payload to DEBUG level logging.
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
### Jellyfin (Webhook) has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
```
|
||||
[API] Jellyfin is not sending a request with valid headers...
|
||||
```
|
||||
|
||||
A workaround that may fix this:
|
||||
|
||||
* In Webhook settings:
|
||||
* [In the webhook you have already configured...](configuration/configuration.mdx#jellyfin)
|
||||
* Add Request Header...
|
||||
* **Key:** `Content-Type`
|
||||
* **Value:** `application/json`
|
||||
* Then Save
|
||||
|
||||
### Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
Ensure any **client id** or **secrets** are correct in your configuration.
|
||||
@@ -104,10 +60,6 @@ If multi-scrobbler is not running on the same machine your browser is on then th
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
### Deezer is not working
|
||||
|
||||
Deezer has discontinued support for their API and the Deezer Source is now [**deprecated.**](configuration/configuration.mdx#deezer) See [this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
|
||||
|
||||
### Youtube Music fails after some time
|
||||
|
||||
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
|
||||
@@ -152,12 +104,6 @@ This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scr
|
||||
|
||||
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
|
||||
|
||||
### Jellyfin (Webhook) does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
|
||||
You can avoid this entirely by using the newer [Jellyfin API Source](configuration/configuration.mdx#jellyfin) which correctly handles multiple artists.
|
||||
|
||||
### Google Cast track information is missing/incorrect or MS player has weird times
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 3,
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configuring Multi-Scrobbler and examples"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"position": 4,
|
||||
"position": 5,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Developing for Multi-Scrobbler and tutorials"
|
||||
|
||||
@@ -6,8 +6,6 @@ title: Flatpak
|
||||
description: Building Flatpak App locally
|
||||
---
|
||||
|
||||
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
|
||||
|
||||
## Install Requirements
|
||||
|
||||
### Flatpak and flatpak-builder
|
||||
|
||||
@@ -14,6 +14,212 @@ For the difference between **ENV** and **File** examples in this document see [C
|
||||
|
||||
:::
|
||||
|
||||
## Docker
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
:::info[Available Images]
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="docker" label="Dockerhub">
|
||||
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
```
|
||||
docker.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="ghcr" label="Github Packages">
|
||||
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
|
||||
```
|
||||
ghcr.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::
|
||||
|
||||
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>`docker-compose.yml`</summary>
|
||||
|
||||
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You should bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
|
||||
```shell
|
||||
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Networking
|
||||
|
||||
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you must map a port to the container in order to access the dashboard and use MS with some sources (Webscrobbler, LFM/LZ Endpoints). The default container port is `9078`.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
|
||||
```shell
|
||||
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
ports:
|
||||
- "9078:9078"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
```shell
|
||||
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
environment:
|
||||
- BASE_URL=http://hostMachineIP
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
|
||||
#### Linux Host
|
||||
|
||||
::::info
|
||||
|
||||
If you are running this container with **Docker** on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
::::
|
||||
|
||||
### Docker Usage Example
|
||||
|
||||
:::tip
|
||||
|
||||
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
|
||||
|
||||
:::
|
||||
|
||||
The example scenario:
|
||||
|
||||
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
|
||||
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
|
||||
* Serving app on port `9078`
|
||||
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
|
||||
* Config/data directory on host machine in a directory next to `docker-compose.yml`
|
||||
* Linux uid/gid is `1000:1000`
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```bash
|
||||
docker run --name multi-scrobbler -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
```bash
|
||||
docker run --name multi-scrobbler -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- SPOTIFY_CLIENT_ID=yourId
|
||||
- SPOTIFY_CLIENT_SECRET=yourSecret
|
||||
- BASE_URL=http://192.168.0.100:9078
|
||||
- MALOJA_URL=http://domain.tld:42010
|
||||
- MALOJA_API_KEY=1234
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
ports:
|
||||
- "9078:9078"
|
||||
restart: unless-stopped
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Local Installation
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
@@ -105,246 +311,8 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
|
||||
|
||||
### Flatpak
|
||||
|
||||
:::warning
|
||||
|
||||
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
|
||||
|
||||
* to use a [Docker](#docker) installation if possible or
|
||||
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
|
||||
|
||||
:::
|
||||
:::warning[Unsupported]
|
||||
|
||||
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
|
||||
|
||||
```shell
|
||||
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
|
||||
|
||||
```shell
|
||||
flatpak run io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Docker
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
:::info[Available Images]
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="docker" label="Dockerhub">
|
||||
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
```
|
||||
docker.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="ghcr" label="Github Packages">
|
||||
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
|
||||
```
|
||||
ghcr.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::
|
||||
|
||||
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>`docker-compose.yml`</summary>
|
||||
|
||||
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
|
||||
```shell
|
||||
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Networking
|
||||
|
||||
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin). The default container port is `9078`.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
|
||||
```shell
|
||||
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
ports:
|
||||
- "9078:9078"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
```shell
|
||||
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
environment:
|
||||
- BASE_URL=http://hostMachineIP
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
|
||||
### Linux Host
|
||||
|
||||
::::info
|
||||
|
||||
:::note
|
||||
|
||||
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) or are running docker on MacOS/Windows this **DOES NOT** apply to you.
|
||||
|
||||
:::
|
||||
|
||||
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
::::
|
||||
|
||||
### Docker Usage Example
|
||||
|
||||
:::tip
|
||||
|
||||
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
|
||||
|
||||
:::
|
||||
|
||||
The example scenario:
|
||||
|
||||
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
|
||||
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
|
||||
* Serving app on port `9078`
|
||||
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
|
||||
* Config/data directory on host machine in a directory next to `docker-compose.yml`
|
||||
* Linux uid/gid is `1000:1000`
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
```bash
|
||||
docker run -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- SPOTIFY_CLIENT_ID=yourId
|
||||
- SPOTIFY_CLIENT_SECRET=yourSecret
|
||||
- BASE_URL=http://192.168.0.100:9078
|
||||
- MALOJA_URL=http://domain.tld:42010
|
||||
- MALOJA_API_KEY=1234
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
ports:
|
||||
- "9078:9078"
|
||||
restart: unless-stopped
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
:::
|
||||
@@ -130,6 +130,34 @@ services:
|
||||
- LASTFM_SECRET=myApiSecret
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="koito" label="Koito">
|
||||
Setup a [Koito server](https://koito.io/guides/installation/) if you have not already done this.
|
||||
|
||||
* [Create (or get) an API Key](https://koito.io/guides/scrobbler/)
|
||||
* From your Koito server dashboard navigate to **Settings** (bottom-left icon) => **API Keys**
|
||||
* Create a new Key (or use an existing) and Copy the key using the copy icon to the right of the key
|
||||
* EX `pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL`
|
||||
* Take note of your Koito username (used below as `myUser`)
|
||||
* Determine your Koito URL
|
||||
* This is the URL you use to access your Koito dashboard OR whatever URL is accessible from your multi-scrobbler instance.
|
||||
* EX dashboard at `http://192.168.0.100:4110` => replace `myURL` below with `http://192.168.0.100:4110`
|
||||
* [More info on the URL to use is the full docs](./configuration/configuration.mdx#koito)
|
||||
|
||||
Add/Substitute values above into these examples values and add them to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- KOTIO_TOKEN=myToken
|
||||
- KOTIO_USER=myUser
|
||||
- KOITO_URL=myURL
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Ensure that Koito is configured to allow requests from multi-scrobbler! In Koito config set [`KOITO_ALLOWED_HOSTS`](https://koito.io/reference/configuration/#koito_allowed_hosts) to the IP the multi-scrobbler dashboard is accessible from.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Start Multi-Scrobbler
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Updating",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to update Multi-Scrobbler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
|
||||
## Updating
|
||||
|
||||
Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction/consideration when updating.
|
||||
|
||||
The majority of major/minor version releases introduce only *new* functionality but occasionally may also include breaking changes in behavior or configuration. These changes, and how to update configs accordingly, are detailed in [Github Release Notes](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading.
|
||||
|
||||
Alternatively, multi-scrobbler version can be pinned using [git tags](https://github.com/FoxxMD/multi-scrobbler/tags) or docker image tags that match release versions IE `foxxmd/multi-scrobbler:0.9.7`
|
||||
|
||||
### Docker
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
```shell
|
||||
docker image pull foxxmd/multi-scrobbler:latest
|
||||
docker container stop multi-scrobbler
|
||||
docker container rm multi-scrobbler
|
||||
docker run --name multi-scrobbler ...
|
||||
```
|
||||
The last command is the [same as the command used to initialy run multi-scrobbler.](../installation/installation.mdx?runType=docker#docker-usage-example)
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
```shell
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Nodejs
|
||||
|
||||
Assuming the [repository was used for initial installation with `git clone`](../installation/installation.mdx#nodejs), update by pulling the latest commit:
|
||||
|
||||
```
|
||||
git pull
|
||||
```
|
||||
|
||||
and restart multi-scrobbler.
|
||||
@@ -5,7 +5,14 @@ import * as themes from 'prism-react-renderer';
|
||||
|
||||
const config: Config = {
|
||||
future: {
|
||||
experimental_faster: true
|
||||
v4: {
|
||||
removeLegacyPostBuildHeadAttribute: true, // required
|
||||
},
|
||||
experimental_faster: {
|
||||
ssgWorkerThreads: true,
|
||||
rspackBundler: true, // required flag
|
||||
rspackPersistentCache: true, // new flag
|
||||
},
|
||||
},
|
||||
title: 'Multi-Scrobbler',
|
||||
tagline: 'Scrobble all the things',
|
||||
@@ -85,12 +92,14 @@ const config: Config = {
|
||||
themeConfig:
|
||||
{
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
image: 'img/icon.png',
|
||||
navbar: {
|
||||
title: 'Multi-Scrobbler',
|
||||
hideOnScroll: true,
|
||||
logo: {
|
||||
alt: 'Logo',
|
||||
src: 'img/icon.svg',
|
||||
height: 40
|
||||
},
|
||||
items: [
|
||||
{
|
||||
@@ -194,5 +203,18 @@ if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
}
|
||||
config.scripts.push(script)
|
||||
}
|
||||
if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
|
||||
const script = {
|
||||
src: process.env.RY_ANALYTICS,
|
||||
defer: true
|
||||
}
|
||||
if (process.env.RY_ANALYTICS_SITEID !== undefined && process.env.RY_ANALYTICS_SITEID !== '') {
|
||||
script['data-site-id'] = process.env.RY_ANALYTICS_SITEID;
|
||||
}
|
||||
if (process.env.RY_ANALYTICS_REPLAY !== undefined && process.env.RY_ANALYTICS_REPLAY !== '') {
|
||||
script['data-session-replay'] = process.env.RY_ANALYTICS_REPLAY;
|
||||
}
|
||||
config.scripts.push(script)
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
Generated
+2860
-2517
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,20 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.6.3",
|
||||
"@docusaurus/faster": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.6.3",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.48.3",
|
||||
"@docusaurus/core": "3.8.1",
|
||||
"@docusaurus/faster": "3.8.1",
|
||||
"@docusaurus/preset-classic": "3.8.1",
|
||||
"@easyops-cn/docusaurus-search-local": "0.51.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-json-schema-plugin": "^1.12.1",
|
||||
"docusaurus-json-schema-plugin": "^1.14.0",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"micromark-extension-directive": "^3.0.1",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.6.3",
|
||||
|
||||
@@ -23,7 +23,7 @@ const AIOExample = (props: AIOProps) => {
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = json5.parse(data);
|
||||
configObj = json5.parse(data) as object[];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
@@ -31,9 +31,12 @@ const AIOExample = (props: AIOProps) => {
|
||||
<CodeBlock>{e.message}</CodeBlock>
|
||||
</Admonition>
|
||||
}
|
||||
configObj[0].type = name;
|
||||
const configType = client ? 'clients' : 'sources';
|
||||
|
||||
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
|
||||
configObj = configObj.map(x => ({...x, type: name}));
|
||||
|
||||
|
||||
const aio = {[configType]: configObj};
|
||||
return <CodeBlock title="CONFIG_DIR/config.json" language="json5">{JSON.stringify(aio, null, 2)}</CodeBlock>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { Fragment } from "react"
|
||||
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import error = Simulate.error;
|
||||
import json5 from 'json5';
|
||||
|
||||
export interface FileProps extends Omit<CodeBlockProps, 'children'> {
|
||||
data: string
|
||||
client?: boolean
|
||||
}
|
||||
|
||||
const FileExample = (props: FileProps) => {
|
||||
const {
|
||||
data,
|
||||
client = false,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = json5.parse(data) as object[];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
<p>Example component crashed because of error!</p>
|
||||
<CodeBlock>{e.message}</CodeBlock>
|
||||
</Admonition>
|
||||
}
|
||||
|
||||
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
|
||||
|
||||
return <CodeBlock {...rest} language="json5">{JSON.stringify(configObj, null, 2)}</CodeBlock>
|
||||
}
|
||||
|
||||
const WrappedFileExample = (props: FileProps) => {
|
||||
return <ErrorBoundary
|
||||
fallback={({error}) => (
|
||||
<div>
|
||||
<p>Example component crashed because of error: {error.message}.</p>
|
||||
</div>
|
||||
)}
|
||||
><FileExample {...props} /></ErrorBoundary>
|
||||
}
|
||||
|
||||
export default WrappedFileExample;
|
||||
+18
-15
@@ -10,36 +10,39 @@ title: Overview
|
||||
|
||||
<img src={require('/img/icon.png').default} align="right" alt="multi-scrobbler logo" width="180" height="180"/>
|
||||
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), [ListenBrainz](https://listenbrainz.org), and [Koito](https://koito.io/).
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Azuracast](docs/configuration#azuracast)
|
||||
* [Deezer](docs/configuration#deezer)
|
||||
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
|
||||
* [Icecast](docs/configuration#icecast)
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Koito](docs/configuration#koito-source)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [VLC](docs/configuration#vlc)
|
||||
* [Azuracast](docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](docs/configuration#icecast)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Koito](docs/configuration#koito)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Supports [Now Playing](docs/configuration#now-playing) for scrobble Clients
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
|
||||
Generated
+138
-49
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@lukehagar/plexjs": "^0.32.1",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -53,6 +53,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"gotify": "^1.1.0",
|
||||
"http-cookie-agent": "^7.0.1",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
@@ -79,6 +80,7 @@
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vite-express": "^0.16.0",
|
||||
@@ -114,7 +116,7 @@
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^4.1.16",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"chai": "^4.3.6",
|
||||
@@ -490,6 +492,32 @@
|
||||
"tough-cookie": "^4.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@curvenote/ansi-to-react": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@curvenote/ansi-to-react/-/ansi-to-react-7.0.0.tgz",
|
||||
@@ -1025,6 +1053,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -1514,9 +1543,9 @@
|
||||
"integrity": "sha512-vduQZw2ctS3kIuSnCSSRiE4J90Y8WShR9xVG+e1lvFWksU2aTxjdkArcQqJ+XLm22JS380OZmrIPY1U06TAsng=="
|
||||
},
|
||||
"node_modules/@lukehagar/plexjs": {
|
||||
"version": "0.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.32.1.tgz",
|
||||
"integrity": "sha512-cEIpepR1/BJYQey2lbzPhy342ZR/JmkChXKS+bOQkGLg1F17n45Zf650W67sBKMvF63effNVT0iUF3Fi3D7yPQ==",
|
||||
"version": "0.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.39.0.tgz",
|
||||
"integrity": "sha512-BXNHTh1Z9K0bb5qFKgYZv7gOC9+wYzOsatp/+DRGShGhp40bH0lTShzD6acKjqlIvqi2Pm7UJnF8+yBipE9Qmg==",
|
||||
"peerDependencies": {
|
||||
"zod": ">= 3"
|
||||
}
|
||||
@@ -2774,6 +2803,13 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -2905,13 +2941,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "4.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.24.tgz",
|
||||
"integrity": "sha512-mEafCgyKiMFin24SDzWN7yAADt4gt6YawFiNMp0QS5ZPboORfyxFt0s3VzJKhTaKg9py/4FUmrHLTNfJKt9Rbw==",
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "*",
|
||||
"@types/node": "*"
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/testing-library__jest-dom": {
|
||||
@@ -3254,12 +3293,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
|
||||
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
@@ -6015,6 +6052,30 @@
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
|
||||
},
|
||||
"node_modules/http-cookie-agent": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.1.tgz",
|
||||
"integrity": "sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/3846masa"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tough-cookie": "^4.0.0 || ^5.0.0",
|
||||
"undici": "^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"undici": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -9095,10 +9156,17 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
|
||||
"dev": true
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
@@ -9142,7 +9210,8 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
@@ -9481,7 +9550,8 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "4.1.8",
|
||||
@@ -10538,6 +10608,24 @@
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
@@ -10631,27 +10719,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie/node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
@@ -10947,14 +11023,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "5.28.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
|
||||
"integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -11032,6 +11108,7 @@
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@@ -11984,6 +12061,18 @@
|
||||
"undici": "^5.19.1"
|
||||
}
|
||||
},
|
||||
"node_modules/youtubei.js/node_modules/undici": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.23.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.9",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -57,7 +57,7 @@
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@lukehagar/plexjs": "^0.32.1",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -85,6 +85,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"gotify": "^1.1.0",
|
||||
"http-cookie-agent": "^7.0.1",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
@@ -111,6 +112,7 @@
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vite-express": "^0.16.0",
|
||||
@@ -146,7 +148,7 @@
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^4.1.16",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"chai": "^4.3.6",
|
||||
|
||||
@@ -30,7 +30,8 @@ export type SourceType =
|
||||
| 'mpd'
|
||||
| 'vlc'
|
||||
| 'icecast'
|
||||
| 'azuracast';
|
||||
| 'azuracast'
|
||||
| 'koito';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
@@ -55,7 +56,8 @@ export const sourceTypes: SourceType[] = [
|
||||
'mpd',
|
||||
'vlc',
|
||||
'icecast',
|
||||
'azuracast'
|
||||
'azuracast',
|
||||
'koito'
|
||||
];
|
||||
|
||||
export const isSourceType = (data: string): data is SourceType => {
|
||||
@@ -67,16 +69,31 @@ export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
|
||||
export type ClientType =
|
||||
'maloja'
|
||||
| 'lastfm'
|
||||
| 'listenbrainz';
|
||||
| 'listenbrainz'
|
||||
| 'koito';
|
||||
export const clientTypes: ClientType[] = [
|
||||
'maloja',
|
||||
'lastfm',
|
||||
'listenbrainz'
|
||||
'listenbrainz',
|
||||
'koito'
|
||||
];
|
||||
export const isClientType = (data: string): data is ClientType => {
|
||||
return clientTypes.includes(data as ClientType);
|
||||
}
|
||||
|
||||
export interface ComponentIdentifier {
|
||||
type: SourceType | ClientType
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SourceIdentifier extends ComponentIdentifier {
|
||||
type: SourceType
|
||||
}
|
||||
|
||||
export interface ClientIdentifier extends ComponentIdentifier {
|
||||
type: ClientType
|
||||
}
|
||||
|
||||
export type InitState = 0 | 1 | 2;
|
||||
export const NOT_INITIALIZED: InitState = 0;
|
||||
export const INITIALIZING: InitState = 1;
|
||||
@@ -194,6 +211,10 @@ export const DEFAULT_POLLING_MAX_INTERVAL = 30;
|
||||
|
||||
export const DEFAULT_RETRY_MULTIPLIER: number = 1.5;
|
||||
|
||||
export const DEFAULT_CLOSE_POSITION_ABSOLUTE = 12;
|
||||
export const DEFAULT_CLOSE_POSITION_PERCENT = 0.15;
|
||||
export const DEFAULT_DURATION_REPEAT_ABSOLUTE = 120;
|
||||
export const DEFAULT_DURATION_REPEAT_PERCENT = 0.50;
|
||||
export interface ScrobbleThresholdResult {
|
||||
passes: boolean
|
||||
duration: {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { KoitoClientAIOConfig, KoitoClientConfig } from "./koito.js";
|
||||
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
|
||||
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
|
||||
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
|
||||
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig;
|
||||
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig;
|
||||
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig;
|
||||
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig;
|
||||
|
||||
@@ -66,6 +66,21 @@ export interface UpstreamRefreshOptions {
|
||||
refreshInitialCount?: number
|
||||
}
|
||||
|
||||
export interface NowPlayingOptions {
|
||||
|
||||
/**
|
||||
* Configure if this Client should report Now Playing from Sources that can scrobble to it
|
||||
*
|
||||
* * `true` (default) => Report Now Playing from any eligible Source.
|
||||
* * If multiple Sources are Playing then reported Play is based on alphabetical order of Source names
|
||||
* * `false` => Do not report Now Playing
|
||||
* * `string` list => list of Source `names` that should be allowed to report Now Playing. Order of list determine priority of Play to Report.
|
||||
*
|
||||
* @default true
|
||||
* */
|
||||
nowPlaying?: boolean | string[]
|
||||
}
|
||||
|
||||
export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions {
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RequestRetryOptions } from "../common.js"
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js"
|
||||
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
total_record_count: number
|
||||
items_per_page: number
|
||||
has_next_page: boolean
|
||||
current_page: number
|
||||
}
|
||||
|
||||
export interface ListenObjectResponse {
|
||||
/** ISO8601 timestamp */
|
||||
time: string
|
||||
track: TrackResponse
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
id: number
|
||||
title: string
|
||||
artists: ArtistResponse[]
|
||||
musicbrainz_id: string | null
|
||||
listen_count: number
|
||||
duration: number
|
||||
image: string | null
|
||||
album_id: number
|
||||
time_listened: number
|
||||
}
|
||||
|
||||
export interface ArtistResponse {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface KoitoData extends RequestRetryOptions {
|
||||
/**
|
||||
* URL for the Koito server
|
||||
*
|
||||
* @examples ["http://192.168.0.100:4110"]
|
||||
* */
|
||||
url: string
|
||||
/**
|
||||
* User token for the user to scrobble for
|
||||
*
|
||||
* @examples ["pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL"]
|
||||
* */
|
||||
token: string
|
||||
|
||||
/**
|
||||
* Username of the user to scrobble for
|
||||
* */
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface KoitoClientData extends KoitoData, CommonClientData {}
|
||||
|
||||
export interface KoitoClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using Koito as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: KoitoClientData
|
||||
}
|
||||
|
||||
export interface KoitoClientAIOConfig extends KoitoClientConfig {
|
||||
type: 'koito'
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
|
||||
|
||||
export interface LastfmData extends RequestRetryOptions {
|
||||
export interface LastfmData extends CommonClientData, RequestRetryOptions {
|
||||
/**
|
||||
* API Key generated from Last.fm account
|
||||
*
|
||||
@@ -27,6 +27,10 @@ export interface LastfmData extends RequestRetryOptions {
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface LastfmClientOptions extends CommonClientOptions, NowPlayingOptions {
|
||||
|
||||
}
|
||||
|
||||
export interface LastfmClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using LastFM as a client
|
||||
@@ -35,7 +39,8 @@ export interface LastfmClientConfig extends CommonClientConfig {
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: CommonClientData & LastfmData
|
||||
data: LastfmData
|
||||
options?: LastfmClientOptions
|
||||
}
|
||||
|
||||
export interface LastfmClientAIOConfig extends LastfmClientConfig {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { Second } from "../../../../../core/Atomic.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
|
||||
export interface DeezerData extends CommonSourceData {
|
||||
export interface DeezerData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* deezer client id
|
||||
*
|
||||
@@ -20,13 +22,6 @@ export interface DeezerData extends CommonSourceData {
|
||||
* @examples ["http://localhost:9078/deezer/callback"]
|
||||
* */
|
||||
redirectUri?: string
|
||||
/**
|
||||
* optional, how long to wait before calling spotify for new tracks (in seconds)
|
||||
*
|
||||
* @default 60
|
||||
* @examples [60]
|
||||
* */
|
||||
interval?: number
|
||||
}
|
||||
export interface DeezerSourceConfig extends CommonSourceConfig {
|
||||
data: DeezerData
|
||||
@@ -35,3 +30,51 @@ export interface DeezerSourceConfig extends CommonSourceConfig {
|
||||
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
|
||||
type: 'deezer'
|
||||
}
|
||||
|
||||
export interface DeezerInternalData extends CommonSourceData, PollingOptions {
|
||||
/** ARL retrieved from Deezer response header */
|
||||
arl: string
|
||||
/** User agent
|
||||
*
|
||||
* @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0"
|
||||
*/
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export interface DeezerInternalSourceConfig extends CommonSourceConfig {
|
||||
data: DeezerInternalData
|
||||
options?: DeezerInternalSourceOptions
|
||||
}
|
||||
|
||||
export interface DeezerInternalSourceOptions extends CommonSourceOptions {
|
||||
fuzzyDiscoveryIgnore?: boolean | 'aggressive'
|
||||
}
|
||||
|
||||
export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
|
||||
type: 'deezer'
|
||||
}
|
||||
|
||||
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
|
||||
|
||||
export interface DeezerInternalTrackData {
|
||||
/** Song Id */
|
||||
SNG_ID: string
|
||||
/** Date listened as unix timestamp in seconds */
|
||||
TS: Second
|
||||
/** Album Id */
|
||||
ALB_ID: string
|
||||
/** Album Title */
|
||||
ALB_TITLE: string
|
||||
/** Album Art Id */
|
||||
ALB_PICTURE: string
|
||||
/** Artist Id */
|
||||
ART_ID: string
|
||||
/** Artist Name */
|
||||
ART_NAME: string
|
||||
/** Song Title */
|
||||
SNG_TITLE: string
|
||||
/** Time listened to track in seconds */
|
||||
DURATION: Second
|
||||
|
||||
__TYPE__: 'song' | string
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { KoitoData } from "../client/koito.js";
|
||||
import { ListenBrainzData } from "../client/listenbrainz.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface KoitoSourceData extends KoitoData, CommonSourceData, PollingOptions {
|
||||
}
|
||||
|
||||
export interface KoitoSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
* When used in `koito.config` this tells multi-scrobbler whether to use this data to configure a source or client.
|
||||
*
|
||||
* @default source
|
||||
* @examples ["source"]
|
||||
* */
|
||||
configureAs?: 'source'
|
||||
data: KoitoSourceData
|
||||
}
|
||||
|
||||
export interface KoitoSourceAIOConfig extends KoitoSourceConfig {
|
||||
type: 'koito'
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
|
||||
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
|
||||
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
|
||||
@@ -21,6 +21,7 @@ import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
|
||||
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
|
||||
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
|
||||
|
||||
|
||||
export type SourceConfig =
|
||||
@@ -29,6 +30,7 @@ export type SourceConfig =
|
||||
| PlexApiSourceConfig
|
||||
| TautulliSourceConfig
|
||||
| DeezerSourceConfig
|
||||
| DeezerInternalSourceConfig
|
||||
| ListenbrainzEndpointSourceConfig
|
||||
| LastFMEndpointSourceConfig
|
||||
| SubSonicSourceConfig
|
||||
@@ -48,7 +50,8 @@ export type SourceConfig =
|
||||
| MPDSourceConfig
|
||||
| VLCSourceConfig
|
||||
| IcecastSourceConfig
|
||||
| AzuracastSourceConfig;
|
||||
| AzuracastSourceConfig
|
||||
| KoitoSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
@@ -56,6 +59,7 @@ export type SourceAIOConfig =
|
||||
| PlexApiSourceAIOConfig
|
||||
| TautulliSourceAIOConfig
|
||||
| DeezerSourceAIOConfig
|
||||
| DeezerInternalAIOConfig
|
||||
| ListenbrainzEndpointSourceAIOConfig
|
||||
| LastFMEndpointSourceAIOConfig
|
||||
| SubsonicSourceAIOConfig
|
||||
@@ -75,4 +79,5 @@ export type SourceAIOConfig =
|
||||
| MPDSourceAIOConfig
|
||||
| VLCSourceAIOConfig
|
||||
| IcecastSourceAIOConfig
|
||||
| AzuracastSourceAIOConfig;
|
||||
| AzuracastSourceAIOConfig
|
||||
| KoitoSourceAIOConfig;
|
||||
|
||||
@@ -55,6 +55,13 @@ export interface SubsonicData extends CommonSourceData, PollingOptions {
|
||||
* @default false
|
||||
* */
|
||||
legacyAuthentication?: boolean
|
||||
|
||||
/**
|
||||
* Only scrobble for specific users (case-insensitive)
|
||||
*
|
||||
* If undefined or an empty string/list MS will scrobble activity from all users
|
||||
* */
|
||||
usersAllow?: string | string[]
|
||||
}
|
||||
export interface SubSonicSourceConfig extends CommonSourceConfig {
|
||||
data: SubsonicData
|
||||
|
||||
@@ -96,6 +96,8 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
|
||||
token?: string;
|
||||
|
||||
version?: string;
|
||||
|
||||
constructor(name: any, config: JRiverData, options: AbstractApiOptions) {
|
||||
super('JRiver', name, config, options);
|
||||
const {
|
||||
@@ -130,6 +132,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
try {
|
||||
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
|
||||
const {body: { data } = {}} = resp;
|
||||
this.version = data.ProgramVersion;
|
||||
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
+7
-2
@@ -46,6 +46,8 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
|
||||
token?: string;
|
||||
|
||||
version?: string;
|
||||
|
||||
declare client: KodiClient;
|
||||
|
||||
constructor(name: any, config: KodiData, options: AbstractApiOptions) {
|
||||
@@ -84,7 +86,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
return url;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
|
||||
const {newFromSource = true} = options;
|
||||
|
||||
const {
|
||||
@@ -113,6 +115,8 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
},
|
||||
meta: {
|
||||
source: 'kodi',
|
||||
mediaPlayerName: 'Kodi',
|
||||
mediaPlayerVersion: options.version,
|
||||
trackId: id.toString(),
|
||||
newFromSource,
|
||||
trackProgressPosition,
|
||||
@@ -135,6 +139,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
// https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties
|
||||
const applicationInfo = await this.client.Application.GetProperties(['version']);
|
||||
|
||||
this.version = `${applicationInfo.version.major}.${applicationInfo.version.minor}`;
|
||||
this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -180,7 +185,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
|
||||
const itemInfo = await this.getPlayerItem(audioActive.playerid);
|
||||
|
||||
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive});
|
||||
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive}, {version: this.version});
|
||||
|
||||
return [play];
|
||||
}
|
||||
|
||||
+12
-35
@@ -1,10 +1,7 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import LastFm, {
|
||||
AuthGetSessionResponse,
|
||||
LastfmTrackUpdateRequest,
|
||||
NowPlayingPayload,
|
||||
NowPlayingResponse,
|
||||
TrackObject,
|
||||
LastfmTrackUpdateRequest, TrackObject,
|
||||
TrackScrobblePayload,
|
||||
UserGetInfoResponse
|
||||
} from "lastfm-node-client";
|
||||
@@ -74,11 +71,20 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
const artistStrings = splitByFirstFound(artists, [','], [artistName]);
|
||||
let al = album;
|
||||
if(al !== undefined) {
|
||||
if(al === null) {
|
||||
al = undefined;
|
||||
} else if (al.trim() === '') {
|
||||
// lastfm may provide empty string when album data is not defined
|
||||
al = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
album: al,
|
||||
duration,
|
||||
playDate: time !== undefined ? dayjs.unix(time) : undefined,
|
||||
meta: {
|
||||
@@ -142,9 +148,7 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
|
||||
}
|
||||
getAuthUrl = () => `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
|
||||
|
||||
authenticate = async (token: any) => {
|
||||
const sessionRes: AuthGetSessionResponse = await this.client.authGetSession({token});
|
||||
@@ -255,33 +259,6 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
return removeUndefinedKeys(rawPayload);
|
||||
}
|
||||
|
||||
updateNowPlaying = async (play: PlayObject) => {
|
||||
try {
|
||||
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
|
||||
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
|
||||
return client.trackUpdateNowPlaying(rest)
|
||||
});
|
||||
const {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
} = {}
|
||||
} = response;
|
||||
if (ignoreCode > 0) {
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) {
|
||||
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
|
||||
|
||||
+200
-46
@@ -1,10 +1,11 @@
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { PlayObject, URLData } from "../../../core/Atomic.js";
|
||||
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
normalizeListenbrainzUrl,
|
||||
normalizeStr,
|
||||
parseArtistCredits,
|
||||
parseCredits,
|
||||
@@ -16,6 +17,12 @@ import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
|
||||
import { parseRegexSingleOrFail, removeUndefinedKeys } from '../../utils.js';
|
||||
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
|
||||
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
|
||||
import { log } from 'console';
|
||||
import { version } from '../../ioc.js';
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -70,8 +77,21 @@ export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
|
||||
recording_msid?: string
|
||||
}
|
||||
|
||||
// using submit-listens example from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
|
||||
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
|
||||
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
|
||||
export interface SubmitListenAdditionalTrackInfo extends AdditionalTrackInfo {
|
||||
artist_names?: string[]
|
||||
release_artist_name?: string
|
||||
release_artist_names?: string[]
|
||||
spotify_album_id?: string
|
||||
spotify_album_artist_ids?: string[]
|
||||
spotify_artist_ids?: string[]
|
||||
albumartist?: string
|
||||
}
|
||||
|
||||
export interface TrackPayload extends MinimumTrack {
|
||||
additional_info?: AdditionalTrackInfo
|
||||
additional_info?: SubmitListenAdditionalTrackInfo
|
||||
}
|
||||
|
||||
export interface ListenPayload {
|
||||
@@ -80,11 +100,18 @@ export interface ListenPayload {
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
|
||||
export type ListenType = 'single' | 'playing_now';
|
||||
|
||||
export interface SubmitPayload {
|
||||
listen_type: 'single' | 'playing_now',
|
||||
listen_type: ListenType,
|
||||
payload: [ListenPayload]
|
||||
}
|
||||
|
||||
interface SubmitOptions {
|
||||
log?: boolean
|
||||
listenType?: ListenType
|
||||
}
|
||||
|
||||
export interface TrackResponse extends MinimumTrack {
|
||||
duration: number
|
||||
additional_info: AdditionalTrackInfoResponse
|
||||
@@ -111,18 +138,26 @@ export interface ListenResponse {
|
||||
recording_msid?: string;
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: string;
|
||||
url: URLData;
|
||||
isKoito: boolean = false;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
const {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
this.url = url;
|
||||
let cleanUrl = url;
|
||||
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
|
||||
if(pathedUrl !== undefined) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
cleanUrl = pathedUrl;
|
||||
}
|
||||
this.url = normalizeWebAddress(cleanUrl);
|
||||
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +213,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(this.url))
|
||||
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
|
||||
this.isKoito = await this.checkKoito();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
|
||||
@@ -190,18 +226,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async checkKoito(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
|
||||
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
|
||||
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play. Please consider switching to the full Koito Source/Client.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -217,11 +265,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
|
||||
.query({
|
||||
period: 'all_time',
|
||||
page: 0,
|
||||
limit: maxTracks
|
||||
})
|
||||
);
|
||||
const { body } = resp as any;
|
||||
return body as KoitoListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -238,6 +304,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
if(this.isKoito) {
|
||||
return this.getRecentlyPlayedKoito(maxTracks)
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
@@ -247,9 +317,24 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
submitListen = async (play: PlayObject, log: boolean = false) => {
|
||||
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
const resp = await this.getUserListensKoito(maxTracks);
|
||||
return resp.items.map(x => listenObjectResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
|
||||
const { log = false, listenType = 'single'} = options;
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]};
|
||||
if(listenType === 'playing_now') {
|
||||
delete listenPayload.payload[0].listened_at;
|
||||
}
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
|
||||
}
|
||||
@@ -257,7 +342,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
// so no useful information
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
|
||||
// TODO may we should make a call to recent-listens to get the parsed scrobble?
|
||||
const resp = await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
|
||||
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Response: ${resp.text}`)
|
||||
}
|
||||
@@ -267,39 +352,6 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static playToListenPayload(play: PlayObject): ListenPayload {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
artists = [],
|
||||
// MB doesn't use this during submission AFAIK
|
||||
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
meta: {
|
||||
brainz = {}
|
||||
} = {}
|
||||
}
|
||||
} = play;
|
||||
return {
|
||||
listened_at: getScrobbleTsSOCDate(play).unix(),
|
||||
track_metadata: {
|
||||
artist_name: artists[0],
|
||||
track_name: track,
|
||||
release_name: album,
|
||||
additional_info: {
|
||||
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
|
||||
track_mbid: brainz.track,
|
||||
artist_mbids: brainz.artist,
|
||||
release_mbid: brainz.album,
|
||||
release_group_mbid: brainz.releaseGroup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
|
||||
const {
|
||||
listened_at = dayjs().unix(),
|
||||
@@ -646,3 +698,105 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
return ListenbrainzApiClient.listenResponseToPlay(obj);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const playToListenPayload = (play: PlayObject): ListenPayload => {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
artists = [],
|
||||
// MB doesn't use this during submission AFAIK
|
||||
// instead it relies on (assumes??) you will submit album/release group/etc where album artist gets credit on an individual release
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
meta: {
|
||||
brainz = {},
|
||||
spotify = {},
|
||||
} = {}
|
||||
},
|
||||
meta: {
|
||||
mediaPlayerName,
|
||||
mediaPlayerVersion,
|
||||
musicService,
|
||||
}
|
||||
} = play;
|
||||
// using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
|
||||
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
|
||||
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
|
||||
|
||||
let addInfo: SubmitListenAdditionalTrackInfo = {
|
||||
// all artists
|
||||
artist_names: Array.from(new Set([...artists, ...albumArtists])),
|
||||
// primary artist
|
||||
release_artist_name: artists[0],
|
||||
release_artist_names: [artists[0]],
|
||||
media_player: mediaPlayerName,
|
||||
media_player_version: mediaPlayerVersion,
|
||||
music_service: musicService !== undefined ? musicServiceToCononical(musicService) : undefined,
|
||||
spotify_id: spotify.track,
|
||||
spotify_album_id: spotify.album,
|
||||
spotify_artist_ids: spotify.artist
|
||||
};
|
||||
|
||||
addInfo = removeUndefinedKeys(addInfo)
|
||||
|
||||
// possible lastfm provides an empty album field when no album data is found
|
||||
let al = album;
|
||||
if(al !== undefined && al !== null) {
|
||||
if(al.trim() === '') {
|
||||
al = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const minTrackData: MinimumTrack = removeUndefinedKeys({
|
||||
artist_name: Array.from(new Set([...artists, ...albumArtists])).join(', '),
|
||||
track_name: track,
|
||||
release_name: al,
|
||||
});
|
||||
|
||||
return {
|
||||
listened_at: getScrobbleTsSOCDate(play).unix(),
|
||||
track_metadata: {
|
||||
...minTrackData,
|
||||
additional_info: {
|
||||
duration: play.data.duration !== undefined ? Math.round(duration) : undefined,
|
||||
track_mbid: brainz.track,
|
||||
artist_mbids: brainz.artist,
|
||||
release_mbid: brainz.album,
|
||||
release_group_mbid: brainz.releaseGroup,
|
||||
submission_client: 'multi-scrobbler',
|
||||
submission_client_version: version,
|
||||
...addInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const musicServices = {
|
||||
spotify: 'spotify.com',
|
||||
bandcamp: 'bandcamp.com',
|
||||
['youtube music']: 'music.youtube.com',
|
||||
youtube: 'youtube.com',
|
||||
deezer: 'deezer.com',
|
||||
tidal: 'tidal.com',
|
||||
apple: 'music.apple.com',
|
||||
archive: 'archive.org',
|
||||
soundcloud: 'soundcloud.com',
|
||||
jamendo: 'jamendo.com',
|
||||
play: 'play.google.com'
|
||||
}
|
||||
/**
|
||||
* Converts MS musicService to LZ cononical Music Service Name, if one exists
|
||||
* @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details
|
||||
* */
|
||||
const musicServiceToCononical = (str: string): string | undefined => {
|
||||
const lower = str.trim().toLocaleLowerCase();
|
||||
for(const [k, v] of Object.entries(musicServices)) {
|
||||
if(lower.includes(k)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import dayjs from "dayjs";
|
||||
import { PlayObject, URLData } from "../../../../core/Atomic.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js";
|
||||
import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
import { normalizeListenbrainzUrl } from "../../../utils/StringUtils.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { UpstreamError } from "../../errors/UpstreamError.js";
|
||||
import { ListenType, playToListenPayload, SubmitPayload } from "../ListenbrainzApiClient.js";
|
||||
import { parseRegexSingleOrFail } from "../../../utils.js";
|
||||
|
||||
interface SubmitOptions {
|
||||
log?: boolean
|
||||
listenType?: ListenType
|
||||
}
|
||||
|
||||
const KOITO_LZ_PATH: RegExp = new RegExp(/^\/apis\/listenbrainz(\/?1?\/?)?$/);
|
||||
|
||||
export class KoitoApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: KoitoData;
|
||||
url: URLData;
|
||||
|
||||
constructor(name: any, config: KoitoData, options: AbstractApiOptions) {
|
||||
super('Koito', name, config, options);
|
||||
|
||||
const {
|
||||
url
|
||||
} = this.config;
|
||||
|
||||
const u = normalizeWebAddress(url);
|
||||
if(u.url.pathname === '/') {
|
||||
this.url = u;
|
||||
} else if(parseRegexSingleOrFail(KOITO_LZ_PATH, u.url.pathname) !== undefined) {
|
||||
this.logger.verbose('Detected Koito Server URL path only contains listenbrainz prefix. Removing this for API calls so non-listenbrainz paths work correctly.');
|
||||
this.url = normalizeWebAddress(getBaseFromUrl(u.url).toString());
|
||||
} else {
|
||||
this.logger.verbose('It looks like Koito server URL contains non-standard parts (not "just" /apis/listenbrainz"). MS will assume this is the ROOT path for the Koito server.');
|
||||
this.url = u;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
|
||||
}
|
||||
|
||||
callApi = async <T = Response>(req: Request, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
req.set('Authorization', `Token ${this.config.token}`);
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
err,
|
||||
status,
|
||||
response: {
|
||||
body = undefined,
|
||||
text = undefined,
|
||||
} = {}
|
||||
} = e;
|
||||
// TODO check err for network exception
|
||||
if (status !== undefined) {
|
||||
const statusMsg = `(HTTP Status ${status})`;
|
||||
const msgParts = [];
|
||||
// if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service
|
||||
const showStopper = status !== 400;
|
||||
if (body !== undefined) {
|
||||
if (typeof body === 'object') {
|
||||
if ('code' in body) {
|
||||
msgParts.push(`Code ${body.code}`);
|
||||
}
|
||||
if ('error' in body) {
|
||||
msgParts.push(`Error => ${body.error}`);
|
||||
}
|
||||
if ('message' in body) {
|
||||
msgParts.push(`Message => ${body.error}`);
|
||||
}
|
||||
} else if (typeof body === 'string') {
|
||||
msgParts.push(`Response => ${body}`);
|
||||
}
|
||||
}
|
||||
if (msgParts.length === 0 && text !== undefined && text.trim() !== '') {
|
||||
msgParts.push(`Response => ${text}`);
|
||||
}
|
||||
throw new UpstreamError(`Koito API Request Failed => ${[statusMsg, ...msgParts].join(' | ')}`, { cause: e, showStopper });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
await isPortReachableConnect(this.url.port, { host: this.url.url.hostname });
|
||||
} catch (e) {
|
||||
throw new Error(`Koito server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e });
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url, 'apis/web/v1/stats')}`));
|
||||
if(resp.type !== 'application/json') {
|
||||
throw new Error(`Expected response from ${resp.request.url} to be 'application/json' but got ${resp.type}. Is the Normalized Koito URL correct?`);
|
||||
}
|
||||
} catch (e) {
|
||||
let allowedHint = '';
|
||||
if(e.cause !== undefined && 'response' in e.cause) {
|
||||
if(e.cause.response.status === 403) {
|
||||
allowedHint = ` HINT: 403 usually means Koito env KOITO_ALLOWED_HOSTS is not configured correctly to allowed requests from multi-scrobbler. Check Koito logs for warnings.`
|
||||
}
|
||||
}
|
||||
throw new Error(`A server exists at ${this.url.url.hostname}:${this.url.port} but is not responding to API calls as expected.${allowedHint}`, { cause: e });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error('Could not validate Koito API Key', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
getUserListens = async (maxTracks: number): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${joinedUrl(this.url.url, '/apis/web/v1/listens')}`)
|
||||
.query({
|
||||
period: 'all_time',
|
||||
page: 0,
|
||||
limit: maxTracks
|
||||
})
|
||||
);
|
||||
const { body } = resp as any;
|
||||
return body as ListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks);
|
||||
return resp.items.map(x => listenObjectResponseToPlay(x, { url: this.url.url }));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
|
||||
const { log = false, listenType = 'single' } = options;
|
||||
try {
|
||||
const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] };
|
||||
if (listenType === 'playing_now') {
|
||||
delete listenPayload.payload[0].listened_at;
|
||||
}
|
||||
if (log) {
|
||||
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
|
||||
}
|
||||
// response consists of {"status": "ok"}
|
||||
// so no useful information
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
|
||||
// TODO may we should make a call to recent-listens to get the parsed scrobble?
|
||||
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/submit-listens')}`).type('json').send(listenPayload));
|
||||
if (log) {
|
||||
this.logger.debug(`Submit Response: ${resp.text}`)
|
||||
}
|
||||
return listenPayload;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track: obj.track.title,
|
||||
artists: (obj.track.artists ?? []).map(x => x.name),
|
||||
duration: obj.track.duration,
|
||||
playDate: dayjs(obj.time)
|
||||
},
|
||||
meta: {
|
||||
source: 'Koito',
|
||||
newFromSource: options.newFromSource ?? false,
|
||||
trackId: obj.track.id.toString(),
|
||||
url: {
|
||||
web: options.url !== undefined ? joinedUrl(options.url, `/track/${obj.track.id.toString()}`).toString() : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (obj.track.musicbrainz_id !== null) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: obj.track.musicbrainz_id
|
||||
}
|
||||
}
|
||||
}
|
||||
return play;
|
||||
}
|
||||
@@ -61,8 +61,14 @@ export type Metadata =
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
export interface Connector {
|
||||
id: string
|
||||
js: string
|
||||
label: string
|
||||
}
|
||||
export interface WebScrobblerSong {
|
||||
controllerTabId: string | number;
|
||||
connector: Connector
|
||||
parsed: ParsedSongData;
|
||||
processed: ProcessedSongData;
|
||||
noRegex: ProcessedSongData;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import { generateBaseURL } from "./utils/NetworkUtils.js";
|
||||
import { PassThrough } from "stream";
|
||||
|
||||
let version: string = 'unknown';
|
||||
export let version: string = 'unknown';
|
||||
|
||||
export const parseVersion = async () => {
|
||||
version = await getVersion({priority: ['env', 'git', 'file']});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { URLData } from "../../core/Atomic.js";
|
||||
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
|
||||
const shortKey = truncateStringToLength(10);
|
||||
|
||||
@@ -98,6 +99,9 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
this.logger.verbose(`Pushed notification to Config ${shortKey(key)}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to Config ${shortKey(key)}`, {cause: e}));
|
||||
if(isDebugMode()) {
|
||||
this.logger.debug({ body }, 'Apprise Request')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +118,9 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
this.logger.verbose(`Pushed notification to URLs`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e});
|
||||
if(isDebugMode()) {
|
||||
this.logger.debug({ body }, 'Apprise Request')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +145,27 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
} = {}
|
||||
} = e;
|
||||
const errorMsgs = [message];
|
||||
if (typeof jsonBody === 'object' && jsonBody.error !== undefined) {
|
||||
errorMsgs.push(jsonBody.error);
|
||||
if(typeof jsonBody === 'object') {
|
||||
let appriseError = 'Apprise Error Response';
|
||||
if ('error' in jsonBody) {
|
||||
appriseError = `${appriseError}: ${jsonBody.error}`;
|
||||
}
|
||||
if('details' in jsonBody) {
|
||||
const appriseDetails = [];
|
||||
if(Array.isArray(jsonBody.details)) {
|
||||
for(const detailEntry of jsonBody.details) {
|
||||
try {
|
||||
appriseDetails.push(`(${detailEntry[0]}) ${detailEntry[2]}`);
|
||||
} catch (e) {
|
||||
appriseDetails.push(JSON.stringify(detailEntry));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appriseDetails.push(JSON.stringify(jsonBody.details));
|
||||
}
|
||||
appriseError = `${appriseError} --> ${appriseDetails.join(' || ')} <--`;
|
||||
}
|
||||
errorMsgs.push(appriseError);
|
||||
}
|
||||
throw new UpstreamError(`Apprise API Request failed => (${status}) ${errorMsgs.join(' => ')}`, {response: e.response});
|
||||
} else {
|
||||
|
||||
@@ -3,15 +3,13 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { nanoid } from "nanoid";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { MarkOptional } from "ts-essentials";
|
||||
import {
|
||||
DeadLetterScrobble,
|
||||
PlayObject,
|
||||
QueuedScrobble,
|
||||
TA_CLOSE,
|
||||
QueuedScrobble, TA_DURING,
|
||||
TA_FUZZY,
|
||||
TrackStringOptions,
|
||||
TrackStringOptions
|
||||
} from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import AbstractComponent from "../common/AbstractComponent.js";
|
||||
@@ -23,15 +21,21 @@ import {
|
||||
DEFAULT_RETRY_MULTIPLIER,
|
||||
DUP_SCORE_THRESHOLD,
|
||||
FormatPlayObjectOptions,
|
||||
PlayPlatformId,
|
||||
ScrobbledPlayObject,
|
||||
SourceIdentifier,
|
||||
TIME_WEIGHT,
|
||||
TITLE_WEIGHT, TRANSFORM_HOOK,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { CommonClientConfig, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
|
||||
import { CommonClientConfig, NowPlayingOptions, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import {
|
||||
comparingMultipleArtists,
|
||||
genGroupId,
|
||||
genGroupIdStr,
|
||||
genGroupIdStrFromPlay,
|
||||
isDebugMode,
|
||||
parseBool,
|
||||
playObjDataMatch,
|
||||
pollingBackoff,
|
||||
setIntersection,
|
||||
@@ -42,17 +46,23 @@ import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/E
|
||||
import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js";
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
hasAcceptableTemporalAccuracy,
|
||||
temporalAccuracyToString,
|
||||
temporalPlayComparisonSummary,
|
||||
} from "../utils/TimeUtils.js";
|
||||
import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
|
||||
type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>;
|
||||
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
|
||||
|
||||
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
|
||||
|
||||
name: string;
|
||||
type: ClientType;
|
||||
|
||||
scheduler: ToadScheduler = new ToadScheduler();
|
||||
|
||||
protected MAX_STORED_SCROBBLES = 40;
|
||||
protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES;
|
||||
|
||||
@@ -77,6 +87,16 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
queuedScrobbles: QueuedScrobble<PlayObject>[] = [];
|
||||
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
|
||||
|
||||
supportsNowPlaying: boolean = false;
|
||||
nowPlayingEnabled: boolean;
|
||||
nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined;
|
||||
nowPlayingThresholds: [number,number] = [10,30];
|
||||
nowPlayingLastUpdated?: Dayjs;
|
||||
nowPlayingLastPlay?: PlayObject;
|
||||
nowPlayingQueue: NowPlayingQueue = new Map();
|
||||
nowPlayingTaskInterval: number = 5000;
|
||||
npLogger: Logger;
|
||||
|
||||
declare config: CommonClientConfig;
|
||||
|
||||
notifier: Notifiers;
|
||||
@@ -87,6 +107,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.logger = childLogger(logger, this.getIdentifier());
|
||||
this.npLogger = childLogger(this.logger, 'Now Playing');
|
||||
this.notifier = notifier;
|
||||
this.emitter = emitter;
|
||||
|
||||
@@ -153,13 +174,137 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
this.emitEvent('notify', payload);
|
||||
}
|
||||
|
||||
protected initializeNowPlaying() {
|
||||
|
||||
if (this.supportsNowPlaying) {
|
||||
|
||||
const {
|
||||
options = {},
|
||||
} = this.config;
|
||||
|
||||
// for future use...if we let user manually toggle now playing off/on
|
||||
if(this.nowPlayingEnabled === undefined) {
|
||||
const npEnv = process.env.NOW_PLAYING;
|
||||
if('nowPlaying' in options) {
|
||||
const nowOpts = options as NowPlayingOptions;
|
||||
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
|
||||
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by 'nowPlaying' config`);
|
||||
} else if (npEnv !== undefined) {
|
||||
this.nowPlayingEnabled = parseBool(npEnv);
|
||||
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by global ENV`);
|
||||
} else {
|
||||
this.nowPlayingEnabled = true;
|
||||
this.npLogger.debug(`Enabled by default config`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initializeNowPlayingFilter();
|
||||
this.initializeNowPlayingSchedule();
|
||||
} else {
|
||||
this.npLogger.debug('Unsupported feature, disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeNowPlayingSchedule() {
|
||||
|
||||
const t = new AsyncTask('Playing Now', (): Promise<any> => {
|
||||
return this.processingPlayingNow();
|
||||
}, (err: Error) => {
|
||||
this.npLogger.error(new Error('Unexpected error while processing Now Playing queue', {cause: err}));
|
||||
});
|
||||
|
||||
this.scheduler.removeById('pn_task');
|
||||
|
||||
// even though we are processing every 5 seconds the interval that Now Playing is updated at, and that the queue is cleared on,
|
||||
// is still set by shouldUpdatePlayingNow()
|
||||
// 5 seconds makes sure our granularity for updates is decently fast *when* we do need to actually update
|
||||
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({milliseconds: this.nowPlayingTaskInterval}, t, {id: 'pn_task'}));
|
||||
}
|
||||
|
||||
protected initializeNowPlayingFilter() {
|
||||
|
||||
const {
|
||||
options = {},
|
||||
} = this.config;
|
||||
|
||||
if (this.supportsNowPlaying) {
|
||||
|
||||
let sourceFilter: (queue: NowPlayingQueue) => PlatformMappedPlays | undefined;
|
||||
|
||||
// sources default to being filters by name-type, alphabetically
|
||||
sourceFilter = (queue: NowPlayingQueue) => {
|
||||
const sorted = Array.from(queue.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return sorted[0][1];
|
||||
}
|
||||
|
||||
if ('nowPlaying' in options) {
|
||||
const nowOpts = options as NowPlayingOptions;
|
||||
if (this.nowPlayingEnabled === undefined) {
|
||||
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
|
||||
}
|
||||
if (Array.isArray(nowOpts.nowPlaying)) {
|
||||
// if user defined priority list of source names then we use that instead, look for source name in name-type
|
||||
sourceFilter = (queue: NowPlayingQueue) => {
|
||||
const entries = Array.from(queue.entries());
|
||||
for (const s of nowOpts.nowPlaying as string[]) {
|
||||
const sLower = s.toLocaleLowerCase();
|
||||
const validSource = entries.find(x => x[0].toLocaleLowerCase().includes(sLower));
|
||||
if (validSource !== undefined) {
|
||||
return validSource[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.nowPlayingFilter = (queue: NowPlayingQueue) => {
|
||||
if (queue.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// get list of play(ers) for top-priority Source
|
||||
const platformPlays = sourceFilter(queue);
|
||||
if (platformPlays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// if only one player then return it
|
||||
const plays = Array.from(platformPlays);
|
||||
if (plays.length === 1) {
|
||||
return plays[0][1].play;
|
||||
}
|
||||
// else we need to sort players to determine which to report
|
||||
|
||||
// if a now playing play already exists use that platform, if any matches...
|
||||
// this way we aren't flip-flopping between multiple players for reporting now playing
|
||||
// (keeps reporting sticky based on first reported)
|
||||
if (this.nowPlayingLastPlay !== undefined) {
|
||||
const lastNowPlayingPlat = genGroupIdStrFromPlay(this.nowPlayingLastPlay);
|
||||
|
||||
for (const [platform, data] of plays) {
|
||||
if (platform === lastNowPlayingPlat) {
|
||||
return data.play;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise sort platform alphabetically and take first
|
||||
plays.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return plays[0][1].play;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async postInitialize(): Promise<void> {
|
||||
const {
|
||||
options: {
|
||||
refreshInitialCount = this.MAX_INITIAL_SCROBBLES_FETCH
|
||||
} = {}
|
||||
} = {},
|
||||
options = {},
|
||||
} = this.config;
|
||||
|
||||
this.initializeNowPlaying();
|
||||
|
||||
let initialLimit = refreshInitialCount;
|
||||
if(refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
|
||||
this.logger.warn(`Defined initial scrobbles count (${refreshInitialCount}) higher than maximum allowed (${this.MAX_INITIAL_SCROBBLES_FETCH}). Will use max instead.`);
|
||||
@@ -315,7 +460,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
|
||||
const temporalComparison = comparePlayTemporally(x.play, playObj);
|
||||
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
|
||||
return hasAcceptableTemporalAccuracy(temporalComparison.match)
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
@@ -394,6 +539,11 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
// only check for fuzzy if we know this play is NOT a repeat
|
||||
// otherwise we may get a false positive on the previously played track ending time == repeat start time
|
||||
// -- this is info we only know if play was generated from MS player so we can be reasonably sure
|
||||
const looseTimeAccuracy = playObj.data.repeat ? [TA_DURING] : [TA_FUZZY, TA_DURING];
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((xPre) => {
|
||||
|
||||
const x = this.transformPlay(xPre, TRANSFORM_HOOK.existing);
|
||||
@@ -403,9 +553,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
|
||||
const temporalComparison = comparePlayTemporally(x, playObj);
|
||||
let timeMatch = 0;
|
||||
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
|
||||
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
|
||||
timeMatch = 1;
|
||||
} else if(temporalComparison.match === TA_FUZZY) {
|
||||
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
|
||||
timeMatch = 0.6;
|
||||
}
|
||||
|
||||
@@ -775,6 +925,74 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
this.emitEvent('deadLetter', {dead: deadData});
|
||||
}
|
||||
|
||||
queuePlayingNow = (data: PlayObject, source: SourceIdentifier) => {
|
||||
const sourceId = `${source.name}-${source.type}`;
|
||||
if(isDebugMode()) {
|
||||
this.npLogger.debug(`Queueing ${buildTrackString(data, {include: ['artist', 'track', 'platform']})} from ${sourceId}`);
|
||||
}
|
||||
const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map();
|
||||
platformPlays.set(genGroupIdStrFromPlay(data), {play: data, source});
|
||||
this.nowPlayingQueue.set(sourceId, platformPlays);
|
||||
}
|
||||
|
||||
processingPlayingNow = async (): Promise<void> => {
|
||||
if(this.supportsNowPlaying && this.nowPlayingEnabled) {
|
||||
const play = this.nowPlayingFilter(this.nowPlayingQueue);
|
||||
if(play === undefined) {
|
||||
return;
|
||||
}
|
||||
if(this.shouldUpdatePlayingNow(play)) {
|
||||
try {
|
||||
await this.doPlayingNow(play);
|
||||
this.npLogger.debug(`Now Playing updated.`);
|
||||
this.emitEvent('nowPlayingUpdated', play);
|
||||
} catch (e) {
|
||||
this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e}));
|
||||
}
|
||||
this.nowPlayingLastPlay = play;
|
||||
this.nowPlayingLastUpdated = dayjs();
|
||||
// only clear queue after we have updated Now Playing, this way we always have the latest and "most complete"
|
||||
// set of Source Player updates available for filtering/sorting
|
||||
this.nowPlayingQueue = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldUpdatePlayingNow = (data: PlayObject): boolean => {
|
||||
if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) {
|
||||
if(isDebugMode()) {
|
||||
this.npLogger.debug(`Now Playing has not yet been set! Should update`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
|
||||
|
||||
// update if play *has* changed and time since last update is greater than min interval
|
||||
// this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently
|
||||
if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[0] < lastUpdateDiff) {
|
||||
if(isDebugMode()) {
|
||||
this.npLogger.debug(`New Play differs from previous Now Playing and time since update > ${lastUpdateDiff}s, should update`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// update if play *has not* changed but last update is greater than max interval
|
||||
// this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long
|
||||
if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[1] < lastUpdateDiff) {
|
||||
if(isDebugMode()) {
|
||||
this.npLogger.debug(`Now Playing has not been updated in > ${lastUpdateDiff}s, should update`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if(isDebugMode()) {
|
||||
this.npLogger.debug(`Updated Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago, not updating`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected doPlayingNow = (data: PlayObject): Promise<any> => Promise.resolve(undefined)
|
||||
|
||||
|
||||
public emitEvent = (eventName: string, payload: object) => {
|
||||
this.emitter.emit(eventName, {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from 'dayjs';
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import { ListenbrainzApiClient, ListenPayload, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js";
|
||||
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
|
||||
|
||||
export default class KoitoScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
api: KoitoApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
|
||||
declare config: KoitoClientConfig;
|
||||
|
||||
constructor(name: any, config: KoitoClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super('koito', name, config, notifier, emitter, logger);
|
||||
this.api = new KoitoApiClient(name, config.data, {logger: this.logger});
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
|
||||
// 1000 is way too high. maxing at 100
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
|
||||
this.supportsNowPlaying = true;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => listenObjectResponseToPlay(obj, options);
|
||||
|
||||
public playToClientPayload(playObject: PlayObject): object {
|
||||
return playToListenPayload(playObject);
|
||||
}
|
||||
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
token,
|
||||
} = {}
|
||||
} = this.config;
|
||||
if (token === undefined) {
|
||||
throw new Error('Must provide a User Token');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
|
||||
try {
|
||||
return await this.api.testAuth();
|
||||
} catch (e) {
|
||||
if(isNodeNetworkException(e)) {
|
||||
this.logger.error('Could not communicate with Koito API');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getScrobblesForRefresh = async (limit: number) => {
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
const {
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
try {
|
||||
await this.api.submitListen(playObj, { log: isDebugMode()});
|
||||
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
return playObj;
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
throw new UpstreamError(`Error occurred while making Koito API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
|
||||
}
|
||||
}
|
||||
|
||||
doPlayingNow = async (data: PlayObject) => {
|
||||
try {
|
||||
await this.api.submitListen(data, { listenType: 'playing_now'});
|
||||
} catch (e) {
|
||||
throw new UpstreamError(`Error occurred while making Koito API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from 'dayjs';
|
||||
import EventEmitter from "events";
|
||||
import { TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
|
||||
import { NowPlayingResponse, TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
@@ -26,6 +25,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
this.api = new LastfmApiClient(name, config.data, {...options, logger})
|
||||
// https://www.last.fm/api/show/user.getRecentTracks
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 200;
|
||||
this.supportsNowPlaying = true;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
|
||||
@@ -170,4 +170,29 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
this.logger.debug('Raw Payload: ', scrobblePayload);
|
||||
}
|
||||
}
|
||||
|
||||
doPlayingNow = async (data: PlayObject) => {
|
||||
try {
|
||||
const {timestamp, mbid, ...rest} = this.api.playToClientPayload(data);
|
||||
const response = await this.api.callApi<NowPlayingResponse>((client: any) => client.trackUpdateNowPlaying(rest));
|
||||
const {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
} = {}
|
||||
} = response;
|
||||
if (ignoreCode > 0) {
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) {
|
||||
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: false});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientConfig } from "../common/infrastructure/config/client/listenbrainz.js";
|
||||
import { ListenbrainzApiClient, ListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { ListenbrainzApiClient, ListenPayload, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzClientConfig;
|
||||
|
||||
@@ -26,6 +28,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
|
||||
// 1000 is way too high. maxing at 100
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
|
||||
this.supportsNowPlaying = true;
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
|
||||
@@ -44,6 +47,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
this.isKoito = this.api.isKoito;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -66,7 +70,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): ListenPayload {
|
||||
return ListenbrainzApiClient.playToListenPayload(playObj);
|
||||
return playToListenPayload(playObj);
|
||||
}
|
||||
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
@@ -78,7 +82,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
} = playObj;
|
||||
|
||||
try {
|
||||
await this.api.submitListen(playObj, true);
|
||||
await this.api.submitListen(playObj, { log: isDebugMode()});
|
||||
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
@@ -91,4 +95,12 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
throw new UpstreamError(`Error occurred while making Listenbrainz API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
|
||||
}
|
||||
}
|
||||
|
||||
doPlayingNow = async (data: PlayObject) => {
|
||||
try {
|
||||
await this.api.submitListen(data, { listenType: 'playing_now'});
|
||||
} catch (e) {
|
||||
throw new UpstreamError(`Error occurred while making Listenbrainz API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { childLogger, Logger } from '@foxxmd/logging';
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { ClientType, clientTypes, ConfigMeta, isClientType } from "../common/infrastructure/Atomic.js";
|
||||
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
|
||||
import { ClientType, clientTypes, ConfigMeta, isClientType, REPORTED_PLAYER_STATUSES, SourceIdentifier } from "../common/infrastructure/Atomic.js";
|
||||
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
|
||||
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
@@ -10,7 +10,7 @@ import { ListenBrainzClientConfig } from "../common/infrastructure/config/client
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
|
||||
import { WildcardEmitter } from "../common/WildcardEmitter.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import { readJson, thresholdResultSummary } from "../utils.js";
|
||||
import { isDebugMode, readJson } from "../utils.js";
|
||||
import { joinedUrl } from "../utils/NetworkUtils.js";
|
||||
import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js";
|
||||
import { validateJson } from "../utils/ValidationUtils.js";
|
||||
@@ -19,6 +19,8 @@ import LastfmScrobbler from "./LastfmScrobbler.js";
|
||||
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
import { Definition } from 'ts-json-schema-generator';
|
||||
import KoitoScrobbler from './KoitoScrobbler.js';
|
||||
import { KoitoClientConfig } from '../common/infrastructure/config/client/koito.js';
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
@@ -45,6 +47,12 @@ export default class ScrobbleClients {
|
||||
this.localUrl = localUrl;
|
||||
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
|
||||
|
||||
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
|
||||
if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing) {
|
||||
this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
|
||||
}
|
||||
});
|
||||
|
||||
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
|
||||
await this.scrobble(payload.data, payload.options);
|
||||
});
|
||||
@@ -93,6 +101,9 @@ export default class ScrobbleClients {
|
||||
case 'listenbrainz':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenBrainzClientConfig");
|
||||
break;
|
||||
case 'koito':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoClientConfig");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.schemaDefinitions[type];
|
||||
@@ -121,13 +132,18 @@ export default class ScrobbleClients {
|
||||
clientDefaults = cd;
|
||||
for (const [index, c] of mainConfigClientConfigs.entries()) {
|
||||
const {name = 'unnamed'} = c;
|
||||
if(c.type === undefined) {
|
||||
const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`;
|
||||
this.logger.error(invalidMsgType);
|
||||
continue;
|
||||
}
|
||||
if(!isClientType(c.type.toLocaleLowerCase())) {
|
||||
const invalidTypeMsg = `Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`;
|
||||
//this.emitter.emit('error', new Error(invalidTypeMsg));
|
||||
this.logger.error(invalidTypeMsg);
|
||||
continue;
|
||||
}
|
||||
if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) {
|
||||
if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) {
|
||||
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`);
|
||||
continue;
|
||||
}
|
||||
@@ -203,6 +219,23 @@ export default class ScrobbleClients {
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'koito':
|
||||
const koit = {
|
||||
url: process.env.KOITO_URL,
|
||||
token: process.env.KOITO_TOKEN,
|
||||
username: process.env.KOITO_USER
|
||||
};
|
||||
if (!Object.values(koit).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'koito',
|
||||
name: 'unnamed-koito',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: 'client',
|
||||
data: koit
|
||||
})
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -229,8 +262,8 @@ export default class ScrobbleClients {
|
||||
continue;
|
||||
}
|
||||
for(const [i,rawConf] of rawClientConfigs.entries()) {
|
||||
if(['lastfm','listenbrainz'].includes(clientType) &&
|
||||
((rawConf as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source'))
|
||||
if(['lastfm','listenbrainz','koito'].includes(clientType) &&
|
||||
((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig).configureAs === 'source'))
|
||||
{
|
||||
this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`);
|
||||
continue;
|
||||
@@ -287,7 +320,7 @@ ${sources.join('\n')}`);
|
||||
try {
|
||||
await this.addClient(c, clientDefaults, notifier);
|
||||
} catch(e) {
|
||||
const addError = new Error(`Client ${c.name} was not added because it had unrecoverable errors`, {cause: e});
|
||||
const addError = new Error(`Client ${c.name} from ${c.source} was not added because it had unrecoverable errors`, {cause: e});
|
||||
this.emitter.emit('error', addError);
|
||||
this.logger.error(addError);
|
||||
}
|
||||
@@ -299,17 +332,17 @@ ${sources.join('\n')}`);
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}*/
|
||||
const {type, name, enable = true, data: d = {}} = clientConfig;
|
||||
const {type, name, enable = true, source, data: d = {}} = clientConfig;
|
||||
|
||||
if(enable === false) {
|
||||
this.logger.warn(`${type} (${name}) client was disabled by config`);
|
||||
this.logger.warn({labels: [`${type} - ${name}`]}, `Client from ${source} was disabled by config`);
|
||||
return;
|
||||
}
|
||||
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
let newClient;
|
||||
this.logger.debug(`Constructing ${type} (${name}) client...`);
|
||||
this.logger.debug({labels: [`${type} - ${name}`]}, `Constructing Client from ${source}`);
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.emitter, this.logger);
|
||||
@@ -320,23 +353,48 @@ ${sources.join('\n')}`);
|
||||
case 'listenbrainz':
|
||||
newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as ListenBrainzClientConfig, {}, notifier, this.emitter, this.logger);
|
||||
break;
|
||||
case 'koito':
|
||||
newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as KoitoClientConfig, {}, notifier, this.emitter, this.logger);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if(newClient === undefined) {
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Client of type ${type} was not recognized??`);
|
||||
throw new Error(`Client of type ${type} from ${source} was not recognized??`);
|
||||
}
|
||||
newClient.logger.info('Client Added');
|
||||
newClient.logger.info(`Client Added from ${source}`);
|
||||
this.clients.push(newClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} data
|
||||
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
|
||||
* @returns {Array}
|
||||
*/
|
||||
playingNow = async (data: (PlayObject | PlayObject[]), options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
scrobbleTo = [],
|
||||
scrobbleFrom,
|
||||
} = options;
|
||||
|
||||
if (this.clients.length === 0) {
|
||||
this.logger.warn('Cannot update Now Playing! No clients are configured.');
|
||||
}
|
||||
|
||||
for (const client of this.clients) {
|
||||
if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
|
||||
continue;
|
||||
}
|
||||
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
|
||||
if(isDebugMode()) {
|
||||
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
client.queuePlayingNow(playObj, scrobbleFrom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { childLogger, LogDataPretty } from '@foxxmd/logging';
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { EventEmitter } from "events";
|
||||
import { FixedSizeList } from "fixed-size-list";
|
||||
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
|
||||
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import AbstractComponent from "../common/AbstractComponent.js";
|
||||
import {
|
||||
@@ -33,11 +33,12 @@ import {
|
||||
sortByNewestPlayDate,
|
||||
sortByOldestPlayDate,
|
||||
} from "../utils.js";
|
||||
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { getRoot } from '../ioc.js';
|
||||
import { componentFileLogger } from '../common/logging.js';
|
||||
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
|
||||
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
|
||||
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
|
||||
|
||||
export interface RecentlyPlayedOptions {
|
||||
limit?: number
|
||||
@@ -80,7 +81,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
|
||||
emitter: EventEmitter;
|
||||
|
||||
protected SCROBBLE_BACKLOG_COUNT: number = 20;
|
||||
protected SCROBBLE_BACKLOG_COUNT: number = 30;
|
||||
|
||||
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
|
||||
|
||||
@@ -130,7 +131,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
|
||||
protected addPlayToDiscovered = (play: PlayObject) => {
|
||||
const platformId = this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID;
|
||||
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
|
||||
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(200);
|
||||
list.add(play);
|
||||
this.recentDiscoveredPlays.set(platformId, list);
|
||||
this.tracksDiscovered++;
|
||||
@@ -152,7 +153,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
return [];
|
||||
}
|
||||
|
||||
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
|
||||
protected getExistingDiscoveredLists = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject[][] => {
|
||||
const lists: PlayObject[][] = [];
|
||||
if(opts.checkAll !== true) {
|
||||
lists.push(this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID));
|
||||
@@ -168,19 +169,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
}
|
||||
});
|
||||
}
|
||||
return lists;
|
||||
}
|
||||
|
||||
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
|
||||
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
|
||||
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
|
||||
for(const list of lists) {
|
||||
const existing = list.find(x => {
|
||||
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
|
||||
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
|
||||
return genericSourcePlayMatch(e, candidate);
|
||||
});
|
||||
if(existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
//const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID);
|
||||
//return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
|
||||
}
|
||||
|
||||
alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => {
|
||||
|
||||
@@ -255,7 +255,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp
|
||||
duration
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: elapsed
|
||||
trackProgressPosition: elapsed,
|
||||
source: 'azuracast',
|
||||
mediaPlayerName: 'Azuracast'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import request, { Request, Response, SuperAgent } from 'superagent';
|
||||
import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js";
|
||||
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
|
||||
import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { CookieJar, Cookie } from 'tough-cookie';
|
||||
import { MixedCookieAgent } from 'http-cookie-agent/http';
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
|
||||
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
|
||||
|
||||
interface DeezerHistoryResponse {
|
||||
errors: []
|
||||
results: {
|
||||
data: DeezerInternalTrackData[]
|
||||
error: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default class DeezerInternalSource extends MemorySource {
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
|
||||
csrfToken?: string;
|
||||
|
||||
agent: request.SuperAgentStatic & request.Request
|
||||
jar: CookieJar
|
||||
|
||||
declare config: DeezerInternalSourceConfig;
|
||||
|
||||
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('deezer', name, config, internal, emitter);
|
||||
const {
|
||||
data: {
|
||||
interval = 60,
|
||||
...rest
|
||||
} = {},
|
||||
} = config;
|
||||
|
||||
if (interval < 15) {
|
||||
this.logger.warn('Interval should be above 30 seconds...😬');
|
||||
}
|
||||
|
||||
// @ts-expect-error not correct structure
|
||||
this.config.data = {
|
||||
...rest,
|
||||
interval,
|
||||
};
|
||||
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
this.supportsUpstreamRecentlyPlayed = true;
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
// https://developers.deezer.com/api/user/history
|
||||
// https://stackoverflow.com/a/19497151/1469797
|
||||
this.SCROBBLE_BACKLOG_COUNT = 50;
|
||||
|
||||
this.jar = new CookieJar();
|
||||
const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
|
||||
// @ts-expect-error not correct structure
|
||||
this.agent = request.agent().use((req) => req.agent(mixedAgent));
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {newFromSource = false} = options;
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
artists: [obj.ART_NAME],
|
||||
album: obj.ALB_TITLE,
|
||||
track: obj.SNG_TITLE,
|
||||
duration: obj.DURATION,
|
||||
playDate: dayjs(obj.TS * 1000),
|
||||
},
|
||||
meta: {
|
||||
source: 'Deezer',
|
||||
trackId: obj.SNG_ID,
|
||||
newFromSource,
|
||||
url: {
|
||||
web: `https://www.deezer.com/track/${obj.SNG_ID}`
|
||||
},
|
||||
musicService: 'Deezer',
|
||||
}
|
||||
};
|
||||
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
|
||||
play.meta.art = {
|
||||
album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
|
||||
}
|
||||
}
|
||||
return play;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
|
||||
if (this.config.data.arl === undefined) {
|
||||
throw new Error('arl must be defined in configuration');
|
||||
}
|
||||
this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await request.get('https://deezer.com');
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
try {
|
||||
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
|
||||
.query({
|
||||
method: 'deezer.getUserData'
|
||||
})
|
||||
const resp = await this.callApi(req);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
|
||||
.query({
|
||||
method: 'user.getSongsHistory'
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
nb: 30,
|
||||
start: 0
|
||||
});
|
||||
// returns listening history in descending order (newest to oldest)
|
||||
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
|
||||
for(const e of resp.results.error) {
|
||||
this.logger.warn(`Error returned in history response: ${e}`);
|
||||
}
|
||||
const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song');
|
||||
if(nonSong.length > 0) {
|
||||
const nonSongTypes = [];
|
||||
for(const n of nonSong) {
|
||||
if(!nonSongTypes.includes(n.__TYPE__)) {
|
||||
nonSongTypes.push(n.__TYPE__);
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
|
||||
}
|
||||
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
|
||||
}
|
||||
|
||||
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config.options;
|
||||
|
||||
req.query({
|
||||
input: 3,
|
||||
api_version: '1.0',
|
||||
api_token: this.csrfToken ?? ''
|
||||
});
|
||||
setRequestHeaders(req);
|
||||
try {
|
||||
const resp = await req;
|
||||
const {
|
||||
body: {
|
||||
error,
|
||||
results
|
||||
} = {}
|
||||
} = resp;
|
||||
if('checkForm' in results) {
|
||||
this.csrfToken = results.checkForm;
|
||||
}
|
||||
if (error !== undefined && error.length > 0) {
|
||||
const err = new Error((error as string[]).join(' | '));
|
||||
throw err;
|
||||
}
|
||||
return resp.body;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return await this.callApi(req, retries + 1)
|
||||
}
|
||||
const {
|
||||
message,
|
||||
response,
|
||||
} = e;
|
||||
const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
|
||||
throw new Error(msg, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
|
||||
|
||||
|
||||
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
|
||||
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
|
||||
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
|
||||
for(const list of lists) {
|
||||
const existing = list.find(x => {
|
||||
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
|
||||
return genericSourcePlayMatch(e, candidate);
|
||||
});
|
||||
if(existing) {
|
||||
return existing;
|
||||
}
|
||||
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
|
||||
const fuzzyIndex = list.findIndex(x => {
|
||||
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
|
||||
let temporalOptions: TemporalPlayComparisonOptions = {};
|
||||
const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
|
||||
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
|
||||
temporalOptions = {
|
||||
fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
|
||||
duringReferences: ['duration', 'listenedFor', 'range']
|
||||
}
|
||||
temporalAccuracy.push(TA_DURING);
|
||||
}
|
||||
return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
|
||||
});
|
||||
if(fuzzyIndex !== -1) {
|
||||
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
|
||||
// always return fuzzy match as existing
|
||||
// likely will make MS miss scrobbles for repeated plays
|
||||
return list[fuzzyIndex];
|
||||
}
|
||||
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
|
||||
// last discovered play was this one, or next played play was also this one
|
||||
// so we'll assume this means the play is on repeat, don't count as existing
|
||||
return undefined;
|
||||
}
|
||||
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
|
||||
// so this is likely a duplicate deezer should not have reported
|
||||
return list[fuzzyIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
|
||||
req
|
||||
.set('Pragma', 'no-cache')
|
||||
.set('Origin', 'https://www.deezer.com')
|
||||
.set('Accept-Encoding', 'gzip, deflate, br')
|
||||
.set('Accept-Language', 'en-US,en;q=0.9')
|
||||
.set('User-Agent', userAgent)
|
||||
.set('Accept', '*/*')
|
||||
.set('Cache-Control', 'no-cache')
|
||||
.set('X-Requested-With', 'XMLHttpRequest')
|
||||
.set('Connection', 'keep-alive')
|
||||
.set('Referer', 'https://www.deezer.com/login')
|
||||
.set('DNT', '1')
|
||||
|
||||
const ct = req.get('Content-Type');
|
||||
if(ct === '' || ct === null || ct === undefined) {
|
||||
req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
const buildInternalUrl = (method: string, token: string = ''): URL => {
|
||||
const params = new URLSearchParams([
|
||||
['api_version', '1.0'],
|
||||
['input', '3']
|
||||
]);
|
||||
params.append('method', method);
|
||||
params.append('api_token', token);
|
||||
|
||||
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
|
||||
|
||||
return u;
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
},
|
||||
meta: {
|
||||
source: 'Deezer',
|
||||
musicService: 'Deezer',
|
||||
trackId: id,
|
||||
newFromSource,
|
||||
url: {
|
||||
@@ -91,7 +92,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
|
||||
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. Refer to the MS documentation for a new Deezer Source implementation.');
|
||||
|
||||
try {
|
||||
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
|
||||
@@ -213,7 +213,8 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions =
|
||||
artists
|
||||
},
|
||||
meta: {
|
||||
source: 'icecast'
|
||||
source: 'icecast',
|
||||
mediaPlayerName: 'Icecast'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export class JRiverSource extends MemoryPositionalSource {
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: Info, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
static formatPlayObj(obj: Info, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
|
||||
const {newFromSource = true} = options;
|
||||
|
||||
const {
|
||||
@@ -109,6 +109,8 @@ export class JRiverSource extends MemoryPositionalSource {
|
||||
},
|
||||
meta: {
|
||||
source: 'mopidy',
|
||||
mediaPlayerName: 'JRiver',
|
||||
mediaPlayerVersion: options.version,
|
||||
trackId: FileKey,
|
||||
newFromSource,
|
||||
trackProgressPosition: trackProgressPosition !== undefined ? Math.round(Number.parseInt(trackProgressPosition.toString()) / 1000) : undefined,
|
||||
@@ -136,7 +138,7 @@ export class JRiverSource extends MemoryPositionalSource {
|
||||
if(data !== undefined) {
|
||||
const {State} = data;
|
||||
if(State !== PLAYER_STATE.STOPPED) {
|
||||
play = [JRiverSource.formatPlayObj(data)];
|
||||
play = [JRiverSource.formatPlayObj(data, {version: this.client.version})];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import EventEmitter from "events";
|
||||
import { nanoid } from "nanoid";
|
||||
import pEvent from "p-event";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
RunTimeTicks,
|
||||
Type, // should be BaseItemKind.Audio
|
||||
UserData,
|
||||
ProviderIds = {}
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
const meta: BrainzMeta = {};
|
||||
|
||||
if(ProviderIds.MusicBrainzAlbum !== undefined) {
|
||||
meta.album = ProviderIds.MusicBrainzAlbum;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzArtist !== undefined) {
|
||||
meta.artist = [ProviderIds.MusicBrainzArtist];
|
||||
}
|
||||
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
|
||||
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
|
||||
}
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
artists: Artists,
|
||||
album: Album,
|
||||
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
source: 'Jellyfin',
|
||||
}
|
||||
}
|
||||
if(Object.keys(meta).length > 0) {
|
||||
play.data.meta = { brainz: meta };
|
||||
}
|
||||
return play;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import dayjs from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
|
||||
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT } from "../../core/Atomic.js";
|
||||
import {
|
||||
buildTrackString,
|
||||
combinePartsToString,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
|
||||
import {
|
||||
comparePlayTemporally,
|
||||
temporalAccuracyIsAtLeast,
|
||||
hasAcceptableTemporalAccuracy,
|
||||
temporalPlayComparisonSummary,
|
||||
} from "../utils/TimeUtils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
@@ -313,7 +313,7 @@ export default class JellyfinSource extends MemorySource {
|
||||
|
||||
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
|
||||
}
|
||||
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
|
||||
if(hasAcceptableTemporalAccuracy(temporalResult.match)) {
|
||||
existingTracked = currPlay;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -47,7 +47,7 @@ export class KodiSource extends MemoryPositionalSource {
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject {
|
||||
return KodiApiClient.formatPlayObj(obj, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import EventEmitter from "events";
|
||||
import request from "superagent";
|
||||
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
|
||||
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
|
||||
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
|
||||
import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
|
||||
|
||||
export default class KoitoSource extends MemorySource {
|
||||
|
||||
api: KoitoApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
|
||||
declare config: KoitoSourceConfig;
|
||||
|
||||
constructor(name: any, config: KoitoSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data: {
|
||||
interval = 15,
|
||||
maxInterval = 60,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
super('koito', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter);
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
this.api = new KoitoApiClient(name, config.data, {logger: this.logger});
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
this.supportsUpstreamRecentlyPlayed = true
|
||||
this.SCROBBLE_BACKLOG_COUNT = 100;
|
||||
this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return listenObjectResponseToPlay(obj, options); }
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
if(this.config.data.token === undefined) {
|
||||
throw new Error('Must provide a User Token in configuration');
|
||||
}
|
||||
try {
|
||||
return await this.api.testAuth();
|
||||
} catch (e) {
|
||||
if(isNodeNetworkException(e)) {
|
||||
this.logger.error('Could not communicate with Koito API');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
this.processRecentPlays([]);
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
}
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
try {
|
||||
return await this.api.getRecentlyPlayed(20);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
|
||||
|
||||
export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzSourceConfig;
|
||||
|
||||
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await request.get(this.api.url);
|
||||
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
|
||||
const isKoito = await this.api.checkKoito();
|
||||
this.api.isKoito = isKoito;
|
||||
this.isKoito = isKoito;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(isNodeNetworkException(e)) {
|
||||
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
if(this.isKoito) {
|
||||
return await this.api.getRecentlyPlayedKoito(limit);
|
||||
}
|
||||
const now = await this.api.getPlayingNow();
|
||||
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
|
||||
@@ -198,6 +198,7 @@ export class MPDSource extends MemoryPositionalSource {
|
||||
meta: {
|
||||
brainz,
|
||||
trackProgressPosition: options.trackProgressPosition,
|
||||
mediaPlayerName: 'mpd'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Interfaces as Notifications } from '@dbus-types/notifications'
|
||||
import dayjs from "dayjs";
|
||||
import { DBusInterface, sessionBus } from 'dbus-ts';
|
||||
import { DBusInterface, MessageBus, sessionBus, Connection, ConnectOpts } from 'dbus-ts';
|
||||
import EventEmitter from "events";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
|
||||
@@ -17,6 +17,9 @@ import { removeDuplicates } from "../utils.js";
|
||||
import { findCauseByMessage } from "../utils/ErrorUtils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { Readable, Writable } from 'stream';
|
||||
import net from 'net';
|
||||
import pEvent from 'p-event';
|
||||
|
||||
|
||||
export class MPRISSource extends MemorySource {
|
||||
@@ -78,7 +81,7 @@ export class MPRISSource extends MemorySource {
|
||||
playDate: dayjs()
|
||||
},
|
||||
meta: {
|
||||
source: 'dbus',
|
||||
source: 'mpris',
|
||||
trackId: trackid,
|
||||
newFromSource,
|
||||
url: {
|
||||
@@ -101,9 +104,26 @@ export class MPRISSource extends MemorySource {
|
||||
}
|
||||
|
||||
protected getDBus = async () => {
|
||||
const busNew = await sessionBus<Notifications>();
|
||||
const obj = await busNew.getInterface('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus');
|
||||
return obj;
|
||||
const conn = new Connection(createStream({}));
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
pEvent(conn, 'error'),
|
||||
conn.init()
|
||||
])
|
||||
if(res instanceof Error) {
|
||||
throw res;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('Failed to connected to session bus', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
const promise = sessionBus<Notifications>();
|
||||
const busNew = await sessionBus<Notifications>();
|
||||
return await busNew.getInterface('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus');
|
||||
} catch (e) {
|
||||
throw new Error('Failed to get DBus interface', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
protected listNew = async () => {
|
||||
@@ -239,3 +259,56 @@ const convertDBusExceptionToError = (e: any): Error => {
|
||||
return err;
|
||||
}
|
||||
|
||||
// unfortunately had to recreate this function from dbus-ts/Connection
|
||||
// in order to be able to create the Connection class without immediate init
|
||||
// so we can catch errors
|
||||
const createStream = (opts: ConnectOpts): Readable&Writable => {
|
||||
if (typeof opts !== 'object') {
|
||||
opts = {};
|
||||
}
|
||||
if ("stream" in opts) {
|
||||
return opts.stream;
|
||||
}
|
||||
if ("socket" in opts) {
|
||||
return net.createConnection(opts.socket);
|
||||
}
|
||||
if ("port" in opts) {
|
||||
return net.createConnection(opts.port, opts.host);
|
||||
}
|
||||
|
||||
const busAddress = opts.busAddress || process.env.DBUS_SESSION_BUS_ADDRESS;
|
||||
if (!busAddress) throw new Error('unknown bus address');
|
||||
|
||||
const addresses = busAddress.split(';');
|
||||
for (let i = 0; i < addresses.length; ++i) {
|
||||
const address = addresses[i];
|
||||
const familyParams = address.split(':');
|
||||
const family = familyParams[0];
|
||||
const params: any = {};
|
||||
familyParams[1].split(',').map(function(p) {
|
||||
const keyVal = p.split('=');
|
||||
params[keyVal[0]] = keyVal[1];
|
||||
});
|
||||
|
||||
try {
|
||||
switch (family.toLowerCase()) {
|
||||
case 'tcp':
|
||||
return net.createConnection(params.port, (params.host || 'localhost'));
|
||||
case 'unix':
|
||||
if (params.socket) return net.createConnection(params.socket);
|
||||
if (params.path) return net.createConnection(params.path);
|
||||
throw new Error(
|
||||
"not enough parameters for 'unix' connection - you need to specify 'socket' or 'path' parameter"
|
||||
);
|
||||
default:
|
||||
throw new Error('unknown address type:' + family);
|
||||
}
|
||||
} catch (e) {
|
||||
if (i < addresses.length - 1) {
|
||||
console.warn(e.message);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,12 @@ import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js";
|
||||
import { buildTrackString } from "../../core/StringUtils.js";
|
||||
import {
|
||||
asPlayerStateData,
|
||||
asPlayerStateDataMaybePlay,
|
||||
CALCULATED_PLAYER_STATUSES,
|
||||
InternalConfig,
|
||||
PlayerStateData,
|
||||
PlayerStateDataMaybePlay,
|
||||
InternalConfig, PlayerStateDataMaybePlay,
|
||||
PlayPlatformId,
|
||||
ProgressAwarePlayObject,
|
||||
SourceType,
|
||||
SourceType
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { PollingOptions } from "../common/infrastructure/config/common.js";
|
||||
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
|
||||
@@ -92,7 +89,12 @@ export default class MemorySource extends AbstractSource {
|
||||
const stateHash = objectHash.sha1(state);
|
||||
if(stateHash !== this.playerState.get(key)) {
|
||||
this.playerState.set(key, stateHash);
|
||||
this.emitEvent('playerUpdate', state);
|
||||
this.emitEvent('playerUpdate', {
|
||||
...state,
|
||||
options: {
|
||||
scrobbleTo: this.clients
|
||||
}
|
||||
});
|
||||
}
|
||||
if(this.config.options?.logPlayerState === true || isDebugMode()) {
|
||||
player.logSummary();
|
||||
@@ -264,7 +266,12 @@ export default class MemorySource extends AbstractSource {
|
||||
}
|
||||
const apiState = player.getApiState();
|
||||
this.playerState.set(key, objectHash.sha1(apiState))
|
||||
this.emitEvent('playerUpdate', apiState);
|
||||
this.emitEvent('playerUpdate', {
|
||||
...apiState,
|
||||
options: {
|
||||
scrobbleTo: this.clients
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const playFromCleanup = this.cleanupPlayer(key);
|
||||
if(playFromCleanup !== undefined) {
|
||||
|
||||
@@ -173,7 +173,8 @@ export class MopidySource extends MemoryPositionalSource {
|
||||
trackId: uri,
|
||||
newFromSource,
|
||||
trackProgressPosition: trackProgressPosition !== undefined ? Math.round(trackProgressPosition / 1000) : undefined,
|
||||
deviceId: name,
|
||||
mediaPlayerName: 'Mopidy'
|
||||
//deviceId: name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export class MusicCastSource extends MemoryPositionalSource {
|
||||
declare config: MusicCastSourceConfig;
|
||||
|
||||
urlData!: URLData;
|
||||
version?: string;
|
||||
|
||||
|
||||
constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
@@ -57,6 +58,9 @@ export class MusicCastSource extends MemoryPositionalSource {
|
||||
const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString())
|
||||
if (resp.body !== undefined && typeof resp.body === 'object') {
|
||||
const deviceInfo = resp.body as DeviceInfoResponse;
|
||||
if(deviceInfo.api_version !== undefined) {
|
||||
this.version = deviceInfo.api_version.toString();
|
||||
}
|
||||
this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`);
|
||||
} else {
|
||||
this.logger.warn('Could not get device info! Ignoring but probably not good...');
|
||||
@@ -130,7 +134,7 @@ export class MusicCastSource extends MemoryPositionalSource {
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject => {
|
||||
|
||||
const {
|
||||
play_time,
|
||||
@@ -152,7 +156,9 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: play_time,
|
||||
deviceId: 'input' in obj ? obj.input : 'cd'
|
||||
deviceId: 'input' in obj ? obj.input : 'cd',
|
||||
mediaPlayerName: 'MusicCast',
|
||||
mediaPlayerVersion: options.version
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
|
||||
client!: WS;
|
||||
deviceId: string
|
||||
version?: string
|
||||
|
||||
constructor(name: any, config: MusikcubeSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
@@ -109,6 +110,7 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
this.client.addEventListener('message', (e) => {
|
||||
const data = getMessageData<any>(e);
|
||||
if(isAuthenticateResponse(data)) {
|
||||
this.version = data.options.environment.app_version;
|
||||
wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`);
|
||||
}
|
||||
});
|
||||
@@ -197,7 +199,9 @@ export class MusikcubeSource extends MemoryPositionalSource {
|
||||
meta: {
|
||||
trackProgressPosition: playing_current_time,
|
||||
deviceId: this.deviceId,
|
||||
trackId: external_id
|
||||
trackId: external_id,
|
||||
mediaPlayerName: 'Musikcube',
|
||||
mediaPlayerVersion: this.version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { PollingOptions } from "../../common/infrastructure/config/common.js";
|
||||
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js";
|
||||
import { ListenProgress } from "./ListenProgress.js";
|
||||
import { ListenRange, ListenRangePositional } from "./ListenRange.js";
|
||||
import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
|
||||
import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
|
||||
|
||||
export interface PlayerStateIntervals {
|
||||
staleInterval?: number
|
||||
@@ -75,11 +75,15 @@ export abstract class AbstractPlayerState {
|
||||
currentPlay?: PlayObject
|
||||
playFirstSeenAt?: Dayjs
|
||||
playLastUpdatedAt?: Dayjs
|
||||
isRepeatPlay?: boolean = false;
|
||||
currentListenRange?: ListenRange
|
||||
listenRanges: ListenRange[] = [];
|
||||
createdAt: Dayjs = dayjs();
|
||||
stateLastUpdatedAt: Dayjs = dayjs();
|
||||
|
||||
lastPlay?: PlayObject
|
||||
lastPlayUpdatedAt?: Dayjs
|
||||
|
||||
protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) {
|
||||
this.platformId = platformId;
|
||||
this.logger = childLogger(logger, `Player ${this.platformIdStr}`);
|
||||
@@ -181,6 +185,9 @@ export abstract class AbstractPlayerState {
|
||||
this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`)
|
||||
this.currentListenSessionEnd();
|
||||
const played = this.getPlayedObject(true);
|
||||
this.isRepeatPlay = false;
|
||||
this.lastPlay = played;
|
||||
this.lastPlayUpdatedAt = dayjs();
|
||||
this.setCurrentPlay(state, {reportedTS});
|
||||
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
|
||||
@@ -194,6 +201,8 @@ export abstract class AbstractPlayerState {
|
||||
this.currentListenSessionEnd();
|
||||
const played = this.getPlayedObject(true);
|
||||
play.data.playDate = dayjs();
|
||||
this.isRepeatPlay = true;
|
||||
this.logger.debug('New Play is a repeat');
|
||||
this.setCurrentPlay(state, {reportedTS});
|
||||
return [this.getPlayedObject(), played];
|
||||
} else {
|
||||
@@ -212,6 +221,17 @@ export abstract class AbstractPlayerState {
|
||||
this.currentListenSessionContinue(state.position, reportedTS);
|
||||
}
|
||||
} else {
|
||||
this.isRepeatPlay = false;
|
||||
// compensate for Players that report as STOPPED between Plays
|
||||
// -- should we check for closeToPlayStart() as well?
|
||||
if(this.lastPlay !== undefined) {
|
||||
const lastPlayDiff = Math.abs(this.lastPlayUpdatedAt.diff(dayjs(), 's'));
|
||||
const shortDiff = lastPlayDiff < 20;
|
||||
const lastPlayMatch = playObjDataMatch(play, this.lastPlay);
|
||||
this.isRepeatPlay = shortDiff && lastPlayMatch;
|
||||
this.logger.debug(`Last Play ${shortDiff ? 'was' : 'was not'} within 20s of new Player session and ${lastPlayMatch ? 'does' : 'does not'} match new Play -- ${this.isRepeatPlay ? 'is' : 'is not'} a repeat Play`);
|
||||
}
|
||||
|
||||
this.setCurrentPlay(state);
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
@@ -226,11 +246,14 @@ export abstract class AbstractPlayerState {
|
||||
protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); }
|
||||
|
||||
protected clearPlayer() {
|
||||
this.lastPlay = this.currentPlay;
|
||||
this.lastPlayUpdatedAt = dayjs();
|
||||
this.currentPlay = undefined;
|
||||
this.playLastUpdatedAt = undefined;
|
||||
this.playFirstSeenAt = undefined;
|
||||
this.listenRanges = [];
|
||||
this.currentListenRange = undefined;
|
||||
this.isRepeatPlay = false;
|
||||
}
|
||||
|
||||
protected stopPlayer() {
|
||||
@@ -255,7 +278,8 @@ export abstract class AbstractPlayerState {
|
||||
playDate: this.playFirstSeenAt,
|
||||
listenedFor: this.getListenDuration(),
|
||||
listenRanges: ranges,
|
||||
playDateCompleted: completed ? dayjs() : undefined
|
||||
playDateCompleted: completed ? dayjs() : undefined,
|
||||
repeat: this.isRepeatPlay
|
||||
},
|
||||
meta: this.currentPlay.meta
|
||||
}
|
||||
@@ -279,6 +303,13 @@ export abstract class AbstractPlayerState {
|
||||
|
||||
protected abstract currentListenSessionEnd();
|
||||
|
||||
/** Check if new Player Position was seeked to a Position that indicates user is repeating the track
|
||||
*
|
||||
* True if:
|
||||
* * New position is close to start of Play and...
|
||||
* * Listened duraton is more than 2 minutes/50% of Play OR...
|
||||
* * Previous Position was close to end of Play
|
||||
*/
|
||||
protected isSessionRepeat(position?: number, reportedTS?: Dayjs) {
|
||||
if(this.currentListenRange === undefined) {
|
||||
return false;
|
||||
@@ -287,45 +318,32 @@ export abstract class AbstractPlayerState {
|
||||
if (isSeeked === false || seekPos > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hints: string[] = [];
|
||||
|
||||
let repeatHint = `New Position (${position})`;
|
||||
const trackDur = this.currentPlay.data.duration;
|
||||
// user is within 10 seconds or 10% of start of track
|
||||
const closeStartNum = position <= 12;
|
||||
if(closeStartNum) {
|
||||
repeatHint = `${repeatHint} is within 12 seconds of track start`;
|
||||
}
|
||||
const closeStartPer = (trackDur !== undefined && ((position / trackDur) <= 0.15));
|
||||
if(!closeStartNum && closeStartPer) {
|
||||
repeatHint = `${repeatHint} is within 15% of track start (${formatNumber((position/trackDur)*100)}%).`;
|
||||
}
|
||||
if (closeStartNum || closeStartPer) {
|
||||
// user has played at least 2 minutes or 50% of track
|
||||
|
||||
// new position is close to start of Play
|
||||
const [closeStart, closeStartHint] = closeToPlayStart(this.currentPlay, position, {hintPrefix: false});
|
||||
hints.push(closeStartHint);
|
||||
|
||||
if (closeStart) {
|
||||
const playerDur = this.getListenDuration();
|
||||
const closeDurNum = playerDur >= 120;
|
||||
if(closeDurNum) {
|
||||
repeatHint = `${repeatHint} and listened to more than 120s (${playerDur}s)`
|
||||
}
|
||||
const closeDurPer = (trackDur !== undefined && (playerDur / trackDur) >= 0.5);
|
||||
if(!closeDurNum && closeDurPer) {
|
||||
repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).`
|
||||
}
|
||||
if (closeDurNum || closeDurPer) {
|
||||
this.logger.verbose(repeatHint);
|
||||
const [repeatDurationOk, repeatDurationHint] = repeatDurationPlayed(this.currentPlay, playerDur, {hintPrefix: false});
|
||||
|
||||
// user has played at least 2 minutes or 50% of track
|
||||
if (repeatDurationOk) {
|
||||
this.logger.verbose(`${repeatHint} ${[closeStartHint, repeatDurationHint].join(' and ')}`);
|
||||
return true;
|
||||
}
|
||||
if (trackDur !== undefined && this.currentListenRange.getPosition() !== undefined) {
|
||||
const lastPos = this.currentListenRange.getPosition();
|
||||
// or last position is within 10 seconds (or 10%) of end of track
|
||||
const nearEndNum = (trackDur - lastPos < 12);
|
||||
if(nearEndNum) {
|
||||
repeatHint = `${repeatHint} and previous position was within 12 seconds of track end.`;
|
||||
}
|
||||
const nearEndPos = ((lastPos / trackDur) > 0.85);
|
||||
if(!nearEndNum && nearEndPos) {
|
||||
repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`;
|
||||
}
|
||||
if(nearEndNum || nearEndPos) {
|
||||
this.logger.verbose(repeatHint);
|
||||
|
||||
const lastPos = this.currentListenRange.getPosition();
|
||||
if (trackDur !== undefined && lastPos !== undefined) {
|
||||
const [nearEnd, nearEndHint] = closeToPlayEnd(this.currentPlay, lastPos, {hintPrefix: false});
|
||||
// last position is close to end of Play
|
||||
if(nearEnd) {
|
||||
this.logger.verbose(`${repeatHint} ${[closeStartHint, nearEndHint].join(' and ')}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -368,7 +386,7 @@ export abstract class AbstractPlayerState {
|
||||
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Player Updated At: ${todayAwareFormat(this.stateLastUpdatedAt)} | Play Updated At: ${this.playLastUpdatedAt === undefined ? 'N/A' : todayAwareFormat(this.playLastUpdatedAt)}`);
|
||||
let progress = '';
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
|
||||
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
|
||||
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s Reported | `;
|
||||
}
|
||||
let listenedPercent = '';
|
||||
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
|
||||
@@ -378,6 +396,10 @@ export abstract class AbstractPlayerState {
|
||||
})
|
||||
}
|
||||
parts.push(`${progress}Listened For: ${formatNumber(this.getListenDuration(), {toFixed: 0})}s ${listenedPercent}`);
|
||||
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentListenRange.rtTruth) {
|
||||
const rtProgress = `${progressBar((this.currentListenRange.rtPlayer.getPosition() / 1000) / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.rtPlayer.getPosition() / 1000, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s`;
|
||||
parts.push(`${rtProgress} Realtime | Drifted ${formatNumber(Math.abs(this.currentListenRange.getDrift() / 1000), {toFixed: 1})}s (Max ${formatNumber(this.currentListenRange.getAllowedDrift() / 1000, {toFixed: 1})})`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,10 @@ export class ListenRangePositional extends ListenRange {
|
||||
return Math.abs(this.getDrift((position ?? this.end.position))) > this.allowedDrift;
|
||||
}
|
||||
|
||||
getAllowedDrift() {
|
||||
return this.allowedDrift;
|
||||
}
|
||||
|
||||
setRangeEnd(data: ListenProgressPositional | PlayProgressPositional/* , force?: boolean */) {
|
||||
const endProgress = data instanceof ListenProgressPositional ? data : new ListenProgressPositional(data)
|
||||
// if(this.rtTruth) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PositionalPlayerState } from "./PositionalPlayerState.js";
|
||||
|
||||
export class PlexPlayerState extends PositionalPlayerState {
|
||||
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
|
||||
super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})});
|
||||
super(logger, platformId, {allowedDrift: 35000, rtTruth: true, ...(opts || {})});
|
||||
this.gracefulEndBuffer = this.allowedDrift / 1000;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ import { genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, isDebugMo
|
||||
import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js";
|
||||
import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js";
|
||||
import { PlexAPI } from "@lukehagar/plexjs";
|
||||
import {
|
||||
SDKValidationError,
|
||||
} from "@lukehagar/plexjs/sdk/models/errors";
|
||||
import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
|
||||
import { isPortReachable, joinedUrl } from '../utils/NetworkUtils.js';
|
||||
import normalizeUrl from 'normalize-url';
|
||||
@@ -29,6 +26,7 @@ import { AbstractPlayerState, PlayerStateOptions } from './PlayerState/AbstractP
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { MemoryPositionalSource } from './MemoryPositionalSource.js';
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { SDKValidationError } from '@lukehagar/plexjs/sdk/models/errors/sdkvalidationerror.js';
|
||||
|
||||
const shortDeviceId = truncateStringToLength(10, '');
|
||||
|
||||
@@ -118,6 +116,10 @@ export default class PlexApiSource extends MemoryPositionalSource {
|
||||
this.librariesAllow = parseArrayFromMaybeString(librariesAllow, {lower: true});
|
||||
this.librariesBlock = parseArrayFromMaybeString(librariesBlock, {lower: true});
|
||||
|
||||
if(this.librariesBlock.length > 0 && this.librariesAllow.length > 0) {
|
||||
this.logger.warn(`When both 'librariesAllow' and 'librariesBlock' are specified only 'librariesAllow' is used.`);
|
||||
}
|
||||
|
||||
const normal = normalizeUrl(this.config.data.url, {removeSingleSlash: true});
|
||||
this.address = new URL(normal);
|
||||
this.logger.debug(`Config URL: ${this.config.data.url} | Normalized: ${this.address.toString()}`);
|
||||
@@ -183,6 +185,17 @@ export default class PlexApiSource extends MemoryPositionalSource {
|
||||
|
||||
this.libraries = libraries.object.mediaContainer.directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid}));
|
||||
} catch (e) {
|
||||
if(e instanceof SDKValidationError) {
|
||||
if((e.rawValue as any).object?.MediaContainer?.Directory !== undefined) {
|
||||
// ensure directory has required values
|
||||
const ok = (e.rawValue as any).object?.MediaContainer?.Directory.every(x => x.title !== undefined && x.type !== undefined && x.uuid !== undefined);
|
||||
if(ok) {
|
||||
this.libraries = (e.rawValue as any).object.MediaContainer.Directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.logger.debug({ rawValue: e.rawValue }, 'Plex Response');
|
||||
}
|
||||
throw new Error('Unable to get server libraries', {cause: e});
|
||||
}
|
||||
|
||||
@@ -232,19 +245,22 @@ export default class PlexApiSource extends MemoryPositionalSource {
|
||||
|
||||
if(state.play !== undefined) {
|
||||
const allowedLibraries = this.getAllowedLibraries();
|
||||
if(allowedLibraries.length > 0 && !allowedLibraries.some(x => state.play.meta.library.toLocaleLowerCase().includes(x.name.toLocaleLowerCase()))) {
|
||||
if(allowedLibraries.length > 0 && !allowedLibraries.some(x => state.play.meta.library.toLocaleLowerCase() === x.name.toLocaleLowerCase())) {
|
||||
return `media not included in librariesAllow`;
|
||||
}
|
||||
|
||||
if(allowedLibraries.length === 0) {
|
||||
const blockedLibraries = this.getBlockedLibraries();
|
||||
if(blockedLibraries.length > 0) {
|
||||
const blockedLibrary = blockedLibraries.find(x => state.play.meta.library.toLocaleLowerCase().includes(x.name.toLocaleLowerCase()));
|
||||
const blockedLibrary = blockedLibraries.find(x => state.play.meta.library.toLocaleLowerCase() === x.name.toLocaleLowerCase());
|
||||
if(blockedLibrary !== undefined) {
|
||||
return `media included in librariesBlock '${blockedLibrary.name}'`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// this is inside this block because we SHOULD allow non-music libraries if
|
||||
// user specified name in librariesAllow
|
||||
// -- so only check for this if nothing is specified
|
||||
if(!this.getValidLibraries().some(x => state.play.meta.library === x.name)) {
|
||||
return `media not included in a valid library`;
|
||||
}
|
||||
@@ -339,7 +355,7 @@ export default class PlexApiSource extends MemoryPositionalSource {
|
||||
library,
|
||||
deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]),
|
||||
sessionId: sessionKey,
|
||||
trackProgressPosition: viewOffset / 1000
|
||||
trackProgressPosition: viewOffset / 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,3 +461,22 @@ ${JSON.stringify(obj)}`);
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PlexPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
async function streamToString(stream: any) {
|
||||
const reader = stream.getReader();
|
||||
const textDecoder = new TextDecoder();
|
||||
let result = '';
|
||||
|
||||
async function read() {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result += textDecoder.decode(value, { stream: true });
|
||||
return read();
|
||||
}
|
||||
|
||||
return read();
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } fro
|
||||
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
|
||||
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
|
||||
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
|
||||
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
|
||||
import { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
|
||||
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
|
||||
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
|
||||
import {
|
||||
@@ -64,6 +64,9 @@ import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
|
||||
import PlexApiSource from './PlexApiSource.js';
|
||||
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
|
||||
import { IcecastSource } from './IcecastSource.js';
|
||||
import DeezerInternalSource from './DeezerInternalSource.js';
|
||||
import KoitoSource from './KoitoSource.js';
|
||||
import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js';
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
@@ -139,7 +142,7 @@ export default class ScrobbleSources {
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig");
|
||||
break;
|
||||
case 'deezer':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerCompatConfig");
|
||||
break;
|
||||
case 'endpointlz':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
|
||||
@@ -198,6 +201,9 @@ export default class ScrobbleSources {
|
||||
case 'azuracast':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig");
|
||||
break;
|
||||
case 'koito':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoSourceConfig");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.schemaDefinitions[type];
|
||||
@@ -264,13 +270,19 @@ export default class ScrobbleSources {
|
||||
sourceDefaults = this.buildSourceDefaults(sd);
|
||||
for (const [index, c] of mainConfigSourcesConfigs.entries()) {
|
||||
const {name = 'unnamed'} = c;
|
||||
if(!isSourceType(c.type.toLocaleLowerCase())) {
|
||||
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source type of '${c.type}'. Must be one of ${sourceTypes.join(' | ')}`;
|
||||
if(c.type === undefined) {
|
||||
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[sourceType]" must be one of ${sourceTypes.join(' | ')}`;
|
||||
this.emitter.emit('error', new Error(invalidMsgType));
|
||||
this.logger.error(invalidMsgType);
|
||||
continue;
|
||||
}
|
||||
if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source'))
|
||||
if(!isSourceType(c.type.toLocaleLowerCase())) {
|
||||
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source "type" of "${c.type}". Must be one of ${sourceTypes.join(' | ')}`;
|
||||
this.emitter.emit('error', new Error(invalidMsgType));
|
||||
this.logger.error(invalidMsgType);
|
||||
continue;
|
||||
}
|
||||
if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
|
||||
{
|
||||
this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`);
|
||||
continue;
|
||||
@@ -411,6 +423,7 @@ export default class ScrobbleSources {
|
||||
clientSecret: process.env.DEEZER_CLIENT_SECRET,
|
||||
redirectUri: process.env.DEEZER_REDIRECT_URI,
|
||||
accessToken: process.env.DEEZER_ACCESS_TOKEN,
|
||||
arl: process.env.DEEZER_ARL,
|
||||
};
|
||||
if (!Object.values(d).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
@@ -684,8 +697,8 @@ export default class ScrobbleSources {
|
||||
continue;
|
||||
}
|
||||
for (const [i,rawConf] of sourceConfigs.entries()) {
|
||||
if(['lastfm','listenbrainz'].includes(sourceType) &&
|
||||
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source'))
|
||||
if(['lastfm','listenbrainz','koito'].includes(sourceType) &&
|
||||
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
|
||||
{
|
||||
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
|
||||
continue;
|
||||
@@ -745,7 +758,7 @@ export default class ScrobbleSources {
|
||||
try {
|
||||
await this.addSource(c, sourceDefaults);
|
||||
} catch(e) {
|
||||
const addError = new Error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`, {cause: e});
|
||||
const addError = new Error(`Source ${c.name} of type ${c.type} from source ${c.source} was not added because of unrecoverable errors`, {cause: e});
|
||||
this.emitter.emit('error', addError);
|
||||
this.logger.error(addError);
|
||||
}
|
||||
@@ -760,17 +773,17 @@ export default class ScrobbleSources {
|
||||
// throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
// }
|
||||
|
||||
const {type, name, data: d = {}, enable = true, options: clientOptions = {}} = clientConfig;
|
||||
const {type, name, data: d = {}, enable = true, source, options: clientOptions = {}} = clientConfig;
|
||||
|
||||
if(enable === false) {
|
||||
this.logger.warn(`${type} (${name}) source was disabled by config`);
|
||||
this.logger.warn({labels: [`${type} - ${name}`]},`Source from ${source} was disabled by config`);
|
||||
return;
|
||||
}
|
||||
|
||||
// add defaults
|
||||
const compositeConfig: SourceConfig = {...clientConfig, data: d, options: {...defaults, ...clientOptions}};
|
||||
|
||||
this.logger.debug(`(${name}) Constructing ${type} source`);
|
||||
this.logger.debug({labels: [`${type} - ${name}`]},`Constructing Source from ${source}...`);
|
||||
let newSource: AbstractSource;
|
||||
switch (type) {
|
||||
case 'spotify':
|
||||
@@ -802,7 +815,12 @@ export default class ScrobbleSources {
|
||||
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'deezer':
|
||||
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
|
||||
const deezerConfig = compositeConfig as DeezerCompatConfig;
|
||||
if(deezerConfig.data.arl !== undefined) {
|
||||
newSource = await new DeezerInternalSource(name, compositeConfig as DeezerInternalSourceConfig, this.internalConfig, this.emitter);
|
||||
} else {
|
||||
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
|
||||
}
|
||||
break;
|
||||
case 'ytmusic':
|
||||
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter);
|
||||
@@ -852,15 +870,19 @@ export default class ScrobbleSources {
|
||||
case 'azuracast':
|
||||
newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
case 'koito':
|
||||
newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if(newSource === undefined) {
|
||||
// really shouldn't get here!
|
||||
this.logger.error(new Error(`Source of type ${type} was not recognized??`));
|
||||
this.logger.error(new Error(`Source of type ${type} from ${source} was not recognized??`));
|
||||
return;
|
||||
}
|
||||
this.sources.push(newSource);
|
||||
newSource.logger.info(`Source Added from ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import request from 'superagent';
|
||||
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js";
|
||||
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js";
|
||||
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
@@ -155,6 +155,7 @@ export default class SpotifySource extends MemoryPositionalSource {
|
||||
}
|
||||
|
||||
const {
|
||||
id: albumId,
|
||||
name: albumName,
|
||||
artists: albumArtists = [],
|
||||
images = []
|
||||
@@ -183,11 +184,20 @@ export default class SpotifySource extends MemoryPositionalSource {
|
||||
track: name,
|
||||
duration: duration_ms / 1000,
|
||||
playDate: played_at,
|
||||
playDateCompleted
|
||||
playDateCompleted,
|
||||
meta: {
|
||||
spotify: {
|
||||
track: id,
|
||||
artist: artists.map(x => x.id),
|
||||
albumArtist: actualAlbumArtists.map(x => x.id),
|
||||
album: albumId
|
||||
}
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`,
|
||||
source: 'Spotify',
|
||||
musicService: 'Spotify',
|
||||
trackId: id,
|
||||
trackProgressPosition: playbackPosition,
|
||||
scrobbleTsSOC,
|
||||
|
||||
@@ -16,6 +16,16 @@ import MemorySource from "./MemorySource.js";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
interface SourceIdentifierData {
|
||||
/** Subsonic Version */
|
||||
version?: string,
|
||||
/** Media Player name */
|
||||
type?: string,
|
||||
/** Media Player version */
|
||||
serverVersion?: string,
|
||||
openSubsonic?: boolean
|
||||
}
|
||||
|
||||
export class SubsonicSource extends MemorySource {
|
||||
|
||||
requiresAuth = true;
|
||||
@@ -24,6 +34,10 @@ export class SubsonicSource extends MemorySource {
|
||||
|
||||
declare config: SubSonicSourceConfig;
|
||||
|
||||
usersAllow: string[] = [];
|
||||
|
||||
sourceData: SourceIdentifierData = {};
|
||||
|
||||
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data: {
|
||||
@@ -36,8 +50,16 @@ export class SubsonicSource extends MemorySource {
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {newFromSource = false} = options;
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject {
|
||||
const {
|
||||
newFromSource = false,
|
||||
sourceData: {
|
||||
version,
|
||||
type,
|
||||
serverVersion,
|
||||
openSubsonic
|
||||
} = {},
|
||||
} = options;
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
@@ -48,6 +70,7 @@ export class SubsonicSource extends MemorySource {
|
||||
playerId,
|
||||
username,
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: [artist],
|
||||
@@ -63,7 +86,9 @@ export class SubsonicSource extends MemorySource {
|
||||
trackId: id,
|
||||
newFromSource,
|
||||
user: username,
|
||||
deviceId: playerId
|
||||
deviceId: playerId,
|
||||
mediaPlayerName: type ?? `${openSubsonic ? 'Open ' : ''}Subsonic`,
|
||||
mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +218,22 @@ export class SubsonicSource extends MemorySource {
|
||||
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
|
||||
}
|
||||
|
||||
let usersAllowVal = this.config.data.usersAllow;
|
||||
if(usersAllowVal !== undefined && usersAllowVal !== null) {
|
||||
if(!Array.isArray(usersAllowVal)) {
|
||||
usersAllowVal = [usersAllowVal];
|
||||
}
|
||||
if(usersAllowVal.filter(x => x.toString().trim() !== '').length > 0) {
|
||||
this.usersAllow = usersAllowVal.map(x => x.toString().trim());
|
||||
}
|
||||
}
|
||||
|
||||
if(this.usersAllow.length === 0) {
|
||||
this.logger.verbose('Will monitor plays by all users');
|
||||
} else {
|
||||
this.logger.verbose(`Will only monitor plays for the following users: ${this.usersAllow.join(', ')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -200,6 +241,7 @@ export class SubsonicSource extends MemorySource {
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${url}/rest/ping`));
|
||||
this.sourceData = resp as SourceIdentifierData;
|
||||
this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -208,6 +250,7 @@ export class SubsonicSource extends MemorySource {
|
||||
if(subResponseError !== undefined) {
|
||||
const resp = getSubsonicResponse(subResponseError.response)
|
||||
this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`);
|
||||
this.sourceData = resp as SourceIdentifierData;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -244,8 +287,9 @@ export class SubsonicSource extends MemorySource {
|
||||
} = {}
|
||||
} = resp;
|
||||
// sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays
|
||||
const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj));
|
||||
return this.processRecentPlays(deduped);
|
||||
const deduped = removeDuplicates(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData})));
|
||||
const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase()));
|
||||
return this.processRecentPlays(userFiltered);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class VLCSource extends MemoryPositionalSource {
|
||||
port?: number
|
||||
client!: VLC.Client;
|
||||
deviceId: string
|
||||
vlcVersion?: string;
|
||||
filenamePatterns: RegExp[] = [];
|
||||
|
||||
constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
@@ -110,6 +111,7 @@ export class VLCSource extends MemoryPositionalSource {
|
||||
try {
|
||||
const status = await this.client.status();
|
||||
this.logger.info(`Connected successfully, found VLC ${status.version}`);
|
||||
this.vlcVersion = status.version;
|
||||
return true;
|
||||
} catch (e) {
|
||||
let friendlyError: string | undefined;
|
||||
@@ -222,6 +224,9 @@ export class VLCSource extends MemoryPositionalSource {
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: time,
|
||||
source: 'vlc',
|
||||
mediaPlayerName: 'VLC',
|
||||
mediaPlayerVersion: this.vlcVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ export class WebScrobblerSource extends MemorySource {
|
||||
} = {}): PlayObject {
|
||||
const {
|
||||
connectorLabel,
|
||||
connector: {
|
||||
label: connectorL
|
||||
} = {},
|
||||
controllerTabId = 'UNK',
|
||||
processed,
|
||||
parsed: {
|
||||
@@ -137,6 +140,7 @@ export class WebScrobblerSource extends MemorySource {
|
||||
origin: originUrl
|
||||
},
|
||||
deviceId: `${connectorLabel}-${controllerTabId}`,
|
||||
musicService: connectorL,
|
||||
scrobbleAllowed: isScrobblingAllowed,
|
||||
nowPlaying: options.nowPlaying ?? false
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ Redirect URI : ${this.redirectUri}`);
|
||||
},
|
||||
meta: {
|
||||
source: 'YTMusic',
|
||||
musicService: 'Youtube Music',
|
||||
trackId: id,
|
||||
newFromSource,
|
||||
comment: shelf
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('Sample Configs', function () {
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
|
||||
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
|
||||
expect(clients.clients).length(1);
|
||||
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
|
||||
expect(clients.clients).length(expectedCount);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -366,6 +366,7 @@ describe('Player listen ranges', function () {
|
||||
const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlay);
|
||||
assert.isTrue(curr.data.repeat)
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
|
||||
@@ -389,6 +390,7 @@ describe('Player listen ranges', function () {
|
||||
player.currentListenRange.rtPlayer.setPosition(381000);
|
||||
const [curr, prevPlay] = player.update(testState({play: positioned, position: 20}), dayjs().add(30, 'seconds'));
|
||||
|
||||
assert.isTrue(curr.data.repeat);
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
@@ -411,9 +413,41 @@ describe('Player listen ranges', function () {
|
||||
player.currentListenRange.rtPlayer.setPosition(50000);
|
||||
const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
|
||||
|
||||
assert.isTrue(curr.data.repeat)
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
});
|
||||
|
||||
it('Resets repeat status when updated with non-matching play', function () {
|
||||
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
|
||||
|
||||
const positioned = clone(newPlay);
|
||||
positioned.data.duration = 70;
|
||||
|
||||
player.update(testState({play: positioned, position: 0}));
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(40000);
|
||||
player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds'));
|
||||
|
||||
const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds'));
|
||||
assert.isTrue(isRepeat);
|
||||
|
||||
positioned.meta.trackProgressPosition = 2;
|
||||
player.currentListenRange.rtPlayer.setPosition(50000);
|
||||
const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
|
||||
|
||||
assert.isTrue(curr.data.repeat)
|
||||
assert.isDefined(prevPlay);
|
||||
assert.equal(player.getListenDuration(), 0);
|
||||
|
||||
player.currentListenRange.rtPlayer.setPosition(55000);
|
||||
const [currNew, prevPlayRepeat] = player.update(testState({play: generatePlay(), position: 1}), dayjs().add(55, 'seconds'));
|
||||
|
||||
assert.isDefined(prevPlayRepeat)
|
||||
assert.isTrue(prevPlayRepeat.data.repeat);
|
||||
assert.isDefined(currNew);
|
||||
assert.isFalse(currNew.data.repeat);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,18 +134,27 @@ describe("Plex API Source", function() {
|
||||
|
||||
it('Should allow activity based on libraries allow', async function () {
|
||||
const s = await createSource({...defaultCreds, librariesAllow: ['music']});
|
||||
s.libraries.push({name: 'SomeOtherLibrary', collectionType: 'artist', uuid: '43543'});
|
||||
s.libraries.push({name: 'Study Music', collectionType: 'artist', uuid: '435437'});
|
||||
s.libraries.push({name: 'Music', collectionType: 'artist', uuid: '4354378'});
|
||||
|
||||
expect(s.isActivityValid(validPlayerState, validSession)).to.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'SomeOtherLibrary'}), nowPlayingSession({librarySectionTitle: 'SomeOtherLibrary'}))).to.not.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'Study Music'}), nowPlayingSession({librarySectionTitle: 'Study Music'}))).to.not.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'Music'}), nowPlayingSession({librarySectionTitle: 'Music'}))).to.be.true;
|
||||
await s.destroy();
|
||||
});
|
||||
|
||||
it('Should disallow activity based on libraries block', async function () {
|
||||
const s = await createSource({...defaultCreds, librariesBlock: ['music']});
|
||||
s.libraries.push({name: 'CoolVideos', collectionType: 'artist', uuid: '43543'});
|
||||
s.libraries.push({name: 'Study Music', collectionType: 'artist', uuid: '435437'});
|
||||
s.libraries.push({name: 'Music', collectionType: 'artist', uuid: '4354378'});
|
||||
|
||||
expect(s.isActivityValid(validPlayerState, validSession)).to.not.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'CoolVideos'}), nowPlayingSession({librarySectionTitle: 'CoolVideos'}))).to.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'Study Music'}), nowPlayingSession({librarySectionTitle: 'Study Music'}))).to.be.true;
|
||||
expect(s.isActivityValid(playWithMeta({library: 'Music'}), nowPlayingSession({librarySectionTitle: 'Music'}))).to.not.be.true;
|
||||
await s.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import request from "superagent";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { Notifiers } from "../../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.js";
|
||||
import { CommonClientConfig, CommonClientOptions, NowPlayingOptions } from "../../common/infrastructure/config/client/index.js";
|
||||
|
||||
export class TestScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
testRecentScrobbles: PlayObject[] = [];
|
||||
|
||||
constructor() {
|
||||
constructor(config: CommonClientConfig = {name: 'test'}) {
|
||||
const logger = loggerTest;
|
||||
const notifier = new Notifiers(new EventEmitter(), new EventEmitter(), new EventEmitter(), logger);
|
||||
super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger);
|
||||
super('test', 'Test', {name: 'test', ...config}, notifier, new EventEmitter(), logger);
|
||||
}
|
||||
|
||||
protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> {
|
||||
@@ -49,3 +50,14 @@ export class TestAuthScrobbler extends TestScrobbler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TestNowPlayingConfig = CommonClientConfig & {options?: CommonClientOptions & NowPlayingOptions};
|
||||
|
||||
export class NowPlayingScrobbler extends TestScrobbler {
|
||||
declare config: TestNowPlayingConfig
|
||||
|
||||
constructor(config?: TestNowPlayingConfig) {
|
||||
super(config);
|
||||
this.supportsNowPlaying = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import { after, before, describe, it } from 'mocha';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import pEvent from 'p-event';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { sleep } from "../../utils.js";
|
||||
import { genGroupIdStr, sleep } from "../../utils.js";
|
||||
import mixedDuration from '../plays/mixedDuration.json';
|
||||
import withDuration from '../plays/withDuration.json';
|
||||
import { MockNetworkError, withRequestInterception } from "../utils/networking.js";
|
||||
import { asPlays, generatePlay, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
|
||||
import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
|
||||
import MockDate from 'mockdate';
|
||||
|
||||
import { TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
|
||||
import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
|
||||
import { PlayPlatformId } from '../../common/infrastructure/Atomic.js';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -244,6 +246,23 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(ballad2));
|
||||
});
|
||||
|
||||
it('Is not detected as duplicate when play date matches fuzzy but play is marked as repeat', async function () {
|
||||
|
||||
const recent = normalizePlays(normalizedWithDur, {
|
||||
initialDate: firstPlayDate,
|
||||
defaultMeta: {source: 'jellyfin'}
|
||||
});
|
||||
testScrobbler.recentScrobbles = recent;
|
||||
|
||||
const repeatPlay = clone(recent[recent.length - 1]);
|
||||
repeatPlay.data.playDate = repeatPlay.data.playDate.add(repeatPlay.data.duration + 2, 's');
|
||||
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(repeatPlay));
|
||||
|
||||
repeatPlay.data.repeat = true;
|
||||
assert.isFalse(await testScrobbler.alreadyScrobbled(repeatPlay));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -737,3 +756,234 @@ describe('Manages scrobble queue', function() {
|
||||
await testScrobbler.tryStopScrobbling()
|
||||
});
|
||||
});
|
||||
|
||||
describe('Now Playing', function() {
|
||||
|
||||
describe('Filtering Aggregated Updates', function() {
|
||||
|
||||
it('When no Now Playing exists, chooses play based on sorted platform id', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
|
||||
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
|
||||
|
||||
const pt = dayjs().subtract(15, 's');
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
|
||||
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
|
||||
|
||||
});
|
||||
|
||||
it('When Now Playing platform does not exist in queued plays, chooses play based on sorted platform id', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
|
||||
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
|
||||
|
||||
npScrobbler.nowPlayingLastPlay = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
|
||||
const pt = dayjs().subtract(15, 's');
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
|
||||
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
|
||||
|
||||
});
|
||||
|
||||
it('Chooses play based on existing Now Playing', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
|
||||
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
|
||||
|
||||
const pt = dayjs().subtract(15, 's');
|
||||
|
||||
const stickyNp = generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)});
|
||||
|
||||
npScrobbler.nowPlayingLastPlay = stickyNp
|
||||
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
|
||||
npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport.meta.deviceId).eq(genGroupIdStr(secondPlatform));
|
||||
|
||||
});
|
||||
|
||||
it('Sorts Sources alphabetically when using default Source sorting', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
|
||||
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
|
||||
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport.meta.deviceId).eq(a.meta.deviceId);
|
||||
|
||||
});
|
||||
|
||||
it('Sorts Sources based on user config', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
|
||||
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
|
||||
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport.meta.deviceId).eq(b.meta.deviceId);
|
||||
|
||||
});
|
||||
|
||||
it('Does not report if source is not in user config', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const c = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
|
||||
npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})
|
||||
|
||||
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
|
||||
|
||||
expect(toReport).to.be.undefined;
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('Updating', function () {
|
||||
|
||||
it('Should update if no existing Now Playing', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
it('Should update if previous Now Playing matches updated and last updated diff is greater than upper limit', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] + 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
it('Should NOT update if previous Now Playing matches updated and last updated diff is less than upper limit', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] - 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
|
||||
expect(res).to.be.false;
|
||||
});
|
||||
|
||||
it('Should update if previous Now Playing does NOT match updated and last updated diff is greater than lower limit', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] + 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
|
||||
expect(res).to.be.true;
|
||||
});
|
||||
|
||||
it('Should NOT update if previous Now Playing does NOT match updated and last updated diff is less than than lower limit', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
await npScrobbler.initialize();
|
||||
|
||||
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
|
||||
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] - 1, 's');
|
||||
npScrobbler.nowPlayingLastPlay = lastUpdate;
|
||||
|
||||
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
|
||||
expect(res).to.be.false;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Scheduling', function () {
|
||||
|
||||
this.afterEach(() => {
|
||||
MockDate.reset();
|
||||
});
|
||||
|
||||
it('Should update when no existing Now Playing', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
npScrobbler.nowPlayingTaskInterval = 10;
|
||||
await npScrobbler.initialize();
|
||||
npScrobbler.scheduler.startById('pn_task');
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
|
||||
|
||||
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
|
||||
|
||||
expect(res).is.not.undefined;
|
||||
});
|
||||
|
||||
it('Should update when updated does not match Now Playing', async function () {
|
||||
|
||||
const npScrobbler = new NowPlayingScrobbler();
|
||||
npScrobbler.nowPlayingTaskInterval = 10;
|
||||
await npScrobbler.initialize();
|
||||
npScrobbler.scheduler.startById('pn_task');
|
||||
|
||||
const now = dayjs();
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
|
||||
|
||||
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
|
||||
|
||||
expect(res).is.not.undefined;
|
||||
|
||||
MockDate.set(now.add(npScrobbler.nowPlayingThresholds[0] + 3, 's').toDate());
|
||||
|
||||
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
|
||||
|
||||
const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
|
||||
|
||||
expect(resUpdate).is.not.undefined;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { after, before, describe, it } from 'mocha';
|
||||
import pEvent from "p-event";
|
||||
import clone from 'clone';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
|
||||
import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
|
||||
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
|
||||
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
|
||||
import SpotifySource from "../../sources/SpotifySource.js";
|
||||
@@ -19,6 +19,8 @@ import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
|
||||
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
|
||||
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
|
||||
import { sleep } from "../../utils.js";
|
||||
import DeezerInternalSource from "../../sources/DeezerInternalSource.js";
|
||||
import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -423,38 +425,142 @@ describe('Player Cleanup', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrobble Threshold Checks', function() {
|
||||
const generateDeezerSource = (options: DeezerInternalSourceOptions = {}) => {
|
||||
return new DeezerInternalSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
|
||||
}
|
||||
const firstPlayDate = dayjs().subtract(1, 'hour');
|
||||
const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate});
|
||||
const lastPlay = normalizedPlays[normalizePlays.length - 1];
|
||||
|
||||
it('uses defaults when no user-configured thresholds are passed', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD);
|
||||
expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD);
|
||||
describe('Deezer Internal Source', function() {
|
||||
|
||||
describe('When fuzzyDiscoveryIgnore is not defined or false', function () {
|
||||
|
||||
it('discovers fuzzy play', function() {
|
||||
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
|
||||
const source = generateDeezerSource();
|
||||
source.discover([...normalizedPlays, interimPlay]);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses user-configured thresholds when passed', function() {
|
||||
const results = timePassesScrobbleThreshold({
|
||||
duration: 20,
|
||||
percent: 15
|
||||
}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(20);
|
||||
expect(results.percent.threshold).to.eq(15);
|
||||
describe('When fuzzyDiscoveryIgnore is true', function () {
|
||||
|
||||
it('does not discover fuzzy play with interim plays', function() {
|
||||
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
|
||||
source.discover([...normalizedPlays, interimPlay]);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(0);
|
||||
});
|
||||
|
||||
it('discovers fuzzy play when it is the last play ', function() {
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
|
||||
source.discover(normalizedPlays);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(1);
|
||||
});
|
||||
|
||||
it('discovers fuzzy play when it is played consecutively', function() {
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
|
||||
const discovered = source.discover(morePlays);
|
||||
|
||||
expect(discovered.length).to.eq(morePlays.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes when duration is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
describe('When fuzzyDiscoveryIgnore is aggressive', function () {
|
||||
|
||||
it('passes when percent is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 30, 50);
|
||||
expect(results.percent.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
it('does not discover fuzzy play with interim plays', function() {
|
||||
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
|
||||
it('handles zero duration', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
|
||||
source.discover([...normalizedPlays, interimPlay]);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(0);
|
||||
});
|
||||
|
||||
it('does not discover play found during duration of previous', function() {
|
||||
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
|
||||
const duringPlay = clone(targetPlay);
|
||||
duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's');
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
|
||||
source.discover([...normalizedPlays, interimPlay]);
|
||||
|
||||
const discovered = source.discover([duringPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(0);
|
||||
});
|
||||
|
||||
it('does not discover fuzzy play with delay of up to 40 seconds', function() {
|
||||
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's');
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
|
||||
source.discover([...normalizedPlays, interimPlay]);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(0);
|
||||
});
|
||||
|
||||
it('it does not discover fuzzy play when it is the last play ', function() {
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
|
||||
source.discover(normalizedPlays);
|
||||
|
||||
const discovered = source.discover([fuzzyPlay]);
|
||||
|
||||
expect(discovered.length).to.eq(0);
|
||||
});
|
||||
|
||||
it('does not discover fuzzy play when it is played consecutively', function() {
|
||||
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
|
||||
const fuzzyPlay = clone(targetPlay);
|
||||
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
|
||||
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
|
||||
|
||||
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
|
||||
const discovered = source.discover(morePlays);
|
||||
|
||||
expect(discovered.length).to.eq(morePlays.length - 1);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { assert, expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timePassesScrobbleThreshold } from '../../utils/TimeUtils.js';
|
||||
import { DEFAULT_CLOSE_POSITION_ABSOLUTE, DEFAULT_CLOSE_POSITION_PERCENT, DEFAULT_DURATION_REPEAT_ABSOLUTE, DEFAULT_DURATION_REPEAT_PERCENT, DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from '../../common/infrastructure/Atomic.js';
|
||||
import { generatePlay } from '../utils/PlayTestUtils.js';
|
||||
|
||||
describe('Play Position', function() {
|
||||
|
||||
describe('Close To Start', function() {
|
||||
|
||||
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
|
||||
expect(closeToPlayStart(generatePlay(), 10)[0]).is.true;
|
||||
});
|
||||
it(`Is not close when later than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
|
||||
expect(closeToPlayStart(generatePlay({duration: 60}), 13)[0]).is.false;
|
||||
});
|
||||
it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
|
||||
expect(closeToPlayStart(generatePlay({duration: 300}), 40)[0]).is.true;
|
||||
});
|
||||
it(`Is not close when greater than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
|
||||
expect(closeToPlayStart(generatePlay({duration: 60}), 15)[0]).is.false;
|
||||
});
|
||||
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds and no duration`, function() {
|
||||
expect(closeToPlayStart(generatePlay({duration: undefined}), 10)[0]).is.true;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Close To End', function() {
|
||||
|
||||
it(`Is not close when no duration`, function() {
|
||||
expect(closeToPlayEnd(generatePlay({duration: undefined}), 50)[0]).is.false;
|
||||
});
|
||||
it(`Is close when within ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
|
||||
expect(closeToPlayEnd(generatePlay({duration: 60}), 50)[0]).is.true;
|
||||
});
|
||||
it(`Is not close when earlier than ${DEFAULT_CLOSE_POSITION_ABSOLUTE} seconds`, function() {
|
||||
expect(closeToPlayEnd(generatePlay({duration: 60}), 45)[0]).is.false;
|
||||
});
|
||||
it(`Is close when within ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
|
||||
expect(closeToPlayEnd(generatePlay({duration: 300}), 261)[0]).is.true;
|
||||
});
|
||||
it(`Is not close when less than ${DEFAULT_CLOSE_POSITION_PERCENT} percent`, function() {
|
||||
expect(closeToPlayEnd(generatePlay({duration: 300}), 240)[0]).is.false;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Play Repeat Duration', function() {
|
||||
|
||||
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() {
|
||||
expect(repeatDurationPlayed(generatePlay({duration: 300}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true;
|
||||
});
|
||||
it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds`, function() {
|
||||
expect(repeatDurationPlayed(generatePlay({duration: 300}), 10)[0]).is.false;
|
||||
});
|
||||
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() {
|
||||
expect(repeatDurationPlayed(generatePlay({duration: 60}), 40)[0]).is.true;
|
||||
});
|
||||
it(`Is not valid for repeat when less than ${DEFAULT_DURATION_REPEAT_PERCENT} percent`, function() {
|
||||
expect(repeatDurationPlayed(generatePlay({duration: 60}), 15)[0]).is.false;
|
||||
});
|
||||
it(`Is valid for repeat when more than ${DEFAULT_DURATION_REPEAT_ABSOLUTE} seconds and no duration`, function() {
|
||||
expect(repeatDurationPlayed(generatePlay({duration: undefined}), DEFAULT_DURATION_REPEAT_ABSOLUTE + 1)[0]).is.true;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Scrobble Threshold Checks', function() {
|
||||
|
||||
it('uses defaults when no user-configured thresholds are passed', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(DEFAULT_SCROBBLE_DURATION_THRESHOLD);
|
||||
expect(results.percent.threshold).to.eq(DEFAULT_SCROBBLE_PERCENT_THRESHOLD);
|
||||
});
|
||||
|
||||
it('uses user-configured thresholds when passed', function() {
|
||||
const results = timePassesScrobbleThreshold({
|
||||
duration: 20,
|
||||
percent: 15
|
||||
}, 1, 1);
|
||||
expect(results.duration.threshold).to.eq(20);
|
||||
expect(results.percent.threshold).to.eq(15);
|
||||
});
|
||||
|
||||
it('passes when duration is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
|
||||
it('passes when percent is above threshold', function() {
|
||||
const results = timePassesScrobbleThreshold({}, 30, 50);
|
||||
expect(results.percent.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
|
||||
it('handles zero duration', function() {
|
||||
const results = timePassesScrobbleThreshold({}, DEFAULT_SCROBBLE_DURATION_THRESHOLD + 1, 0);
|
||||
expect(results.duration.passes).is.true;
|
||||
expect(results.passes).is.true;
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { faker, fakerEL, FakerError } from '@faker-js/faker';
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
@@ -167,6 +167,12 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePlayPlatformId = (deviceId?: string, userId?: string): PlayPlatformId => {
|
||||
const did = deviceId ?? [NO_DEVICE, faker.hacker.noun(), faker.hacker.noun()][faker.number.int({min: 0, max: 2})];
|
||||
const uid = userId ?? [NO_USER, faker.internet.username(), faker.internet.username()][faker.number.int({min: 0, max: 2})];
|
||||
return [did, uid];
|
||||
}
|
||||
|
||||
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject[] => {
|
||||
return Array.from(Array(numberOfPlays), () => generatePlay(data, meta));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { loggerTest } from "@foxxmd/logging";
|
||||
import { assert } from 'chai';
|
||||
import { assert, expect } from 'chai';
|
||||
import clone from "clone";
|
||||
import { describe, it } from 'mocha';
|
||||
import { playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
|
||||
import { genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
|
||||
import { generatePlay, generatePlays } from "./PlayTestUtils.js";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
|
||||
@@ -249,4 +249,37 @@ describe('Compare lists by order', function () {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Source Play Comparisons', function() {
|
||||
|
||||
describe('Generic Source Comparison', function() {
|
||||
|
||||
it('matches identical plays', function() {
|
||||
expect(genericSourcePlayMatch(newPlay, newPlay)).to.be.true;
|
||||
});
|
||||
|
||||
it('matches identical plays with close timestamps', function() {
|
||||
const closePlay = clone(newPlay);
|
||||
closePlay.data.playDate = closePlay.data.playDate.add(3, 's');
|
||||
expect(genericSourcePlayMatch(newPlay, closePlay)).to.be.true;
|
||||
});
|
||||
|
||||
it('does not match unique plays', function() {
|
||||
expect(genericSourcePlayMatch(newPlay, generatePlay())).to.be.false;
|
||||
});
|
||||
|
||||
it('does not match unique plays with exact timestamps', function() {
|
||||
const diffPlay = generatePlay();
|
||||
diffPlay.data.playDate = newPlay.data.playDate;
|
||||
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
|
||||
});
|
||||
|
||||
it('does not match unique plays with close timestamps', function() {
|
||||
const diffPlay = generatePlay();
|
||||
diffPlay.data.playDate = newPlay.data.playDate.add(3, 's');
|
||||
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
|
||||
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
|
||||
|
||||
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
|
||||
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
||||
let v4: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getListDiff, ListDiff } from "@donedeal0/superdiff";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
|
||||
import { buildTrackString } from "../../core/StringUtils.js";
|
||||
import { playObjDataMatch } from "../utils.js";
|
||||
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
|
||||
|
||||
|
||||
export const metaInvariantTransform = (play: PlayObject): PlayObject => {
|
||||
@@ -221,3 +223,7 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu
|
||||
return `${a} => ${b}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean =>
|
||||
playObjDataMatch(a, b)
|
||||
&& hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t);
|
||||
@@ -366,3 +366,13 @@ export const buildStatePlayerPlayIdententifyingInfo = (data: PlayObject | Player
|
||||
}
|
||||
return idInfo;
|
||||
}
|
||||
|
||||
|
||||
export const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
|
||||
|
||||
export const normalizeListenbrainzUrl = (urlVal: string): string | undefined => {
|
||||
if (parseRegexSingleOrFail(LZ_VERSION_PATH, urlVal)) {
|
||||
return urlVal.replace(LZ_VERSION_PATH, '');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
+155
-41
@@ -1,11 +1,14 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import isToday from 'dayjs/plugin/isToday.js';
|
||||
import {
|
||||
AcceptableTemporalDuringReference,
|
||||
PlayObject,
|
||||
SCROBBLE_TS_SOC_END,
|
||||
SCROBBLE_TS_SOC_START,
|
||||
ScrobbleTsSOC,
|
||||
TA_CLOSE,
|
||||
TA_DEFAULT_ACCURACY,
|
||||
TA_DURING,
|
||||
TA_EXACT,
|
||||
TA_FUZZY,
|
||||
TA_NONE,
|
||||
@@ -14,6 +17,10 @@ import {
|
||||
} from "../../core/Atomic.js";
|
||||
import { capitalize } from "../../core/StringUtils.js";
|
||||
import {
|
||||
DEFAULT_CLOSE_POSITION_ABSOLUTE,
|
||||
DEFAULT_CLOSE_POSITION_PERCENT,
|
||||
DEFAULT_DURATION_REPEAT_ABSOLUTE,
|
||||
DEFAULT_DURATION_REPEAT_PERCENT,
|
||||
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
|
||||
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
|
||||
lowGranularitySources,
|
||||
@@ -38,31 +45,31 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
|
||||
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
|
||||
}
|
||||
if (data.date.fuzzyDurationDiff !== undefined) {
|
||||
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
|
||||
parts.push(`Fuzzy Duration Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
|
||||
}
|
||||
if (data.date.fuzzyListenedDiff !== undefined) {
|
||||
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= 10s)`);
|
||||
parts.push(`Fuzzy Listened Diff: ${formatNumber(data.date.fuzzyDurationDiff, {toFixed: 0})}s (Needed <= ${data.date.fuzzyDiffThreshold}s)`);
|
||||
}
|
||||
if (data.range !== undefined) {
|
||||
if (data.range === false) {
|
||||
parts.push('Candidate not played during Existing tracked listening');
|
||||
} else {
|
||||
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
|
||||
}
|
||||
} else {
|
||||
|
||||
if(data.range === undefined) {
|
||||
parts.push('Range Comparison N/A');
|
||||
} else if(data.range.type === 'none') {
|
||||
parts.push(`Candidate not played during Existing ${data.duringReferences.join(' or ')}`);
|
||||
} else {
|
||||
parts.push(`Candidate played during tracked listening range from Existing "${data.range.type}" ${data.range.timestamps[0].format('HH:mm:ssZ')} => ${data.range.timestamps[1].format('HH:mm:ssZ')}`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
|
||||
|
||||
export interface TemporalPlayComparisonOptions {
|
||||
diffThreshold?: number,
|
||||
fuzzyDuration?: boolean,
|
||||
useListRanges?: boolean
|
||||
} = {}): TemporalPlayComparison => {
|
||||
fuzzyDiffThreshold?: number
|
||||
duringReferences?: AcceptableTemporalDuringReference
|
||||
}
|
||||
|
||||
export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: TemporalPlayComparisonOptions = {}): TemporalPlayComparison => {
|
||||
|
||||
const result: TemporalPlayComparison = {
|
||||
match: TA_NONE
|
||||
};
|
||||
|
||||
const {
|
||||
meta: {
|
||||
@@ -98,9 +105,15 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
|
||||
const {
|
||||
diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10,
|
||||
fuzzyDuration = false,
|
||||
useListRanges = true,
|
||||
fuzzyDiffThreshold = 10,
|
||||
duringReferences = ['range']
|
||||
} = options;
|
||||
|
||||
const result: TemporalPlayComparison = {
|
||||
match: TA_NONE,
|
||||
duringReferences
|
||||
};
|
||||
|
||||
// cant compare!
|
||||
if (existingTsSOCDate === undefined || candidateTsSOCDate === undefined) {
|
||||
return result;
|
||||
@@ -115,7 +128,8 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
|
||||
const scrobblePlayDiff = Math.abs(existingTsSOCDate.unix() - candidateTsSOCDate.unix());
|
||||
result.date = {
|
||||
threshold: diffThreshold,
|
||||
diff: scrobblePlayDiff
|
||||
diff: scrobblePlayDiff,
|
||||
fuzzyDiffThreshold
|
||||
};
|
||||
|
||||
if(scrobblePlayDiff <= 1) {
|
||||
@@ -124,29 +138,57 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
|
||||
result.match = TA_CLOSE;
|
||||
}
|
||||
|
||||
if (useListRanges && existingRanges !== undefined) {
|
||||
// since we know when the existing track was listened to
|
||||
// we can check if the new track play date took place while the existing one was being listened to
|
||||
// which would indicate (assuming same source) the new track is a duplicate
|
||||
for (const range of existingRanges) {
|
||||
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
|
||||
result.range = range;
|
||||
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
|
||||
result.match = TA_CLOSE;
|
||||
if(result.match !== TA_NONE) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if(duringReferences.length > 0) {
|
||||
|
||||
if (duringReferences.includes('range') && existingRanges !== undefined) {
|
||||
// since we know when the existing track was listened to
|
||||
// we can check if the new track play date took place while the existing one was being listened to
|
||||
// which would indicate (assuming same source) the new track is a duplicate
|
||||
for (const range of existingRanges) {
|
||||
if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) {
|
||||
result.range = {
|
||||
type: 'range',
|
||||
timestamps: [range.start.timestamp, range.end.timestamp]
|
||||
}
|
||||
result.match = TA_DURING;
|
||||
return result;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.range === undefined) {
|
||||
result.range = false;
|
||||
|
||||
if(duringReferences.includes('listenedFor') && existingPlay.data.listenedFor !== undefined) {
|
||||
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's'))) {
|
||||
result.match = TA_DURING;
|
||||
result.range = {
|
||||
type: 'listenedFor',
|
||||
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.listenedFor, 's')]
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if(duringReferences.includes('duration') && existingPlay.data.duration !== undefined) {
|
||||
if (candidateTsSOCDate.isBetween(existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's'))) {
|
||||
result.match = TA_DURING;
|
||||
result.range = {
|
||||
type: 'duration',
|
||||
timestamps: [existingTsSOCDate, existingTsSOCDate.add(existingPlay.data.duration, 's')]
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
|
||||
// so check if the duration matches the diff between the two play dates
|
||||
if (result.match === TA_NONE && referenceDuration !== undefined) {
|
||||
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
|
||||
if (result.date.fuzzyDurationDiff <= 10) { // TODO use finer comparison for this?
|
||||
if (result.date.fuzzyDurationDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
|
||||
result.match = TA_FUZZY;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +197,7 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
|
||||
// so check if there is a close match between candidate play date and source + listened for
|
||||
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
|
||||
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
|
||||
if (result.date.fuzzyListenedDiff <= 10) { // TODO use finer comparison for this?
|
||||
if (result.date.fuzzyListenedDiff <= fuzzyDiffThreshold) { // TODO use finer comparison for this?
|
||||
result.match = TA_FUZZY
|
||||
}
|
||||
}
|
||||
@@ -194,15 +236,7 @@ export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, seco
|
||||
}
|
||||
}
|
||||
|
||||
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
|
||||
if(typeof expected === 'number') {
|
||||
if(typeof found === 'number') {
|
||||
return found <= expected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return found === false;
|
||||
}
|
||||
export const hasAcceptableTemporalAccuracy = (found: TemporalAccuracy, expected: TemporalAccuracy[] = TA_DEFAULT_ACCURACY): boolean => expected.includes(found);
|
||||
|
||||
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
|
||||
switch(acc) {
|
||||
@@ -212,7 +246,9 @@ export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
|
||||
return 'close';
|
||||
case 3:
|
||||
return 'fuzzy';
|
||||
case false:
|
||||
case 4:
|
||||
return 'during';
|
||||
case 99:
|
||||
return 'no correlation';
|
||||
}
|
||||
}
|
||||
@@ -297,4 +333,82 @@ export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Mi
|
||||
}
|
||||
// EX 01:15:45
|
||||
return new Date(ms).toISOString().substring(11, 19);
|
||||
}
|
||||
|
||||
/** Is Position earlier than X seconds or Y% percent of the start of a Play? */
|
||||
export const closeToPlayStart = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
|
||||
const {
|
||||
absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE,
|
||||
percent = DEFAULT_CLOSE_POSITION_PERCENT,
|
||||
hintPrefix = true
|
||||
} = thresholds;
|
||||
|
||||
let hintStart = hintPrefix ? `Position (${position}) ` : '';
|
||||
const trackDur = play.data.duration;
|
||||
const closeStartNum = position <= absolute;
|
||||
const hints: string[] = [];
|
||||
hints.push(`${closeStartNum ? 'is' : 'is not'} within ${absolute}s of track start`);
|
||||
|
||||
let closeStartPer = false;
|
||||
if(trackDur !== undefined) {
|
||||
const positionPercent = (position / trackDur);
|
||||
closeStartPer = (positionPercent <= percent);
|
||||
if(!closeStartNum) {
|
||||
hints.push(`${closeStartPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track start (${formatNumber(positionPercent*100)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return [closeStartNum || closeStartPer, `${hintStart}${hints.join(' and ')}`];
|
||||
}
|
||||
|
||||
/** Is Position closer than X seconds or Y% percent of the end of a Play? */
|
||||
export const closeToPlayEnd = (play: PlayObject, position: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
|
||||
const {
|
||||
absolute = DEFAULT_CLOSE_POSITION_ABSOLUTE,
|
||||
percent = DEFAULT_CLOSE_POSITION_PERCENT,
|
||||
hintPrefix = true
|
||||
} = thresholds;
|
||||
|
||||
let hintStart = hintPrefix ? `Position (${position}) ` : '';
|
||||
const trackDur = play.data.duration;
|
||||
|
||||
if(trackDur === undefined) {
|
||||
return [false, `Cannot determine how close Position ${position} is to end of track because no duration data is available.`];
|
||||
}
|
||||
|
||||
const nearEndNum = trackDur - position <= absolute;
|
||||
const hints: string[] = [];
|
||||
hints.push(`${nearEndNum ? 'is' : 'is not'} within ${absolute}s of track end`);
|
||||
const positionPercent = 1 - (position / trackDur);
|
||||
const nearEndPer = (positionPercent < percent);
|
||||
if(!nearEndNum) {
|
||||
hints.push(`${nearEndPer ? 'is' : 'is not'} within ${formatNumber(percent * 100, {toFixed: 0})}% of track end (${formatNumber(positionPercent*100)}%)`);
|
||||
}
|
||||
return [nearEndNum || nearEndPer, `${hintStart}${hints.join(' and ')}`];
|
||||
}
|
||||
|
||||
/** Has more than X seconds or Y% percent of Play duration been played? */
|
||||
export const repeatDurationPlayed = (play: PlayObject, duration: number, thresholds: {absolute?: number, percent?: number, hintPrefix?: boolean} = {}): [boolean, string] => {
|
||||
const {
|
||||
absolute = DEFAULT_DURATION_REPEAT_ABSOLUTE,
|
||||
percent = DEFAULT_DURATION_REPEAT_PERCENT,
|
||||
hintPrefix = true
|
||||
} = thresholds;
|
||||
|
||||
let hintStart = hintPrefix ? `Duration listened (${duration}s) ` : '';
|
||||
const trackDur = play.data.duration;
|
||||
const absPlayed = duration >= absolute;
|
||||
const hints: string[] = [];
|
||||
hints.push(`${absPlayed ? 'is' : 'is not'} more than ${absolute}s`);
|
||||
|
||||
let majorityDurationPercent = false;
|
||||
if(trackDur !== undefined) {
|
||||
const durationPercent = (duration / trackDur);
|
||||
majorityDurationPercent = (durationPercent >= percent);
|
||||
if(!absPlayed) {
|
||||
hints.push(`${majorityDurationPercent ? 'is' : 'is not'} more than ${formatNumber(percent * 100, {toFixed: 0})}% of track duration (${formatNumber(durationPercent*100)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return [absPlayed || majorityDurationPercent, `${hintStart}${hints.join(' and ')}`];
|
||||
}
|
||||
+49
-3
@@ -74,6 +74,13 @@ export interface BrainzMeta {
|
||||
releaseGroup?: string
|
||||
}
|
||||
|
||||
export interface SpotifyMeta {
|
||||
artist?: string[]
|
||||
albumArtist?: string[]
|
||||
album?: string
|
||||
track?: string
|
||||
}
|
||||
|
||||
export interface TrackData {
|
||||
artists?: string[]
|
||||
albumArtists?: string[]
|
||||
@@ -86,6 +93,7 @@ export interface TrackData {
|
||||
|
||||
meta?: {
|
||||
brainz?: BrainzMeta
|
||||
spotify?: SpotifyMeta
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +106,17 @@ export interface PlayData extends TrackData {
|
||||
listenedFor?: number
|
||||
listenRanges?: ListenRangeData[]
|
||||
playDateCompleted?: Dayjs | string
|
||||
repeat?: boolean
|
||||
}
|
||||
|
||||
export interface PlayMeta {
|
||||
source?: string
|
||||
|
||||
/*
|
||||
* If applicable, the name of the Service providing the track (Spotify, Tidal, etc...)
|
||||
*/
|
||||
musicService?: string
|
||||
|
||||
/**
|
||||
* Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc...
|
||||
* */
|
||||
@@ -151,6 +165,16 @@ export interface PlayMeta {
|
||||
* The position the "player" is at in the track at the time the play was reported, in seconds
|
||||
* */
|
||||
trackProgressPosition?: number
|
||||
|
||||
/*
|
||||
* Name of the media player (program)
|
||||
*/
|
||||
mediaPlayerName?: string
|
||||
|
||||
/*
|
||||
* Version of the media player (program)
|
||||
*/
|
||||
mediaPlayerVersion?: string
|
||||
/**
|
||||
* A unique identifier for the device playing this track
|
||||
* */
|
||||
@@ -244,12 +268,29 @@ export interface DeadLetterScrobble<PlayType, RetryType = Dayjs> extends QueuedS
|
||||
export type Second = number;
|
||||
export type Millisecond = number;
|
||||
|
||||
export type TemporalAccuracy = 1 | 2 | 3 | false;
|
||||
export type TemporalAccuracy = 1 | 2 | 3 | 4 | 99;
|
||||
|
||||
/** Timestamp diffs are close to exact (less than or equal to 1 second difference) */
|
||||
export const TA_EXACT: TemporalAccuracy = 1;
|
||||
/** Timestamp diffs are within source reporting granularity margin-of-error (see lowGranularitySources):
|
||||
* normal granularity is 10 seconds
|
||||
* low granularity (subsonic usually) is 60 seconds
|
||||
*/
|
||||
export const TA_CLOSE: TemporalAccuracy = 2;
|
||||
/** Timestamp diffs are not CLOSE but Scrobble A's timestamp +/- duration is within fuzzyDiffThreshold seconds of Scrobble B's timestamp */
|
||||
export const TA_FUZZY: TemporalAccuracy = 3;
|
||||
export const TA_NONE: TemporalAccuracy = false;
|
||||
/** Timestamp diffs are not FUZZY and Scrobble B's timestamp is within potential full play of Scrobble A (timestamp +/- duration) */
|
||||
export const TA_DURING: TemporalAccuracy = 4;
|
||||
/** No correlation between timestamps */
|
||||
export const TA_NONE: TemporalAccuracy = 99;
|
||||
|
||||
export type AcceptableTemporaryAccuracy = TemporalAccuracy[]
|
||||
|
||||
export const TA_DEFAULT_ACCURACY: AcceptableTemporaryAccuracy = [TA_EXACT, TA_CLOSE];
|
||||
|
||||
export type TemporalDuringReference = 'range' | 'duration' | 'listenedFor';
|
||||
|
||||
export type AcceptableTemporalDuringReference = TemporalDuringReference[];
|
||||
|
||||
export interface TemporalPlayComparison {
|
||||
match: TemporalAccuracy
|
||||
@@ -258,8 +299,13 @@ export interface TemporalPlayComparison {
|
||||
diff: number
|
||||
fuzzyDurationDiff?: number
|
||||
fuzzyListenedDiff?: number
|
||||
fuzzyDiffThreshold?: number
|
||||
}
|
||||
range?: false | ListenRangeData
|
||||
duringReferences: AcceptableTemporalDuringReference
|
||||
range?: {
|
||||
timestamps: [Dayjs, Dayjs]
|
||||
type: TemporalDuringReference
|
||||
} | { type: 'none' }
|
||||
}
|
||||
|
||||
export type SOURCE_SOT_TYPES = 'player' | 'history';
|
||||
|
||||
@@ -33,7 +33,12 @@ export const truncateStringToLength = (length: any, truncStr = '...') => (val: a
|
||||
export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input;
|
||||
export const defaultReducer = (acc, curr) => `${acc} ${curr}`;
|
||||
export const defaultArtistFunc = (a: string[]) => a.join(' / ');
|
||||
export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `--- ${input}` : input;
|
||||
export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => {
|
||||
if(input === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return hasExistingParts ? `--- ${input}` : input
|
||||
};
|
||||
export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t === undefined ? '@ N/A' : `@ ${t.local().format()} ${i === undefined ? '' : (i === SCROBBLE_TS_SOC_START ? '(S)' : '(C)')}`;
|
||||
export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;
|
||||
export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`;
|
||||
|
||||
Reference in New Issue
Block a user