mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
113
Commits
0.9.5
...
b3a6beb8f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a6beb8f4 | ||
|
|
e86945355c | ||
|
|
8dd9958b30 | ||
|
|
869c7dcfe1 | ||
|
|
7068da5fc2 | ||
|
|
3704fba1f8 | ||
|
|
6d99790f7d | ||
|
|
3e7f1965e7 | ||
|
|
87d05b7c75 | ||
|
|
aca23ed90d | ||
|
|
a01c8e3f17 | ||
|
|
eba9bc1b6b | ||
|
|
34be8add14 | ||
|
|
bd44fa5609 | ||
|
|
c81bdf608f | ||
|
|
e9e5987330 | ||
|
|
ce4b0f7aa7 | ||
|
|
eb6e469dc9 | ||
|
|
4f6fb4ffea | ||
|
|
24df22781f | ||
|
|
abf532c6bb | ||
|
|
976c7c0e82 | ||
|
|
06b23d6287 | ||
|
|
38a15d6426 | ||
|
|
c2c86033cb | ||
|
|
72c94ca1e3 | ||
|
|
bf6bd4be55 | ||
|
|
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 |
@@ -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,34 +12,35 @@ 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)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using 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)
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja-source)
|
||||
* [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)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* [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,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,15 +16,5 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
[
|
||||
{
|
||||
"name": "myMaloja",
|
||||
"name": "myMaloja-client",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myMaloja-source",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
"password": "yourPassword"
|
||||
"password": "yourPassword",
|
||||
"usersAllow": ["yourUser"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ services:
|
||||
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=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- JELLYFIN_APIKEY=
|
||||
#- JELLYFIN_URL=
|
||||
#- JELLYFIN_USER=
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
+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
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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,127 +14,6 @@ For the difference between **ENV** and **File** examples in this document see [C
|
||||
|
||||
:::
|
||||
|
||||
## Local Installation
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
|
||||
### Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm run docs:install && npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```shell
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<details>
|
||||
<summary>`./config/config.json`</summary>
|
||||
|
||||
```json title="./config/config.json"
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```shell
|
||||
npm run start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
|
||||
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
:::
|
||||
|
||||
### 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
|
||||
|
||||
:::
|
||||
|
||||
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)
|
||||
@@ -172,7 +51,7 @@ 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.
|
||||
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>
|
||||
|
||||
@@ -202,7 +81,7 @@ You **should** bind a host directory into the container for storing configuratio
|
||||
|
||||
#### 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`.
|
||||
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>
|
||||
|
||||
@@ -270,17 +149,11 @@ set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the
|
||||
|
||||
* (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
|
||||
#### 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)
|
||||
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:
|
||||
|
||||
@@ -299,7 +172,7 @@ See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-com
|
||||
|
||||
The example scenario:
|
||||
|
||||
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
|
||||
* [Jellyfin **Source**](../configuration/configuration.mdx#jellyfin)
|
||||
* [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)
|
||||
@@ -311,12 +184,12 @@ The example scenario:
|
||||
<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
|
||||
docker run --name multi-scrobbler -e "JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c" -e "JELLYFIN_URL=192.168.0.101:8096" -e "JELLYFIN_USER=MyUser" -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
|
||||
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>
|
||||
@@ -331,8 +204,9 @@ The example scenario:
|
||||
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
|
||||
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c
|
||||
- JELLYFIN_URL=192.168.0.101:8096
|
||||
- JELLYFIN_USER=MyUser
|
||||
- BASE_URL=http://192.168.0.100:9078
|
||||
- MALOJA_URL=http://domain.tld:42010
|
||||
- MALOJA_API_KEY=1234
|
||||
@@ -347,4 +221,100 @@ The example scenario:
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Local Installation
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
|
||||
### Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm run docs:install && npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```shell
|
||||
JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56bd6540c JELLYFIN_URL=192.168.0.101:8096 JELLYFIN_USER=MyUser MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<details>
|
||||
<summary>`./config/config.json`</summary>
|
||||
|
||||
```json title="./config/config.json"
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"type": "jellyfin",
|
||||
"clients": ["myConfig"],
|
||||
"name": "myJellyfinSource",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"url": "http://192.168.0.101:8096",
|
||||
"user": "MyUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```shell
|
||||
npm run start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
|
||||
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
:::
|
||||
|
||||
### Flatpak
|
||||
|
||||
:::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)
|
||||
|
||||
:::
|
||||
@@ -57,20 +57,17 @@ services:
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="spotify" label="Spotify">
|
||||
To access your Spotify activity you must [register a Spotify application](https://developer.spotify.com/dashboard) to get a
|
||||
**Client ID/Secret**.
|
||||
To access your Spotify activity you must [create a Spotify App](https://developer.spotify.com/dashboard) to get a
|
||||
**Client ID/Secret** and set a valid **Redirect URI**.
|
||||
|
||||
Refer to the [Spotify Authentication instructions in the docs.](./configuration/configuration.mdx#authenticate-spotify-with-multi-scrobbler)
|
||||
|
||||
When creating the application add this to **Redirect URIs**
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/callback
|
||||
```
|
||||
|
||||
After the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
Assuming you used the **HTTPS** method for your **Redirect URI** from the instructions above...after the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- SPOTIFY_CLIENT_ID=yourClientId
|
||||
- SPOTIFY_CLIENT_SECRET=yourClientSecret
|
||||
- SPOTIFY_REDIRECT_URI=https://multi-scrobbler.mydomain.com/callback
|
||||
```
|
||||
|
||||
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the Spotify card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
|
||||
@@ -130,6 +127,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;
|
||||
+19
-17
@@ -10,38 +10,40 @@ 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)
|
||||
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [Deezer](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Maloja](docs/configuration#maloja-source)
|
||||
* [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)
|
||||
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* [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
+37
-38
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.11",
|
||||
"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",
|
||||
@@ -86,7 +86,7 @@
|
||||
"vite-express": "^0.16.0",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.1",
|
||||
"youtubei.js": "^13.1.0"
|
||||
"youtubei.js": "^15.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
@@ -116,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",
|
||||
@@ -1049,15 +1049,6 @@
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
|
||||
@@ -1543,9 +1534,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"
|
||||
}
|
||||
@@ -2803,6 +2794,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",
|
||||
@@ -2934,13 +2932,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": {
|
||||
@@ -6904,12 +6905,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jintr": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz",
|
||||
"integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.3.1.tgz",
|
||||
"integrity": "sha512-nnOzyhf0SLpbWuZ270Omwbj5LcXUkTcZkVnK8/veJXtSZOiATM5gMZMdmzN75FmTyj+NVgrGaPdH12zIJ24oIA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.8.0"
|
||||
}
|
||||
@@ -12038,29 +12040,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/youtubei.js": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-13.1.0.tgz",
|
||||
"integrity": "sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==",
|
||||
"version": "15.0.1",
|
||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-15.0.1.tgz",
|
||||
"integrity": "sha512-2slapqJS5NuXKHvcACEknyVz0AjH/TrXaOhDM0q2twQKa54kCmfj+7B/2nGfd20uzAe29zW1ejk2qOc4ABuGkg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.0.0",
|
||||
"jintr": "^3.2.1",
|
||||
"tslib": "^2.5.0",
|
||||
"undici": "^5.19.1"
|
||||
"jintr": "^3.3.1",
|
||||
"undici": "^6.21.3"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.11",
|
||||
"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",
|
||||
@@ -118,7 +118,7 @@
|
||||
"vite-express": "^0.16.0",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.1",
|
||||
"youtubei.js": "^13.1.0"
|
||||
"youtubei.js": "^15.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
@@ -148,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",
|
||||
|
||||
@@ -26,11 +26,13 @@ export type SourceType =
|
||||
| 'kodi'
|
||||
| 'webscrobbler'
|
||||
| 'chromecast'
|
||||
| 'maloja'
|
||||
| 'musikcube'
|
||||
| 'mpd'
|
||||
| 'vlc'
|
||||
| 'icecast'
|
||||
| 'azuracast';
|
||||
| 'azuracast'
|
||||
| 'koito';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
@@ -51,11 +53,13 @@ export const sourceTypes: SourceType[] = [
|
||||
'kodi',
|
||||
'webscrobbler',
|
||||
'chromecast',
|
||||
'maloja',
|
||||
'musikcube',
|
||||
'mpd',
|
||||
'vlc',
|
||||
'icecast',
|
||||
'azuracast'
|
||||
'azuracast',
|
||||
'koito'
|
||||
];
|
||||
|
||||
export const isSourceType = (data: string): data is SourceType => {
|
||||
@@ -67,16 +71,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 +213,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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { RequestRetryOptions } from "../common.js"
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js"
|
||||
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
total_record_count: number
|
||||
@@ -27,4 +30,41 @@ export interface TrackResponse {
|
||||
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,7 +1,7 @@
|
||||
import { RequestRetryOptions } from "../common.js";
|
||||
import { CommonClientConfig, CommonClientData } from "./index.js";
|
||||
|
||||
export interface MalojaClientData extends RequestRetryOptions, CommonClientData {
|
||||
export interface MalojaData extends RequestRetryOptions {
|
||||
/**
|
||||
* URL for maloja server
|
||||
*
|
||||
@@ -16,7 +16,18 @@ export interface MalojaClientData extends RequestRetryOptions, CommonClientData
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface MalojaClientData extends MalojaData, CommonClientData {
|
||||
|
||||
}
|
||||
|
||||
export interface MalojaClientConfig extends CommonClientConfig {
|
||||
/**
|
||||
* Should always be `client` when using Maloja as a client
|
||||
*
|
||||
* @default client
|
||||
* @examples ["client"]
|
||||
* */
|
||||
configureAs?: 'client' | 'source'
|
||||
data: MalojaClientData
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MalojaData } from "../client/maloja.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions {
|
||||
}
|
||||
|
||||
export interface MalojaSourceConfig extends CommonSourceConfig {
|
||||
/**
|
||||
* When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client.
|
||||
*
|
||||
* @default source
|
||||
* @examples ["source"]
|
||||
* */
|
||||
configureAs?: 'source'
|
||||
data: MalojaSourceData
|
||||
}
|
||||
|
||||
export interface MalojaSourceAIOConfig extends MalojaSourceConfig {
|
||||
type: 'maloja'
|
||||
}
|
||||
@@ -21,6 +21,8 @@ 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";
|
||||
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
|
||||
|
||||
|
||||
export type SourceConfig =
|
||||
@@ -44,12 +46,14 @@ export type SourceConfig =
|
||||
| KodiSourceConfig
|
||||
| WebScrobblerSourceConfig
|
||||
| ChromecastSourceConfig
|
||||
| MalojaSourceConfig
|
||||
| MusikcubeSourceConfig
|
||||
| MusicCastSourceConfig
|
||||
| MPDSourceConfig
|
||||
| VLCSourceConfig
|
||||
| IcecastSourceConfig
|
||||
| AzuracastSourceConfig;
|
||||
| AzuracastSourceConfig
|
||||
| KoitoSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
@@ -72,9 +76,11 @@ export type SourceAIOConfig =
|
||||
| KodiSourceAIOConfig
|
||||
| WebScrobblerSourceAIOConfig
|
||||
| ChromecastSourceAIOConfig
|
||||
| MalojaSourceAIOConfig
|
||||
| MusikcubeSourceAIOConfig
|
||||
| MusicCastSourceAIOConfig
|
||||
| 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];
|
||||
}
|
||||
|
||||
+71
-91
@@ -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});
|
||||
@@ -200,88 +204,6 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): TrackScrobblePayload {
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
album,
|
||||
albumArtists = [],
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
track: mbid
|
||||
} = {},
|
||||
} = {}
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
|
||||
if(duration !== 0) {
|
||||
additionalRichPayload.duration = duration;
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
...additionalRichPayload
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
if (albumArtists.length > 0) {
|
||||
rawPayload.albumArtist = albumArtists[0];
|
||||
}
|
||||
|
||||
// I don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// 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 => {
|
||||
@@ -339,4 +261,62 @@ export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
}
|
||||
|
||||
export const playToClientPayload = (playObj: PlayObject): TrackScrobblePayload => {
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
album,
|
||||
albumArtists = [],
|
||||
track,
|
||||
duration,
|
||||
playDate,
|
||||
meta: {
|
||||
brainz: {
|
||||
track: mbid
|
||||
} = {},
|
||||
} = {}
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
let artist: string;
|
||||
if (artists.length === 0) {
|
||||
artist = "";
|
||||
} else {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
|
||||
if(duration !== 0) {
|
||||
additionalRichPayload.duration = duration;
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
...additionalRichPayload
|
||||
};
|
||||
|
||||
// LFM ignores scrobbles where album artist is VA
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/340#issuecomment-3220774257
|
||||
const nonVaAlbumArtists = albumArtists.filter(x => x.trim().toLocaleLowerCase() !== 'va');
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
// https://www.last.fm/api/show/track.scrobble
|
||||
if (nonVaAlbumArtists.length > 0) {
|
||||
rawPayload.albumArtist = nonVaAlbumArtists[0];
|
||||
}
|
||||
|
||||
// I don't know if its lastfm-node-client building the request params incorrectly
|
||||
// or the last.fm api not handling the params correctly...
|
||||
//
|
||||
// ...but in either case if any of the below properties is undefined (possibly also null??)
|
||||
// then last.fm responds with an IGNORED scrobble and error code 1 (totally unhelpful)
|
||||
// so remove all undefined keys from the object before passing to the api client
|
||||
return removeUndefinedKeys(rawPayload);
|
||||
}
|
||||
+191
-137
@@ -1,10 +1,11 @@
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject, URLData } from "../../../core/Atomic.js";
|
||||
import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js";
|
||||
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
normalizeListenbrainzUrl,
|
||||
normalizeStr,
|
||||
parseArtistCredits,
|
||||
parseCredits,
|
||||
@@ -17,90 +18,15 @@ import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions }
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
|
||||
import { parseRegexSingleOrFail } from '../../utils.js';
|
||||
import { removeUndefinedKeys } from '../../utils.js';
|
||||
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
|
||||
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
|
||||
import { version } from '../../ioc.js';
|
||||
import { ListenPayload, ListenResponse, ListenType, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from './listenbrainz/interfaces.js';
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
artist_credit_name: string
|
||||
artist_mbid: string
|
||||
join_phrase: string
|
||||
}
|
||||
|
||||
export interface MinimumTrack {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
}
|
||||
|
||||
export interface AdditionalTrackInfo {
|
||||
artist_mbids?: string[]
|
||||
release_mbid?: string
|
||||
release_group_mbid?: string
|
||||
recording_mbid?: string
|
||||
submission_client?: string
|
||||
submission_client_version?: string
|
||||
spotify_id?: string
|
||||
media_player?: string
|
||||
media_player_version?: string
|
||||
|
||||
music_service?: string
|
||||
music_service_name?: string
|
||||
origin_url?: string
|
||||
tags?: string[]
|
||||
duration?: number
|
||||
|
||||
duration_ms?: number
|
||||
track_mbid?: string
|
||||
work_mbids?: string[]
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
artist_mbids?: string[];
|
||||
artist_msid?: string;
|
||||
recording_mbid?: string;
|
||||
release_mbid?: string;
|
||||
release_msid?: string;
|
||||
tags?: string[];
|
||||
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
|
||||
recording_msid?: string
|
||||
}
|
||||
|
||||
export interface TrackPayload extends MinimumTrack {
|
||||
additional_info?: AdditionalTrackInfo
|
||||
}
|
||||
|
||||
export interface ListenPayload {
|
||||
listened_at: Date | number;
|
||||
recording_msid?: string;
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
listen_type: 'single' | 'playing_now',
|
||||
payload: [ListenPayload]
|
||||
}
|
||||
|
||||
export interface TrackResponse extends MinimumTrack {
|
||||
duration: number
|
||||
additional_info: AdditionalTrackInfoResponse
|
||||
mbid_mapping: {
|
||||
recording_name?: string
|
||||
artist_mbids?: string[]
|
||||
artists?: ArtistMBIDMapping[]
|
||||
caa_id?: number
|
||||
caa_release_mbid?: string
|
||||
recording_mbid?: string
|
||||
release_mbid?: string
|
||||
}
|
||||
interface SubmitOptions {
|
||||
log?: boolean
|
||||
listenType?: ListenType
|
||||
}
|
||||
|
||||
export interface ListensResponse {
|
||||
@@ -108,16 +34,6 @@ export interface ListensResponse {
|
||||
listens: ListenResponse[];
|
||||
}
|
||||
|
||||
export interface ListenResponse {
|
||||
|
||||
inserted_at: number
|
||||
listened_at: number;
|
||||
recording_msid?: string;
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
|
||||
|
||||
export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
@@ -130,9 +46,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
let cleanUrl = url;
|
||||
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
|
||||
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
|
||||
if(pathedUrl !== undefined) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
cleanUrl = url.replace(LZ_VERSION_PATH, '');
|
||||
cleanUrl = pathedUrl;
|
||||
}
|
||||
this.url = normalizeWebAddress(cleanUrl);
|
||||
|
||||
@@ -216,7 +133,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
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.');
|
||||
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.');
|
||||
@@ -307,9 +224,13 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
|
||||
submitListen = async (play: PlayObject, log: boolean = false) => {
|
||||
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
|
||||
const { log = false, listenType = 'single'} = options;
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
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)}`);
|
||||
}
|
||||
@@ -327,45 +248,13 @@ 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(),
|
||||
track_metadata: {
|
||||
artist_name,
|
||||
track_name,
|
||||
release_name,
|
||||
additional_info: {
|
||||
duration,
|
||||
track_mbid,
|
||||
@@ -381,6 +270,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
|
||||
track: track_name,
|
||||
artists: [artist_name],
|
||||
album: release_name,
|
||||
duration,
|
||||
meta: {
|
||||
brainz: {
|
||||
@@ -406,7 +296,12 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
mbid_mapping: {
|
||||
recording_name,
|
||||
artists: artistMappings = [],
|
||||
recording_mbid: mRecordingMbid
|
||||
recording_mbid: mRecordingMbid,
|
||||
release_mbid
|
||||
} = {},
|
||||
additional_info: {
|
||||
release_artists_names = [],
|
||||
release_group_mbid
|
||||
} = {}
|
||||
} = {}
|
||||
} = listen;
|
||||
@@ -428,6 +323,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
let filteredSubmittedArtistName = artist_name;
|
||||
let filteredSubmittedTrackName = track_name;
|
||||
|
||||
let primaryArtist;
|
||||
|
||||
if(artistMappings.length > 0) {
|
||||
|
||||
// first get mapped artists they have joiners we usually look for
|
||||
@@ -524,7 +421,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
// now we check that primary artist exists in mapped artists
|
||||
const candidatedPrimaryArtist = artistsFromUserValues[0];
|
||||
const normalizedCandidatePrimary = normalizeStr(candidatedPrimaryArtist);
|
||||
const primaryArtist = mappedArtists.find(x => {
|
||||
primaryArtist = mappedArtists.find(x => {
|
||||
if(normalizeStr(x) === normalizedCandidatePrimary)
|
||||
{
|
||||
return true;
|
||||
@@ -544,6 +441,12 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
// now we have the primary artist matched!
|
||||
// at this point we assume any differences between user and MB artists is a data discrepancy rather than being outright wrong
|
||||
|
||||
/*
|
||||
*
|
||||
* Can safely use musicbrainz metadata beyond this point!
|
||||
*
|
||||
*/
|
||||
|
||||
// next we match up user artists with mapped artists in order to use "more correct" spelling/punctuation/etc. from MB mapping
|
||||
const mappedUserArtists: string[] = [];
|
||||
for(const userArtist of artistsFromUserValues) {
|
||||
@@ -605,14 +508,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
normalTrackName = recording_name;
|
||||
}
|
||||
|
||||
return {
|
||||
const brainzMetaRaw: BrainzMeta = {
|
||||
...(naivePlay.data.meta.brainz ?? {}),
|
||||
artist: artistMappings.map(x => x.artist_mbid),
|
||||
album: release_mbid,
|
||||
releaseGroup: release_group_mbid
|
||||
}
|
||||
|
||||
// this should always find an artist but to be safe...
|
||||
const primaryArtistMBMapping = artistMappings.find(x => x.artist_credit_name === primaryArtist);
|
||||
if(primaryArtistMBMapping !== undefined) {
|
||||
// only include as primary if musicbrainz does not disagree with us
|
||||
if(release_artists_names.length === 0 || (release_artists_names.length > 0 && release_artists_names.includes(primaryArtist))) {
|
||||
brainzMetaRaw.albumArtist = primaryArtistMBMapping.artist_mbid;
|
||||
}
|
||||
}
|
||||
|
||||
const brainzMeta = removeUndefinedKeys(brainzMetaRaw);
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
...naivePlay.data,
|
||||
track: normalTrackName,
|
||||
artists: derivedArtists,
|
||||
artists: derivedArtists
|
||||
},
|
||||
meta: naivePlay.meta
|
||||
}
|
||||
|
||||
if(brainzMeta !== undefined) {
|
||||
play.data.meta = {
|
||||
brainz: brainzMeta
|
||||
}
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,7 +568,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
} = {},
|
||||
mbid_mapping: {
|
||||
recording_mbid: mRecordingMbid
|
||||
} = {}
|
||||
} = {},
|
||||
additional_info = {},
|
||||
} = {}
|
||||
} = listen;
|
||||
|
||||
@@ -670,7 +600,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
artists = uniqueNormalizedStrArr(artists);
|
||||
|
||||
return {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
playDate: dayjs.unix(listened_at),
|
||||
track: normalTrackName,
|
||||
@@ -680,11 +610,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
},
|
||||
meta: {
|
||||
source: 'listenbrainz',
|
||||
trackId,
|
||||
playId,
|
||||
deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version])
|
||||
}
|
||||
}
|
||||
|
||||
const brainzMeta: BrainzMeta = {};
|
||||
if(Object.keys(additional_info).length > 0) {
|
||||
brainzMeta.additionalInfo = additional_info;
|
||||
}
|
||||
|
||||
// we shouldn't include more metdata here because we don't know if the MB mapped data is actually correct
|
||||
if(trackId !== undefined) {
|
||||
brainzMeta.track = trackId;
|
||||
play.meta.trackid = trackId;
|
||||
}
|
||||
|
||||
if(Object.keys(brainzMeta).length > 0) {
|
||||
play.data.meta = {
|
||||
brainz: brainzMeta
|
||||
}
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
|
||||
static submitToPlayObj(submitObj: SubmitPayload, playObj: PlayObject): PlayObject {
|
||||
@@ -706,3 +654,109 @@ 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
|
||||
|
||||
const msAdditionalInfo = brainz.additionalInfo ?? {};
|
||||
|
||||
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]],
|
||||
// use data from LZ response, if this Play was originally from LZ Source
|
||||
media_player: mediaPlayerName ?? msAdditionalInfo.media_player,
|
||||
media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version,
|
||||
music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service,
|
||||
spotify_id: spotify.track ?? msAdditionalInfo.spotify_id,
|
||||
spotify_album_id: spotify.album ?? msAdditionalInfo.spotify_album_id,
|
||||
spotify_artist_ids: spotify.artist ?? msAdditionalInfo.spotify_artist_ids
|
||||
};
|
||||
|
||||
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,
|
||||
recording_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;
|
||||
}
|
||||
+178
-8
@@ -1,19 +1,186 @@
|
||||
import dayjs from "dayjs";
|
||||
import { PlayObject } from "../../../../core/Atomic.js";
|
||||
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
|
||||
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
|
||||
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 { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { UpstreamError } from "../../errors/UpstreamError.js";
|
||||
import { playToListenPayload } from "../ListenbrainzApiClient.js";
|
||||
import { SubmitPayload } from '../listenbrainz/interfaces.js';
|
||||
import { ListenType } from '../listenbrainz/interfaces.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 {
|
||||
|
||||
constructor(name: any, config: any, options: AbstractApiOptions) {
|
||||
super('Koito', name, config, options);
|
||||
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} = {}): PlayObject => {
|
||||
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track: obj.track.title,
|
||||
@@ -24,10 +191,13 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {
|
||||
meta: {
|
||||
source: 'Koito',
|
||||
newFromSource: options.newFromSource ?? false,
|
||||
trackId: obj.track.id.toString()
|
||||
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) {
|
||||
if (obj.track.musicbrainz_id !== null) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: obj.track.musicbrainz_id
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
|
||||
/*
|
||||
* https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview
|
||||
*/
|
||||
|
||||
/** A unique product a Recording is issued on.
|
||||
*
|
||||
* This is like an album (release group) but is specific to the type, year, catalog, etc... for this release
|
||||
*
|
||||
* EX: 1984 US release of "The Wall" by "Pink Floyd", release on label "Columbia Records" with catalog number "C2K 36183"
|
||||
*
|
||||
* @see https://musicbrainz.org/doc/Release
|
||||
*
|
||||
* Referred to in MB api response as release_mbid
|
||||
*
|
||||
*/
|
||||
export type ReleaseMbid = string;/** The "abstract", non-unique album/single/EP the Recording belongs to
|
||||
*
|
||||
* This is what people normally think of as an album (release group)
|
||||
*
|
||||
* EX: "The Wall" by "Pink Floyd"
|
||||
*
|
||||
* @see https://musicbrainz.org/doc/Release
|
||||
*
|
||||
* Referred to in MB api response as release_group -> mbid
|
||||
*
|
||||
*/
|
||||
export type ReleaseGroupMbid = string;
|
||||
/** A unique mix/edit/master of a Work
|
||||
*
|
||||
* This is like a song but is unique to the master/edit of the song
|
||||
*
|
||||
*
|
||||
* * Album version of the track "Into the Blue" by "Moby"
|
||||
* * Remix "Into the Blue (Buzz Boys Main Room Mayhem mix)" by "Moby"
|
||||
*
|
||||
* @see https://musicbrainz.org/doc/Recording
|
||||
*
|
||||
* Referred to in MB api response as recording_mbid
|
||||
*/
|
||||
export type RecordingMbid = string;
|
||||
/** The "abstract", non-unique Song produced by an Artist
|
||||
*
|
||||
* All Recordings "belong" to a single Work
|
||||
*
|
||||
* EX: Song "Into the Blue" by "Moby"
|
||||
*
|
||||
* @see Song "Into the Blue" by "Moby"
|
||||
*/
|
||||
export type WorkMbid = string;
|
||||
/** A musician or group or musicians that release music
|
||||
*
|
||||
* @see https://musicbrainz.org/doc/Artist
|
||||
*
|
||||
* MB does not distinguish between Artist and Album Artists in API responses except for by release_artist_name in additional_info
|
||||
* All artists/album artists are included in mbid_mappings artists
|
||||
*
|
||||
*/
|
||||
export type ArtistMbid = string;
|
||||
/** A unique, random identifier used for each scrobble. Not the same as recording_mbid */
|
||||
export type RecordingMsid = string;
|
||||
export interface ArtistMBIDMapping {
|
||||
artist_credit_name: string;
|
||||
artist_mbid: ArtistMbid;
|
||||
join_phrase: string;
|
||||
}
|
||||
export interface MinimumTrack {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
}
|
||||
export interface AdditionalTrackInfo {
|
||||
artist_mbids?: ArtistMbid[];
|
||||
release_mbid?: ReleaseMbid;
|
||||
release_group_mbid?: ReleaseGroupMbid;
|
||||
recording_mbid?: RecordingMbid;
|
||||
submission_client?: string;
|
||||
submission_client_version?: string;
|
||||
spotify_id?: string;
|
||||
media_player?: string;
|
||||
media_player_version?: string;
|
||||
|
||||
music_service?: string;
|
||||
music_service_name?: string;
|
||||
origin_url?: string;
|
||||
tags?: string[];
|
||||
duration?: number;
|
||||
|
||||
duration_ms?: number;
|
||||
track_mbid?: string;
|
||||
work_mbids?: WorkMbid[];
|
||||
}
|
||||
export interface Track {
|
||||
artist_name: string;
|
||||
track_name: string;
|
||||
release_name?: string;
|
||||
artist_mbids?: ArtistMbid[];
|
||||
artist_msid?: ArtistMbid;
|
||||
recording_mbid?: string;
|
||||
release_mbid?: ReleaseMbid;
|
||||
release_msid?: string;
|
||||
tags?: string[];
|
||||
|
||||
duration?: number;
|
||||
}
|
||||
export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
|
||||
recording_msid?: RecordingMsid;
|
||||
release_artist_name?: string;
|
||||
release_artists_names?: string;
|
||||
spotify_album_id?: string;
|
||||
spotify_album_artist_ids?: string[];
|
||||
spotify_artist_ids?: 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?: SubmitListenAdditionalTrackInfo;
|
||||
}
|
||||
export interface ListenPayload {
|
||||
listened_at: Date | number;
|
||||
recording_msid?: RecordingMsid;
|
||||
track_metadata: TrackPayload;
|
||||
}
|
||||
export type ListenType = 'single' | 'playing_now';
|
||||
export interface SubmitPayload {
|
||||
listen_type: ListenType;
|
||||
payload: [ListenPayload];
|
||||
}
|
||||
export interface TrackResponse extends MinimumTrack {
|
||||
duration: number;
|
||||
additional_info: AdditionalTrackInfoResponse;
|
||||
mbid_mapping: {
|
||||
recording_name?: string;
|
||||
artist_mbids?: ArtistMbid[];
|
||||
artists?: ArtistMBIDMapping[];
|
||||
caa_id?: number;
|
||||
/** cover album archive mbid, not related to anything else I think */
|
||||
caa_release_mbid?: string;
|
||||
recording_mbid?: RecordingMbid;
|
||||
release_mbid?: ReleaseMbid;
|
||||
};
|
||||
}
|
||||
export interface ListenResponse {
|
||||
|
||||
inserted_at: number;
|
||||
listened_at: number;
|
||||
recording_msid?: RecordingMsid;
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
import dayjs from 'dayjs';
|
||||
import request, { SuperAgentRequest, Response } from 'superagent';
|
||||
import compareVersions from "compare-versions";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
|
||||
import { MalojaData } from "../../infrastructure/config/client/maloja.js";
|
||||
import { PlayObject, URLData } from "../../../../core/Atomic.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
|
||||
import { isNodeNetworkException } from "../../errors/NodeErrors.js";
|
||||
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
|
||||
import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep } from "../../../utils.js";
|
||||
import { UpstreamError } from "../../errors/UpstreamError.js";
|
||||
import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js";
|
||||
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js';
|
||||
import { buildTrackString } from '../../../../core/StringUtils.js';
|
||||
|
||||
|
||||
|
||||
export class MalojaApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: MalojaData;
|
||||
url: URLData;
|
||||
serverVersion: any;
|
||||
|
||||
constructor(name: any, config: MalojaData, options: AbstractApiOptions) {
|
||||
super('Maloja', name, config, options);
|
||||
|
||||
const {
|
||||
url
|
||||
} = this.config;
|
||||
|
||||
const u = normalizeWebAddress(url);
|
||||
this.url = u;
|
||||
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
|
||||
}
|
||||
|
||||
callApi = async <T = Response>(req: SuperAgentRequest, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
if ((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
|
||||
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)
|
||||
} else {
|
||||
throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, { cause: e, showStopper: true });
|
||||
}
|
||||
} else if (isSuperAgentResponseError(e)) {
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
status,
|
||||
body,
|
||||
} = {},
|
||||
response,
|
||||
} = e;
|
||||
if (isMalojaAPIErrorBody(body)) {
|
||||
throw new UpstreamError(buildMalojaErrorString(body), { cause: e })
|
||||
} else {
|
||||
throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, { cause: e })
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unexpected error occurred during API call', { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
await isPortReachableConnect(this.url.port, { host: this.url.url.hostname });
|
||||
} catch (e) {
|
||||
throw new Error(`Maloja server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e });
|
||||
}
|
||||
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
|
||||
}
|
||||
|
||||
this.logger.info('Communication test succeeded.');
|
||||
|
||||
if (version.length === 0) {
|
||||
throw new Error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. Maloja versions below 3.0.0 are not supported.');
|
||||
} else {
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
this.serverVersion = versionstring;
|
||||
if (compareVersions(versionstring, '3.0.0') < 0) {
|
||||
throw new Error(`Maloja versions below 3.0.0 are not supported.`);
|
||||
} else if (compareVersions(versionstring, '3.2.0') < 0) {
|
||||
this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error('Communication test failed', { cause: e })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
testHealth = async () => {
|
||||
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`), 0);
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
db_status: {
|
||||
healthy = false,
|
||||
rebuildinprogress = false,
|
||||
complete = false,
|
||||
}
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
throw new Error(`Server responded with NOT OK status: ${statusCode}`);
|
||||
}
|
||||
|
||||
if (rebuildinprogress) {
|
||||
throw new Error(`Server is rebuilding database`);
|
||||
}
|
||||
|
||||
if (!healthy) {
|
||||
throw new Error('Server responded that it is not healthy');
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
throw new Error('Error encountered while testing server health', { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url.url}/apis/mlj_1/test`)
|
||||
.query({ key: this.config.apiKey }));
|
||||
|
||||
const {
|
||||
status,
|
||||
body: {
|
||||
status: bodyStatus,
|
||||
} = {},
|
||||
body = {},
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Auth test passed!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.error('Maloja API Response', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getRecentScrobbles = async (limit: number) => {
|
||||
const resp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/scrobbles?perpage=${limit}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
return list.map(formatPlayObj);
|
||||
}
|
||||
|
||||
scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
album,
|
||||
albumArtists = [],
|
||||
duration,
|
||||
} = {},
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const pd = getScrobbleTsSOCDate(playObj);
|
||||
|
||||
const scrobbleData = playToScrobblePayload(playObj, this.config.apiKey);
|
||||
|
||||
try {
|
||||
|
||||
|
||||
const response = await this.callApi(request.post(`${this.url.url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send(scrobbleData));
|
||||
|
||||
let scrobbleResponse: MalojaScrobbleData,
|
||||
scrobbledPlay: PlayObject;
|
||||
|
||||
let responseBody: MalojaScrobbleV3ResponseData;
|
||||
let warnStr: string;
|
||||
|
||||
responseBody = response.body;
|
||||
const {
|
||||
track,
|
||||
status,
|
||||
warnings = [],
|
||||
} = responseBody;
|
||||
if (status === 'success') {
|
||||
if (track !== undefined) {
|
||||
scrobbleResponse = {
|
||||
time: pd.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
},
|
||||
}
|
||||
if (album !== undefined) {
|
||||
const {
|
||||
album: malojaAlbum = {},
|
||||
} = track;
|
||||
scrobbleResponse.track.album = {
|
||||
name: album,
|
||||
artists: albumArtists,
|
||||
...malojaAlbum,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
for (const w of warnings) {
|
||||
warnStr = builMalojadWarningString(w);
|
||||
if (warnStr.includes('The submitted scrobble was not added')) {
|
||||
throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false });
|
||||
}
|
||||
this.logger.warn(`Maloja Warning: ${warnStr}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false });
|
||||
}
|
||||
|
||||
return [scrobbleResponse, responseBody, warnStr]
|
||||
} catch (e) {
|
||||
this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData });
|
||||
const responseError = getMalojaResponseError(e);
|
||||
if (responseError !== undefined) {
|
||||
if (responseError.status < 500 && e instanceof UpstreamError) {
|
||||
e.showStopper = false;
|
||||
}
|
||||
if (responseError.response?.text !== undefined) {
|
||||
this.logger.error('Raw Response:', { text: responseError.response?.text });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMalojaErrorString = (body: MalojaResponseV3CommonData) => {
|
||||
let valString: string | undefined = undefined;
|
||||
const {
|
||||
status,
|
||||
error: {
|
||||
type,
|
||||
value,
|
||||
desc
|
||||
} = {}
|
||||
} = body;
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'string') {
|
||||
valString = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
valString = value.map(x => {
|
||||
if (typeof x === 'string') {
|
||||
return x;
|
||||
}
|
||||
return JSON.stringify(x);
|
||||
}).join(', ');
|
||||
} else {
|
||||
valString = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
|
||||
}
|
||||
|
||||
export const builMalojadWarningString = (w: MalojaScrobbleWarning): string => {
|
||||
const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
|
||||
let vals: string[] = [];
|
||||
if (w.value !== null && w.value !== undefined) {
|
||||
if (Array.isArray(w.value)) {
|
||||
vals = w.value;
|
||||
} else {
|
||||
vals.push(w.value);
|
||||
}
|
||||
}
|
||||
if (vals.length > 0) {
|
||||
parts.push(vals.join(' | '));
|
||||
}
|
||||
return parts.join(' => ');
|
||||
}
|
||||
|
||||
export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject => {
|
||||
let artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time,
|
||||
listenedFor;
|
||||
|
||||
const { url } = options;
|
||||
|
||||
// scrobble data structure changed for v3
|
||||
const {
|
||||
// when the track was scrobbled
|
||||
time: mTime,
|
||||
track: {
|
||||
artists: mArtists = [],
|
||||
title: mTitle,
|
||||
album: mAlbum,
|
||||
// length of the track
|
||||
length: mLength,
|
||||
} = {},
|
||||
// how long the track was listened to before it was scrobbled
|
||||
duration: mDuration,
|
||||
} = obj;
|
||||
|
||||
artists = mArtists;
|
||||
time = mTime;
|
||||
title = mTitle;
|
||||
duration = getNonEmptyVal(mLength);
|
||||
listenedFor = getNonEmptyVal(mDuration);
|
||||
if (mAlbum !== null) {
|
||||
const {
|
||||
albumtitle,
|
||||
name: mAlbumName,
|
||||
artists: albumArtists = []
|
||||
} = mAlbum || {};
|
||||
album = albumtitle ?? mAlbumName;
|
||||
}
|
||||
|
||||
const artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
} else if (typeof curr === 'object') {
|
||||
aString = curr.name;
|
||||
}
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
|
||||
return {
|
||||
data: removeUndefinedKeys({
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
listenedFor,
|
||||
playDate: dayjs.unix(time),
|
||||
}),
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
url: {
|
||||
web: `${url}/track?${urlParams.toString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => {
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
listenedFor
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
|
||||
|
||||
const scrobbleData: MalojaScrobbleV3RequestData = {
|
||||
title: track,
|
||||
artists,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: pd.unix(),
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
if (listenedFor !== undefined && listenedFor > 0) {
|
||||
scrobbleData.duration = listenedFor;
|
||||
}
|
||||
|
||||
if (albumArtists.length > 0) {
|
||||
scrobbleData.albumartists = albumArtists;
|
||||
}
|
||||
|
||||
return scrobbleData;
|
||||
}
|
||||
+4
-23
@@ -3,20 +3,6 @@ import { ResponseError } from "superagent";
|
||||
import { findCauseByFunc } from "../../../utils/ErrorUtils.js";
|
||||
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
|
||||
|
||||
export interface MalojaV2ScrobbleData {
|
||||
artists: string[]
|
||||
title: string
|
||||
album: string
|
||||
/**
|
||||
* Length of the track
|
||||
* */
|
||||
duration: number
|
||||
/**
|
||||
* unix timestamp (seconds) scrobble was made at
|
||||
* */
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface MalojaAlbumData {
|
||||
name?: string
|
||||
albumtitle?: string
|
||||
@@ -40,14 +26,14 @@ export interface MalojaV3ScrobbleData {
|
||||
/**
|
||||
* how long the track was listened to before it was scrobbled
|
||||
* */
|
||||
duration: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
|
||||
export type MalojaScrobbleData = MalojaV3ScrobbleData;
|
||||
|
||||
export interface MalojaScrobbleRequestData {
|
||||
/** The auth key used to scrobble */
|
||||
key: string
|
||||
key?: string
|
||||
/** name of the track */
|
||||
title: string
|
||||
/** name of the album */
|
||||
@@ -60,16 +46,11 @@ export interface MalojaScrobbleRequestData {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
|
||||
/** comma-separated list of artists for this track */
|
||||
artist: string
|
||||
}
|
||||
|
||||
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
|
||||
/** a list of artists for this track */
|
||||
artists: string[]
|
||||
/** a list of artists for the album the track is on */
|
||||
albumartists: string[]
|
||||
albumartists?: string[]
|
||||
/** skip server-side metadata parsing */
|
||||
nofix?: boolean
|
||||
}
|
||||
|
||||
@@ -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,17 +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,
|
||||
TA_DEFAULT_ACCURACY,
|
||||
TA_DURING,
|
||||
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";
|
||||
@@ -25,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,
|
||||
@@ -49,12 +51,18 @@ import {
|
||||
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;
|
||||
|
||||
@@ -79,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;
|
||||
@@ -89,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;
|
||||
|
||||
@@ -155,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.`);
|
||||
@@ -396,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);
|
||||
@@ -407,7 +555,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
let timeMatch = 0;
|
||||
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
|
||||
timeMatch = 1;
|
||||
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, [TA_FUZZY, TA_DURING])) {
|
||||
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
|
||||
timeMatch = 0.6;
|
||||
}
|
||||
|
||||
@@ -777,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,105 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
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 { 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,14 +1,13 @@
|
||||
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";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
|
||||
import LastfmApiClient, { playToClientPayload } from "../common/vendor/LastfmApiClient.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.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);
|
||||
@@ -103,7 +103,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObject: PlayObject): object {
|
||||
return this.api.playToClientPayload(playObject);
|
||||
return playToClientPayload(playObject);
|
||||
}
|
||||
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
@@ -116,7 +116,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobblePayload = this.api.playToClientPayload(playObj);
|
||||
const scrobblePayload = playToClientPayload(playObj);
|
||||
|
||||
try {
|
||||
const response = await this.api.callApi<TrackScrobbleResponse>((client: any) => client.trackScrobble(
|
||||
@@ -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} = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -7,10 +6,12 @@ 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, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { isDebugMode } from "../utils.js";
|
||||
|
||||
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
@@ -27,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);
|
||||
@@ -68,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) => {
|
||||
@@ -80,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)}`);
|
||||
@@ -93,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,32 +1,17 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import compareVersions from 'compare-versions';
|
||||
import dayjs from 'dayjs';
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import EventEmitter from "events";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import request, { SuperAgentRequest } from 'superagent';
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
|
||||
import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
|
||||
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
|
||||
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
|
||||
import {
|
||||
getMalojaResponseError,
|
||||
isMalojaAPIErrorBody,
|
||||
MalojaResponseV3CommonData,
|
||||
MalojaScrobbleData,
|
||||
MalojaScrobbleRequestData,
|
||||
MalojaScrobbleV2RequestData,
|
||||
MalojaScrobbleV3RequestData,
|
||||
MalojaScrobbleV3ResponseData, MalojaScrobbleWarning,
|
||||
MalojaV2ScrobbleData,
|
||||
MalojaV3ScrobbleData,
|
||||
} from "../common/vendor/maloja/interfaces.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
import { parseRetryAfterSecsFromObj, sleep } from "../utils.js";
|
||||
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js";
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
@@ -36,208 +21,20 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
serverVersion: any;
|
||||
webUrl: string;
|
||||
|
||||
api: MalojaApiClient;
|
||||
|
||||
declare config: MalojaClientConfig
|
||||
|
||||
constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super('maloja', name, config, notifier, emitter,logger);
|
||||
super('maloja', name, config, notifier, emitter, logger);
|
||||
this.api = new MalojaApiClient(name, this.config.data, { logger: childLogger(this.logger, 'API') });
|
||||
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
let artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time,
|
||||
listenedFor;
|
||||
|
||||
const {serverVersion, url} = options;
|
||||
|
||||
if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
|
||||
// scrobble data structure changed for v3
|
||||
const {
|
||||
// when the track was scrobbled
|
||||
time: mTime,
|
||||
track: {
|
||||
artists: mArtists = [],
|
||||
title: mTitle,
|
||||
album: mAlbum,
|
||||
// length of the track
|
||||
length: mLength,
|
||||
} = {},
|
||||
// how long the track was listened to before it was scrobbled
|
||||
duration: mDuration,
|
||||
} = obj as MalojaV3ScrobbleData;
|
||||
artists = mArtists;
|
||||
time = mTime;
|
||||
title = mTitle;
|
||||
duration = mLength;
|
||||
listenedFor = mDuration;
|
||||
if(mAlbum !== null) {
|
||||
const {
|
||||
albumtitle,
|
||||
name: mAlbumName,
|
||||
artists: albumArtists = []
|
||||
} = mAlbum || {};
|
||||
album = albumtitle ?? mAlbumName;
|
||||
}
|
||||
} else {
|
||||
// scrobble data structure for v2 and below
|
||||
const {
|
||||
artists: mArtists = [],
|
||||
title: mTitle,
|
||||
album: mAlbum,
|
||||
duration: mDuration,
|
||||
time: mTime,
|
||||
} = obj as MalojaV2ScrobbleData;
|
||||
artists = mArtists;
|
||||
title = mTitle;
|
||||
album = mAlbum;
|
||||
duration = mDuration;
|
||||
time = mTime;
|
||||
}
|
||||
const artistStrings = artists.reduce((acc: any, curr: any) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
} else if (typeof curr === 'object') {
|
||||
aString = curr.name;
|
||||
}
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)] as string[],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
listenedFor,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
url: {
|
||||
web: `${url}/track?${urlParams.toString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl});
|
||||
|
||||
callApi = async (req: SuperAgentRequest, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
} = this.config.data;
|
||||
|
||||
try {
|
||||
return await req;
|
||||
} catch (e) {
|
||||
if((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
|
||||
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)
|
||||
} else {
|
||||
throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, {cause : e, showStopper: true});
|
||||
}
|
||||
} else if(isSuperAgentResponseError(e)) {
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
status,
|
||||
body,
|
||||
} = {},
|
||||
response,
|
||||
} = e;
|
||||
if(isMalojaAPIErrorBody(body)) {
|
||||
throw new UpstreamError(buildErrorString(body), {cause: e})
|
||||
} else {
|
||||
throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e})
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unexpected error occurred during API call', {cause : e});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
|
||||
}
|
||||
|
||||
this.logger.info('Communication test succeeded.');
|
||||
|
||||
if (version.length === 0) {
|
||||
this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. multi-scrobbler will most likely not work with this server.');
|
||||
} else {
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
this.serverVersion = versionstring;
|
||||
if(compareVersions(versionstring, '3.0.0') < 0) {
|
||||
this.logger.warn(`Support for Maloja versions below 3.0.0 is DEPRECATED and will be removed in a future minor release.`);
|
||||
} else if(compareVersions(versionstring, '3.2.0') < 0) {
|
||||
this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error('Communication test failed', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
testHealth = async () => {
|
||||
|
||||
const {url} = this.config.data;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0);
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
db_status: {
|
||||
healthy = false,
|
||||
rebuildinprogress = false,
|
||||
complete = false,
|
||||
}
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
|
||||
if (statusCode >= 300) {
|
||||
throw new Error(`Server responded with NOT OK status: ${statusCode}`);
|
||||
}
|
||||
|
||||
if(rebuildinprogress) {
|
||||
throw new Error(`Server is rebuilding database`);
|
||||
}
|
||||
|
||||
if(!healthy) {
|
||||
throw new Error('Server responded that it is not healthy');
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
throw new Error('Error encountered while testing server health', {cause: e});
|
||||
}
|
||||
}
|
||||
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => formatMalojaScrobbleToPlay(obj, { url: this.webUrl });
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {data: {url, apiKey} = {}} = this.config;
|
||||
const { data: { url, apiKey } = {} } = this.config;
|
||||
if (apiKey === undefined) {
|
||||
throw new Error("'apiKey' not found in config!");
|
||||
}
|
||||
@@ -249,291 +46,83 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.testConnection();
|
||||
await this.testHealth();
|
||||
return true;
|
||||
|
||||
try {
|
||||
await this.api.testConnection();
|
||||
await this.api.testHealth();
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
doAuthentication = async () => {
|
||||
|
||||
const {url, apiKey} = this.config.data;
|
||||
const { data: { url, apiKey } = {} } = this.config;
|
||||
if (apiKey === undefined) {
|
||||
throw new Error("'apiKey' not found in config!");
|
||||
}
|
||||
try {
|
||||
const resp = await this.callApi(request
|
||||
.get(`${url}/apis/mlj_1/test`)
|
||||
.query({key: apiKey}));
|
||||
|
||||
const {
|
||||
status,
|
||||
body: {
|
||||
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
|
||||
status: bodyStatus,
|
||||
} = {},
|
||||
body = {},
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Auth test passed!');
|
||||
return true;
|
||||
} else {
|
||||
this.logger.error('Maloja API Response', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {cause: new Error(`Maloja API Response was ${status}: ${text.slice(0,50)}`)})
|
||||
}
|
||||
await this.api.testAuth();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e instanceof UpstreamError) {
|
||||
if((e?.cause as any)?.status === 403) {
|
||||
// may be an older version that doesn't support auth readiness before db upgrade
|
||||
// and if it was before api was accessible during db build then test would fail during testConnection()
|
||||
if(compareVersions(this.serverVersion, '2.12.19') < 0) {
|
||||
if(!(await this.isReady())) {
|
||||
throw new UpstreamError(`Could not test auth because server is not ready`, {showStopper: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNodeNetworkException(e)) {
|
||||
this.logger.error('Could not communicate with Maloja API');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getScrobblesForRefresh = async (limit: number) => {
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
return list.map((x: any) => this.formatPlayObj(x));
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
artists: sourceArtists = [],
|
||||
} = {},
|
||||
} = playObj;
|
||||
let lowerTitle = track.toLocaleLowerCase();
|
||||
lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
|
||||
// also remove [artist] from the track if found since that gets removed as well
|
||||
const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
|
||||
lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle);
|
||||
|
||||
// remove any whitespace in parenthesis
|
||||
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
|
||||
// replace parenthesis
|
||||
.replace('()', '')
|
||||
.replace('( )', '')
|
||||
.trim();
|
||||
|
||||
return lowerTitle;
|
||||
return await this.api.getRecentScrobbles(limit);
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
|
||||
public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData {
|
||||
|
||||
const {apiKey} = this.config.data;
|
||||
const { apiKey } = this.config.data;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists = [],
|
||||
albumArtists = [],
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
listenedFor
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
|
||||
|
||||
const scrobbleData: MalojaScrobbleRequestData = {
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: pd.unix(),
|
||||
// https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
|
||||
length: duration,
|
||||
};
|
||||
if(listenedFor !== undefined && listenedFor > 0) {
|
||||
scrobbleData.duration = listenedFor;
|
||||
}
|
||||
|
||||
// 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
|
||||
(scrobbleData as MalojaScrobbleV3RequestData).artists = artists;
|
||||
if(albumArtists.length > 0) {
|
||||
(scrobbleData as MalojaScrobbleV3RequestData).albumartists = albumArtists;
|
||||
}
|
||||
} else {
|
||||
// maloja seems to detect this deliminator much better than commas
|
||||
// also less likely artist has a forward slash in their name than a comma
|
||||
(scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
|
||||
}
|
||||
|
||||
return scrobbleData;
|
||||
return playToScrobblePayload(playObj);
|
||||
}
|
||||
|
||||
doScrobble = async (playObj: PlayObject) => {
|
||||
const {url, apiKey} = this.config.data;
|
||||
|
||||
const {
|
||||
data: {
|
||||
album,
|
||||
duration,
|
||||
playDate,
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const pd = getScrobbleTsSOCDate(playObj);
|
||||
const scrobbleData = playToScrobblePayload(playObj);
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
const scrobbleData = this.playToClientPayload(playObj);
|
||||
|
||||
let responseBody: MalojaScrobbleV3ResponseData;
|
||||
let scrobbledPlay: PlayObject;
|
||||
|
||||
try {
|
||||
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send(scrobbleData));
|
||||
const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj);
|
||||
|
||||
let scrobbleResponse: any | undefined = undefined,
|
||||
scrobbledPlay: PlayObject;
|
||||
|
||||
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
|
||||
responseBody = response.body;
|
||||
const {
|
||||
track,
|
||||
status,
|
||||
warnings = [],
|
||||
} = responseBody;
|
||||
if(status === 'success') {
|
||||
if(track !== undefined) {
|
||||
scrobbleResponse = {
|
||||
time: pd.unix(),
|
||||
track: {
|
||||
...track,
|
||||
length: duration
|
||||
},
|
||||
}
|
||||
if (album !== undefined) {
|
||||
const {
|
||||
album: malojaAlbum = {},
|
||||
} = track;
|
||||
scrobbleResponse.track.album = {
|
||||
...malojaAlbum,
|
||||
name: album
|
||||
}
|
||||
}
|
||||
}
|
||||
if(warnings.length > 0) {
|
||||
for(const w of warnings) {
|
||||
const warnStr = buildWarningString(w);
|
||||
if(warnStr.includes('The submitted scrobble was not added')) {
|
||||
throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false});
|
||||
}
|
||||
this.logger.warn(`Maloja Warning: ${warnStr}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new UpstreamError(buildErrorString(response), {showStopper: false});
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
body: {
|
||||
track: {
|
||||
time: mTime = pd.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
} = {}
|
||||
} = {}
|
||||
} = response;
|
||||
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
|
||||
}
|
||||
let warning = '';
|
||||
if(scrobbleResponse === undefined) {
|
||||
if (scrobbleResp === undefined) {
|
||||
warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`;
|
||||
scrobbledPlay = playObj;
|
||||
} else {
|
||||
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
|
||||
scrobbledPlay = this.formatPlayObj(scrobbleResp)
|
||||
}
|
||||
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
|
||||
if(warning !== '') {
|
||||
if (warning !== '') {
|
||||
this.logger.warn(`${scrobbleInfo} | ${warning}`);
|
||||
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`);
|
||||
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`);
|
||||
} else {
|
||||
this.logger.info(scrobbleInfo);
|
||||
}
|
||||
return scrobbledPlay;
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
|
||||
const responseError = getMalojaResponseError(e);
|
||||
if(responseError !== undefined) {
|
||||
if(responseError.status < 500 && e instanceof UpstreamError) {
|
||||
e.showStopper = false;
|
||||
}
|
||||
if(responseError.response?.text !== undefined) {
|
||||
this.logger.error('Raw Response:', { text: responseError.response?.text });
|
||||
}
|
||||
}
|
||||
await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' });
|
||||
throw e;
|
||||
} finally {
|
||||
this.logger.debug('Raw Payload:', scrobbleData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildErrorString = (body: MalojaResponseV3CommonData) => {
|
||||
let valString: string | undefined = undefined;
|
||||
const {
|
||||
status,
|
||||
error: {
|
||||
type,
|
||||
value,
|
||||
desc
|
||||
} = {}
|
||||
} = body;
|
||||
if(value !== undefined && value !== null) {
|
||||
if(typeof value === 'string') {
|
||||
valString = value;
|
||||
} else if(Array.isArray(value)) {
|
||||
valString = value.map(x => {
|
||||
if(typeof x === 'string') {
|
||||
return x;
|
||||
}
|
||||
return JSON.stringify(x);
|
||||
}).join(', ');
|
||||
} else {
|
||||
valString = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
|
||||
}
|
||||
|
||||
const buildWarningString = (w: MalojaScrobbleWarning): string => {
|
||||
const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
|
||||
let vals: string[] = [];
|
||||
if(w.value !== null && w.value !== undefined) {
|
||||
if(Array.isArray(w.value)) {
|
||||
vals = w.value;
|
||||
} else {
|
||||
vals.push(w.value);
|
||||
}
|
||||
}
|
||||
if(vals.length > 0) {
|
||||
parts.push(vals.join(' | '));
|
||||
}
|
||||
return parts.join(' => ');
|
||||
}
|
||||
}
|
||||
@@ -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,15 +262,14 @@ 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;
|
||||
}
|
||||
try {
|
||||
const validConfig = validateJson<ClientConfig>(rawConf, this.getSchemaByType(clientType), this.logger);
|
||||
// @ts-expect-error configureAs should exist
|
||||
const {configureAs = defaultConfigureAs} = validConfig;
|
||||
if (configureAs === 'client') {
|
||||
const parsedConfig: ParsedConfig = {
|
||||
@@ -287,7 +319,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 +331,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 +352,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 {
|
||||
|
||||
@@ -255,7 +255,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp
|
||||
duration
|
||||
},
|
||||
meta: {
|
||||
trackProgressPosition: elapsed
|
||||
trackProgressPosition: elapsed,
|
||||
source: 'azuracast',
|
||||
mediaPlayerName: 'Azuracast'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -79,7 +80,8 @@ export default class DeezerInternalSource extends MemorySource {
|
||||
newFromSource,
|
||||
url: {
|
||||
web: `https://www.deezer.com/track/${obj.SNG_ID}`
|
||||
}
|
||||
},
|
||||
musicService: 'Deezer',
|
||||
}
|
||||
};
|
||||
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
|
||||
|
||||
@@ -81,6 +81,7 @@ export default class DeezerSource extends AbstractSource {
|
||||
},
|
||||
meta: {
|
||||
source: 'Deezer',
|
||||
musicService: 'Deezer',
|
||||
trackId: id,
|
||||
newFromSource,
|
||||
url: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
@@ -15,6 +16,9 @@ import MemorySource from "./MemorySource.js";
|
||||
import { LastFMEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlfm.js";
|
||||
import { LastfmTrackUpdateRequest, NowPlayingPayload, TrackScrobblePayload } from "lastfm-node-client";
|
||||
import { scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i);
|
||||
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i);
|
||||
@@ -77,6 +81,8 @@ export class EndpointLastfmSource extends MemorySource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => {
|
||||
|
||||
@@ -7,13 +7,19 @@ import {
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js";
|
||||
import { ListenbrainzApiClient, ListenPayload, SubmitPayload } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { SubmitPayload } from '../common/vendor/listenbrainz/interfaces.js';
|
||||
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
|
||||
import { parseRegexSingleOrFail } from "../utils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
|
||||
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i);
|
||||
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
|
||||
@@ -92,6 +98,8 @@ export class EndpointListenbrainzSource extends MemorySource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
|
||||
|
||||
@@ -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})];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import { TrackObject, UserGetRecentTracksResponse } from "lastfm-node-client";
|
||||
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 { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
|
||||
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
|
||||
import LastfmApiClient from "../common/vendor/LastfmApiClient.js";
|
||||
import { sortByOldestPlayDate } from "../utils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
|
||||
export default class LastfmSource extends MemorySource {
|
||||
|
||||
@@ -149,4 +152,6 @@ export default class LastfmSource extends MemorySource {
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ 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 { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } 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 { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
|
||||
export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
@@ -89,4 +92,6 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
|
||||
import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
|
||||
import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js";
|
||||
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
|
||||
|
||||
export default class MalojaSource extends MemorySource {
|
||||
|
||||
api: MalojaApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
|
||||
declare config: MalojaSourceConfig;
|
||||
|
||||
constructor(name: any, config: MalojaSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data: {
|
||||
interval = 15,
|
||||
maxInterval = 60,
|
||||
...restData
|
||||
} = {}
|
||||
} = config;
|
||||
super('maloja', name, { ...config, data: { interval, maxInterval, ...restData } }, internal, emitter);
|
||||
this.canPoll = true;
|
||||
this.canBacklog = true;
|
||||
this.api = new MalojaApiClient(name, config.data, { logger: this.logger });
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
this.supportsUpstreamRecentlyPlayed = true
|
||||
this.SCROBBLE_BACKLOG_COUNT = 20;
|
||||
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.`)
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
if (this.config.data.apiKey === undefined) {
|
||||
throw new Error(`Must provide 'apiKey' in configuration`);
|
||||
}
|
||||
try {
|
||||
return await this.api.testAuth();
|
||||
} catch (e) {
|
||||
if (isNodeNetworkException(e)) {
|
||||
this.logger.error('Could not communicate with Maloja API');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
const { limit = 20 } = options;
|
||||
this.processRecentPlays([]);
|
||||
return await this.api.getRecentScrobbles(limit);
|
||||
}
|
||||
|
||||
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
|
||||
try {
|
||||
return await this.api.getRecentScrobbles(20);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({ formatted: true, ...options })
|
||||
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -94,6 +98,14 @@ export abstract class AbstractPlayerState {
|
||||
protected abstract newListenProgress(data?: Partial<PlayProgress>): ListenProgress;
|
||||
protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange;
|
||||
|
||||
protected getStaleInterval(): number {
|
||||
return this.stateIntervalOptions.staleInterval;
|
||||
}
|
||||
|
||||
protected getOrphanedInterval(): number {
|
||||
return this.stateIntervalOptions.orphanedInterval;
|
||||
}
|
||||
|
||||
get platformIdStr() {
|
||||
return genGroupIdStr(this.platformId);
|
||||
}
|
||||
@@ -104,7 +116,7 @@ export abstract class AbstractPlayerState {
|
||||
|
||||
isUpdateStale(reportedTS?: Dayjs) {
|
||||
if (this.currentPlay !== undefined) {
|
||||
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
|
||||
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.getStaleInterval();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -113,7 +125,7 @@ export abstract class AbstractPlayerState {
|
||||
const isStale = this.isUpdateStale(reportedTS);
|
||||
if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale;
|
||||
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`);
|
||||
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.getStaleInterval()}s)`);
|
||||
// end current listening sessions
|
||||
this.currentListenSessionEnd();
|
||||
}
|
||||
@@ -121,18 +133,18 @@ export abstract class AbstractPlayerState {
|
||||
}
|
||||
|
||||
isOrphaned() {
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval;
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval();
|
||||
}
|
||||
|
||||
isDead() {
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2;
|
||||
return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval()* 2;
|
||||
}
|
||||
|
||||
checkOrphaned() {
|
||||
const isOrphaned = this.isOrphaned();
|
||||
if (isOrphaned && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.orphaned) {
|
||||
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.orphaned;
|
||||
this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.stateIntervalOptions.orphanedInterval}s)`);
|
||||
this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.getOrphanedInterval()}s)`);
|
||||
}
|
||||
return isOrphaned;
|
||||
}
|
||||
@@ -181,6 +193,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 +209,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 +229,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 +254,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 +286,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 +311,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 +326,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 +394,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 +404,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) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SourcePlayerObj } from "../../../core/Atomic.js";
|
||||
import { GenericPlayerState } from "./GenericPlayerState.js";
|
||||
|
||||
/**
|
||||
* This Player is only used for displaying data reported to EndpointListenbrainzSource, and parsed from ListenbrainzSource, as playing_now
|
||||
* and the behvior for Listenbrainz server is to display playing now info with a timeout equal to duraion of the submitted track
|
||||
* https://github.com/FoxxMD/multi-scrobbler/discussions/338
|
||||
*
|
||||
* We'll use duration as a generic timeout for any Source that *only* parses Now Playing data for Player
|
||||
*/
|
||||
export class NowPlayingPlayerState extends GenericPlayerState {
|
||||
|
||||
protected getStaleInterval() {
|
||||
if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
return this.currentPlay.data.duration;
|
||||
}
|
||||
return super.getStaleInterval();
|
||||
}
|
||||
|
||||
protected getOrphanedInterval() {
|
||||
if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
|
||||
return this.currentPlay.data.duration;
|
||||
}
|
||||
return super.getOrphanedInterval();
|
||||
}
|
||||
|
||||
public getApiState(): SourcePlayerObj {
|
||||
const data = super.getApiState();
|
||||
data.nowPlayingMode = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
|
||||
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
|
||||
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
|
||||
import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
|
||||
import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
|
||||
import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js";
|
||||
import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js";
|
||||
import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js";
|
||||
@@ -65,6 +66,9 @@ 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';
|
||||
import MalojaSource from './MalojaSource.js';
|
||||
|
||||
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
|
||||
|
||||
@@ -163,6 +167,9 @@ export default class ScrobbleSources {
|
||||
case 'ytmusic':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig");
|
||||
break;
|
||||
case 'maloja':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaSourceConfig");
|
||||
break;
|
||||
case 'mpris':
|
||||
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig");
|
||||
break;
|
||||
@@ -199,6 +206,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];
|
||||
@@ -265,13 +275,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;
|
||||
@@ -686,8 +702,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','maloja'].includes(sourceType) &&
|
||||
((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source'))
|
||||
{
|
||||
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
|
||||
continue;
|
||||
@@ -747,7 +763,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);
|
||||
}
|
||||
@@ -762,17 +778,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':
|
||||
@@ -859,15 +875,22 @@ 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;
|
||||
case 'maloja':
|
||||
newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InternalConfig,
|
||||
NO_USER,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
|
||||
import { joinedUrl } from "../utils/NetworkUtils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
|
||||
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js";
|
||||
|
||||
export class WebScrobblerSource extends MemorySource {
|
||||
|
||||
@@ -93,6 +97,9 @@ export class WebScrobblerSource extends MemorySource {
|
||||
} = {}): PlayObject {
|
||||
const {
|
||||
connectorLabel,
|
||||
connector: {
|
||||
label: connectorL
|
||||
} = {},
|
||||
controllerTabId = 'UNK',
|
||||
processed,
|
||||
parsed: {
|
||||
@@ -137,6 +144,7 @@ export class WebScrobblerSource extends MemorySource {
|
||||
origin: originUrl
|
||||
},
|
||||
deviceId: `${connectorLabel}-${controllerTabId}`,
|
||||
musicService: connectorL,
|
||||
scrobbleAllowed: isScrobblingAllowed,
|
||||
nowPlaying: options.nowPlaying ?? false
|
||||
}
|
||||
@@ -181,4 +189,6 @@ export class WebScrobblerSource extends MemorySource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import { joinedUrl } from "../utils/NetworkUtils.js";
|
||||
import { FixedSizeList } from "fixed-size-list";
|
||||
import { todayAwareFormat } from "../utils/TimeUtils.js";
|
||||
import { RestType } from "ts-json-schema-generator";
|
||||
import { parseArtistCredits, parseCredits } from "../utils/StringUtils.js";
|
||||
import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js";
|
||||
|
||||
export interface HistoryIngressResult {
|
||||
plays: PlayObject[],
|
||||
@@ -72,18 +70,43 @@ export const ytiHistoryResponseFromShelfToPlays = (res: ApiResponse, options: {n
|
||||
return items;
|
||||
}
|
||||
|
||||
const GOOGLE_OAUTH_OPTS: GenerateAuthUrlOpts = {
|
||||
access_type: 'offline',
|
||||
scope: [
|
||||
"http://gdata.youtube.com",
|
||||
"https://www.googleapis.com/auth/youtube",
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
"https://www.googleapis.com/auth/youtube-paid-content",
|
||||
"https://www.googleapis.com/auth/accounts.reauth",
|
||||
],
|
||||
include_granted_scopes: true,
|
||||
prompt: 'consent',
|
||||
};
|
||||
const DEFAULT_SCOPES = [
|
||||
"http://gdata.youtube.com",
|
||||
"https://www.googleapis.com/auth/youtube",
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
"https://www.googleapis.com/auth/youtube-paid-content",
|
||||
"https://www.googleapis.com/auth/accounts.reauth",
|
||||
];
|
||||
|
||||
const VALID_SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube",
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
"https://www.googleapis.com/auth/youtube-paid-content",
|
||||
]
|
||||
|
||||
const getGoogleOauthOpts = (): GenerateAuthUrlOpts => {
|
||||
let scopes: string[];
|
||||
const userInput = parseArrayFromMaybeString(process.env.YTM_SCOPES);
|
||||
if (userInput.length > 0) {
|
||||
scopes = userInput.map(x => {
|
||||
if (x.toLocaleLowerCase() === 'default') {
|
||||
return DEFAULT_SCOPES;
|
||||
} else if (x.toLocaleLowerCase() === 'valid') {
|
||||
return VALID_SCOPES;
|
||||
}
|
||||
return x;
|
||||
}).flat(1);
|
||||
} else {
|
||||
scopes = DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
return {
|
||||
access_type: 'offline',
|
||||
scope: scopes,
|
||||
include_granted_scopes: true,
|
||||
prompt: 'consent',
|
||||
};
|
||||
}
|
||||
|
||||
export default class YTMusicSource extends AbstractSource {
|
||||
|
||||
@@ -162,6 +185,9 @@ export default class YTMusicSource extends AbstractSource {
|
||||
this.authed = true;
|
||||
await this.yti.session.oauth.cacheCredentials();
|
||||
});
|
||||
if(isDebugMode()) {
|
||||
Log.setLevel(Log.Level.DEBUG);
|
||||
}
|
||||
}
|
||||
|
||||
protected configureCustomOauth() {
|
||||
@@ -203,7 +229,9 @@ export default class YTMusicSource extends AbstractSource {
|
||||
redirectUri: this.redirectUri,
|
||||
});
|
||||
|
||||
const authorizationUrl = this.oauthClient.generateAuthUrl(GOOGLE_OAUTH_OPTS);
|
||||
const scopeOpts = getGoogleOauthOpts();
|
||||
this.logger.debug(`Using scopes:\n${(scopeOpts.scope as string[]).join('\n')}`)
|
||||
const authorizationUrl = this.oauthClient.generateAuthUrl(getGoogleOauthOpts());
|
||||
this.verificationUrl = authorizationUrl;
|
||||
}
|
||||
|
||||
@@ -395,6 +423,7 @@ Redirect URI : ${this.redirectUri}`);
|
||||
},
|
||||
meta: {
|
||||
source: 'YTMusic',
|
||||
musicService: 'Youtube Music',
|
||||
trackId: id,
|
||||
newFromSource,
|
||||
comment: shelf
|
||||
@@ -419,6 +448,10 @@ Redirect URI : ${this.redirectUri}`);
|
||||
});
|
||||
return res;
|
||||
} catch (e) {
|
||||
const info = loggedErrorExtra(e);
|
||||
if(info !== undefined) {
|
||||
this.logger.error(info, 'Additional API response details')
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,7 @@ 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));
|
||||
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
|
||||
expect(clients.clients).length(expectedCount);
|
||||
expect(clients.clients).length(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import chai, { expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import { generatePlay } from "../utils/PlayTestUtils.js";
|
||||
|
||||
import { playToClientPayload } from '../../common/vendor/LastfmApiClient.js';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
describe('#LFM Scrobble Payload Behavior', function () {
|
||||
|
||||
it('Should remove VA from album artist', function() {
|
||||
const play = generatePlay({albumArtists: ['VA']});
|
||||
expect(playToClientPayload(play).albumArtist).to.be.undefined;
|
||||
|
||||
const okPlay = generatePlay({albumArtists: ['My Dude']});
|
||||
expect(playToClientPayload(okPlay).albumArtist).eq('My Dude');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { http, HttpResponse } from "msw";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { UpstreamError } from "../../common/errors/UpstreamError.js";
|
||||
|
||||
import { ListenbrainzApiClient, ListenResponse } from "../../common/vendor/ListenbrainzApiClient.js";
|
||||
import { ListenbrainzApiClient } from "../../common/vendor/ListenbrainzApiClient.js";
|
||||
import { ListenResponse } from '../../common/vendor/listenbrainz/interfaces.js';
|
||||
import { ExpectedResults } from "../utils/interfaces.js";
|
||||
import { withRequestInterception } from "../utils/networking.js";
|
||||
import artistWithProperJoiner from './correctlyMapped/artistProperHasJoinerInName.json';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -425,42 +425,6 @@ describe('Player Cleanup', function () {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import chai, { assert, expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import clone from "clone";
|
||||
import YTMusicSource, { ytiHistoryResponseFromShelfToPlays, ytiHistoryResponseToListItems } from "../../sources/YTMusicSource.js";
|
||||
import ytHistoryRes from './ytres.json' assert {type: 'json'};
|
||||
import ytHistoryRes from './ytres.json' with {type: 'json'};
|
||||
import EventEmitter from "events";
|
||||
import { generatePlay, generatePlays, normalizePlays } from '../utils/PlayTestUtils.js';
|
||||
import { YTMusicSourceConfig } from '../../common/infrastructure/config/source/ytmusic.js';
|
||||
import { sleep } from '../../utils.js';
|
||||
import dayjs from 'dayjs';
|
||||
import { ApiResponse } from 'youtubei.js';
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -33,12 +34,12 @@ const createYtSource = (opts?: {
|
||||
describe('Parses History', function () {
|
||||
|
||||
it(`Parses a history response to tracks`, async function () {
|
||||
const items = ytiHistoryResponseToListItems(ytHistoryRes);
|
||||
const items = ytiHistoryResponseToListItems(ytHistoryRes as ApiResponse);
|
||||
expect(items).length(10);
|
||||
});
|
||||
|
||||
it(`Parses a history response plays with shelf name`, async function () {
|
||||
const items = ytiHistoryResponseFromShelfToPlays(ytHistoryRes);
|
||||
const items = ytiHistoryResponseFromShelfToPlays(ytHistoryRes as ApiResponse);
|
||||
expect(items[0]?.meta?.comment).to.eq('March 2023');
|
||||
});
|
||||
});
|
||||
|
||||
+27
-12
@@ -285,7 +285,9 @@ export const removeUndefinedKeys = <T extends Record<string, any>>(obj: T): T |
|
||||
if(Array.isArray(obj[key])) {
|
||||
newObj[key] = obj[key];
|
||||
} else if (obj[key] === Object(obj[key])) {
|
||||
newObj[key] = removeUndefinedKeys(obj[key]);
|
||||
// dumb assign nested objects
|
||||
// bc they may be third party library-objects that use prototyping and we don't want to mess with
|
||||
newObj[key] = obj[key];
|
||||
} else if (obj[key] !== undefined) {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
@@ -609,23 +611,36 @@ export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayOb
|
||||
return eArtists.length > 1 || cArtists.length > 1;
|
||||
}
|
||||
|
||||
export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable<T> | undefined => {
|
||||
export interface NonEmptyOptions<T> {
|
||||
ofType?: string,
|
||||
test?: (val: T) => boolean
|
||||
}
|
||||
export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => {
|
||||
for(const v of values) {
|
||||
if(v === undefined || v === null) {
|
||||
continue;
|
||||
const nonEmptyVal = getNonEmptyVal(v, options);
|
||||
if(nonEmptyVal !== undefined) {
|
||||
return nonEmptyVal as T;
|
||||
}
|
||||
if(options.ofType !== undefined && typeof v !== options.ofType) {
|
||||
continue;
|
||||
}
|
||||
if(options.test !== undefined && options.test(v as T) === false) {
|
||||
continue;
|
||||
}
|
||||
return v as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, {ofType: 'string', test: (v) => v.trim() !== ''});
|
||||
export const getNonEmptyVal = <T = unknown>(value: unknown, options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.ofType !== undefined && typeof value !== options.ofType) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.test !== undefined && options.test(value as T) === false) {
|
||||
return undefined;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const nonEmptyStringOpts: NonEmptyOptions<string> = { ofType: 'string', test: (v) => v.trim() !== '' };
|
||||
export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, nonEmptyStringOpts);
|
||||
export const getNonEmptyString = (value: unknown) => getNonEmptyVal<string>(value, nonEmptyStringOpts);
|
||||
|
||||
/**
|
||||
* Runs the function `fn`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,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,
|
||||
@@ -329,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 ')}`];
|
||||
}
|
||||
@@ -42,6 +42,7 @@ art = {},
|
||||
} = {},
|
||||
play,
|
||||
listenedDuration = 0,
|
||||
nowPlayingMode = false,
|
||||
status: {
|
||||
calculated = '???',
|
||||
reported,
|
||||
@@ -51,14 +52,17 @@ art = {},
|
||||
} = data;
|
||||
|
||||
let durPer = null;
|
||||
if(duration !== undefined && duration !== null && duration !== 0) {
|
||||
if(listenedDuration === 0 || listenedDuration === null) {
|
||||
durPer = ' (0%)';
|
||||
} else {
|
||||
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
|
||||
if(!nowPlayingMode) {
|
||||
if(duration !== undefined && duration !== null && duration !== 0) {
|
||||
if(listenedDuration === 0 || listenedDuration === null) {
|
||||
durPer = ' (0%)';
|
||||
} else {
|
||||
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const [viewMode, setViewMode] = useState('player');
|
||||
|
||||
const toggleViewMode = useCallback(() => {
|
||||
@@ -94,10 +98,10 @@ art = {},
|
||||
<p className="subtitle">{calculated !== 'stopped' ? artists.join(' / ') : '-'}</p>
|
||||
</div>
|
||||
|
||||
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
|
||||
<PlayerTimestamp duration={duration} indeterminate={(calculated === 'playing' && data.position === undefined) || nowPlayingMode} current={data.position || 0} />
|
||||
<div className="flex">
|
||||
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
<p className="stats flex-1 text-left">Status: {['unknown','playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
</div>
|
||||
</section>
|
||||
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
|
||||
|
||||
+46
-1
@@ -1,6 +1,7 @@
|
||||
import { LogDataPretty, LogLevel } from "@foxxmd/logging";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js";
|
||||
import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js";
|
||||
|
||||
export interface SourceStatusData {
|
||||
status: string;
|
||||
@@ -66,12 +67,38 @@ export interface ListenRangeData {
|
||||
end: ListenProgress
|
||||
}
|
||||
|
||||
/** https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview */
|
||||
export interface BrainzMeta {
|
||||
/**
|
||||
* artist_mbids
|
||||
*
|
||||
* All artists, including ft guests etc... go here */
|
||||
artist?: string[]
|
||||
/**
|
||||
* artists_mbid
|
||||
*
|
||||
* If multiple artists for track this is the "original" artist who is releasing the single/album */
|
||||
albumArtist?: string
|
||||
/**
|
||||
* release_mbid
|
||||
*
|
||||
* The unique release like --> 1984 US release of "The Wall" by "Pink Floyd", release on label "Columbia Records" with catalog number "C2K 36183"
|
||||
* */
|
||||
album?: string
|
||||
/** Unique track id, recording_mbid */
|
||||
track?: string
|
||||
/**
|
||||
*
|
||||
* The "consolidated" album like --> "The Wall" by "Pink Floyd" */
|
||||
releaseGroup?: string
|
||||
additionalInfo?: AdditionalTrackInfoResponse
|
||||
}
|
||||
|
||||
export interface SpotifyMeta {
|
||||
artist?: string[]
|
||||
albumArtist?: string[]
|
||||
album?: string
|
||||
track?: string
|
||||
releaseGroup?: string
|
||||
}
|
||||
|
||||
export interface TrackData {
|
||||
@@ -86,6 +113,7 @@ export interface TrackData {
|
||||
|
||||
meta?: {
|
||||
brainz?: BrainzMeta
|
||||
spotify?: SpotifyMeta
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +126,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 +185,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
|
||||
* */
|
||||
@@ -213,6 +257,7 @@ export interface SourcePlayerObj {
|
||||
playerLastUpdatedAt: string
|
||||
position?: Second
|
||||
listenedDuration: Second
|
||||
nowPlayingMode?: boolean
|
||||
status: {
|
||||
reported: string
|
||||
calculated: string
|
||||
|
||||
@@ -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