Compare commits

...
70 Commits
Author SHA1 Message Date
FoxxMD 46bcf6d6a6 chore: Bump version for release 2025-05-29 13:20:34 +00:00
FoxxMD 4da931269c fix(plex): On Plex webhook routes filter plex sources to get webhook source only
#291
2025-05-27 17:31:34 +00:00
FoxxMD d19d40d479 test: Fix real time player not triggered during event loop
Running tests synchronously preventing rt player from advancing which cause each player update to be considered an overdrift
2025-05-27 17:07:30 +00:00
FoxxMD b3e1c7f660 chore: Update devcontainer to node 20 to match docker image 2025-05-27 17:05:50 +00:00
FoxxMD 1242a710df fix: Fix NaN and undefined initial states for rt player #292 2025-05-27 15:29:06 +00:00
FoxxMD b724eca9be docs: Add missing var for docs deploy 2025-04-23 15:04:20 +00:00
FoxxMD 376d4936cc docs: Add google site verification 2025-04-23 15:01:42 +00:00
FoxxMD a5da0573c5 docs: Remove flatpak installation method
See #287
2025-03-31 18:31:46 +00:00
FoxxMD 4694ce45a7 chore: Update flatpak description 2025-03-31 12:12:28 -04:00
FoxxMD 7c7d710cd3 fix(ui): Guard against null listenedDuration
Fixes #285
2025-03-28 08:21:04 -04:00
FoxxMD e2cd7e2991 chore: Bump version for release 2025-03-25 01:15:40 +00:00
FoxxMD 92d8eb8d6f chore: Update static config schema 2025-03-25 01:15:26 +00:00
FoxxMD d14e7d9b2e fix(lastfm): Remove duration for scrobble if value is 0 2025-03-25 01:09:31 +00:00
FoxxMD e4befae55a fix(lastfm): Fix endpoint regex to exclude auth callback url 2025-03-20 19:14:34 +00:00
FoxxMD 0e00b425b4 fix(icecast): rename old scrobble behavior variable 2025-03-20 16:27:30 +00:00
FoxxMD fcf6c8eb4c docs: Add some common source options 2025-03-20 16:23:01 +00:00
FoxxMD 242bacc1be feat(source): Optional position for non-positional sources
* Allow non-positional Sources to still report position (for display only)
* Refactor Azuracast to be non-positional due to reported position not always being accurate (azuracast approximates track length)
2025-03-20 15:40:31 +00:00
FoxxMD cbe28f97a0 feat(source): Improve "Should Scrobble" functionality
* Rename from manualListening to Should Scrobble to better describe functionality
* Move check to Source scrobble function to avoid short circuting Source player for no reason
* Add to Azuracast and make functionality more generic
2025-03-20 15:12:00 +00:00
FoxxMD 336972f586 fix(ui): Update status indicator for ingress sources 2025-03-20 14:13:48 +00:00
Matt Foxx 81edb31e7b Merge pull request #283 from FoxxMD/icecast
Icecast
2025-03-19 12:57:38 -04:00
FoxxMD dbde66ad77 docs: Add Icecast docs 2025-03-19 16:50:57 +00:00
FoxxMD efbf2da816 feat(icecast): Switch to metadata-only parsing 2025-03-19 16:34:19 +00:00
FoxxMD 38d1db2f3f feat: Implement Icecast Source MVP 2025-03-19 15:20:44 +00:00
Matt Foxx 265ae82663 Merge pull request #278 from FoxxMD/GH-263/azuracast
feat: Add Azuracast Source
2025-03-19 09:28:27 -04:00
FoxxMD 2bd2972af6 docs: Fix bad merge of azuracast/musiccast config 2025-03-19 13:22:52 +00:00
Matt Foxx 1bdf083e28 Merge branch 'master' into GH-263/azuracast 2025-03-19 09:17:44 -04:00
Matt Foxx 4c233fdded Merge pull request #279 from FoxxMD/GH-274/musiccast
feat(musiccast): Implement MusicCast Source
2025-03-19 08:42:40 -04:00
FoxxMD e0060fe3e5 feat: Add ENVs for default scrobble threshold for duration/percent
* SOURCE_SCROBBLE_DURATION env sets default duration
* SOURCE_SCROBBLE_PERCENT env sets default percent
2025-03-18 17:28:14 +00:00
FoxxMD 2adb3bd052 docs(musiccast): Add docs for Yahama MusicCast 2025-03-17 21:02:26 +00:00
FoxxMD 0dca49ab43 fix(player): Check for 0 duration before finalizing listen session with duration-based calculation 2025-03-17 20:40:06 +00:00
FoxxMD 6a71d4a495 fix(musiccast): Treat 0 total_total as undefined
Prevents some positional player logic from running on a functionally-useless value
2025-03-17 20:40:06 +00:00
FoxxMD 230ddf4a48 fix(source): Do not calculate polling interval decrease if duration does not exist 2025-03-17 20:40:06 +00:00
FoxxMD e4e1797ac5 fix(musiccast): Remove hardocded port for debugging 2025-03-17 20:40:06 +00:00
FoxxMD 1dd3185715 fix: Better handling of 0 duration tracks
* Check for zero duration before using progress bar in debug logging
* Adds tests for scrobble thresholds to make sure 0 duration is handled
2025-03-17 20:40:06 +00:00
FoxxMD 4fee231a7b test(musiccast): Add tests for basic connectivity 2025-03-17 20:36:15 +00:00
FoxxMD b5ead39792 feat(musiccast): Switch play info fetch order
net/usb is more likely to be used since it has more sources. Likely using it will reduce required fetches in most usage scenarios.
2025-03-17 20:36:15 +00:00
FoxxMD 190431b9d4 feat(musiccast): Implement MusicCast Source 2025-03-17 20:36:15 +00:00
FoxxMD 210fa35cb6 docs: Add Last.fm Endpoint source docs 2025-03-14 16:34:43 +00:00
FoxxMD 479af1cd5e docs: Add Listenbrainz Endpoint source docs 2025-03-14 16:29:48 +00:00
Matt Foxx c72831cd12 Merge pull request #281 from FoxxMD/normalScrobbleEndpoints
feat: Implement dummy endpoint Sources for Listenbrainz and Last.fm
2025-03-14 11:24:08 -04:00
FoxxMD f68a9493ce fix(listenbrainz): add dummy validate-token endpoint 2025-03-14 08:16:56 -04:00
Matt Foxx 78a93d0809 Merge pull request #256 from FoxxMD/configurablePlayerStale
feat(source): Stale/orphan scrobble and timestamp improvments
2025-03-13 15:26:46 -04:00
FoxxMD 3680ac8af1 feat(lastm): Implement MVP Lastfm endpoint source 2025-03-13 18:44:08 +00:00
FoxxMD e74ba52c47 fix(listenbrainz): Improved slug and normal lz endpoint detection 2025-03-13 16:53:25 +00:00
FoxxMD d3cd07c5c3 feat(listenbrainz): Implement MVP LZ endpoint source 2025-03-13 16:20:37 +00:00
FoxxMD 2b890d4704 refactor: Pass reportedTS for testing purposes 2025-03-13 11:06:26 -04:00
FoxxMD 47eead2a18 feat: Use timestamp for position MS change on RT player
Makes testing easier
2025-03-13 11:06:26 -04:00
FoxxMD 38ea3115f6 test: Add tests for scrobble behavior when cleaning up players 2025-03-13 11:06:26 -04:00
FoxxMD f11a2b5442 fix: Use last known position for final range when cleaning up stale RT player
Prevents listen duration from being reported as overdrift RT position due to lack of player updates
2025-03-13 11:06:26 -04:00
FoxxMD 818f67d6da feat(source): Add relative time since last activity to logging
Makes logs easier to read when looking for stale source/player
2025-03-13 11:06:26 -04:00
FoxxMD 84f353220d feat(source): Clear listening range on stale to prevent erroneous scrobble on resume
Clearing pre-stale listening range prevents any future resume (player becomes non-stale) from using old listening range to scrobble. Represents a physical "break" between listening sessions for the same song
2025-03-13 11:06:26 -04:00
FoxxMD 47541a449b feat: Use human-friendly timestamp for stale/ophan interva logging 2025-03-13 11:06:26 -04:00
FoxxMD 1a62edab37 feat(source): Add stale/orphaned intervals as user configurable 2025-03-13 11:06:26 -04:00
FoxxMD 5927450840 fix(ytm): Recover artist/album from YTM when not surfaced by youtube.js
Use flex_columns for parsing raw data when musicshelf when artist/album does not have an endpoint LuanRT/YouTube.js#381

Fixes #280
2025-03-13 10:40:47 -04:00
FoxxMD ec9bea70fd chore: Bump YTM dependency version 2025-03-13 10:37:53 -04:00
FoxxMD 2f9a45b1e4 fix: Filter components by name and check for undefined
Fixes #273
2025-03-12 19:16:32 +00:00
FoxxMD 3c649df2e9 docs(azuracast): Add azuracast Source docs 2025-03-11 16:41:42 +00:00
FoxxMD ab1766ca0a feat(azuracast): Add station name hint and fix labels 2025-03-11 16:10:38 +00:00
FoxxMD a42de78ad3 feat(azuracast): Implement manual listening 2025-03-11 16:06:46 +00:00
FoxxMD 81bde3a771 feat(azuracast): Add MVP azuracast source implementation 2025-03-11 15:21:07 +00:00
FoxxMD 982e6655ef chore: Add more log guidance on bug report 2025-02-14 17:51:17 +00:00
FoxxMD 44ce0cba3c feat(azuracast): Implement config and types 2025-02-03 16:53:39 +00:00
FoxxMD c1e1c69b5e fix: Missing optional period on vs. when parsing artist 2025-01-27 21:13:32 +00:00
FoxxMD 142defdd20 docs: Put delimited artist example in expander 2025-01-27 14:57:15 +00:00
FoxxMD aad9f2b4de docs: Add primary artist extraction examples for scrobble modification 2025-01-27 09:46:14 -05:00
FoxxMD 8e92407182 test: Add modification example for parsing a primary artist from delimited string 2025-01-27 09:36:15 -05:00
FoxxMD 94210e29d1 docs: Fix extra array in title modification example
Fixes #258
2025-01-27 09:16:34 -05:00
FoxxMD d2c6194314 ci: Add comments on PR SHA usage 2025-01-21 14:06:16 -05:00
FoxxMD 11ca7a724d try pull SHA directory for build
So that build summary details used SHA
2025-01-21 13:58:47 -05:00
FoxxMD 3057908074 fix(ci): Add context to build-push so it uses checked out PR ref 2025-01-21 13:28:45 -05:00
79 changed files with 3710 additions and 220 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
{
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
+10 -2
View File
@@ -50,9 +50,17 @@ body:
attributes:
label: Logs
description: |
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode)
Please copy and paste any/all relevant from the DEBUG level log output.
Even if your issue does not seem to be shown in the logs you should still add as much detail as possible, a _lack_ of issues in logs may be a good hint during debugging.
Your logs will be automatically formatted into code, do not need use backticks.
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode).
render: shell
placeholder: |
[2025-02-14 12:47:57.170 -0500] VERBOSE: [Init] Config Dir ENV: undefined -> Resolved: CWD/config
[2025-02-14 12:47:57.236 -0500] INFO : [Init] Debug Mode: NO
[2025-02-14 12:47:57.258 -0500] INFO : [Init] Version: master
[2025-02-14 12:47:57.259 -0500] INFO : [Init] Generating schema definitions...
[2025-02-14 12:47:59.148 -0500] DEBUG : [App] [Scrobblers] [Maloja - default] Found 100 recent scrobbles
- type: textarea
attributes:
label: Additional Context
+1
View File
@@ -30,6 +30,7 @@ jobs:
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
working-directory: ./docsite
+7 -3
View File
@@ -53,9 +53,11 @@ jobs:
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# do not use here!
# instead we can specify repo#sha in build-push-action so we only deal with foreign SHA for actually building image
# - uses: actions/checkout@v4
# with:
# ref: ${{ github.event.pull_request.head.sha }}
- name: Set short git commit SHA
run: |
@@ -95,6 +97,8 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
# specify foreign repo#sha from PR that is used to build image
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
platforms: ${{ matrix.platform }}
+6 -1
View File
@@ -19,7 +19,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
@@ -29,6 +31,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [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)
* [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)
* Supports scrobbling to many **Clients**
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
@@ -40,7 +45,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
+13
View File
@@ -0,0 +1,13 @@
[
{
"type": "azuracast",
"enable": true,
"name": "azura",
"data": {
"url": "ws://192.168.0.101",
"station": "my-station-name",
"monitorWhenLive": true,
"monitorWhenListeners": 1
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myLfm",
"enable": true,
"data": {
"slug": "mySlug"
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myLz",
"enable": true,
"data": {
"token": "myToken"
}
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "myIcy",
"enable": true,
"data": {
"url": "http://myIcecast.com/radio"
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myYamaha",
"enable": true,
"data": {
"url": "192.168.0.101"
}
}
]
+410 -2
View File
@@ -10,17 +10,22 @@ import SchemaLink from "../../src/components/SchemaLink";
import AIOExample from "../../src/components/AIOExample";
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
import KodiConfig from '!!raw-loader!../../../config/kodi.json.example';
import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example';
import LastfmEndpointConfig from '!!raw-loader!../../../config/endpointlfm.json.example';
import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example';
import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example';
import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example';
import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example';
import MprisConfig from '!!raw-loader!../../../config/mpris.json.example';
import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example';
import MusiccastConfig from '!!raw-loader!../../../config/musiccast.json.example';
import MPDConfig from '!!raw-loader!../../../config/mpd.json.example';
import PlexConfig from '!!raw-loader!../../../config/plex.json.example';
import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example';
@@ -214,6 +219,67 @@ Disable using either:
* ENV `DISABLE_WEB=true`
* In [All-in-One File](./?configType=aio#configuration-types) use the top-level property `"disableWeb": true`
## Common Source Functionality
Some options and functionality is shared between all or some Sources. Detailed below is information on what these features are and how to use/configured them.
#### Scrobble Thresholds
For Sources where multi-scrobbler monitors active listening, it uses one of two metrics to determine if a Song is scrobbable based on your listening:
* **percent** of track listened to (50% or more)
* or amount of time (**duration**) track was listened to (4 minutes or more)
These default values are based on [last.fm's scrobble guidance.](https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble)
Each Source can have these values customized using values in the Source's `options` in [file or aio config](./?configType=file#configuration-types):
```json5 title="azuracast.json"
{
"data": {
// ...
},
"options": {
"scrobbleThresholds": {
"duration": 40, // scrobbable if listened to for 40 seconds or more
"percent": 20 // scrobbable if listened to for 20% or more of the track's length
}
}
}
```
These can also be configured **globally** for all Sources using ENVs:
```
SOURCE_SCROBBLE_DURATION=40
SOURCE_SCROBBLE_PERCENT=20
```
#### Should Scrobble Behavior
:::note
This feature is currently available only for some Sources. Check each Source's documentation to see if it supported.
:::
For _most_ Sources multi-scrobbler can determine if a user is listening and scrobble automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not scrobble. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card.
Additionally, for these Sources, the default behavior for Scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](./?configType=file#configuration-types):
```json5 title="azuracast.json"
{
"data": {
// ...
},
"options": {
// set to true to always scrobble automatically
// set to false to never scrobble automatically
"systemScrobble": true
}
}
```
## Source Configurations
### [Spotify](https://www.spotify.com)
@@ -638,8 +704,67 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions.
</TabItem>
</Tabs>
### [Last.fm (Endpoint)](https://www.last.fm)
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server.
##### URL
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
```
http://localhost:9078/2.0/
```
which is the "standard" Last.fm server path for scrobbling.
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
If a slug is used then the URL will be:
```
http://localhost:9078/api/lastfm/mySlug
```
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
| `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/endpointlfm.json" language="json5">{LastfmEndpointConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={LastfmEndpointConfig} name="endpointlfm"/>
</details>
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
</TabItem>
</Tabs>
### [Listenbrainz (Source)](https://listenbrainz.org)
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
@@ -679,6 +804,73 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
</TabItem>
</Tabs>
### [Listenbrainz (Endpoint)](https://listenbrainz.org)
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server. An example of this would be configuring [pano-scrobbler](https://github.com/kawaiiDango/pano-scrobbler) with a custom Listenbrainz URL to point to multi-scrobbler.
##### URL
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
```
http://localhost:9078/1/submit-listens
```
which is the "standard" Listenbrainz server path for scrobbling.
Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens.
If a slug is used then the URL will be:
```
http://localhost:9078/api/listenbrainz/mySlug
```
:::note
Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
:::
##### Token
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/endpointlz.json" language="json5">{ListenbrainzEndpointConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={ListenbrainzEndpointConfig} name="endpointlz"/>
</details>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
</Tabs>
### [~~Deezer~~](https://deezer.com/)
:::warning
@@ -1729,8 +1921,6 @@ If the file being played in VLC does not have ID3 tags/metadata information mult
To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used.
<!-- Would like to put this in a <details> node but the <> in the named capture groups are causing the parser to incorrectly parse them as an actual tag -->
Example usage in [a file-based config:](./?configType=file#webscrobbler-config)
```json5 title="config/vlc.json" {10-15}
@@ -1773,6 +1963,224 @@ If you find that VLC is incorrectly reporting track information (in its interfac
]
```
### [Azuracast](https://www.azuracast.com/)
:::tip[Scrobbling Threshold]
Azuracast may not correctly report track length or position. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
:::
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
##### URL
The URL used by MS to connect to Azuracast has the syntax:
```
[ws|wss]://HOST:[PORT]
```
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
```
URL From Config => MS Uses
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
```
##### Scrobble Behavior
Multi-scrobbler will set the default [**scrobbling behavior**](#should-scrobble-behavior) based on if either of these settings is configured:
* live status (`AZURA_LIVE` or `monitorWhenLive`) or
* listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`)
If either is configured then MS will automatically scrobble based on when these conditions are met.
If neither is configured automatic scrobble defaults to **off** and you should either [manually initiate it or explicitly set the default behavior.](#should-scrobble-behavior)
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
| `AZ_STATION` | Yes | | The station name shown on the public page |
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={AzuracastConfig} name="azuracast"/>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
</Tabs>
### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html)
Monitor Musiccast device/receivers for music played on Network/USB/CD inputs.
:::tip[Scrobbling Threshold]
Musiccast may not correctly report track length. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
:::
##### URL
If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use.
EX
| `url` | Transformed URL |
| --------------------------------------- | :---------------------------------------------- |
| `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` |
| `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` |
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | :----------------------- |
| `MCAST_URL` | Yes | | The Musiccast device URL |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/musiccast.json" language="json5">{MusiccastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={MusiccastConfig} name="musiccast"/>
</details>
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
</TabItem>
</Tabs>
### [Icecast](https://icecast.org)
Parse metadata from any Icecast Station (streams) to use for scrobbling.
:::note
There is no standard format for presenting track information in an Icecast stream and the majority of streams that do include metadata only use a single string (`StreamTitle`) from which metadata can be parsed.
Multi-scrobbler will do its best to parse Artist/Album/Track information from this data but the quality and consistency for this is solely based on how the Icecast broadcaster chooses to format their tracks.
:::
:::tip[Scrobbling Behavior]
Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic scrobbling for Icecast Sources is off by default.**
Use [Should Scrobble Behavior](#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
:::
:::tip[Scrobbling Threshold]
Icecast does not report track lengths so multi-scrobbler cannot determine if it should scrobble based on percent played. You should instead set a shorter "listened to" **duration** using [Scrobble Thresholds.](#scrobble-thresholds)
:::
##### URL and Sources
The URL should be the URL you would use to stream audio.
Additional sources are supported based on those found in [`icecast-metadata-stats`](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#supported-status-apis). To use these with automatic parsing [based on the regular URL](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) include a `sources` array in your config the same way as shown in `icecast-metadata-stats`:
<details>
<summary>Example</summary>
```json5 title="config.json"
[
{
"name": "myIcy",
"enable": true,
"data": {
"url": "http://myIcecast.com/radio",
"sources": ["icy", "ogg", "sevenhtml"]
}
}
]
```
</details>
To manually set these sources include the same [`*Endpoint` options](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) found in `icecast-metadata-stats`.
If no `sources` are configured for multi-scrobbler it will automatically try to use `icy` and `ogg`.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ----------- |
| `ICECAST_URL` | Yes | | Icecast URL |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={IcecastConfig} name="icecast"/>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
</Tabs>
## Client Configurations
### [Maloja](https://github.com/krateng/maloja)
+47 -7
View File
@@ -132,13 +132,11 @@ Putting it all together:
"playTransform": {
"preCompare": {
"title": [
[
"badTerm",
{
"search": "badTerm",
"replace": "goodTerm"
}
]
"badTerm",
{
"search": "badTerm",
"replace": "goodTerm"
}
]
},
}
@@ -481,3 +479,45 @@ Removes the phrase `(Album Version)` from the Title of a Play
}
```
</details>
### Extract primary Artist from delimited, multi-Artist string
<details>
When the Artist string is actually a multi-artist, delimited string, this search-and-replace will replace the string with just the first artist found.
Ex
```
My Artist One / My Artist Two / Another Guy
My Artist One
```
Artists are delimited with a spaced forward slash (`/`) in the regex below. Replace the contents of the `delim` capture group with the delimiter for your use case. Some more common scenarios:
* `(?<delim>\\/)` No spaces between slash IE `My Artist One/My Artist Two/Another Guy`
* `(?<delim>\\s*\\\\\s*)` Backslash instead of forward slash IE `My Artist One \ My Artist Two \ Another Guy`
* `(?<delim>,)` Comma IE `My Artist One, My Artist Two, Another Guy`
<details>
<summary>Example</summary>
```json5 title="config.json"
{
"sourceDefaults": {
"playTransform": {
"preCompare": {
"artists": [
{
"search": "(.*?)(?<delim>\\s*\\/\\s*)(.*$)",
"replace": "$1"
}
]
}
}
}
}
```
</details>
</details>
-6
View File
@@ -6,12 +6,6 @@ title: Flatpak
description: Building Flatpak App locally
---
:::note
These steps are for building the flatpak from source. If you want to install the application normally then [get it through flathub](../installation/installation.mdx#flatpak)
:::
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
+3 -7
View File
@@ -105,12 +105,6 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
### Flatpak
You must have [Flatpak](https://flatpak.org/) installed on your system.
```shell
flatpak install flathub io.github.foxxmd.multiscrobbler
```
:::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:
@@ -118,7 +112,9 @@ Flatpak users have experienced issues when using multi-scrobbler as a long-runni
* 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
+2 -15
View File
@@ -31,27 +31,14 @@ After=network.target
[Service]
Type=simple
ExecStart=flatpak run io.github.multiscrobbler
WorkingDirectory=/path/to/multi-scrobbler/directory
ExecStart=npm run start
Restart=no
[Install]
WantedBy=default.target
```
The above assumes you [installed multi-scrobbler using flatpak](installation.mdx#flatpak)
### Node.js Installs
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
```ini
[Service]
Type=simple
WorkingDirectory=/path/to/multi-scrobbler/directory
ExecStart=node src/index.js
Restart=no
```
## Start the Service
Save the file then run:
+9
View File
@@ -173,6 +173,15 @@ const config: Config = {
runmeLinkLabel: 'Checkout via Runme'
}
} satisfies Preset.ThemeConfig,
headTags: [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: process.env.GSITEVERIFICATION ?? 'none'
}
}
]
};
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
+6 -1
View File
@@ -20,7 +20,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [WebScrobbler](docs/configuration#webscrobbler)
* [Youtube Music](docs/configuration#youtube-music)
* [Last.fm](docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
@@ -30,6 +32,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Musikcube](docs/configuration#musikcube)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* [VLC](docs/configuration#vlc)
* [Azuracast](docs/configuration#azuracast)
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
* [Icecast](docs/configuration#icecast)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
@@ -41,7 +46,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
* Install using [Docker images for x86/ARM](docs/installation#docker) or [locally with NodeJS](docs/installation#nodejs)
[**Quick Start Guide**](docs/quickstart)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,8 +33,12 @@
<li>MPRIS (linux desktop)</li>
<li>Google Cast (Chromecast)</li>
<li>Musikcube</li>
<li>MPDF (Music Player Daemon)</li>
<li>MPD (Music Player Daemon)</li>
<li>VLC</li>
<li>Azuracast</li>
<li>Icecast</li>
<li>Yamaha MusicCast</li>
<li>Listenbrainz and Last.fm Endpoints</li>
</ul>
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
<ul>
@@ -51,6 +55,7 @@
</screenshot>
</screenshots>
<releases>
<release version="0.9.2" date="2025-03-24"/>
<release version="0.9.0" date="2025-01-15"/>
<release version="0.8.4" date="2024-09-27"/>
<release version="0.8.3" date="2024-08-28"/>
+62 -13
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -22,6 +22,7 @@
"@foxxmd/logging": "^0.2.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@foxxmd/string-sameness": "^0.4.0",
"@gr2m/net-interceptor": "^1.0.0",
"@jellyfin/sdk": "^0.11.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@lukehagar/plexjs": "^0.32.1",
@@ -52,6 +53,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
@@ -82,7 +84,7 @@
"vite-express": "^0.16.0",
"vlc-client": "^1.1.1",
"xml2js": "0.6.1",
"youtubei.js": "^12.0.0"
"youtubei.js": "^13.1.0"
},
"devDependencies": {
"@dbus-types/notifications": "^0.0.5",
@@ -121,6 +123,7 @@
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"ts-essentials": "^9.1.2",
@@ -1135,6 +1138,14 @@
"npm": ">=9.3.0"
}
},
"node_modules/@gr2m/net-interceptor": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@gr2m/net-interceptor/-/net-interceptor-1.0.0.tgz",
"integrity": "sha512-qLTp3npmB5SDiEIh1Jg1v8a09dlnfYV1kcSyLmsyucvZBT89nM+6L1QdlrCC/W1cTvdTpvI+Vleit94UVX7/Cg==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@homebridge/long": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@homebridge/long/-/long-5.2.1.tgz",
@@ -4097,6 +4108,11 @@
"node": ">=6"
}
},
"node_modules/codec-parser": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/codec-parser/-/codec-parser-2.5.0.tgz",
"integrity": "sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ=="
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -6026,6 +6042,33 @@
"node": ">= 14"
}
},
"node_modules/icecast-metadata-js": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/icecast-metadata-js/-/icecast-metadata-js-1.2.9.tgz",
"integrity": "sha512-8YqPrJ4AjM64O28xF9TSUUFczxnTKwXwnIPmZKRxdbaZb6hn0nP+ke1OGNA+UsIfLpNRW4acDDBkIkbynYVQig==",
"dependencies": {
"codec-parser": "2.5.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/icecast-metadata-stats": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/icecast-metadata-stats/-/icecast-metadata-stats-0.1.12.tgz",
"integrity": "sha512-qywYIIvxjAmZIFNUXMVZ/IgIJh87z0W6oOmJ5htPw3SUauXcYoY6rRexvzN5Ibct8hXsqoTcB+k8m6Wa53bfJg==",
"dependencies": {
"icecast-metadata-js": "1.2.9"
},
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -6810,9 +6853,9 @@
}
},
"node_modules/jintr": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.1.0.tgz",
"integrity": "sha512-azhCHApkRfBH8INpiUCwKBYaNCdB5G+x3NApsI2MxQXSlgFAx7rap3YwE3JAkN08GO8f3ilZsGB0Yvc+412ntQ==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz",
"integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
@@ -7393,6 +7436,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"node_modules/mopidy": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz",
@@ -11746,9 +11795,9 @@
"peer": true
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"engines": {
"node": ">=10.0.0"
},
@@ -11922,15 +11971,15 @@
}
},
"node_modules/youtubei.js": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-12.0.0.tgz",
"integrity": "sha512-pGmVb1I9b2gseqmuMx+BCajzVUi04+r+8zxj4Fk/iQaGQGvBCbY87Tu9mdvEgIQYTkkb4Fza7GZGrH9AjYNbrw==",
"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==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"jintr": "^3.1.0",
"jintr": "^3.2.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
}
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.3",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -54,6 +54,7 @@
"@foxxmd/logging": "^0.2.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@foxxmd/string-sameness": "^0.4.0",
"@gr2m/net-interceptor": "^1.0.0",
"@jellyfin/sdk": "^0.11.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@lukehagar/plexjs": "^0.32.1",
@@ -84,6 +85,7 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
@@ -114,7 +116,7 @@
"vite-express": "^0.16.0",
"vlc-client": "^1.1.1",
"xml2js": "0.6.1",
"youtubei.js": "^12.0.0"
"youtubei.js": "^13.1.0"
},
"devDependencies": {
"@dbus-types/notifications": "^0.0.5",
@@ -153,6 +155,7 @@
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.1.2",
"nodemon": "^3.0.3",
"ts-essentials": "^9.1.2",
@@ -0,0 +1,44 @@
diff --git a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
index 7b70725..8836a99 100644
--- a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
+++ b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
@@ -59,6 +59,7 @@ const sources = Symbol();
const interval = Symbol();
const onStats = Symbol();
const onStatsFetch = Symbol();
+const onError = Symbol();
const state = Symbol();
const intervalId = Symbol();
@@ -106,6 +107,7 @@ export default class IcecastMetadataStats {
[interval]: (options.interval || 30) * 1000,
[onStats]: options.onStats || noOp,
[onStatsFetch]: options.onStatsFetch || noOp,
+ [onError]: options.onError,
[icyMetaInt]: options.icyMetaInt,
[icyCharacterEncoding]: options.icyCharacterEncoding,
[icyDetectionTimeout]: options.icyDetectionTimeout,
@@ -425,13 +427,21 @@ export default class IcecastMetadataStats {
signal: p.get(this)[controller].signal,
})
.then((res) => {
- if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
+ if (!res.ok) {
+ const responseError = new Error(`HTTP Error ${res.status}`);
+ responseError.res = res;
+ throw responseError;
+ }
return res;
})
.then(mapper)
.catch((e) => {
if (e.name !== "AbortError") {
- console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
+ if(p.get(this)[onError] !== undefined) {
+ p.get(this)[onError](new Error(`Failed to fetch ${p.get(this)[endpoint]}`, {cause: e}));
+ } else {
+ console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
+ }
}
})
.finally(() => {
+12 -2
View File
@@ -15,9 +15,12 @@ export type SourceType =
| 'jellyfin'
| 'lastfm'
| 'deezer'
| 'endpointlz'
| 'endpointlfm'
| 'ytmusic'
| 'mpris'
| 'mopidy'
| 'musiccast'
| 'listenbrainz'
| 'jriver'
| 'kodi'
@@ -25,7 +28,9 @@ export type SourceType =
| 'chromecast'
| 'musikcube'
| 'mpd'
| 'vlc';
| 'vlc'
| 'icecast'
| 'azuracast';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -35,9 +40,12 @@ export const sourceTypes: SourceType[] = [
'jellyfin',
'lastfm',
'deezer',
'endpointlz',
'endpointlfm',
'ytmusic',
'mpris',
'mopidy',
'musiccast',
'listenbrainz',
'jriver',
'kodi',
@@ -45,7 +53,9 @@ export const sourceTypes: SourceType[] = [
'chromecast',
'musikcube',
'mpd',
'vlc'
'vlc',
'icecast',
'azuracast'
];
export const isSourceType = (data: string): data is SourceType => {
@@ -48,5 +48,32 @@ export interface PollingOptions {
* @examples [30]
* */
maxInterval?: number
/**
* Number of seconds after which A Player is considered Stale
*
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale:
*
* * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening)
* * If the player has an existing session w/ track then MS attempts to scrobble it
*
* This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined.
*/
staleAfter?: number
/**
* Number of seconds after which A Player is considered Orphaned
*
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned:
*
* * The current Player session is ended and the Player is removed from MS
* * MS attempts to scrobble, if the Player has an existing session w/ track
*
* A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale.
*
* * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined.
* * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`)
*/
orphanedAfter?: number
}
@@ -0,0 +1,106 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
export interface AzuraStationInfoResponse {
id: string
name: string
shortcode: string
is_public: boolean
}
export interface AzuraListenersResponse {
total: number
unique: number
current: number
}
export interface AzuraSongResponse {
id: string
text: string
artist: string
title: string
album: string
genre: string
isrc: string
}
export interface AzuraNowPlayingResponse {
sh_id: number
played_at: number
duration: number
streamer: string
elapsed: number
remaining: number
song: AzuraSongResponse
}
export interface AzuraLiveResponse {
is_live: boolean
streamer_name: string
broadcast_start: number | null
}
export interface AzuraStationResponse {
is_online: boolean
station: AzuraStationInfoResponse
listeners: AzuraListenersResponse
now_playing: AzuraNowPlayingResponse
}
export interface AzuracastData extends CommonSourceData {
/**
* Base URL of the Azuracast instance
*
* This does NOT include the station. If a station is included it will be ignored. Use `station` field to specify station, if necessary
*
*
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
* */
url: string
/**
* The specific station to monitor
*
* Scrobbling will only occur if any of the monitor conditions are met AND the station is ONLINE.
*
* To monitor multiple stations create a Source for each station.
*
* @examples ["my-station-1"]
* */
station: string
/**
* Only activate scrobble monitoring if station
*
* * `true` => has any current listeners
* * `number` => has EQUAL TO or MORE THAN X number of listeners
*
*/
monitorWhenListeners?: boolean | number
/**
* Only activate scrobble monitoring if station has a live DJ/Streamer
*
* @default true
*/
monitorWhenLive?: boolean
/**
* API Key used to access data about private streams
*
* https://www.azuracast.com/docs/developers/apis/#api-authentication
* */
apiKey?: string
}
export interface AzuracastSourceoptions extends CommonSourceOptions, ManualListeningOptions {
}
export interface AzuracastSourceConfig extends CommonSourceConfig {
data: AzuracastData
}
export interface AzuracastSourceAIOConfig extends AzuracastSourceConfig {
type: 'azuracast'
}
@@ -0,0 +1,25 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface LastFMEndpointData extends CommonSourceData {
/**
* The URL ending that should be used to identify scrobbles for this source
*
* If you are using multiple Last.fm endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
*
* Example:
*
* * slug: 'usera' => API URL: http://localhost:9078/api/lastfm/usera
* * slug: 'originb' => API URL: http://localhost:9078/api/lastfm/originb
*
* If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used
* */
slug?: string | null
}
export interface LastFMEndpointSourceConfig extends CommonSourceConfig {
data?: LastFMEndpointData
}
export interface LastFMEndpointSourceAIOConfig extends LastFMEndpointSourceConfig {
type: 'endpointlfm'
}
@@ -0,0 +1,32 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface ListenbrainzEndpointData extends CommonSourceData {
/**
* The URL ending that should be used to identify scrobbles for this source
*
* If you are using multiple Listenbrainz endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
*
* Example:
*
* * slug: 'usera' => API URL: http://localhost:9078/api/listenbrainz/usera
* * slug: 'originb' => API URL: http://localhost:9078/api/listenbrainz/originb
*
* If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used
* */
slug?: string | null
/**
* If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source
*
* See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests
* */
token?: string | null
}
export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig {
data?: ListenbrainzEndpointData
}
export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig {
type: 'endpointlz'
}
@@ -0,0 +1,47 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
export interface IcecastMetadata {
icy?: {
/** Title of the ICY metadata update, usually Artist - Title */
StreamTitle?: string
/** URL of the ICY metadata update, usually album art */
StreamUrl?: string
}
ogg?: {
/** Title of the OGG metadata update, usually Artist - Title */
TITLE?: string
ALBUM?: string
ARTIST?: string
}
}
export type IcecastSource = 'icy' | 'ogg' | 'icestats' | 'stats' | 'sevenhtml' | 'nextsongs';
export interface IcecastOptions {
sources?: IcecastSource[]
icestatsEndpoint?: string
statsEndpoint?: string
nextsongsEndpoint?: string
sevenhtmlEndpoint?: string
icyMetaInt?: number
}
export interface IcecastData extends CommonSourceData, IcecastOptions {
/**
* The Icecast stream URL
* */
url: string
}
export interface IcecastSourceOptions extends CommonSourceOptions, ManualListeningOptions {
}
export interface IcecastSourceConfig extends CommonSourceConfig {
data: IcecastData
options?: IcecastSourceOptions
}
export interface IcecastSourceAIOConfig extends IcecastSourceConfig {
type: 'icecast'
}
@@ -109,6 +109,15 @@ export interface CommonSourceOptions extends SourceRetryOptions {
playTransform?: PlayTransformOptions
}
export interface ManualListeningOptions {
/**
* For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred?
*
* If not specified MS will use a Source's specific behavior, see Source's documentation.
*/
systemScrobble?: boolean
}
export interface CommonSourceData extends CommonData {
}
@@ -0,0 +1,87 @@
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
export interface MusicCastResponse {
response_code: number
}
export interface DeviceInfoResponse extends MusicCastResponse {
model_name: string
device_id: string
system_version: number
api_version: number
}
export interface DeviceStatusResponse extends MusicCastResponse {
power: 'on' | 'standby'
}
/** use with /netusb/getPlayInfo or /cd/getPlayInfo */
export interface PlayInfoCDResponse extends MusicCastResponse {
device_status: 'open' | 'close' | 'ready' | 'not_ready'
playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
/** in seconds */
play_time: number
/** in seconds */
total_time: number
artist: string
album: string
track: string
}
export interface PlayInfoNetResponse extends PlayInfoCDResponse {
input: string
}
export const MusicCastResponseCodes = new Map<number, string>([
[0, 'Success'],
[1, 'Initializing'],
[2, 'Internal Error'],
[3, 'Invalid Request'],
[4, 'Invalid Parameter'],
[5, 'Guarded (Unable to setup in current status)'],
[6, 'Time out'],
[100, 'Access Error'],
[101, 'Other Error'],
[107, 'Service Maintenance'],
[109, 'License Error'],
[110, 'Read Only Mode'],
[112, 'Access Denied'],
[115, 'Simultaneous logins has reached the upper limit'],
[200, 'Linking in progress'],
[201, 'Unlinking in progress']
]);
export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => {
switch(pb) {
case 'play':
case 'fast_forward':
case 'fast_reverse':
return REPORTED_PLAYER_STATUSES.playing;
case 'pause':
return REPORTED_PLAYER_STATUSES.paused;
case 'stop':
return REPORTED_PLAYER_STATUSES.stopped;
default:
return REPORTED_PLAYER_STATUSES.unknown;
}
}
export interface MusicCastData extends CommonSourceData {
/**
* The host or URL of the YamahaExtendedControl endpoint to use
*
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
* */
url: string
}
export interface MusicCastSourceConfig extends CommonSourceConfig {
data: MusicCastData
}
export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig {
type: 'musiccast'
}
@@ -1,4 +1,7 @@
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
@@ -9,6 +12,7 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js";
import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js";
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
@@ -16,6 +20,7 @@ import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
export type SourceConfig =
@@ -24,6 +29,8 @@ export type SourceConfig =
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
| JellySourceConfig
| JellyApiSourceConfig
@@ -37,8 +44,11 @@ export type SourceConfig =
| WebScrobblerSourceConfig
| ChromecastSourceConfig
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
| VLCSourceConfig;
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -46,6 +56,8 @@ export type SourceAIOConfig =
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
| JellySourceAIOConfig
| JellyApiSourceAIOConfig
@@ -59,5 +71,8 @@ export type SourceAIOConfig =
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
| VLCSourceAIOConfig;
| VLCSourceAIOConfig
| IcecastSourceAIOConfig
| AzuracastSourceAIOConfig;
@@ -30,6 +30,10 @@ declare module 'lastfm-node-client' {
mbid?: string
}
export interface TrackScrobbleRequest extends TrackScrobblePayload {
method: 'track.scrobble'
}
export interface TrackScrobbleResponse {
scrobbles: {
'@attr': {
@@ -52,6 +56,10 @@ declare module 'lastfm-node-client' {
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
export type NowPlayingRequest = NowPlayingPayload & { method: 'track.updateNowPlaying' };
export type LastfmTrackUpdateRequest = TrackScrobbleRequest | NowPlayingRequest;
export interface NowPlayingResponse {
nowplaying: {
ignoredMessage: {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67 -2
View File
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import LastFm, {
AuthGetSessionResponse,
LastfmTrackUpdateRequest,
NowPlayingPayload,
NowPlayingResponse,
TrackObject,
TrackScrobblePayload,
@@ -16,6 +18,7 @@ import { UpstreamError } from "../errors/UpstreamError.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
import AbstractApiClient from "./AbstractApiClient.js";
import { parseArtistCredits } from "../../utils/StringUtils.js";
const badErrors = [
'api key suspended',
@@ -224,13 +227,18 @@ export default class LastfmApiClient extends AbstractApiClient {
artist = artists[0];
}
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
if(duration !== 0) {
additionalRichPayload.duration = duration;
}
const rawPayload: TrackScrobblePayload = {
artist: artist,
duration,
track,
album,
timestamp: getScrobbleTsSOCDate(playObj).unix(),
mbid,
...additionalRichPayload
};
// LFM does not support multiple artists in scrobble payload
@@ -275,3 +283,60 @@ export default class LastfmApiClient extends AbstractApiClient {
}
}
}
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
const {
artist,
track,
duration,
album,
albumArtist,
} = obj;
let ts: Dayjs | undefined;
if('timestamp' in obj) {
ts = dayjs.unix(obj.timestamp);
}
const mbid = 'mbid' in obj ? obj.mbid : undefined;
let artists: string[] = [];
const credits = parseArtistCredits(artist);
if(credits !== undefined) {
artists.push(credits.primary);
if(credits.secondary !== undefined) {
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
if(nonEmptyArtists.length > 0) {
artists = [...artists, ...nonEmptyArtists];
}
}
} else {
artists = [artist];
}
const play: PlayObject = {
data: {
track,
album: nonEmptyStringOrDefault(album),
albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined,
duration: typeof duration === 'string' ? parseInt(duration, 10) : duration,
playDate: ts,
artists
},
meta: {
source: 'lastfm',
nowPlaying: obj.method === 'track.updateNowPlaying'
}
};
if(nonEmptyStringOrDefault(mbid) !== undefined) {
play.data.meta = {
brainz: {
track: mbid
}
};
}
return play;
}
+38 -1
View File
@@ -81,7 +81,7 @@ export interface ListenPayload {
}
export interface SubmitPayload {
listen_type: 'single',
listen_type: 'single' | 'playing_now',
payload: [ListenPayload]
}
@@ -300,6 +300,43 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
const {
listened_at = dayjs().unix(),
track_metadata: {
artist_name,
track_name,
additional_info: {
duration,
track_mbid,
artist_mbids,
release_mbid,
release_group_mbid
} = {}
} = {},
} = payload;
return {
data: {
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
track: track_name,
artists: [artist_name],
duration,
meta: {
brainz: {
artist: artist_mbids !== undefined ? artist_mbids : undefined,
album: release_mbid,
albumArtist: release_group_mbid,
track: track_mbid
}
}
},
meta: {
nowPlaying,
}
}
}
static listenResponseToPlay(listen: ListenResponse): PlayObject {
const {
listened_at,
@@ -0,0 +1,85 @@
import { childLogger } from "@foxxmd/logging";
import { URLData } from "../../../../core/Atomic.js";
import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
export class AzuracastApiClient extends AbstractApiClient {
declare config: AzuracastData
urlData: URLData;
wsNowPlaying: AzuraStationResponse
wsCurrenTime: number = 0;
socket!: WS;
constructor(name: any, config: AzuracastData, options: AbstractApiOptions) {
super('Azuracast API', name, config, options);
this.urlData = normalizeWSAddress(config.url);
}
connectWS() {
const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket');
const socket = new WebSocket(url);
socket.onopen = (e) => {
socket.send(JSON.stringify({
subs: {
[`station:${this.config.station}`]: {"recover": true}
}
}));
};
socket.onerror = (e) => {
this.logger.error(e);
}
// Handle a now-playing event from a station. Update your now-playing data accordingly.
function handleSseData(ssePayload, useTime = true) {
const jsonData = ssePayload.data;
if (useTime && 'current_time' in jsonData) {
this.wsCurrenTime = jsonData.current_time;
}
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
}
socket.onmessage = (e) => {
const jsonData = JSON.parse(e.data as string);
if ('connect' in jsonData) {
const connectData = jsonData.connect;
if ('data' in connectData) {
// Legacy SSE data
connectData.data.forEach(
(initialRow) => handleSseData(initialRow)
);
} else {
// New Centrifugo time format
if ('time' in connectData) {
this.wsCurrenTime = Math.floor(connectData.time / 1000);
}
// New Centrifugo cached NowPlaying initial push.
for (const subName in connectData.subs) {
const sub = connectData.subs[subName];
if ('publications' in sub && sub.publications.length > 0) {
sub.publications.forEach((initialRow) => handleSseData(initialRow, false));
}
}
}
} else if ('pub' in jsonData) {
handleSseData(jsonData.pub);
}
};
}
}
+5 -2
View File
@@ -55,7 +55,7 @@ export default class ScrobbleClients {
getByType = (type: any) => this.clients.filter(x => x.type === type)
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
let clients: AbstractScrobbleClient[];
let clients: AbstractScrobbleClient[] = [];
const messages: string[] = [];
let clientsReady = true;
@@ -63,7 +63,10 @@ export default class ScrobbleClients {
if(type !== undefined) {
clients = this.getByType(type);
} else if(name !== undefined) {
clients = [this.getByName(name)];
const clientByName = this.getByName(name);
if(clientByName !== undefined) {
clients = [clientByName];
}
} else {
clients = this.clients;
}
+35 -1
View File
@@ -24,11 +24,12 @@ import { parseBool, sortByNewestPlayDate } from "../utils.js";
import { setupAuthRoutes } from "./auth.js";
import { setupDeezerRoutes } from "./deezerRoutes.js";
import { setupJellyfinRoutes } from "./jellyfinRoutes.js";
import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js";
import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.js";
import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js";
import { setupPlexRoutes } from "./plexRoutes.js";
import { setupTautulliRoutes } from "./tautulliRoutes.js";
import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js";
import { Readable } from 'node:stream';
const maxBufferSize = 300;
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
@@ -152,6 +153,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
setupJellyfinRoutes(app, logger, scrobbleSources);
setupDeezerRoutes(app, logger, scrobbleSources);
setupWebscrobblerRoutes(app, logger, scrobbleSources);
setupLZEndpointRoutes(app, logger, scrobbleSources);
setupLastfmEndpointRoutes(app, logger, scrobbleSources);
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
app.putAsync('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
@@ -192,6 +195,9 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
players: 'players' in x ? (x as MemorySource).playersToObject() : {},
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY,
supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed,
supportsManualListening: x.supportsManualListening,
manualListening: x.manualListening,
systemListeningBehavior: x.getSystemListeningBehavior(),
...x.additionalApiData()
};
if(!x.isReady()) {
@@ -445,6 +451,34 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
}
});
app.use('/api/source/listen', sourceRequiredMiddle);
app.postAsync('/api/source/listen', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const source = req.scrobbleSource as AbstractSource;
const {
query: {
listening: listeningQ
}
} = req;
if(!source.supportsManualListening)
{
source.logger.warn('This source does not support manual Should Scrobble state');
res.status(400).send();
return;
}
let listening: boolean | undefined;
if(listeningQ !== undefined) {
listening = parseBool(listeningQ)
}
source.logger.verbose(`User requested Should Scrobble status ${listening === undefined ? 'system' : listening}`);
source.manualListening = listening;
res.status(200).json({listening});
});
app.use('/api/client/init', clientRequiredMiddle);
app.postAsync('/api/client/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
@@ -0,0 +1,60 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import { ExpressWithAsync } from "@awaitjs/express";
import { childLogger, Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import ScrobbleSources from "../sources/ScrobbleSources.js";
import { nonEmptyBody } from "./middleware.js";
import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.js";
import { EndpointLastfmSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.js";
import { LastfmTrackUpdateRequest } from "lastfm-node-client";
export const setupLastfmEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'LFM']);
const nonEmptyCheck = nonEmptyBody(logger, 'LFM Endpoint');
const webhookIngress = new LFMEndpointNotifier(logger);
app.useAsync(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
webhookIngress.trackIngress(req, true);
if (req.method !== 'POST') {
return res.sendStatus(405);
}
next();
},
bodyParser.urlencoded({ extended: true }),
nonEmptyCheck, async function (req, res) {
webhookIngress.trackIngress(req, false);
const sources = scrobbleSources.getByType('endpointlfm') as EndpointLastfmSource[];
if (sources.length === 0) {
logger.warn('Received Lastfm endpoint payload but no Lastfm endpoint sources are configured');
}
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug] = parseDisplayIdentifiersFromRequest(req);
logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`);
}
if(!('method' in req.body)) {
return res.status(400).json({error: `Missing 'method' param`});
}
const method = (req.body as LastfmTrackUpdateRequest).method;
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
return res.status(400).json({error: `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`});
}
res.sendStatus(200);
const playerState = playStateFromRequest(req.body);
for (const source of validSources) {
await source.handle(playerState);
}
});
}
@@ -0,0 +1,63 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import { ExpressWithAsync } from "@awaitjs/express";
import { childLogger, Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import { EndpointListenbrainzSource, playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.js";
import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.js";
import ScrobbleSources from "../sources/ScrobbleSources.js";
import { nonEmptyBody } from "./middleware.js";
export const setupLZEndpointRoutes = (app: ExpressWithAsync, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']);
const lzJsonParser = bodyParser.json({
type: ['text/*', 'application/json'],
});
const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint');
const webhookIngress = new LZEndpointNotifier(logger);
app.useAsync(/(\/api\/listenbrainz.*)|(\/1\/submit-listens)/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
webhookIngress.trackIngress(req, true);
if (req.method !== 'POST') {
return res.sendStatus(405);
}
next();
},
lzJsonParser, nonEmptyCheck, async function (req, res) {
webhookIngress.trackIngress(req, false);
res.sendStatus(200);
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
}
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug, token] = parseDisplayIdentifiersFromRequest(req);
logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`);
}
const playerState = playStateFromRequest(req.body);
for (const source of validSources) {
await source.handle(playerState);
}
});
app.getAsync('/1/validate-token', async function (req, res) {
//https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token
return res.status(200).json({
code: 200,
message: "Token valid.",
valid: true,
user_name: "Multi-Scrobbler"
})
});
}
+2 -2
View File
@@ -23,9 +23,9 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
if (payload !== undefined) {
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
const pSources = scrobbleSources.getByType('plex').filter(x => x instanceof PlexSource) as PlexSource[];
if (pSources.length === 0) {
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
plexLog.warn('Received valid Plex webhook payload but no Plex Webhook sources are configured');
}
for (const source of pSources) {
+32 -6
View File
@@ -33,7 +33,7 @@ import {
sortByNewestPlayDate,
sortByOldestPlayDate,
} from "../utils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, todayAwareFormat } from "../utils/TimeUtils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
@@ -74,6 +74,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
supportsUpstreamRecentlyPlayed: boolean = false;
supportsUpstreamNowPlaying: boolean = false;
supportsManualListening: boolean = false;
manualListening?: boolean
emitter: EventEmitter;
@@ -103,6 +106,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
return `${capitalize(this.type)} - ${this.name}`
}
getSystemListeningBehavior = (): boolean | undefined => {
if(this.supportsManualListening) {
return this.config.options !== undefined && 'systemScrobble' in this.config.options ? this.config.options?.systemScrobble : undefined;
}
return undefined;
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => []
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
@@ -195,10 +205,24 @@ export default abstract class AbstractSource extends AbstractComponent implement
return newDiscoveredPlays;
}
protected shouldScrobble = (discoverLocation?: 'backlog' | [key: string]) => {
if(this.supportsManualListening && discoverLocation !== 'backlog') {
const manualFlag = this.manualListening ?? this.getSystemListeningBehavior() ?? true;
if(manualFlag === false) {
this.logger.debug(`NOT scrobbling because Should Scrobble is FALSE (${this.manualListening === false ? 'user' : 'system'})`);
return false;
}
}
return true;
}
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any } = {}) => {
protected scrobble = (newDiscoveredPlays: PlayObject[], options: { forceRefresh?: boolean, [key: string]: any, discoverLocation?: 'backlog' | [key: string] } = {}) => {
if(newDiscoveredPlays.length > 0) {
if(!this.shouldScrobble(options.discoverLocation)) {
return;
}
newDiscoveredPlays.sort(sortByOldestPlayDate);
this.emitter.emit('discoveredToScrobble', {
data: newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare)),
@@ -230,7 +254,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
} catch (e) {
throw new Error('Error occurred while fetching backlogged plays', {cause: e});
}
const discovered = this.discover(backlogPlays);
const discovered = this.discover(backlogPlays, {discoverLocation: 'backlog'});
const {
options: {
@@ -440,6 +464,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
const activeThreshold = this.lastActivityAt.add(checkActiveFor, 's');
const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false);
const relativeActivity = dayjs.duration(this.lastActivityAt.diff(dayjs(), 'ms'));
const humanRelativeActivity = relativeActivity.asSeconds() > -3 ? '' : ` (${timeToHumanTimestamp(relativeActivity)} ago)`;
let friendlyInterval = '';
const friendlyLastFormat = todayAwareFormat(this.lastActivityAt);
if (activeThreshold.isBefore(dayjs())) {
@@ -452,12 +478,12 @@ export default abstract class AbstractSource extends AbstractComponent implement
sleepTime = interval + backoff;
}
if(isDebugMode()) {
debugMsgs.push(`Last activity ${friendlyLastFormat} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`);
debugMsgs.push(`Last activity ${friendlyLastFormat}${humanRelativeActivity} is ${inactiveFor} outside of polling period (last activity + ${checkActiveFor}s)`);
} else {
debugMsgs.push(`Last activity was at ${friendlyLastFormat}`);
debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`);
}
} else {
debugMsgs.push(`Last activity was at ${friendlyLastFormat}`);
debugMsgs.push(`Last activity was at ${friendlyLastFormat}${humanRelativeActivity}`);
friendlyInterval = `${formatNumber(sleepTime)}s`;
}
debugMsgs.push(`Next check in ${friendlyInterval}`);
+275
View File
@@ -0,0 +1,275 @@
import MemorySource from "./MemorySource.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { childLogger, Logger } from "@foxxmd/logging";
import { EventEmitter } from "events";
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
import pEvent from 'p-event';
import { PlayObject, URLData } from "../../core/Atomic.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import {
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
SINGLE_USER_PLATFORM_ID,
} from "../common/infrastructure/Atomic.js";
import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js";
import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js";
export class AzuracastSource extends MemorySource {
declare config: AzuracastSourceConfig;
urlData!: URLData;
wsNowPlaying: AzuraStationResponse
wsCurrenTime: number = 0;
client!: WS;
constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data = {},
options = {},
} = config;
const {
...rest
} = data;
const {
data: {
monitorWhenListeners,
monitorWhenLive
} = {}
} = config;
super('azuracast', name, { ...config, options: {systemScrobble: monitorWhenListeners !== undefined || monitorWhenLive === true, ...options}, data: { ...rest } }, internal, emitter);
this.requiresAuth = false;
this.canPoll = true;
this.supportsManualListening = true;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
url
} = {}
} = this.config;
if (url === null || url === undefined || url === '') {
throw new Error('url must be defined');
}
this.urlData = normalizeWSAddress(url, { defaultPath: '/api/live/nowplaying/websocket' });
const normal = this.urlData.normal;
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
if (!normal.includes('ws://') && !normal.includes('wss://')) {
throw new Error(`Server URL must be start with with ws:// or wss://`);
}
this.client = new WS(this.urlData.url.toString(), {
automaticOpen: false,
retry: {
retries: 0
}
});
const wsLogger = childLogger(this.logger, 'WS');
this.client.addEventListener('retry', (e) => {
wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, { labels: 'WS' });
});
this.client.addEventListener('close', (e) => {
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, { labels: 'WS' });
if (e.reason.includes('unauthenticated')) {
this.authed = false;
}
});
this.client.addEventListener('open', (e) => {
wsLogger.verbose(`Connection was established.`, { labels: 'WS' });
// if (this.authed) {
// // was a reconnect, try auto authenticating
// wsLogger.verbose('Resending auth message after (probably) reconnection...');
// this.client.send(JSON.stringify(this.getAuthPayload()));
// }
});
this.client.addEventListener('error', (e) => {
if (e.message.includes('Connection failed after')) {
this.connectionOK = false;
//this.authed = false;
}
const hint = e.error?.cause?.message ?? undefined;
wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error }));
});
this.client.addEventListener('message', (e) => {
this.parseWSData(getMessageData<any>(e));
// if (isAuthenticateResponse(data)) {
// wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`);
// }
});
return true;
}
private parseWSPayload(payload: any, useTime = true) {
const jsonData = payload.data;
if (useTime && 'current_time' in jsonData) {
this.wsCurrenTime = jsonData.current_time;
}
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
}
private parseWSData(jsonData: any) {
if ('connect' in jsonData) {
const connectData = jsonData.connect;
if ('data' in connectData) {
// Legacy SSE data
connectData.data.forEach(
(initialRow) => this.parseWSPayload(initialRow)
);
} else {
// New Centrifugo time format
if ('time' in connectData) {
this.wsCurrenTime = Math.floor(connectData.time / 1000);
}
// New Centrifugo cached NowPlaying initial push.
for (const subName in connectData.subs) {
const sub = connectData.subs[subName];
if ('publications' in sub && sub.publications.length > 0) {
sub.publications.forEach((initialRow) => this.parseWSPayload(initialRow, false));
}
}
}
} else if ('pub' in jsonData) {
this.parseWSPayload(jsonData.pub);
}
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
try {
await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname });
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
} catch (e) {
throw e;
}
this.client.open();
const opened = await pEvent(this.client, 'open');
return true;
} catch (e) {
this.client.close();
const hint = e.error?.cause?.message ?? undefined;
throw new Error(`Could not connect to Azuracast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
}
}
onPollPostAuthCheck = async (): Promise<boolean> => {
this.logger.verbose(`Listening for activity on Station ${this.config.data.station}`);
this.client.send(JSON.stringify({
subs: {
[`station:${this.config.data.station}`]: { "recover": true }
}
}));
return true;
}
// TODO return based on user intervention
protected isStationValidListen = () => {
if(this.wsNowPlaying === undefined) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `No data returned yet (check station name is correct?)`);
return false;
}
if(!this.wsNowPlaying.is_online && this.config.data.monitorWhenLive) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Currently offline`);
return false;
}
if(this.manualListening === true) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`);
return this.manualListening;
}
if(this.config.data.monitorWhenListeners !== undefined) {
if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `No listeners`);
return false;
}
if(typeof this.config.data.monitorWhenListeners === 'number' && this.wsNowPlaying.listeners.current < this.config.data.monitorWhenListeners) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Requries ${this.config.data.monitorWhenListeners} listeners to be active but currently only ${this.wsNowPlaying.listeners.current}`);
return false;
}
}
return true;
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
if (this.client.readyState !== this.client.OPEN) {
throw new Error('WS connection is no longer open.');
}
let play: PlayObject | undefined;
const online = this.isStationValidListen();
if(this.isStationValidListen() && this.wsNowPlaying.now_playing !== undefined) {
play = formatPlayObj(this.wsNowPlaying.now_playing);
}
const playerState: PlayerStateData = {
platformId: SINGLE_USER_PLATFORM_ID,
status: online ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.stopped,
play,
position: online && play !== undefined ? play.meta.trackProgressPosition : undefined
}
return this.processRecentPlays([playerState]);
}
}
const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
const {
song,
duration,
elapsed,
remaining,
} = obj;
const {
text,
artist,
title,
album
} = song;
const track: string = title ?? text;
return {
data: {
artists: artist !== undefined && artist !== '' ? [artist] : [],
album: album !== '' ? album : undefined,
track,
duration
},
meta: {
trackProgressPosition: elapsed
}
}
}
const getMessageData = <T>(e: any): T => {
return JSON.parse(e.data) as T;
}
const isCloseEvent = (e: Event): e is CloseEvent => {
return e.type === 'close';
}
const isErrorEvent = (e: Event): e is ErrorEvent => {
return e.type === 'error';
}
const isRetryEvent = (e: Event): e is RetryEvent => {
return e.type === 'retry';
}
+122
View File
@@ -0,0 +1,122 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import {
ExpressRequest,
FormatPlayObjectOptions,
InternalConfig,
NO_USER,
PlayerStateData,
REPORTED_PLAYER_STATUSES,
ReportedPlayerStatus
} from "../common/infrastructure/Atomic.js";
import { parseRegexSingleOrFail } from "../utils.js";
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";
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i);
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i);
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
export class EndpointLastfmSource extends MemorySource {
declare config: LastFMEndpointSourceConfig;
constructor(name: any, config: LastFMEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('endpointlfm', name, config, internal, emitter);
this.multiPlatform = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
const {
data = {},
data: {
slug,
} = {}
} = this.config;
this.config.data = {
...data,
slug: slug === null ? undefined : slug,
};
}
matchRequest(req: ExpressRequest): boolean {
let matchesPath = false;
const slug = parseSlugFromRequest(req);
if (slug === false) {
return false;
} else {
matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
}
return matchesPath;
}
static formatPlayObj(obj: LastfmTrackUpdateRequest, options: FormatPlayObjectOptions = {}): PlayObject {
return scrobblePayloadToPlay(obj);
}
getRecentlyPlayed = async (options = {}) => {
return this.getFlatRecentlyDiscoveredPlays();
}
isValidScrobble = (playObj: PlayObject) => {
return true;
}
handle = async (stateData: PlayerStateData) => {
this.processRecentPlays([stateData]);
if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) {
const discovered = this.discover([stateData.play]);
if (discovered.length > 0) {
this.scrobble(discovered);
}
}
}
}
export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => {
const play = scrobblePayloadToPlay(obj);
return {
platformId: [play.meta.deviceId, NO_USER],
play,
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
timestamp: dayjs()
}
}
export const parseSlugFromString = (path: string): string | false | undefined => {
const noSlug = parseRegexSingleOrFail(noSlugMatch, path);
if (noSlug !== undefined) {
return undefined;
}
const slugResult = parseRegexSingleOrFail(slugMatch, path);
if (slugResult !== undefined) {
return slugResult.groups[0];
}
return false;
}
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => {
const slug = parseSlugFromRequest(req);
return [slug];
}
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string] => {
const [slug] = parseIdentifiersFromRequest(req);
let slugStr = '(no slug)';
if (slug === false) {
slugStr = '(invalid slug)';
} else if (slug !== undefined) {
slugStr = slug;
}
return [slugStr];
}
@@ -0,0 +1,179 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
import {
ExpressRequest,
FormatPlayObjectOptions,
InternalConfig,
NO_USER,
PlayerStateData,
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 { parseRegexSingleOrFail } from "../utils.js";
import MemorySource from "./MemorySource.js";
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i);
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
export class EndpointListenbrainzSource extends MemorySource {
declare config: ListenbrainzEndpointSourceConfig;
constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('endpointlz', name, config, internal, emitter);
this.multiPlatform = false;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
const {
data = {},
data: {
slug,
} = {}
} = this.config;
this.config.data = {
token: undefined,
...data,
slug: slug === null ? undefined : slug,
};
}
matchRequest(req: ExpressRequest): boolean {
let matchesToken = this.config.data.token === undefined;
const reqToken = parseTokenFromRequest(req);
if (reqToken === false) {
return false;
}
matchesToken = this.config.data.token === undefined && reqToken === undefined ||
(reqToken !== undefined && this.config.data.token !== undefined
&& this.config.data.token.toLowerCase().trim() === reqToken.toLowerCase().trim());
if (!matchesToken) {
return false;
}
let matchesPath = false;
const slug = parseSlugFromRequest(req);
if (slug === false) {
return false;
} else {
matchesPath = (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
}
return matchesToken && matchesPath;
}
static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & {
nowPlaying?: boolean
} = {}): PlayObject {
return ListenbrainzApiClient.listenPayloadToPlay(obj, options.nowPlaying);
}
getRecentlyPlayed = async (options = {}) => {
return this.getFlatRecentlyDiscoveredPlays();
}
isValidScrobble = (playObj: PlayObject) => {
return true;
}
handle = async (stateData: PlayerStateData) => {
this.processRecentPlays([stateData]);
if (stateData.play.meta.nowPlaying === false && this.isValidScrobble(stateData.play)) {
const discovered = this.discover([stateData.play]);
if (discovered.length > 0) {
this.scrobble(discovered);
}
}
}
}
export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
const {
listen_type,
payload,
} = obj;
const play = ListenbrainzApiClient.listenPayloadToPlay(payload[0], listen_type === 'playing_now');
return {
platformId: [play.meta.deviceId, NO_USER],
play,
status: listenTypeAsPlayerStatus(listen_type),
timestamp: dayjs()
}
}
export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus => {
switch (event) {
case 'single':
case 'playing_now':
return REPORTED_PLAYER_STATUSES.playing;
default:
return REPORTED_PLAYER_STATUSES.unknown;
}
}
export const parseTokenFromString = (str: string): string | undefined => {
const tokenMatch = parseRegexSingleOrFail(authHeaderRegex, str);
if(tokenMatch !== undefined) {
return tokenMatch.groups[0];
}
return undefined;
}
export const parseTokenFromRequest = (req: ExpressRequest): string | false | undefined => {
const auth = req.header('Authorization');
if(typeof auth === 'string' && auth !== '') {
const matchedToken = parseTokenFromString(auth);
if(matchedToken === undefined) {
return false;
}
return matchedToken;
}
return undefined;
}
export const parseSlugFromString = (path: string): string | false | undefined => {
const noSlug = parseRegexSingleOrFail(noSlugMatch, path);
if (noSlug !== undefined) {
return undefined;
}
const slugResult = parseRegexSingleOrFail(slugMatch, path);
if (slugResult !== undefined) {
return slugResult.groups[0];
}
return false;
}
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined, false | string | undefined] => {
const slug = parseSlugFromRequest(req);
const token = parseTokenFromRequest(req);
return [slug, token];
}
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string, string] => {
const [slug, token] = parseIdentifiersFromRequest(req);
let slugStr = '(no slug)';
if (slug === false) {
slugStr = '(invalid slug)';
} else if (slug !== undefined) {
slugStr = slug;
}
let tokenStr = '(no token)';
if (token === false) {
tokenStr = '(invalid token)';
} else if (token !== undefined) {
tokenStr = `${token.substring(0,3)}****`
}
return [slugStr, tokenStr];
}
+219
View File
@@ -0,0 +1,219 @@
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { EventEmitter } from "events";
import { PlayObject, URLData } from "../../core/Atomic.js";
import {
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
REPORTED_PLAYER_STATUSES,
SINGLE_USER_PLATFORM_ID,
} from "../common/infrastructure/Atomic.js";
import { isPortReachableConnect, normalizeWebAddress } from "../utils/NetworkUtils.js";
import MemorySource from "./MemorySource.js";
import { IcecastMetadata, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js";
import IcecastMetadataStats from "icecast-metadata-stats";
import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js";
import { isDebugMode, sleep } from "../utils.js";
export class IcecastSource extends MemorySource {
declare config: IcecastSourceConfig;
urlData!: URLData;
currentMetadata?: IcecastMetadata
statsListener?: IcecastMetadataStats
streamError?: Error;
streaming: boolean = false;
constructor(name: any, config: IcecastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data,
options = {},
} = config;
const {
...rest
} = data || {};
super('icecast', name, { ...config, options: {systemScrobble: false, ...options}, data: { ...rest } }, internal, emitter);
this.requiresAuth = false;
this.canPoll = true;
this.supportsManualListening = true;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
url
} = {}
} = this.config;
if (url === null || url === undefined || url === '') {
throw new Error('url must be defined');
}
this.urlData = normalizeWebAddress(url);
const normal = this.urlData.normal;
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname });
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
return true;
} catch (e) {
const hint = e.error?.cause?.message ?? undefined;
throw new Error(`Could not connect to Icecast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
}
}
readIcecastStream = () => {
if (this.statsListener !== undefined) {
this.statsListener.stop();
}
const {
url,
...icecastUserOpts
} = this.config.data;
const icecastOpts = {sources: ["icy", 'ogg'], ...icecastUserOpts};
this.statsListener = new IcecastMetadataStats(this.urlData.url.toString(), {
...icecastOpts,
onStats: (stats) => {
if(isDebugMode()) {
this.logger.debug(stats);
}
this.currentMetadata = stats;
if (this.polling === false) {
this.statsListener.stop();
this.streaming = false;
}
},
onError: (e) => {
this.streaming = false;
this.streamError = e;
this.statsListener.stop();
},
interval: 10
});
this.statsListener.start();
this.streaming = true;
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
if (this.streaming === false) {
this.readIcecastStream();
// wait a bit so stream can start and parse metadata
await sleep(1000);
}
if (this.streamError !== undefined) {
if (this.streamError.cause !== undefined && ('res' in (this.streamError.cause as object))) {
const res = (this.streamError.cause as Error & { res: Response }).res as Response
}
throw new Error('Icecast stream produced an error', { cause: this.streamError });
}
if (this.currentMetadata === undefined) {
return this.processRecentPlays([]);
}
// if (this.manualListening === false || (this.config.options.scrobbleOnStart === false && this.manualListening === undefined)) {
// const playerState: PlayerStateData = {
// platformId: SINGLE_USER_PLATFORM_ID,
// status: REPORTED_PLAYER_STATUSES.stopped,
// play: undefined,
// }
// return this.processRecentPlays([playerState]);
// }
let play: PlayObject | undefined = formatPlayObj(this.currentMetadata);
if (play.data.track === undefined) {
play = undefined;
}
const playerState: PlayerStateData = {
platformId: SINGLE_USER_PLATFORM_ID,
status: REPORTED_PLAYER_STATUSES.playing,
play
}
return this.processRecentPlays([playerState]);
}
}
const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = {}): PlayObject => {
let artist: string,
track: string,
artists: string[] = [],
album: string;
if (obj.ogg?.TITLE !== undefined) {
const {
TITLE: oggTitle,
ARTIST: oggArtist,
ALBUM: oggAlbum
} = obj.ogg;
track = oggTitle;
album = oggAlbum;
const artistCred = parseArtistCredits(oggArtist);
if (artistCred !== undefined) {
artists.push(artistCred.primary);
if (artistCred.secondary !== undefined) {
artists = artists.concat(artistCred.secondary);
}
} else {
artists.push(oggArtist);
}
} else if(obj.icy?.StreamTitle !== undefined) {
const value: string = obj.icy.StreamTitle;
// naive implementation for now
const splitStr = value.split('-').map(x => x.trim());
if (splitStr.length > 1) {
artist = splitStr[0];
track = value.substring(value.indexOf('-') + 1).trim();
const artistCred = parseArtistCredits(artist);
if (artistCred !== undefined) {
artists.push(artistCred.primary);
if (artistCred.secondary !== undefined) {
artists = artists.concat(artistCred.secondary);
}
} else {
artists.push(artist);
}
const trackCred = parseTrackCredits(track);
if (trackCred !== undefined) {
track = trackCred.primary;
if (trackCred.secondary !== undefined) {
artists = artists.concat(trackCred.secondary);
}
}
}
}
return {
data: {
track,
artists
},
meta: {
source: 'icecast'
}
}
}
+1 -1
View File
@@ -14,9 +14,9 @@ import {
doubleReturnNewline,
isDebugMode,
parseBool,
parseDurationFromTimestamp,
playObjDataMatch,
} from "../utils.js";
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
@@ -1,10 +1,16 @@
import { Logger } from "@foxxmd/logging";
import { PlayerStateDataMaybePlay, PlayPlatformId } from "../common/infrastructure/Atomic.js";
import { InternalConfig, PlayerStateDataMaybePlay, PlayPlatformId, SourceType } from "../common/infrastructure/Atomic.js";
import MemorySource from "./MemorySource.js";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { PositionalPlayerState } from "./PlayerState/PositionalPlayerState.js";
import { PlayObject } from "../../core/Atomic.js";
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
import EventEmitter from "events";
export class MemoryPositionalSource extends MemorySource {
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super(type, name, config, internal, emitter);
this.isPositional = true;
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PositionalPlayerState(logger, id, opts)
}
+17 -7
View File
@@ -1,5 +1,5 @@
import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import objectHash from 'object-hash';
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
@@ -27,7 +27,7 @@ import {
playObjDataMatch,
thresholdResultSummary,
} from "../utils.js";
import { timePassesScrobbleThreshold } from "../utils/TimeUtils.js";
import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/TimeUtils.js";
import AbstractSource from "./AbstractSource.js";
import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js";
@@ -52,6 +52,8 @@ export default class MemorySource extends AbstractSource {
scheduler: ToadScheduler = new ToadScheduler();
protected isPositional: boolean = false;
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super(type, name, config, internal, emitter);
@@ -116,11 +118,19 @@ export default class MemorySource extends AbstractSource {
}
if(discoverable) {
discoveredCleanupPlay = cleanupPlay;
// we are discovering/scrobbling play
// and since player is now stale we should treat this "session" as ended
// -- so if user resumes a stale play later its a new session (new real time period of them listening)
// basically this is the same as if the player was orphaned and removed
//
// so we remove listen ranges so the old accumulated listen time can't be used for the "new" listening session
player.listenRanges = [];
player.currentListenRange = undefined;
}
}
}
if(deletePlayer) {
this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds').asMinutes()} minutes`);
this.deletePlayer(player.platformIdStr, `Removed after being orphaned for ${timeToHumanTimestamp(dayjs.duration(player.stateIntervalOptions.orphanedInterval, 'seconds'))}`);
}
return discoveredCleanupPlay;
@@ -141,7 +151,7 @@ export default class MemorySource extends AbstractSource {
setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
...createPlayerOptions(this.config.data as Partial<PollingOptions>),
...createPlayerOptions(this.config.data as Partial<PollingOptions>, this.playerSourceOfTruth, this.logger),
...opts
}));
this.playerState.set(idStr, '');
@@ -176,7 +186,7 @@ export default class MemorySource extends AbstractSource {
return sessions[0];
}
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => {
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => {
const {
options: {
@@ -231,7 +241,7 @@ export default class MemorySource extends AbstractSource {
playerState.position = playerState.play.meta?.trackProgressPosition;
}
const [currPlay, prevPlay] = player.update(playerState);
const [currPlay, prevPlay] = player.update(playerState, reportedTS);
const candidate = prevPlay !== undefined ? prevPlay : currPlay;
const playChanged = prevPlay !== undefined;
@@ -323,7 +333,7 @@ export default class MemorySource extends AbstractSource {
for(const player of this.players.values()) {
if(player.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing) {
const pos = player.getPosition();
if(pos !== undefined && player.currentPlay !== undefined) {
if(pos !== undefined && this.isPositional && player.currentPlay !== undefined && player.currentPlay.data?.duration !== undefined) {
const {
data: {
duration
+158
View File
@@ -0,0 +1,158 @@
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { EventEmitter } from "events";
import { PlayObject, URLData } from "../../core/Atomic.js";
import {
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
SINGLE_USER_PLATFORM_ID,
} from "../common/infrastructure/Atomic.js";
import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js";
import request, { Request, Response } from 'superagent';
export class MusicCastSource extends MemoryPositionalSource {
declare config: MusicCastSourceConfig;
urlData!: URLData;
constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data = {}
} = config;
const {
...rest
} = data;
super('musiccast', name, { ...config, data: { ...rest } }, internal, emitter);
this.requiresAuth = false;
this.canPoll = true;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
url
} = {}
} = this.config;
if (url === null || url === undefined || url === '') {
throw new Error('url must be defined');
}
this.urlData = normalizeWebAddress(url, { defaultPath: '/YamahaExtendedControl/v1' });
const normal = this.urlData.normal;
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname });
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
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;
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...');
}
return true;
} catch (e) {
const hint = e.error?.cause?.message ?? undefined;
throw new Error(`Could not connect to MusicCast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
}
}
getAnyPlayInfo = async (): Promise<PlayInfoCDResponse | PlayInfoNetResponse | undefined> => {
try {
const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString());
if (netResp.body !== undefined && typeof netResp.body === 'object') {
const resp = netResp.body as PlayInfoNetResponse
if(resp.response_code !== 0) {
throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`);
}
return resp;
}
} catch (e) {
this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e}));
}
try {
const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString());
if (cdResp.body !== undefined && typeof cdResp.body === 'object') {
const resp = cdResp.body as PlayInfoCDResponse;
if(resp.response_code !== 0) {
throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`);
}
return resp;
}
} catch (e) {
this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e}));
}
return undefined;
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const statusResp = await request.get(joinedUrl(this.urlData.url, 'main/getStatus').toString());
if (statusResp.body == undefined || typeof statusResp.body !== 'object') {
this.logger.error({ getStatusResponse: statusResp });
throw new Error('Could not determine status of MusicCast device');
}
if ((statusResp.body as DeviceStatusResponse).power !== 'on') {
this.logger.debug('MusicCast device is offline');
return this.processRecentPlays([]);
}
const playInfo = await this.getAnyPlayInfo();
if(playInfo === undefined) {
return this.processRecentPlays([]);
}
const play = formatPlayObj(playInfo);
const playerState: PlayerStateData = {
platformId: SINGLE_USER_PLATFORM_ID,
status: playbackToReportedStatus(playInfo.playback),
play,
position: play.meta.trackProgressPosition
}
return this.processRecentPlays([playerState]);
}
}
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
const {
play_time,
total_time,
artist,
album,
track,
device_status,
playback
} = obj;
return {
data: {
artists: artist !== undefined && artist !== '' ? [artist] : [],
album: album !== '' ? album : undefined,
track,
// we should treat 0 time as the same as not being provided
duration: total_time === 0 ? undefined : total_time
},
meta: {
trackProgressPosition: play_time,
deviceId: 'input' in obj ? obj.input : 'cd'
}
}
}
+7 -29
View File
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import normalizeUrl from 'normalize-url';
import pEvent from 'p-event';
import { URL } from "url";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, URLData } from "../../core/Atomic.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import {
FormatPlayObjectOptions,
@@ -23,6 +23,7 @@ import {
import { sleep } from "../utils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { normalizeWSAddress } from "../utils/NetworkUtils.js";
const CLIENT_STATE = {
0: 'connecting',
@@ -34,7 +35,7 @@ const CLIENT_STATE = {
export class MusikcubeSource extends MemoryPositionalSource {
declare config: MusikcubeSourceConfig;
url: URL;
url: URLData;
client!: WS;
@@ -56,46 +57,23 @@ export class MusikcubeSource extends MemoryPositionalSource {
} = {}
} = config;
this.deviceId = device_id ?? name;
this.url = MusikcubeSource.parseConnectionUrl(url);
this.url = normalizeWSAddress(url, {defaultPort: 7905});
this.requiresAuth = true;
this.canPoll = true;
}
static parseConnectionUrl(valRaw: string) {
let val = valRaw.trim();
if(!val.match(/^(?:wss?|https?):/i)) {
val = `ws://${val}`;
}
const normal = normalizeUrl(val, {removeTrailingSlash: false})
const url = new URL(normal);
// default WS
if (url.protocol === 'https:') {
url.protocol = 'wss:';
} else if (url.protocol === 'http:') {
url.protocol = 'ws:';
} else {
url.protocol = 'ws:'
}
if (url.port === null || url.port === '') {
url.port = '7905';
}
return url;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
url
} = {}
} = this.config;
const normal = this.url.toString();
const normal = this.url.normal;
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
if (!normal.includes('ws://') && !normal.includes('wss://')) {
throw new Error(`Server URL must be start with with ws:// or wss://`);
throw new Error(`Server URL must start with ws:// or wss://`);
}
this.client = new WS(this.url.toString(), {
this.client = new WS(this.url.url.toString(), {
automaticOpen: false,
retry: {
retries: 0
@@ -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 { todayAwareFormat } from "../../utils/TimeUtils.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js";
export interface PlayerStateIntervals {
staleInterval?: number
@@ -30,22 +30,38 @@ export interface PlayerStateOptions extends PlayerStateIntervals {
export const DefaultPlayerStateOptions: PlayerStateOptions = {};
export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER): PlayerStateOptions => {
export const createPlayerOptions = (pollingOpts?: Partial<PollingOptions>, sot: SOURCE_SOT_TYPES = SOURCE_SOT.PLAYER, logger?: Logger): PlayerStateOptions => {
const {
interval = 30,
maxInterval = 60,
staleAfter,
orphanedAfter
} = pollingOpts || {};
if(sot === SOURCE_SOT.PLAYER) {
return {
staleInterval: interval * 3,
orphanedInterval: interval * 5
}
}
let sa = staleAfter,
oa = orphanedAfter;
// if this player is not the source of truth we don't care about waiting around to see if the state comes back
// in fact, we probably want to get rid of it as fast as possible since its superficial and more of an ephemeral "Now Playing" status than something we are actually tracking
const staleAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 3 : interval;
const orphanedAfterDefault = sot === SOURCE_SOT.PLAYER ? interval * 5 : maxInterval;
if(sa === undefined) {
sa = staleAfterDefault;
}
if(oa === undefined) {
oa = orphanedAfterDefault;
}
if(oa < sa) {
oa = sa;
if(logger !== undefined) {
logger.warn(`'orhanedAfter' (${oa}s) was less than 'staleAfter' (${sa}s) which is not allowed! 'orhanedAfter' has been set to equal 'staleAfter'`);
}
}
return {
staleInterval: interval,
orphanedInterval: maxInterval
staleInterval: sa,
orphanedInterval: oa
}
}
@@ -86,18 +102,18 @@ export abstract class AbstractPlayerState {
return this.platformId[0] === candidateId[0] && this.platformId[1] === candidateId[1];
}
isUpdateStale() {
isUpdateStale(reportedTS?: Dayjs) {
if (this.currentPlay !== undefined) {
return Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval;
}
return false;
}
checkStale() {
const isStale = this.isUpdateStale();
checkStale(reportedTS?: Dayjs) {
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 ${Math.abs(dayjs().diff(this.playLastUpdatedAt, 'seconds'))} seconds`);
this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`);
// end current listening sessions
this.currentListenSessionEnd();
}
@@ -116,7 +132,7 @@ export abstract class AbstractPlayerState {
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 ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} minutes`);
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)`);
}
return isOrphaned;
}
@@ -154,7 +170,7 @@ export abstract class AbstractPlayerState {
protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
const {play, status, sessionId} = state;
this.playLastUpdatedAt = dayjs();
this.playLastUpdatedAt = reportedTS ?? dayjs();
if (status !== undefined) {
this.reportedStatus = status;
}
@@ -328,7 +344,7 @@ export abstract class AbstractPlayerState {
const {play, position} = state;
this.currentPlay = play;
this.playFirstSeenAt = dayjs();
this.playFirstSeenAt = reportedTS ?? dayjs();
this.listenRanges = [];
this.currentListenRange = undefined;
@@ -351,11 +367,11 @@ 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) {
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 | `;
}
let listenedPercent = '';
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) {
if (this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined && this.currentPlay.data.duration !== 0) {
listenedPercent = formatNumber((this.getListenDuration() / this.currentPlay.data.duration) * 100, {
suffix: '%',
toFixed: 0
@@ -19,13 +19,13 @@ export class GenericPlayerState extends AbstractPlayerState {
super(logger, platformId, opts);
}
protected currentListenSessionContinue(position: number = 0, timestamp?: Dayjs) {
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
if (this.currentListenRange === undefined) {
this.logger.debug('Started new Player listen range.');
this.currentListenRange = this.newListenRange(this.newListenProgress({timestamp}));
this.currentListenRange = this.newListenRange(this.newListenProgress({position, timestamp}));
} else {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
this.currentListenRange.setRangeEnd(this.newListenProgress({timestamp}));
this.currentListenRange.setRangeEnd(this.newListenProgress({position, timestamp}));
}
}
@@ -6,11 +6,13 @@ export class ListenProgressTS implements PlayProgress {
public timestamp: Dayjs;
public positionPercent?: number;
public position?: Second;
constructor(data: Partial<PlayProgress> = {}) {
const {timestamp, positionPercent} = data;
const {timestamp, positionPercent, position} = data;
this.timestamp = timestamp ?? dayjs();
this.positionPercent = positionPercent;
this.position = position;
}
getDuration(end: ListenProgressTS): Second {
@@ -27,7 +29,7 @@ export class ListenProgressTS implements PlayProgress {
}
export class ListenProgressPositional extends ListenProgressTS implements PlayProgressPositional {
public position: Second;
declare public position: Second;
constructor(data: PlayProgressPositional) {
super(data);
@@ -66,7 +66,7 @@ export class ListenRangeTS extends ListenRange implements ListenRangeData {
}
public getPosition(): Second {
return undefined;
return this.end.position;
}
public finalize(position?: number) {
@@ -90,13 +90,25 @@ export class PositionalPlayerState extends AbstractPlayerState {
duration,
} = {}
} = this.currentPlay;
if(duration !== undefined && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) {
if(duration !== undefined && duration !== 0 && (duration - this.currentListenRange.end.position) < this.gracefulEndBuffer) {
// likely the track was listened to until it ended
// but polling interval or network delays caused MS to not get data on the very end
// also...within 3 seconds of ending is close enough to call this complete IMO
//
// -- STALE is included in this because there are some Sources (jellyfin or plex with 3rd party apps?)
// that will created a new "player" if the queue is cleared or stopping the player,
// from the user's perspective its all the same but then MS sees it as different
// so the player will eventually be pruned but we want to treat the play "going stale" it as if it finished to account for this behavior
this.logger.debug(`Listen duration was within ${this.gracefulEndBuffer}s of Play duration, bumping duration to 100% ${this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale ? 'because stale player probably finished Play before going dark.' : ' because we probably just missed Source reporting 100% before changing Play.'}`)
finalPosition = duration;
//this.currentListenRange.end.position = duration;
} else if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.stale && this.currentListenRange.isOverDrifted(this.currentListenRange.end.position)) {
// if player uses realtime but source went stale and WAS NOT close to the end its likely the RT is way overdrifted
// in which case we definitely do want to use RT as final position
// so use last known position before stale instead
this.logger.debug(`Player became Stale and realtime position overdrifted before session ended! Using last known position instead of RT position so listen duration stays accurate.`);
finalPosition = this.currentListenRange.end.position;
}
}
this.currentListenRange.finalize(finalPosition);
@@ -1,7 +1,13 @@
import { childLogger, Logger } from "@foxxmd/logging";
import dayjs, { Dayjs } from "dayjs";
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
const RT_TICK = 500;
export const RT_TICK_DEFAULT = 500;
let RT_TICK = 500;
export const setRtTick = (tick: number) => {
RT_TICK = tick;
}
export abstract class RealtimePlayer {
@@ -9,22 +15,30 @@ export abstract class RealtimePlayer {
scheduler: ToadScheduler = new ToadScheduler();
protected position: number = 0;
private clockTS: Dayjs = dayjs();
protected constructor(/* logger: Logger */) {
//this.logger = childLogger(logger, `RT`);
this.position = 0;
const job = new SimpleIntervalJob({
milliseconds: RT_TICK,
runImmediately: true
}, new Task('updatePos', () => this.position += RT_TICK), { id: 'rt' });
}, new Task('updatePos', () => {
// in production RT_TICK and the diff between now and clockTS should always be the same
// but in order to mock for testing (where we manipulate Date now()) the source of truth
// needs to come from TS rather than simple TICK increase
this.setPosition()
//this.position += Math.abs(dayjs().diff(this.clockTS, 'ms')); // RT_TICK
}), { id: 'rt' });
this.scheduler.addSimpleIntervalJob(job);
this.scheduler.stop();
this.position = 0;
}
public play(position?: number) {
if (position !== undefined) {
this.position = position;
}
this.clockTS = dayjs();
this.scheduler.startById('rt');
}
@@ -42,11 +56,19 @@ export abstract class RealtimePlayer {
}
public getPosition(asSeconds: boolean = false) {
if(this.position === 0 || this.position === undefined) {
return 0;
}
return !asSeconds ? this.position : this.position / 1000;
}
public setPosition(time: number) {
this.position = time;
public setPosition(time?: number) {
if(time === undefined) {
this.position += Math.abs(dayjs().diff(this.clockTS, 'ms'));
} else {
this.position = time;
}
this.clockTS = dayjs();
}
}
+174 -3
View File
@@ -3,8 +3,11 @@ import { childLogger, Logger } from '@foxxmd/logging';
import EventEmitter from "events";
import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js";
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
import {
JellyApiData,
JellyApiSourceConfig,
@@ -16,6 +19,8 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
import { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.js";
import { IcecastData, IcecastSourceConfig } from "../common/infrastructure/config/source/icecast.js";
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";
@@ -31,8 +36,11 @@ import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { parseBool, readJson } from "../utils.js";
import { validateJson } from "../utils/ValidationUtils.js";
import AbstractSource from "./AbstractSource.js";
import { AzuracastSource } from "./AzuracastSource.js";
import { ChromecastSource } from "./ChromecastSource.js";
import DeezerSource from "./DeezerSource.js";
import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js";
import { EndpointLastfmSource } from "./EndpointLastfmSource.js";
import JellyfinApiSource from "./JellyfinApiSource.js";
import JellyfinSource from "./JellyfinSource.js";
import { JRiverSource } from "./JRiverSource.js";
@@ -43,6 +51,7 @@ import { MopidySource } from "./MopidySource.js";
import { MPDSource } from "./MPDSource.js";
import { MPRISSource } from "./MPRISSource.js";
import { MusikcubeSource } from "./MusikcubeSource.js";
import { MusicCastSource } from "./MusicCastSource.js";
import PlexSource from "./PlexSource.js";
import SpotifySource from "./SpotifySource.js";
import { SubsonicSource } from "./SubsonicSource.js";
@@ -53,6 +62,8 @@ import YTMusicSource from "./YTMusicSource.js";
import { Definition } from 'ts-json-schema-generator';
import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js';
import PlexApiSource from './PlexApiSource.js';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
import { IcecastSource } from './IcecastSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -86,14 +97,17 @@ export default class ScrobbleSources {
getByNameAndType = (name: string, type: SourceType) => this.sources.find(x => x.name === name && x.type === type)
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
let sources: AbstractSource[]
let sources: AbstractSource[] = [];
let sourcesReady = true;
const messages: string[] = [];
if(type !== undefined) {
sources = this.getByType(type);
} else if(name !== undefined) {
sources = [this.getByName(name)];
const sourceByName = this.getByName(name);
if(sourceByName !== undefined) {
sources = [sourceByName];
}
} else {
sources = this.sources;
}
@@ -127,6 +141,15 @@ export default class ScrobbleSources {
case 'deezer':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
break;
case 'endpointlz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
break;
case 'endpointlfm':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastFMEndpointSourceConfig");
break;
case 'icecast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("IcecastSourceConfig");
break;
case 'subsonic':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig");
break;
@@ -163,17 +186,62 @@ export default class ScrobbleSources {
case 'musikcube':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig");
break;
case 'musiccast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusicCastSourceConfig");
break;
case 'mpd':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig");
break;
case 'vlc':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig");
break;
case 'azuracast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig");
break;
}
}
return this.schemaDefinitions[type];
}
buildSourceDefaults = (fileDefaults: SourceDefaults = {}): SourceDefaults => {
const scrobbleDurationEnv = process.env.SOURCE_SCROBBLE_DURATION;
const scrobblePercentEnv = process.env.SOURCE_SCROBBLE_PERCENT;
const buildDefaults = {...fileDefaults};
if(nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined || nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) {
const {
scrobbleThresholds: {
duration,
percent
} = {},
scrobbleThresholds = {}
} = fileDefaults;
buildDefaults.scrobbleThresholds = {...scrobbleThresholds};
if(duration === undefined && nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined) {
const envDur = Number.parseInt(scrobbleDurationEnv);
if(Number.isNaN(envDur)) {
this.logger.warn(`Ignoring value '${scrobbleDurationEnv}' for env SOURCE_SCROBBLE_DURATION because it is not a number`);
} else {
buildDefaults.scrobbleThresholds.duration = envDur;
this.logger.verbose(`Set default scrobble threshold duration to '${scrobbleDurationEnv}' based on env SOURCE_SCROBBLE_DURATION`);
}
}
if(percent === undefined && nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) {
const envPercent = Number.parseInt(scrobblePercentEnv);
if(Number.isNaN(envPercent)) {
this.logger.warn(`Ignoring value '${scrobblePercentEnv}' for env SOURCE_SCROBBLE_PERCENT because it is not a number`);
} else {
buildDefaults.scrobbleThresholds.percent = envPercent;
this.logger.verbose(`Set default scrobble threshold percent to '${scrobblePercentEnv}' based on env SOURCE_SCROBBLE_PERCENT`);
}
}
}
return buildDefaults;
}
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => {
const configs: ParsedConfig[] = additionalConfigs;
@@ -193,7 +261,7 @@ export default class ScrobbleSources {
sources: mainConfigSourcesConfigs = [],
sourceDefaults: sd = {},
} = aioConfig;
sourceDefaults = sd;
sourceDefaults = this.buildSourceDefaults(sd);
for (const [index, c] of mainConfigSourcesConfigs.entries()) {
const {name = 'unnamed'} = c;
if(!isSourceType(c.type.toLocaleLowerCase())) {
@@ -221,6 +289,8 @@ export default class ScrobbleSources {
configureAs: 'source' // override user value
});
}
} else {
sourceDefaults = this.buildSourceDefaults();
}
for (const sourceType of sourceTypes) {
@@ -374,6 +444,58 @@ export default class ScrobbleSources {
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
defaultConfigureAs = 'client';
break;
case 'endpointlz':
const lzShouldUse = parseBool(process.env.LZENDPOINT_ENABLE);
const lze = {
slug: process.env.LZE_SLUG,
token: process.env.LZE_TOKEN
}
if (!Object.values(lze).every(x => x === undefined) || lzShouldUse) {
configs.push({
type: 'endpointlz',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: lze as ListenbrainzEndpointData
});
}
break;
case 'endpointlfm':
const lfmShouldUse = parseBool(process.env.LFMENDPOINT_ENABLE);
const lfme = {
slug: process.env.LFM_SLUG,
}
if (!Object.values(lfme).every(x => x === undefined) || lfmShouldUse) {
configs.push({
type: 'endpointlfm',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: lfme as LastFMEndpointData
});
}
break;
case 'icecast':
const icecast = {
url: process.env.ICECAST_URL,
scrobbleOnStart: process.env.ICECAST_SCROBBLE_START,
}
if (!Object.values(icecast).every(x => x === undefined)) {
configs.push({
type: 'icecast',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: icecast as IcecastData,
options: {
systemScrobble: parseBool(icecast.scrobbleOnStart)
}
});
}
break;
case 'jriver':
const jr = {
url: process.env.JRIVER_URL,
@@ -452,6 +574,21 @@ export default class ScrobbleSources {
});
}
break;
case 'musiccast':
const musecase = {
url: process.env.MCAST_URL,
}
if (!Object.values(musecase).every(x => x === undefined)) {
configs.push({
type: 'musiccast',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: musecase as MusicCastData
});
}
break;
case 'musikcube':
const mc = {
url: process.env.MC_URL,
@@ -501,6 +638,25 @@ export default class ScrobbleSources {
data: ytm as YTMusicData
});
}
break;
case 'azuracast':
const azura = {
station: process.env.AZURA_STATION,
url: process.env.AZURA_URL,
monitorWhenListeners: process.env.AZURA_LISTENERS_NUM,
monitorWhenLive: process.env.AZURA_LIVE,
apiKey: process.env.AZURA_KEY
}
if (!Object.values(azura).every(x => x === undefined)) {
configs.push({
type: 'azuracast',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: azura as unknown as AzuracastData
});
}
break;
default:
break;
@@ -660,6 +816,15 @@ export default class ScrobbleSources {
case 'listenbrainz':
newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, this.internalConfig, this.emitter);
break;
case 'endpointlz':
newSource = await new EndpointListenbrainzSource(name, compositeConfig as ListenbrainzEndpointSourceConfig, this.internalConfig, this.emitter);
break;
case 'endpointlfm':
newSource = await new EndpointLastfmSource(name, compositeConfig as LastFMEndpointSourceConfig, this.internalConfig, this.emitter);
break;
case 'icecast':
newSource = await new IcecastSource(name, compositeConfig as IcecastSourceConfig, this.internalConfig, this.emitter);
break;
case 'jriver':
newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, this.internalConfig, this.emitter);
break;
@@ -675,12 +840,18 @@ export default class ScrobbleSources {
case 'musikcube':
newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, this.internalConfig, this.emitter);
break;
case 'musiccast':
newSource = await new MusicCastSource(name, compositeConfig as MusicCastSourceConfig, this.internalConfig, this.emitter);
break;
case 'mpd':
newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, this.internalConfig, this.emitter);
break;
case 'vlc':
newSource = await new VLCSource(name, compositeConfig as VLCSourceConfig, this.internalConfig, this.emitter);
break;
case 'azuracast':
newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter);
break;
default:
break;
}
+23
View File
@@ -22,6 +22,7 @@ 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";
export interface HistoryIngressResult {
plays: PlayObject[],
@@ -348,6 +349,25 @@ Redirect URI : ${this.redirectUri}`);
} else if(authorData !== undefined) {
artists = authorData.map(x => x.name) as string[];
}
if(artists.length === 0 && obj.flex_columns.at(1)?.title?.text !== undefined) {
// if YTM doesn't have an endpoint (page) for an artist (combined) then YouTube.js doesn't include it
// in the music shelf object created from parsing data
// BUT YouTube.js does expose the raw data so we can try to recover artists from plain text
// https://github.com/LuanRT/YouTube.js/issues/381
const credits = parseArtistCredits(obj.flex_columns.at(1)?.title?.text);
if(credits !== undefined) {
// try to be clever
artists.push(credits.primary);
if(credits.secondary !== undefined) {
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
if(nonEmptyArtists.length > 0) {
artists = [...artists, ...nonEmptyArtists];
}
}
} else {
artists = [obj.flex_columns.at(1)?.title?.text];
}
}
let albumArtists: string[] = [];
if(artistsData !== undefined && authorData !== undefined) {
@@ -356,6 +376,9 @@ Redirect URI : ${this.redirectUri}`);
if(albumData !== undefined) {
album = albumData.name;
}
if(album === undefined && obj.flex_columns.at(2)?.title?.text !== undefined) {
album = obj.flex_columns.at(2)?.title?.text;
}
if(dur!== undefined) {
const durObj = dayjs.duration(dur.seconds, 's')
duration = durObj.asSeconds();
@@ -0,0 +1,50 @@
import { Logger } from "@foxxmd/logging";
import { Request } from "express";
import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.js";
import { IngressNotifier } from "./IngressNotifier.js";
import { LastfmTrackUpdateRequest } from "lastfm-node-client";
export class LFMEndpointNotifier extends IngressNotifier {
constructor(logger: Logger) {
super('Lastfm Endpoint', logger);
}
seenSlugs: Record<string, boolean> = {};
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
if(!isRaw) {
const [slug] = parseIdentifiersFromRequest(req);
if(slug === false) {
return [false, `Request URL was not valid: ${req.baseUrl}`];
}
const slugStr = slug ?? '(no slug)';
const identifier = `${slugStr}`;
if(this.seenSlugs[identifier] === undefined) {
this.seenSlugs[identifier] = true;
return [true, `Received a request to endpoint with -- Slug: ${slugStr} -- for the first time.`];
}
}
return [true, undefined];
}
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
if(req.method !== 'POST') {
return `Expected POST request (track.scrobble payload) but received ${req.method}`;
}
if(!isRaw) {
if(!('method' in req.body)) {
return `Body is missing 'method' param`
}
const method = (req.body as LastfmTrackUpdateRequest).method;
if(!['track.updateNowPlaying','track.scrobble'].includes(method)) {
return `Unexpected 'method' param value '${method}', expected either 'track.updateNowPlaying' or 'track.scrobble'`
}
}
return;
}
}
@@ -0,0 +1,46 @@
import { Logger } from "@foxxmd/logging";
import { Request } from "express";
import { EndpointListenbrainzSource, parseIdentifiersFromRequest } from "../EndpointListenbrainzSource.js";
import { IngressNotifier } from "./IngressNotifier.js";
export class LZEndpointNotifier extends IngressNotifier {
constructor(logger: Logger) {
super('Listenbrainz Endpoint', logger);
}
seenSlugs: Record<string, boolean> = {};
notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] {
if(!isRaw) {
const [slug, token] = parseIdentifiersFromRequest(req);
if(slug === false) {
return [false, `Request URL was not valid: ${req.baseUrl}`];
}
const slugStr = slug ?? '(no slug)';
if(token === false) {
return [false, `Request URL was valid and 'Authorization' header was present but invalid. Authorization header should be 'Token tokenValue' but was '${req.header('Authorization')}'`];
}
const tokenStr = token ?? '(no token)';
const redactedToken = token !== undefined ? `${(token as string).substring(0,3)}****` : '(no token)';
const identifier = `${slugStr}-${tokenStr}`;
if(this.seenSlugs[identifier] === undefined) {
this.seenSlugs[identifier] = true;
return [true, `Received a well formed request to endpoint with -- Slug: ${slugStr} | Token: ${redactedToken} -- for the first time.`];
}
}
return [true, undefined];
}
notifyByRequest(req: Request, isRaw: boolean): string | undefined {
if(req.method !== 'POST') {
return `Expected POST request (submit-listen payload) but received ${req.method}`;
}
return;
}
}
@@ -216,6 +216,30 @@ describe('Play Transforms', function () {
expect(transformed.data.track).equal('My cool thing track');
});
it('Transforms using parsed regex to get primary artist from delimited artist string', function () {
component.config = {
options: {
playTransform: {
preCompare: {
artists: [
{
search: '/(.*?)(\\s*\\/\\s*)(.*$)/i',
replace: '$1'
}
]
}
}
}
}
component.buildTransformRules();
const play = generatePlay({artists: ['My Artist One / My Artist Two / Another Guy']});
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
expect(transformed.data.artists).length(1)
expect(transformed.data.artists[0]).equal('My Artist One');
});
it('Removes title when transform replaces with empty string', function () {
component.config = {
options: {
+51 -1
View File
@@ -1,4 +1,4 @@
import { after, before, describe, it } from 'mocha';
import { after, before, afterEach, describe, it } from 'mocha';
import chai, { assert, expect } from 'chai';
import asPromised from 'chai-as-promised';
import withLocalTmpDir from 'with-local-tmp-dir';
@@ -11,6 +11,7 @@ import EventEmitter from "events";
import {loggerTest, loggerDebug} from '@foxxmd/logging';
import { clientTypes, SourceType, sourceTypes } from '../../common/infrastructure/Atomic.js';
import { Notifiers } from '../../notifier/Notifiers.js';
import { difference, intersect } from '../../utils.js';
chai.use(asPromised);
@@ -97,3 +98,52 @@ describe('Sample Configs', function () {
});
});
});
describe('Global ENVs with Config', function () {
let baseEnvKeys: string[] = [];
before(function() {
baseEnvKeys = Array.from(Object.keys(process.env));
});
afterEach(function() {
const modified = difference(Array.from(Object.keys(process.env)), baseEnvKeys);
for(const key of modified) {
delete process.env[key];
}
});
it('Parses default scrobble duration', async function () {
process.env.SOURCE_SCROBBLE_DURATION = '20';
process.env.MPRIS_ENABLE = 'true';
const emitter = new EventEmitter();
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
version: 'test'
}, loggerTest);
await sources.buildSourcesFromConfig();
expect(sources.sources).length(1);
expect(sources.sources[0].config?.options?.scrobbleThresholds?.duration).to.eq(20);
});
it('Parses default scrobble precentage', async function () {
process.env.SOURCE_SCROBBLE_PERCENT = '20';
process.env.MPRIS_ENABLE = 'true';
const emitter = new EventEmitter();
const sources = new ScrobbleSources(emitter, {
localUrl: new URL('http://example.com'),
configDir: process.cwd(),
version: 'test'
}, loggerTest);
await sources.buildSourcesFromConfig();
expect(sources.sources).length(1);
expect(sources.sources[0].config?.options?.scrobbleThresholds?.percent).to.eq(20);
});
});
@@ -0,0 +1,229 @@
import { loggerTest, loggerDebug } from "@foxxmd/logging";
import { assert, expect } from 'chai';
import EventEmitter from "events";
import dayjs from "dayjs";
import { describe, it, before, after } from 'mocha';
import { http, HttpResponse } from "msw";
import { withRequestInterception } from "../utils/networking.js";
import { MusicCastData } from "../../common/infrastructure/config/source/musiccast.js";
import { MusicCastSource } from "../../sources/MusicCastSource.js";
import netInterceptor from "@gr2m/net-interceptor";
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
const TEST_IP = '192.168.10.101';
const createSource = (data: MusicCastData = { url: TEST_IP }): MusicCastSource => {
const source = new MusicCastSource('Test', {
data,
options: {}
}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
return source;
}
describe('MusicCast Startup', function () {
after(() => {
netInterceptor.stop();
});
it('tests for device info correctly', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/system/getDeviceInfo`, () => {
return HttpResponse.json({
model_name: 'test',
device_id: 'testid',
system_version: '1234',
version: '1test',
response_code: 0
}, { status: 200 });
})
],
async function () {
const source = createSource();
await source.buildInitData();
netInterceptor.start();
netInterceptor.on("connection", (socket) => {
socket.write("Hello there.");
netInterceptor.stop();
});
await source.checkConnection();
expect(source.connectionOK).to.be.true;
}
));
});
describe('MusicCast State Handling', function() {
it('Handles standby mode', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
return HttpResponse.json({
power: 'standby',
response_code: 0
}, { status: 200 });
})
],
async function () {
const source = createSource();
await source.buildInitData();
source.connectionOK = true;
await source.getRecentlyPlayed();
expect(source.players.size).to.eq(0);
}
));
it('Handles valid netusb device playing state', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
return HttpResponse.json({
power: 'on',
response_code: 0
}, { status: 200 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
return HttpResponse.json({
device_status: 'ready',
playback: 'play',
play_time: 10,
total_time: 60,
artist: 'Test Artist',
album: 'Test Album',
track: 'Cool Track',
input: 'av1',
response_code: 0
}, { status: 200 });
}),
],
async function () {
const source = createSource();
await source.buildInitData();
source.connectionOK = true;
await source.getRecentlyPlayed();
expect(source.players.size).to.eq(1);
const playerState = source.players.get(source.players.keys().next().value).getApiState();
expect(playerState.play.data.album).to.eq('Test Album');
expect(playerState.play.data.track).to.eq('Cool Track');
expect(playerState.play.data.duration).to.eq(60);
expect(playerState.play.meta.trackProgressPosition).to.eq(10);
expect(playerState.play.meta.deviceId).to.eq('av1');
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing);
}
));
it('Handles valid cd device playing state', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
return HttpResponse.json({
power: 'on',
response_code: 0
}, { status: 200 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
return HttpResponse.json({
response_code: 100
}, { status: 200 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => {
return HttpResponse.json({
device_status: 'ready',
playback: 'play',
play_time: 10,
total_time: 60,
artist: 'Test Artist',
album: 'Test Album',
track: 'Cool Track',
response_code: 0
}, { status: 200 });
}),
],
async function () {
const source = createSource();
await source.buildInitData();
source.connectionOK = true;
await source.getRecentlyPlayed();
expect(source.players.size).to.eq(1);
const playerState = source.players.get(source.players.keys().next().value).getApiState();
expect(playerState.play.data.album).to.eq('Test Album');
expect(playerState.play.data.track).to.eq('Cool Track');
expect(playerState.play.data.duration).to.eq(60);
expect(playerState.play.meta.trackProgressPosition).to.eq(10);
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing);
}
));
it('Handles non 200 status from getPlayInfo', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
return HttpResponse.json({
power: 'on',
response_code: 0
}, { status: 200 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
return HttpResponse.json({
response_code: 100
}, { status: 500 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => {
return HttpResponse.json({
device_status: 'ready',
playback: 'play',
play_time: 10,
total_time: 60,
artist: 'Test Artist',
album: 'Test Album',
track: 'Cool Track',
response_code: 0
}, { status: 200 });
}),
],
async function () {
const source = createSource();
await source.buildInitData();
source.connectionOK = true;
await source.getRecentlyPlayed();
expect(source.players.size).to.eq(1);
}
));
it('Handles stopped player', withRequestInterception(
[
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => {
return HttpResponse.json({
power: 'on',
response_code: 0
}, { status: 200 });
}),
http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => {
return HttpResponse.json({
device_status: 'ready',
playback: 'stop',
play_time: 10,
total_time: 60,
artist: 'Test Artist',
album: 'Test Album',
track: 'Cool Track',
input: 'av1',
response_code: 0
}, { status: 200 });
}),
],
async function () {
const source = createSource();
await source.buildInitData();
source.connectionOK = true;
await source.getRecentlyPlayed();
expect(source.players.size).to.eq(1);
const playerState = source.players.get(source.players.keys().next().value).getApiState();
expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.stopped);
}
));
});
+63 -3
View File
@@ -1,5 +1,5 @@
import { loggerTest } from "@foxxmd/logging";
import { assert } from 'chai';
import { assert, expect } from 'chai';
import clone from "clone";
import dayjs, { Dayjs } from "dayjs";
import { describe, it } from 'mocha';
@@ -26,7 +26,7 @@ const testState = (data: Omit<PlayerStateDataMaybePlay, 'platformId'>): PlayerSt
class TestPositionalPlayerState extends PositionalPlayerState {
protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional {
const range = super.newListenRange(start, end, {rtImmediate: false, ...options});
const range = super.newListenRange(start, end, {allowedDrift: this.allowedDrift, rtImmediate: false, rtTruth: this.rtTruth, ...options});
return range;
}
public testSessionRepeat(position: number, reportedTS?: Dayjs) {
@@ -151,6 +151,29 @@ describe('Player status', function () {
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
});
it('Uses last known position for final range when cleaning up stale player', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER], {staleInterval: 20, rtTruth: true});
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(13000);
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000);
player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds'));
const staleDate = dayjs().add(41, 'seconds')
player.currentListenRange.rtPlayer.setPosition(44000);
expect(player.currentListenRange.isOverDrifted(23)).to.be.true;
expect(player.checkStale(staleDate)).to.be.true;
expect(player.listenRanges[player.listenRanges.length - 1].end.position).to.eq(23);
expect(player.getListenDuration()).to.eq(20);
});
// TODO playback position reported and conflicts with player reported status
});
});
@@ -207,7 +230,7 @@ describe('Player listen ranges', function () {
describe('When source does provide playback position', function () {
it('Duration is position based', function () {
it('Listened duration is position based', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
@@ -220,6 +243,43 @@ describe('Player listen ranges', function () {
assert.equal(player.getListenDuration(), 7);
});
it('Listened duration is track duration invariant', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 0;
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(10000);
player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 7);
});
it('Listened for is track duration invariant', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 0;
player.update(testState({play: positioned, position: 3, status: REPORTED_PLAYER_STATUSES.playing}));
player.currentListenRange.rtPlayer.setPosition(10000);
player.update(testState({play: positioned, position: 10, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(20000);
player.update(testState({play: positioned, position: 20, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
const otherPlay = clone(positioned);
otherPlay.data.track = "A New Track";
player.currentListenRange.rtPlayer.setPosition(30000);
const [currPlay, prevPlay] = player.update(testState({play: otherPlay, position: 2, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(30, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(prevPlay.data.listenedFor, 17);
});
it('Range ends if position over drifts', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
+9
View File
@@ -1,8 +1,17 @@
import { PlayObject } from "../../../core/Atomic.js";
import AbstractSource from "../../sources/AbstractSource.js";
import { MemoryPositionalSource } from "../../sources/MemoryPositionalSource.js";
import MemorySource from "../../sources/MemorySource.js";
export class TestSource extends AbstractSource {
handle(plays: PlayObject[]) {
this.scrobble(plays);
}
}
export class TestMemorySource extends MemorySource {
}
export class TestMemoryPositionalSource extends MemoryPositionalSource {
}
+300 -2
View File
@@ -6,10 +6,19 @@ import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay } from "../utils/PlayTestUtils.js";
import { TestSource } from "./TestSource.js";
import { generatePlay, generatePlayerStateData } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
import MockDate from 'mockdate';
import dayjs, { Dayjs } from "dayjs";
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
import { SourceConfig } from "../../common/infrastructure/config/source/sources.js";
import MemorySource from "../../sources/MemorySource.js";
import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
import { sleep } from "../../utils.js";
chai.use(asPromised);
@@ -20,6 +29,20 @@ const generateSource = () => {
}
let source: TestSource = generateSource();
const generateMemorySource = (config: SourceConfig = {}) => {
const s = new TestMemorySource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
s.buildTransformRules();
s.scheduler.stop();
return s;
}
const generateMemoryPositionalSource = (config: SourceConfig = {}) => {
const s = new TestMemoryPositionalSource('spotify', 'test', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
s.buildTransformRules();
s.scheduler.stop();
return s;
}
describe('Sources use transform plays correctly', function () {
beforeEach(function() {
@@ -159,4 +182,279 @@ describe('Sources correctly parse incoming payloads', function () {
expect(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']);
expect(identicalArtistsPlay.data.albumArtists).to.be.empty;
});
});
describe('Player Cleanup', function () {
this.afterEach(() => {
MockDate.reset();
setRtTick(RT_TICK_DEFAULT);
});
this.beforeEach(() => {
setRtTick(1);
});
const cleanedUpDuration = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
}
it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () {
await cleanedUpDuration(generateMemorySource);
});
it('Discovers cleaned up Play with correct duration (Positional Source)', async function () {
await cleanedUpDuration(generateMemoryPositionalSource);
});
const noScrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
timeSince += 10;
position -= 9;
// simulate polling another 20 seconds with active source again
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
const plays = source.processRecentPlays([advancedState])
expect(plays.length).to.be.eq(0);
}
it('Does not discover same Play after becoming active again (Non Positional Source)', async function () {
await noScrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does not discover same Play after becoming active again (Positional Source)', async function () {
await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
const noScrobbleStale = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
const discoveredPlays = source.processRecentPlays([]);
// cleanup should not discover stale play
expect(discoveredPlays.length).to.be.eq(0);
}
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () {
await noScrobbleStale(generateMemorySource);
});
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () {
await noScrobbleStale(generateMemoryPositionalSource);
});
const scrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
// if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([initialState]).length).to.be.eq(0);
let position = 0;
let timeSince = 0;
// simulate polling playing source for 30 seconds, 10 second interval
for(let i = 0; i < 3; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
// simulate polling another 20 seconds without any updates from the Source
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should not discover stale play
expect(discoveredPlays.length).to.be.eq(0);
// so that loop starts 1 second after "paused" position
position -= 9;
// simulate ~50 seconds of listening (enough for scrobble)
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
for(let i = 0; i < 5; i++) {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should return discovered play with ~90 seconds of duration
const plays = source.processRecentPlays([advancedState])
expect(plays.length).to.be.eq(1);
expect(plays[0].data.duration).to.be.closeTo(90, 2);
}
it('Does discover Play after becoming active again (Non Positional Source)', async function () {
await scrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does discover Play after becoming active again (Positional Source)', async function () {
await scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
});
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;
});
});
+16
View File
@@ -7,6 +7,7 @@ import timezone from "dayjs/plugin/timezone.js";
import utc from "dayjs/plugin/utc.js";
import { JsonPlayObject, ObjectPlayData, PlayMeta, PlayObject } from "../../../core/Atomic.js";
import { sortByNewestPlayDate } from "../../utils.js";
import { NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from '../../common/infrastructure/Atomic.js';
dayjs.extend(utc)
dayjs.extend(isBetween);
@@ -134,6 +135,21 @@ export const normalizePlays = (plays: PlayObject[],
return normalizedPlays;
}
export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay, 'platformId'> & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => {
let play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta);
if(options.position !== undefined) {
play.meta.trackProgressPosition = options.position;
}
return {
platformId: options.platformId ?? [NO_DEVICE, NO_USER],
sessionId: options.sessionId,
play,
status: options.status,
position: options.position,
timestamp: options.timestamp ?? dayjs()
}
}
export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject => {
return {
data: {
+2 -43
View File
@@ -230,7 +230,7 @@ export const parseRetryAfterSecsFromObj = (err: any) => {
// }
const {
response: {
// @ts-ignore
// @ts-expect-error
headers, // returned in superagent error
} = {},
retryAfter: ra // possible custom property we have set
@@ -302,48 +302,6 @@ export const removeUndefinedKeys = <T extends Record<string, any>>(obj: T): T |
return newObj;
}
export const parseDurationFromTimestamp = (timestamp: any) => {
if (timestamp === null || timestamp === undefined) {
return undefined;
}
if (!(typeof timestamp === 'string')) {
throw new Error('Timestamp must be a string');
}
if (timestamp.trim() === '') {
return undefined;
}
const parsedRuntime = timestamp.split(':');
let hours = '0',
minutes = '0',
seconds = '0',
milli = '0';
switch (parsedRuntime.length) {
case 3:
hours = parsedRuntime[0];
minutes = parsedRuntime[1];
seconds = parsedRuntime[2];
break;
case 2:
minutes = parsedRuntime[0];
seconds = parsedRuntime[1];
break;
case 1:
seconds = parsedRuntime[0];
}
const splitSec = seconds.split('.');
if (splitSec.length > 1) {
seconds = splitSec[0];
milli = splitSec[1];
}
return dayjs.duration({
hours: Number.parseInt(hours),
minutes: Number.parseInt(minutes),
seconds: Number.parseInt(seconds),
milliseconds: Number.parseInt(milli)
});
}
export const remoteHostIdentifiers = (req: Request): RemoteIdentityParts => {
const remote = req.connection.remoteAddress;
const proxyRemote = Array.isArray(req.headers["x-forwarded-for"]) ? req.headers["x-forwarded-for"][0] : req.headers["x-forwarded-for"];
@@ -525,6 +483,7 @@ export const intersect = (a: Array<any>, b: Array<any>) => {
return Array.from(intersection);
}
/** Return an array of elements from array a (first arg) that are not in array b (second arg) */
export const difference = (a: Array<any>, b: Array<any>) => {
const setA = new Set(a);
const setB = new Set(b);
+91 -1
View File
@@ -47,15 +47,55 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) =>
}
}
/** Test if a host:port is reachable via TCP
*
* Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket
*
* https://github.com/gr2m/node-net-interceptor/issues/2
* https://github.com/moll/node-mitm/issues/42
*
*/
export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => {
const {host, timeout = 1000} = opts;
const promise = new Promise(((resolve, reject) => {
const client = net.connect({
timeout,
port,
host
}, () => {
client.end();
resolve(true);
});
client.on('error', (err) => {
client.destroy();
reject(err);
});
client.on('timeout', () => {
reject(new Error(`Connection timed out after ${timeout}ms`));
});
}));
try {
await promise;
return true;
} catch (e) {
throw e;
}
}
const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);
export const normalizeWebAddress = (val: string): URLData => {
export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => {
let cleanUserUrl = val.trim();
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
if (results !== undefined && results.groups && results.groups.length > 0) {
cleanUserUrl = results.groups[0];
}
const {defaultPath} = options;
let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true});
const u = new URL(normal);
let port: number;
@@ -72,6 +112,12 @@ export const normalizeWebAddress = (val: string): URLData => {
normal = normal.replace('http:', 'https:');
}
}
if(u.pathname === '/' && defaultPath !== undefined) {
u.pathname = defaultPath;
normal = normalizeUrl(u.toString());
}
return {
url: u,
normal,
@@ -79,6 +125,50 @@ export const normalizeWebAddress = (val: string): URLData => {
}
}
export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => {
let cleanUserUrl = val.trim();
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
if (results !== undefined && results.groups && results.groups.length > 0) {
cleanUserUrl = results.groups[0];
}
if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) {
cleanUserUrl = `ws://${cleanUserUrl}`;
}
const normal = normalizeUrl(val, {removeTrailingSlash: false})
const url = new URL(normal);
// default WS
if (url.protocol === 'https:') {
url.protocol = 'wss:';
} else if (url.protocol === 'http:') {
url.protocol = 'ws:';
} else if(url.protocol === '') {
url.protocol = 'ws:'
}
const {defaultPort, defaultPath} = options;
let port: number;
if(url.port === null || url.port === '') {
if(defaultPort !== undefined) {
url.port = defaultPort.toString();
port = parseInt(url.port);
} else {
port = url.protocol === 'ws:' ? 80 : 443;
}
}
if(url.pathname === '/' && defaultPath !== undefined) {
url.pathname = defaultPath;
}
return {
url,
normal: url.toString(),
port
}
}
export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => {
const urlStr = userUrl ?? `http://localhost:${defaultPort}`;
let cleanUserUrl = urlStr.trim();
+1 -1
View File
@@ -76,7 +76,7 @@ const SECONDARY_REGEX_STRATS: RegExp[] = [SECONDARY_CAPTURED_REGEX, SECONDARY_FR
* ^^^^^^^^^^^^^^**********************************************
*
* */
export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?<primary>.+?)(?<secondary>(?:[([]?(?:\Wft\.?|\Wfeat\.?|featuring|\Wvs\.)).*)/i);
export const PRIMARY_SECONDARY_SECTIONS_REGEX = new RegExp(/^(?<primary>.+?)(?<secondary>(?:[([]?(?:\Wft\.?|\Wfeat\.?|featuring|\Wvs\.?)).*)/i);
/**
* For matching the most common track/artist pattern that has a joiner
+53 -1
View File
@@ -245,4 +245,56 @@ export const todayAwareFormat = (date: Dayjs, opts: {fullFormat?: string, todayF
todayFormat = 'HH:mm:ssZ'
} = opts;
return date.format(date.isToday() ? todayFormat : fullFormat);
}
};
export const parseDurationFromTimestamp = (timestamp: any) => {
if (timestamp === null || timestamp === undefined) {
return undefined;
}
if (!(typeof timestamp === 'string')) {
throw new Error('Timestamp must be a string');
}
if (timestamp.trim() === '') {
return undefined;
}
const parsedRuntime = timestamp.split(':');
let hours = '0', minutes = '0', seconds = '0', milli = '0';
switch (parsedRuntime.length) {
case 3:
hours = parsedRuntime[0];
minutes = parsedRuntime[1];
seconds = parsedRuntime[2];
break;
case 2:
minutes = parsedRuntime[0];
seconds = parsedRuntime[1];
break;
case 1:
seconds = parsedRuntime[0];
}
const splitSec = seconds.split('.');
if (splitSec.length > 1) {
seconds = splitSec[0];
milli = splitSec[1];
}
return dayjs.duration({
hours: Number.parseInt(hours),
minutes: Number.parseInt(minutes),
seconds: Number.parseInt(seconds),
milliseconds: Number.parseInt(milli)
});
};
export type Milliseconds = number;
export const timeToHumanTimestamp = (val: ReturnType<typeof dayjs.duration> | Milliseconds): string => {
const ms = dayjs.isDuration(val) ? Math.abs(val.asMilliseconds()) : val;
// less than one hour
if(ms < 3600000) {
// EX 14:07
return new Date(ms).toISOString().substring(14, 19)
}
// EX 01:15:45
return new Date(ms).toISOString().substring(11, 19);
}
+3 -3
View File
@@ -41,7 +41,7 @@ art = {},
} = {}
} = {},
play,
listenedDuration,
listenedDuration = 0,
status: {
calculated = '???',
reported,
@@ -52,7 +52,7 @@ art = {},
let durPer = null;
if(duration !== undefined && duration !== null && duration !== 0) {
if(listenedDuration === 0) {
if(listenedDuration === 0 || listenedDuration === null) {
durPer = ' (0%)';
} else {
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
@@ -97,7 +97,7 @@ art = {},
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} 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.toFixed(0)}s` : '-'}{durPer}</p>
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
</div>
</section>
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
@@ -1,4 +1,4 @@
import React, {Fragment, useCallback} from 'react';
import React, {Fragment, useCallback, useMemo} from 'react';
import StatusCardSkeleton, {StatusCardSkeletonData} from "./StatusCardSkeleton";
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
import {Link} from "react-router-dom";
@@ -6,7 +6,7 @@ import {sourceAdapter} from "../../status/ducks";
import {RootState} from "../../store";
import {connect, ConnectedProps} from "react-redux";
import Player from "../player/Player";
import {useStartSourceMutation} from "./sourceDucks";
import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks";
import './statusCard.scss';
export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
@@ -15,10 +15,10 @@ export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromR
const statusToStatusType = (status: string) => {
const lower = status.toLowerCase();
if(lower.includes('running') || lower.includes('polling') || lower.includes('awaiting data')) {
if(lower.includes('running') || lower.includes('polling') || lower.includes('received data')) {
return 'active';
}
if(lower.includes('idle')) {
if(lower.includes('idle') || lower.includes('awaiting data')) {
return 'warn';
}
return 'error';
@@ -39,9 +39,28 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
let body = <SkeletonParagraph/>;
const [startPut, startResult] = useStartSourceMutation();
const [listenPut, listenResult] = useListenSourceMutation();
const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]);
const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => {
// cycle through states
let nextListen: boolean | undefined;
switch(currentListening) {
case true:
nextListen = false;
break;
case false:
nextListen = undefined;
break;
case undefined:
nextListen = true;
break;
}
listenPut({name, type, listening: nextListen});
}, [listenPut]);
let startSourceElement = null;
let manualListenElement = null;
let subtitleElement = null;
if(data !== undefined)
{
@@ -57,7 +76,10 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
type,
players = {},
sot,
supportsUpstreamRecentlyPlayed
supportsUpstreamRecentlyPlayed,
supportsManualListening,
manualListening,
systemListeningBehavior
} = data;
if(type === 'listenbrainz' || type === 'lastfm') {
header = `${display} (Source)`;
@@ -70,6 +92,27 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
startText = status === 'Running' ? 'Reinit' : 'Init'
}
const ml = useMemo(() => {
if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) {
return manualListening;
}
return (listenResult.data as any).listening;
}, [manualListening, listenResult]);
if(supportsManualListening) {
manualListenElement = (<Fragment>
<span>Should Scrobble:</span>
<div onClick={() => tryListen(name, type, ml)}
className="capitalize underline cursor-pointer inline mr-1 ml-1">
{ml !== undefined ? (ml ? 'Yes' : 'No') : null}
{ml === undefined ? <span>System {systemListeningBehavior ? '(Yes)' : '(No)'}</span> : null}
</div>
{/* {ml !== undefined ? <div onClick={() => tryListen(name, type, undefined)}
className="capitalize underline cursor-pointer inline">Clear
</div> : null} */}
</Fragment>);
}
startSourceElement = (<Fragment>
<div onClick={() => tryStart(name, type)}
className="capitalize underline cursor-pointer inline mr-1">{startText}
@@ -79,6 +122,12 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
</div>)
</Fragment>);
if(manualListenElement !== null) {
subtitleElement = <Fragment>{manualListenElement} | {startSourceElement}</Fragment>
} else {
subtitleElement = startSourceElement;
}
const platformIds = Object.keys(players);
const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
@@ -103,7 +152,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
title={header}
subtitle={name}
status={status}
subtitleRight={startSourceElement}
subtitleRight={subtitleElement}
statusType={statusToStatusType(status)}>
{body}
</StatusCardSkeleton>
@@ -18,8 +18,23 @@ export const sourceApi = createApi({
force: params.force
}
})
}),
listenSource: builder.mutation<undefined, {
name: string,
type: string,
listening?: boolean
}>({
query: (params) => ({
url: '/source/listen',
method: 'POST',
params: {
name: params.name,
type: params.type,
listening: params.listening
}
})
})
})
});
export const {useStartSourceMutation} = sourceApi;
export const {useStartSourceMutation, useListenSourceMutation} = sourceApi;
+3
View File
@@ -15,6 +15,9 @@ export interface SourceStatusData {
players: Record<string, SourcePlayerJson>
sot: SOURCE_SOT_TYPES
supportsUpstreamRecentlyPlayed: boolean;
supportsManualListening: boolean;
manualListening?: boolean
systemListeningBehavior?: boolean
}
export interface ClientStatusData {