Compare commits

...
115 Commits
Author SHA1 Message Date
FoxxMD 5d2c9261ae chore: Bump version for release 2025-07-07 12:22:23 +00:00
FoxxMD e039d4512c ci: Add optional rybbit analytics to docs build 2025-07-07 12:20:16 +00:00
FoxxMD 51509a3531 ci: Add rybbit variables for gh actions 2025-07-03 17:51:38 +00:00
FoxxMD 8f032f2379 feat(listenbrainz): Allow now playing reporting for koito
Doesn't affect scrobbling and means if/when NP is actually supported it'll work automatically
2025-07-03 13:10:52 +00:00
FoxxMD ff11e26219 Clean up logging for Now Playing 2025-07-03 13:10:13 +00:00
FoxxMD f004aa4cfd feat(listenbrainz): Implement Now Playing functionality 2025-07-03 12:50:40 +00:00
FoxxMD 2104f2a99d fix(now playing): Catch update errors but only log
We don't need to stop the scrobbler for now playing issues since its not a critical function. Just log as warning.
2025-07-03 12:19:34 +00:00
Matt Foxx 389129aadf Merge pull request #316 from msparkles/feat/lastfm-nowplaying
feat: lastfm nowplaying
2025-07-02 16:59:21 -04:00
FoxxMD f885726ed1 docs: Add Now Playing and configuration 2025-07-02 20:50:03 +00:00
FoxxMD 3cc452323d feat: Allow Now Playing to be globally toggled by ENV 2025-07-02 20:42:12 +00:00
FoxxMD 780acb32d7 fix(scrobbler): Fix duplicate map keys causing delay in Now Playing update 2025-07-02 17:03:48 +00:00
FoxxMD bf5fdb95f4 test: Add Now Playing updating and scheduler tests 2025-07-02 17:03:26 +00:00
FoxxMD 7d7ccf53bb test: Add Now Playing filter/sorting tests 2025-07-02 16:03:09 +00:00
Matt Foxx 94837b7e03 feat(subsonic): Implement filtering plays by user (#319)
* feat(subsonic): Implement filtering plays by user #301
2025-07-02 08:15:44 -04:00
FoxxMD a9c1c7083d feat: Add config source to startup logging #309 2025-07-01 19:33:10 +00:00
FoxxMD 8dd6fc15cc docs: Improve example config rendering for ambiguous configs
* Filter examples based on client/source configuration
* Add `type` to all configs in AIO examples

Fixes #310
2025-07-01 18:30:24 +00:00
FoxxMD 32ca57fc65 feat: Implement Now Playing queue, scheduler, and source sorting
* Add queue for aggregating Now Playing updates
* Filter play based on 1. source priority and 2. player stickiness or deterministic order
* Use toad scheduler to processing NP queue
2025-07-01 16:18:15 +00:00
FoxxMD 9d748f26c0 feat: Pass source type-name to playingNow 2025-07-01 14:55:38 +00:00
FoxxMD 5af1454f36 feat: Add Now Playing user config
* Enable/disable now playing reporting
* WIP source filters for now playing
2025-06-30 16:12:11 +00:00
FoxxMD bf7a660633 feat: Add Now Playing as class configuration and implement rate limit intervals
* Define if scrobble client can update Now Playing based on class property, use this to filter now playing update
* Use thresholds to determine when/if Now Playing should be updated
* Set LastfmScrobbler as supported for Now Playing
2025-06-30 15:29:51 +00:00
Madeline Sparkles f0723f23e8 feat: lastfm now-playing scrobbling 2025-06-30 00:14:02 +02:00
Madeline Sparkles 36357135cb fix(docs): remove archaic JSDocs on ScrobbleClients.scrobble 2025-06-29 23:17:28 +02:00
FoxxMD 8be08a5c29 chore: Bump for release 2025-06-20 20:09:21 +00:00
Matt Foxx 9fe5b9b671 Merge pull request #312 from FoxxMD/lzEnrich
feat: Enrich Listenbrainz submit payload
2025-06-20 16:07:43 -04:00
FoxxMD 720c07b008 feat(listenbrainz): Include more ids/fields in submit additional_info #311 2025-06-20 16:43:01 +00:00
FoxxMD 7fdf93c5d3 feat(spotify): Add spotify meta ids to play 2025-06-20 16:42:04 +00:00
FoxxMD a0fd466bcf dev: Add ENV only launch config 2025-06-19 18:15:45 +00:00
FoxxMD 35d62c913a fix: Guard against undefined type in config.json #310 2025-06-19 18:12:57 +00:00
FoxxMD ede2d9f71a chore: Bump version for release 2025-06-19 16:22:39 +00:00
Matt Foxx acccc32813 Merge pull request #296 from FoxxMD/deezer-internal
feat: Implement Deezer Internal (ARL) Source
2025-06-17 20:41:08 -04:00
FoxxMD 69f73ff09f docs(deezer): Rewrite Deezer Source with ARL implementation
* Add ARL implementation docs and warnings
* Update json examples to include both types of deezer configs
2025-06-17 15:16:13 +00:00
FoxxMD 305f3c42ae fix(deezer): options are optional 2025-06-17 15:14:01 +00:00
FoxxMD d6ac4c34c5 feat: Implement better during/range temporal comparisons
* Refactor temporal comparisons to provide granular "during" thresholds
* Use duration for ignoring discovery with aggressive mode in Deezer #296
2025-06-17 10:10:34 -04:00
FoxxMD 9c6eeaafdb feat: Fuzzy diff threshold configurable
* Refactor fuzzy diff theshold to be configurable
* Configure aggressive fuzzy discovery for Deezer to be 40 seconds #296
2025-06-17 10:10:34 -04:00
FoxxMD 6bd841fea2 feat(deezer internal): Add fuzzy discovery ignore option
Allows user-configurable decision on whether to ignore fuzzy-matched discovered plays
2025-06-17 10:10:34 -04:00
FoxxMD e05c46e9ce fix(deezer official): Fix missing interval interface property 2025-06-17 10:10:34 -04:00
FoxxMD e94109dc75 feat: Implement Deezer Internal (ARL) Source 2025-06-17 10:10:34 -04:00
FoxxMD a68dea5193 chore: Bump version for release 2025-06-16 19:22:43 +00:00
FoxxMD b5d56b75d7 test(listenbrainz): Fix expected client count 2025-06-16 16:06:57 +00:00
FoxxMD c8daffb935 docs(listenbrainz): Add Koito compatibility and config example #300 2025-06-16 16:02:13 +00:00
FoxxMD 18a7f26c54 feat(listenbrainz): Implement limited support for Koito as Listenbrainz Source/Client #300
* Support Koito server detection via public route
  * Warns users of limited support on detection
* Implement play history retrival for Koito server
2025-06-16 15:49:39 +00:00
FoxxMD e7395e79a5 feat(listenbrainz): Detect /1/ version prefix on base URL
Remove /1/ from base URL to further normalize user URL and make config easier #300
2025-06-16 14:15:48 +00:00
FoxxMD 08ee6d379f fix(listenbrainz): Normalize URL and join parts correctly
* Normalize base URL to make user URL configuration easier/more robust
* Use joinPath for concetenating URL to avoid missing slashes #300
2025-06-16 14:04:15 +00:00
Matt Foxx 9c692539ce Merge pull request #298 from FoxxMD/jellyfin-musicbrainz
feat(jellyfin): Add musicbrainz metadata #297
2025-06-04 14:15:09 -04:00
FoxxMD 5cd5061f20 feat(jellyfin): Add musicbrainz metadata #297 2025-06-04 13:44:15 +00:00
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
105 changed files with 5684 additions and 478 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
+4
View File
@@ -30,6 +30,10 @@ jobs:
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
RY_ANALYTICS: ${{ vars.RY_ANALYTICS }}
RY_ANALYTICS_SITEID: ${{ vars.RY_ANALYTICS_SITEID }}
RY_ANALYTICS_REPLAY: ${{ vars.RY_ANALYTICS_REPLAY }}
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
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 }}
+19
View File
@@ -16,6 +16,25 @@
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "dev (ENV Only)",
"type": "node",
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
"CONFIG_DIR": "./not-real"
},
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**",
// Ignore all dependencies (optional)
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "tsx",
"type": "node",
+9 -2
View File
@@ -19,8 +19,11 @@ 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)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [Deezer](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
@@ -29,10 +32,14 @@ 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)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
@@ -40,7 +47,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
}
}
]
+13
View File
@@ -0,0 +1,13 @@
[
{
"name": "DeezerOfficalDeprecated",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
}
}
]
+2 -5
View File
@@ -1,13 +1,10 @@
[
{
"name": "FoxxMDeezer",
"name": "DeezerARL",
"enable": true,
"clients": [],
"data": {
"clientId": "a89cba1569901a0671d5a9875fed4be1",
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/deezer/callback",
"interval": 60
"arl": "UOsRPjT3U5Dhaaup3xQ30DWFoSgqugdSYzL3QE743waaVy2eur2ckyJcZ4RV123Q3PsbKXkLwA6ahc9XmEfev6BtfSP8LujE3JswWRQogZCDUOwVzWO5sAig",
}
}
]
+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"
}
}
]
+11 -1
View File
@@ -16,5 +16,15 @@
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
}
},
{
"name": "koitoClient",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD",
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
}
},
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myYamaha",
"enable": true,
"data": {
"url": "192.168.0.101"
}
}
]
+2 -1
View File
@@ -5,7 +5,8 @@
"data": {
"url": "http://localhost:4040/airsonic",
"user": "yourUser",
"password": "yourPassword"
"password": "yourPassword",
"usersAllow": ["yourUser"]
}
}
]
+595 -42
View File
@@ -8,19 +8,26 @@ import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import SchemaLink from "../../src/components/SchemaLink";
import AIOExample from "../../src/components/AIOExample";
import FileExample from "../../src/components/FileExample";
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 DeezerConfig from '!!raw-loader!../../../config/deezer.json.example';
import DeezerDeprecatedConfig from '!!raw-loader!../../../config/deezer-deprecated.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 +221,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)
@@ -479,6 +547,8 @@ Can use this source for any application that implements the [Subsonic API](http:
:::
Use the optional `usersAllow` property with **File** or **AIO** configuration to restrict scrobbling to a list of defined users.
#### Configuration
<Tabs groupId="configType" queryString>
@@ -618,7 +688,7 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions.
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/lastfm.json" language="json5">{LastfmConfig}</CodeBlock>
<FileExample title="CONFIG_DIR/lastfm.json" data={LastfmConfig}/>
</details>
@@ -638,19 +708,79 @@ 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)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
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, [Koito](https://koito.io) 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.
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
:::note
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the **File** or AOI config to setup Listenbrainz as a Source.
:::
</TabItem>
<TabItem value="file" label="File">
@@ -659,7 +789,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/listenbrainz.json" language="json5">{ListenbrainzConfig}</CodeBlock>
<FileExample title="CONFIG_DIR/listenbrainz.json" data={ListenbrainzConfig}/>
</details>
@@ -679,72 +809,237 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
</TabItem>
</Tabs>
### [~~Deezer~~](https://deezer.com/)
### [Listenbrainz (Endpoint)](https://listenbrainz.org)
:::warning
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.
**This Source is DEPRECATED because Deezer has dropped official API support.** This Source will **not** be removed but no further support or fixes will be given.
##### URL
Users cannot create new applications on Deezer Developers and there is no guarantee existing applications will continue to work.
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
As a workaround consider integrating Deezer with last.fm and then using [last.fm as a Source](#lastfm-source).
```
http://localhost:9078/1/submit-listens
```
Users with existing Deezer applications in use with multi-scrobbler should consider this change as well to avoid future breaking issues with the unsupported API.
which is the "standard" Listenbrainz server path for scrobbling.
[See this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
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.
:::
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
##### Token
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
* Redirect URL must end in `deezer/callback`
* Default would be `http://localhost:9078/deezer/callback`
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.
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
* **Application ID**
* **Secret Key**
* **Redirect URL** (if not the default)
**If no access token is provided...**
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-----------------------------------------|-----------------------------------|
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
| 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/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
<CodeBlock title="CONFIG_DIR/endpointlz.json" language="json5">{ListenbrainzEndpointConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={DeezerConfig} name="deezer"/>
<AIOExample data={ListenbrainzEndpointConfig} name="endpointlz"/>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
</Tabs>
### [Deezer](https://deezer.com/)
<Tabs groupId="deezerSource" queryString>
<TabItem value="arl" label="Unofficial ARL">
:::warning
This Source uses unofficial methods to access Deezer data that are likely in violation of Deezer's TOS. Deezer may change or remove these methods at any time, breaking functionality. Use this Source at your own risk.
:::
:::info
This Source will work only if you have a **Premium** Deezer account.
:::
##### Retrieve ARL
The `arl` is a browser cookie that Deezer uses to store your login information. Multi-scrobbler uses the `arl` value to make authorized requests to Deezer's API where it can retrieve listening history.
**After logging into Deezer**, use one of the methods below to retrieve the `arl` cookie value:
<Tabs groupId="arlRetrival">
<TabItem value="chrome" label="Chrome">
1. On deezer.com, press F12 to open the Developer Tools window.
2. Go to the Applications tab. Press the '>>' More tabs icon if it's hidden.
3. Under the Cookies Filter, click deezer.com, then the text bar name `arl`.
4. Copy all the contents shown in the **Cookie Value** pane.
</TabItem>
<TabItem value="firefox" label="Firefox">
1. On deezer.com, hit Ctrl+Shift+I to open Developer Tools
2. Go to the Storage Tab, then expand Cookies in the sidebar and select deezer.com
3. Find the row with `arl` as the name, then double click the Value column and right click -> copy the value
</TabItem>
</Tabs>
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ------------------------------------------------ |
| `DEEZER_ARL` | Yes | | The ARL cookie value retrieved from your browser |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={DeezerConfig} name="deezer"/>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
</Tabs>
##### Duplicate detection
Third party integrations with Deezer (Sonos) may cause Deezer to [report the same track many times in listening history.](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2922374738) If you experience this issue modify your Deezer Source config (file-based only) to include the option `"fuzzyDiscoveryIgnore": "aggressive"`
<details>
<summary>deezer.json example</summary>
```json title="deezer.json"
[
{
"name": "MyDeezer",
"data": {
"arl": "UOsRPjT3U5Dhaaup3x...",
},
"options": {
"fuzzyDiscoveryIgnore": "aggressive"
}
}
]
```
</details>
This option comes with some trade-offs: MS will aggressively detect repeated tracks within a window of time that should eliminate all duplicates. However, this will also prevent *intentionally* repeated tracks from being scrobbled. See [this thread](https://github.com/FoxxMD/multi-scrobbler/pull/296#issuecomment-2970417070) for more information on how this works.
</TabItem>
<TabItem value="official" label="Official API">
:::warning
**This Source is DEPRECATED because Deezer has dropped official API support.** This Source will **not** be removed but no further support or fixes will be given. [See this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
Users cannot create new applications on Deezer Developers and there is no guarantee existing applications will continue to work.
As a workaround consider:
* Using the alternative [Deezer Source that uses unofficial, internal API via ARL](./?deezerSource=arl#deezer)
* Connect Deezer with Last.fm and then use [Last.fm as a Source](#lastfm-source)
:::
Create a new application at [Deezer Developers](https://developers.deezer.com/myapps)
* Application Domain must be the same as your multi-scrobbler domain. Default is `localhost:9078`
* Redirect URL must end in `deezer/callback`
* Default would be `http://localhost:9078/deezer/callback`
After application creation you should have credentials displayed in the "My Apps" dashboard. You will need:
* **Application ID**
* **Secret Key**
* **Redirect URL** (if not the default)
**If no access token is provided...**
After starting multi-scrobbler with credentials in-place open the dashboard (`http://localhost:9078`) and find your Deezer source. Click **(Re)authenticate and (re)start polling** to start the login process. After login is complete polling will begin automatically.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|-----------------------------------------|-----------------------------------|
| `DEEZER_CLIENT_ID` | Yes | | Your **Application ID** |
| `DEEZER_CLIENT_SECRET` | Yes | | Your **Secret Key** |
| `DEEZER_REDIRECT_URI` | No | `http://localhost:9078/deezer/callback` | URI must end in `deezer/callback` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/deezer.json" language="json5">{DeezerDeprecatedConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={DeezerDeprecatedConfig} name="deezer"/>
</details>
or <SchemaLink lower objectName="DeezerSourceConfig"/>
</TabItem>
</Tabs>
</TabItem>
</Tabs>
### [Youtube Music](https://music.youtube.com)
:::warning
@@ -1729,8 +2024,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 +2066,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)
@@ -1819,7 +2330,7 @@ The Callback URL is actually specified by multi-scrobbler but to keep things con
```
http://localhost:9078/lastfm/callback
```
or replace `localhost:9078` with your own base URL
or replace `localhost:9078` with your own base URL.
#### Configuration
@@ -1838,7 +2349,7 @@ or replace `localhost:9078` with your own base URL
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/lastfm.json" language="json5">{LastfmConfig}</CodeBlock>
<FileExample title="CONFIG_DIR/lastfm.json" data={LastfmConfig} client/>
</details>
@@ -1849,7 +2360,7 @@ or replace `localhost:9078` with your own base URL
<summary>Example</summary>
<AIOExample client data={LastfmConfig} name="lastfm"/>
<AIOExample client data={LastfmConfig} name="lastfm" client/>
</details>
@@ -1859,10 +2370,12 @@ or replace `localhost:9078` with your own base URL
### [Listenbrainz](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
You will need to run your own Listenbrainz server, [Koito](https://koito.io) 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.
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
#### Configuration
<Tabs groupId="configType" queryString>
@@ -1879,7 +2392,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/listenbrainz.json" language="json5">{ListenbrainzConfig}</CodeBlock>
<FileExample title="CONFIG_DIR/listenbrainz.json" data={ListenbrainzConfig} client/>
</details>
@@ -1890,7 +2403,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
<summary>Example</summary>
<AIOExample client data={ListenbrainzConfig} name="listenbrainz"/>
<AIOExample client data={ListenbrainzConfig} name="listenbrainz" client/>
</details>
@@ -1898,6 +2411,46 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
</TabItem>
</Tabs>
## Now Playing
Multi-scrobbler can report the currently **playing** tracks it is monitoring to some Scrobble Clients via their individual **Now Playing** functionality. The behavior multi-scrobbler uses for determining Now Playing reporting:
* Now Playing will be only be reported to the same Clients a Source is configured to scrobble to
* A Source's Player status must be **Playing**, IE **active**, for reporting to occur
* This status can be verified via Player UI on the Dashboard
* If multiple Sources having active Players then the scrobble Client will default to reporting the track based on Source **configuration** name, alphabetically
* This is the `name` property set in [File or AIO source configs](./?configType=file#configuration-types)
* Now Playing is **default enabled** for all Clients that support it
* Now Playing can be explicitly enabled or disabled globally using ENV `NOW_PLAYING=true` or `NOW_PLAYING=false`
* This only affects Clients that don't have behavior set via File/AIO (below)
Clients can customize the Now Playing behavior individually using [File or AIO source configs:](./?configType=file#configuration-types)
```json5 title="CONFIG_DIR/lastfm.json"
[
{
"name": "myLastFmClient",
"configureAs": "client",
"data": {
// ...
},
"options": {
// disable or enable
//"nowPlaying": true
//
// OR define a list of Source *config* names that should be allowed to report Now Playing
// order of the list determines priority for reporting Now Playing
//"nowPlaying": ["mySpotify1","myJellyfin2"]
}
},
]
```
### Supported Scrobble Clients
* [Last.fm](#lastfm)
* [Listenbrainz](#listenbrainz)
## Monitoring
multi-scrobbler supports some common webhooks and a healthcheck endpoint in order to monitor Sources and Clients for errors.
+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:
+22
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 !== '') {
@@ -185,5 +194,18 @@ if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
}
config.scripts.push(script)
}
if (process.env.RY_ANALYTICS !== undefined && process.env.RY_ANALYTICS !== '') {
const script = {
src: process.env.RY_ANALYTICS,
defer: true
}
if (process.env.RY_ANALYTICS_SITEID !== undefined && process.env.RY_ANALYTICS_SITEID !== '') {
script['data-site-id'] = process.env.RY_ANALYTICS_SITEID;
}
if (process.env.RY_ANALYTICS_REPLAY !== undefined && process.env.RY_ANALYTICS_REPLAY !== '') {
script['data-session-replay'] = process.env.RY_ANALYTICS_REPLAY;
}
config.scripts.push(script)
}
export default config;
+5 -2
View File
@@ -23,7 +23,7 @@ const AIOExample = (props: AIOProps) => {
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data);
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
@@ -31,9 +31,12 @@ const AIOExample = (props: AIOProps) => {
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
}
configObj[0].type = name;
const configType = client ? 'clients' : 'sources';
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
configObj = configObj.map(x => ({...x, type: name}));
const aio = {[configType]: configObj};
return <CodeBlock title="CONFIG_DIR/config.json" language="json5">{JSON.stringify(aio, null, 2)}</CodeBlock>
}
+49
View File
@@ -0,0 +1,49 @@
import React, { Fragment } from "react"
import CodeBlock, {Props as CodeBlockProps} from '@theme/CodeBlock';
import Admonition from '@theme/Admonition';
import ErrorBoundary from "@docusaurus/ErrorBoundary"
import Error from "@theme/Error"
import { Simulate } from "react-dom/test-utils";
import error = Simulate.error;
import json5 from 'json5';
export interface FileProps extends Omit<CodeBlockProps, 'children'> {
data: string
client?: boolean
}
const FileExample = (props: FileProps) => {
const {
data,
client = false,
...rest
} = props;
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = json5.parse(data) as object[];
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
<p>Example component crashed because of error!</p>
<CodeBlock>{e.message}</CodeBlock>
</Admonition>
}
configObj = configObj.filter(x => x.configureAs === undefined || x.configureAs === (client ? 'client' : 'source'));
return <CodeBlock {...rest} language="json5">{JSON.stringify(configObj, null, 2)}</CodeBlock>
}
const WrappedFileExample = (props: FileProps) => {
return <ErrorBoundary
fallback={({error}) => (
<div>
<p>Example component crashed because of error: {error.message}.</p>
</div>
)}
><FileExample {...props} /></ErrorBoundary>
}
export default WrappedFileExample;
+10 -2
View File
@@ -20,8 +20,11 @@ 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)
* [~~Deezer~~](docs/configuration#deezer)
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [Deezer](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
* [JRiver](docs/configuration#jriver)
@@ -30,18 +33,23 @@ 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)
* [ListenBrainz](docs/configuration#listenbrainz)
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports [Now Playing](docs/configuration#now-playing) for scrobble Clients
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Graceful network and client failure handling (queued scrobbles that auto-retry)
* 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"/>
+178 -50
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.7",
"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,8 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
@@ -77,12 +80,13 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
"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 +125,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",
@@ -487,6 +492,32 @@
"tough-cookie": "^4.1.4"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@curvenote/ansi-to-react": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@curvenote/ansi-to-react/-/ansi-to-react-7.0.0.tgz",
@@ -1022,6 +1053,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
@@ -1135,6 +1167,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",
@@ -3243,12 +3283,10 @@
}
},
"node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"dependencies": {
"debug": "^4.3.4"
},
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
@@ -4097,6 +4135,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",
@@ -5999,6 +6042,30 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-cookie-agent": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.1.tgz",
"integrity": "sha512-lZHFZUdPTw64PdksQac5xbUd4NWjUbyDYnvR//2sbLpcC4UqEUW0x/6O+rDntVzJzJ07QvhtL5XZSC+c5EK+IQ==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0",
"undici": "^7.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -6026,6 +6093,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 +6904,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 +7487,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",
@@ -9046,10 +9146,17 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -9093,7 +9200,8 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -9432,7 +9540,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "4.1.8",
@@ -10489,6 +10598,24 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -10582,27 +10709,15 @@
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
"node": ">=16"
}
},
"node_modules/tr46": {
@@ -10898,14 +11013,14 @@
"dev": true
},
"node_modules/undici": {
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
"integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=14.0"
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
@@ -10983,6 +11098,7 @@
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -11746,9 +11862,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,19 +12038,31 @@
}
},
"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"
}
},
"node_modules/youtubei.js/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.1",
"version": "0.9.7",
"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,8 @@
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"http-cookie-agent": "^7.0.1",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
@@ -109,12 +112,13 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
"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 +157,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(() => {
+25 -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 => {
@@ -67,6 +77,19 @@ export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
}
export interface ComponentIdentifier {
type: SourceType | ClientType
name: string
}
export interface SourceIdentifier extends ComponentIdentifier {
type: SourceType
}
export interface ClientIdentifier extends ComponentIdentifier {
type: ClientType
}
export type InitState = 0 | 1 | 2;
export const NOT_INITIALIZED: InitState = 0;
export const INITIALIZING: InitState = 1;
@@ -66,6 +66,21 @@ export interface UpstreamRefreshOptions {
refreshInitialCount?: number
}
export interface NowPlayingOptions {
/**
* Configure if this Client should report Now Playing from Sources that can scrobble to it
*
* * `true` (default) => Report Now Playing from any eligible Source.
* * If multiple Sources are Playing then reported Play is based on alphabetical order of Source names
* * `false` => Do not report Now Playing
* * `string` list => list of Source `names` that should be allowed to report Now Playing. Order of list determine priority of Play to Report.
*
* @default true
* */
nowPlaying?: boolean | string[]
}
export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions {
/**
@@ -0,0 +1,30 @@
export interface ListensResponse {
items: ListenObjectResponse[]
total_record_count: number
items_per_page: number
has_next_page: boolean
current_page: number
}
export interface ListenObjectResponse {
/** ISO8601 timestamp */
time: string
track: TrackResponse
}
export interface TrackResponse {
id: number
title: string
artists: ArtistResponse[]
musicbrainz_id: string | null
listen_count: number
duration: number
image: string | null
album_id: number
time_listened: number
}
export interface ArtistResponse {
id: number
name: string
}
@@ -1,7 +1,7 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
export interface LastfmData extends RequestRetryOptions {
export interface LastfmData extends CommonClientData, RequestRetryOptions {
/**
* API Key generated from Last.fm account
*
@@ -27,6 +27,10 @@ export interface LastfmData extends RequestRetryOptions {
redirectUri?: string
}
export interface LastfmClientOptions extends CommonClientOptions, NowPlayingOptions {
}
export interface LastfmClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using LastFM as a client
@@ -35,7 +39,8 @@ export interface LastfmClientConfig extends CommonClientConfig {
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: CommonClientData & LastfmData
data: LastfmData
options?: LastfmClientOptions
}
export interface LastfmClientAIOConfig extends LastfmClientConfig {
@@ -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'
}
@@ -1,6 +1,8 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { Second } from "../../../../../core/Atomic.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface DeezerData extends CommonSourceData {
export interface DeezerData extends CommonSourceData, PollingOptions {
/**
* deezer client id
*
@@ -20,13 +22,6 @@ export interface DeezerData extends CommonSourceData {
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri?: string
/**
* optional, how long to wait before calling spotify for new tracks (in seconds)
*
* @default 60
* @examples [60]
* */
interval?: number
}
export interface DeezerSourceConfig extends CommonSourceConfig {
data: DeezerData
@@ -35,3 +30,51 @@ export interface DeezerSourceConfig extends CommonSourceConfig {
export interface DeezerSourceAIOConfig extends DeezerSourceConfig {
type: 'deezer'
}
export interface DeezerInternalData extends CommonSourceData, PollingOptions {
/** ARL retrieved from Deezer response header */
arl: string
/** User agent
*
* @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0"
*/
userAgent?: string
}
export interface DeezerInternalSourceConfig extends CommonSourceConfig {
data: DeezerInternalData
options?: DeezerInternalSourceOptions
}
export interface DeezerInternalSourceOptions extends CommonSourceOptions {
fuzzyDiscoveryIgnore?: boolean | 'aggressive'
}
export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig {
type: 'deezer'
}
export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig;
export interface DeezerInternalTrackData {
/** Song Id */
SNG_ID: string
/** Date listened as unix timestamp in seconds */
TS: Second
/** Album Id */
ALB_ID: string
/** Album Title */
ALB_TITLE: string
/** Album Art Id */
ALB_PICTURE: string
/** Artist Id */
ART_ID: string
/** Artist Name */
ART_NAME: string
/** Song Title */
SNG_TITLE: string
/** Time listened to track in seconds */
DURATION: Second
__TYPE__: 'song' | string
}
@@ -0,0 +1,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,5 +1,8 @@
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
@@ -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,9 @@ export type SourceConfig =
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| DeezerInternalSourceConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
| JellySourceConfig
| JellyApiSourceConfig
@@ -37,8 +45,11 @@ export type SourceConfig =
| WebScrobblerSourceConfig
| ChromecastSourceConfig
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
| VLCSourceConfig;
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -46,6 +57,9 @@ export type SourceAIOConfig =
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| DeezerInternalAIOConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
| JellySourceAIOConfig
| JellyApiSourceAIOConfig
@@ -59,5 +73,8 @@ export type SourceAIOConfig =
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
| VLCSourceAIOConfig;
| VLCSourceAIOConfig
| IcecastSourceAIOConfig
| AzuracastSourceAIOConfig;
@@ -55,6 +55,13 @@ export interface SubsonicData extends CommonSourceData, PollingOptions {
* @default false
* */
legacyAuthentication?: boolean
/**
* Only scrobble for specific users (case-insensitive)
*
* If undefined or an empty string/list MS will scrobble activity from all users
* */
usersAllow?: string | string[]
}
export interface SubSonicSourceConfig extends CommonSourceConfig {
data: SubsonicData
@@ -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
+64 -31
View File
@@ -1,8 +1,7 @@
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import LastFm, {
AuthGetSessionResponse,
NowPlayingResponse,
TrackObject,
LastfmTrackUpdateRequest, TrackObject,
TrackScrobblePayload,
UserGetInfoResponse
} from "lastfm-node-client";
@@ -16,6 +15,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',
@@ -139,9 +139,7 @@ export default class LastfmApiClient extends AbstractApiClient {
}
}
getAuthUrl = () => {
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
}
getAuthUrl = () => `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`
authenticate = async (token: any) => {
const sessionRes: AuthGetSessionResponse = await this.client.authGetSession({token});
@@ -224,13 +222,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
@@ -247,31 +250,61 @@ export default class LastfmApiClient extends AbstractApiClient {
// so remove all undefined keys from the object before passing to the api client
return removeUndefinedKeys(rawPayload);
}
}
updateNowPlaying = async (play: PlayObject) => {
try {
const {timestamp, mbid, ...rest} = this.playToClientPayload(play);
const response = await this.callApi<NowPlayingResponse>((client: LastFm) => {
return client.trackUpdateNowPlaying(rest)
});
const {
nowplaying: {
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
}
return response;
} catch (e) {
if (!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: true});
} else {
throw e;
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
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;
}
+164 -15
View File
@@ -1,7 +1,7 @@
import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject } from "../../../core/Atomic.js";
import { PlayObject, URLData } from "../../../core/Atomic.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
@@ -16,6 +16,11 @@ import { UpstreamError } from "../errors/UpstreamError.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
import AbstractApiClient from "./AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
import { parseRegexSingleOrFail } from '../../utils.js';
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
import { log } from 'console';
export interface ArtistMBIDMapping {
@@ -70,8 +75,21 @@ export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo {
recording_msid?: string
}
// using submit-listens example from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
export interface SubmitListenAdditionalTrackInfo extends AdditionalTrackInfo {
artist_names?: string[]
release_artist_name?: string
release_artist_names?: string[]
spotify_album_id?: string
spotify_album_artist_ids?: string[]
spotify_artist_ids?: string[]
albumartist?: string
}
export interface TrackPayload extends MinimumTrack {
additional_info?: AdditionalTrackInfo
additional_info?: SubmitListenAdditionalTrackInfo
}
export interface ListenPayload {
@@ -80,11 +98,18 @@ export interface ListenPayload {
track_metadata: TrackPayload;
}
export type ListenType = 'single' | 'playing_now';
export interface SubmitPayload {
listen_type: 'single',
listen_type: ListenType,
payload: [ListenPayload]
}
interface SubmitOptions {
log?: boolean
listenType?: ListenType
}
export interface TrackResponse extends MinimumTrack {
duration: number
additional_info: AdditionalTrackInfoResponse
@@ -112,17 +137,27 @@ export interface ListenResponse {
track_metadata: TrackResponse;
}
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
export class ListenbrainzApiClient extends AbstractApiClient {
declare config: ListenBrainzClientData;
url: string;
url: URLData;
isKoito: boolean = false;
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
super('ListenBrainz', name, config, options);
const {
url = 'https://api.listenbrainz.org/'
} = config;
this.url = url;
let cleanUrl = url;
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
cleanUrl = url.replace(LZ_VERSION_PATH, '');
}
this.url = normalizeWebAddress(cleanUrl);
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
}
@@ -178,7 +213,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testConnection = async () => {
try {
const resp = await this.callApi(request.get(this.url))
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
this.isKoito = await this.checkKoito();
return true;
} catch (e) {
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
@@ -190,18 +226,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testAuth = async () => {
try {
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
return true;
} catch (e) {
throw e;
}
}
async checkKoito(): Promise<boolean> {
try {
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.');
return true;
} catch (e) {
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
}
return false;
}
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -217,11 +265,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
.query({
period: 'all_time',
page: 0,
limit: maxTracks
})
);
const { body } = resp as any;
return body as KoitoListensResponse;
} catch (e) {
throw e;
}
}
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -238,6 +304,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
if(this.isKoito) {
return this.getRecentlyPlayedKoito(maxTracks)
}
try {
const resp = await this.getUserListens(maxTracks, user);
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
@@ -247,9 +317,24 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
submitListen = async (play: PlayObject, log: boolean = false) => {
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
try {
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
const resp = await this.getUserListensKoito(maxTracks);
return resp.items.map(x => listenObjectResponseToPlay(x));
} catch (e) {
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
return [];
}
}
submitListen = async (play: PlayObject, options: SubmitOptions = {}) => {
const { log = false, listenType = 'single'} = options;
try {
const listenPayload: SubmitPayload = {listen_type: listenType, payload: [ListenbrainzApiClient.playToListenPayload(play)]};
if(listenType === 'playing_now') {
delete listenPayload.payload[0].listened_at;
}
if(log) {
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
}
@@ -257,7 +342,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
// so no useful information
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
// TODO may we should make a call to recent-listens to get the parsed scrobble?
const resp = await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
if(log) {
this.logger.debug(`Submit Response: ${resp.text}`)
}
@@ -279,14 +364,40 @@ export class ListenbrainzApiClient extends AbstractApiClient {
track,
duration,
meta: {
brainz = {}
brainz = {},
spotify = {}
} = {}
}
} = play;
// using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
// which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification
// and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941
const addInfo: SubmitListenAdditionalTrackInfo = {
// all artists
artist_names: Array.from(new Set([...artists, ...albumArtists])),
// primary artist
release_artist_name: artists[0],
release_artist_names: [artists[0]],
};
if(spotify.track !== undefined) {
addInfo.spotify_id = spotify.track;
}
if(spotify.album !== undefined) {
addInfo.spotify_album_id = spotify.album;
}
if(spotify.albumArtist !== undefined && spotify.albumArtist.length > 0) {
addInfo.spotify_album_artist_ids = spotify.albumArtist;
}
if(spotify.artist !== undefined) {
addInfo.spotify_artist_ids = spotify.artist;
}
return {
listened_at: getScrobbleTsSOCDate(play).unix(),
track_metadata: {
artist_name: artists[0],
artist_name: Array.from(new Set([...artists, ...albumArtists])).join(', '),
track_name: track,
release_name: album,
additional_info: {
@@ -294,12 +405,50 @@ export class ListenbrainzApiClient extends AbstractApiClient {
track_mbid: brainz.track,
artist_mbids: brainz.artist,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup
release_group_mbid: brainz.releaseGroup,
...addInfo
}
}
}
}
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);
}
};
}
}
+38
View File
@@ -0,0 +1,38 @@
import dayjs from "dayjs";
import { PlayObject } from "../../../../core/Atomic.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
import AbstractApiClient from "../AbstractApiClient.js";
export class KoitoApiClient extends AbstractApiClient {
constructor(name: any, config: any, options: AbstractApiOptions) {
super('Koito', name, config, options);
}
}
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => {
const play: PlayObject = {
data: {
track: obj.track.title,
artists: (obj.track.artists ?? []).map(x => x.name),
duration: obj.track.duration,
playDate: dayjs(obj.time)
},
meta: {
source: 'Koito',
newFromSource: options.newFromSource ?? false,
trackId: obj.track.id.toString()
}
}
if(obj.track.musicbrainz_id !== null) {
play.data.meta = {
brainz: {
track: obj.track.musicbrainz_id
}
}
}
return play;
}
+223 -10
View File
@@ -3,15 +3,13 @@ import dayjs, { Dayjs } from "dayjs";
import EventEmitter from "events";
import { FixedSizeList } from 'fixed-size-list';
import { nanoid } from "nanoid";
import { Simulate } from "react-dom/test-utils";
import { MarkOptional } from "ts-essentials";
import {
DeadLetterScrobble,
PlayObject,
QueuedScrobble,
TA_CLOSE,
QueuedScrobble, TA_DURING,
TA_FUZZY,
TrackStringOptions,
TrackStringOptions
} from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
@@ -23,15 +21,21 @@ import {
DEFAULT_RETRY_MULTIPLIER,
DUP_SCORE_THRESHOLD,
FormatPlayObjectOptions,
PlayPlatformId,
ScrobbledPlayObject,
SourceIdentifier,
TIME_WEIGHT,
TITLE_WEIGHT, TRANSFORM_HOOK,
} from "../common/infrastructure/Atomic.js";
import { CommonClientConfig, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
import { CommonClientConfig, NowPlayingOptions, UpstreamRefreshOptions } from "../common/infrastructure/config/client/index.js";
import { Notifiers } from "../notifier/Notifiers.js";
import {
comparingMultipleArtists,
genGroupId,
genGroupIdStr,
genGroupIdStrFromPlay,
isDebugMode,
parseBool,
playObjDataMatch,
pollingBackoff,
setIntersection,
@@ -42,17 +46,23 @@ import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/E
import { compareScrobbleArtists, compareScrobbleTracks, normalizeStr } from "../utils/StringUtils.js";
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalAccuracyToString,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>;
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
name: string;
type: ClientType;
scheduler: ToadScheduler = new ToadScheduler();
protected MAX_STORED_SCROBBLES = 40;
protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES;
@@ -77,6 +87,16 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
queuedScrobbles: QueuedScrobble<PlayObject>[] = [];
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
supportsNowPlaying: boolean = false;
nowPlayingEnabled: boolean;
nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined;
nowPlayingThresholds: [number,number] = [10,30];
nowPlayingLastUpdated?: Dayjs;
nowPlayingLastPlay?: PlayObject;
nowPlayingQueue: NowPlayingQueue = new Map();
nowPlayingTaskInterval: number = 5000;
npLogger: Logger;
declare config: CommonClientConfig;
notifier: Notifiers;
@@ -87,6 +107,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
this.type = type;
this.name = name;
this.logger = childLogger(logger, this.getIdentifier());
this.npLogger = childLogger(this.logger, 'Now Playing');
this.notifier = notifier;
this.emitter = emitter;
@@ -153,13 +174,137 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
this.emitEvent('notify', payload);
}
protected initializeNowPlaying() {
if (this.supportsNowPlaying) {
const {
options = {},
} = this.config;
// for future use...if we let user manually toggle now playing off/on
if(this.nowPlayingEnabled === undefined) {
const npEnv = process.env.NOW_PLAYING;
if('nowPlaying' in options) {
const nowOpts = options as NowPlayingOptions;
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by 'nowPlaying' config`);
} else if (npEnv !== undefined) {
this.nowPlayingEnabled = parseBool(npEnv);
this.npLogger.debug(`${this.nowPlayingEnabled ? 'Enabled' : 'Disabled'} by global ENV`);
} else {
this.nowPlayingEnabled = true;
this.npLogger.debug(`Enabled by default config`);
}
}
this.initializeNowPlayingFilter();
this.initializeNowPlayingSchedule();
} else {
this.npLogger.debug('Unsupported feature, disabled.');
}
}
protected initializeNowPlayingSchedule() {
const t = new AsyncTask('Playing Now', (): Promise<any> => {
return this.processingPlayingNow();
}, (err: Error) => {
this.npLogger.error(new Error('Unexpected error while processing Now Playing queue', {cause: err}));
});
this.scheduler.removeById('pn_task');
// even though we are processing every 5 seconds the interval that Now Playing is updated at, and that the queue is cleared on,
// is still set by shouldUpdatePlayingNow()
// 5 seconds makes sure our granularity for updates is decently fast *when* we do need to actually update
this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({milliseconds: this.nowPlayingTaskInterval}, t, {id: 'pn_task'}));
}
protected initializeNowPlayingFilter() {
const {
options = {},
} = this.config;
if (this.supportsNowPlaying) {
let sourceFilter: (queue: NowPlayingQueue) => PlatformMappedPlays | undefined;
// sources default to being filters by name-type, alphabetically
sourceFilter = (queue: NowPlayingQueue) => {
const sorted = Array.from(queue.entries()).sort((a, b) => a[0].localeCompare(b[0]));
return sorted[0][1];
}
if ('nowPlaying' in options) {
const nowOpts = options as NowPlayingOptions;
if (this.nowPlayingEnabled === undefined) {
this.nowPlayingEnabled = nowOpts.nowPlaying === true || Array.isArray(nowOpts.nowPlaying);
}
if (Array.isArray(nowOpts.nowPlaying)) {
// if user defined priority list of source names then we use that instead, look for source name in name-type
sourceFilter = (queue: NowPlayingQueue) => {
const entries = Array.from(queue.entries());
for (const s of nowOpts.nowPlaying as string[]) {
const sLower = s.toLocaleLowerCase();
const validSource = entries.find(x => x[0].toLocaleLowerCase().includes(sLower));
if (validSource !== undefined) {
return validSource[1];
}
}
return undefined;
}
}
}
this.nowPlayingFilter = (queue: NowPlayingQueue) => {
if (queue.size === 0) {
return undefined;
}
// get list of play(ers) for top-priority Source
const platformPlays = sourceFilter(queue);
if (platformPlays === undefined) {
return undefined;
}
// if only one player then return it
const plays = Array.from(platformPlays);
if (plays.length === 1) {
return plays[0][1].play;
}
// else we need to sort players to determine which to report
// if a now playing play already exists use that platform, if any matches...
// this way we aren't flip-flopping between multiple players for reporting now playing
// (keeps reporting sticky based on first reported)
if (this.nowPlayingLastPlay !== undefined) {
const lastNowPlayingPlat = genGroupIdStrFromPlay(this.nowPlayingLastPlay);
for (const [platform, data] of plays) {
if (platform === lastNowPlayingPlat) {
return data.play;
}
}
}
// otherwise sort platform alphabetically and take first
plays.sort((a, b) => a[0].localeCompare(b[0]));
return plays[0][1].play;
}
}
}
protected async postInitialize(): Promise<void> {
const {
options: {
refreshInitialCount = this.MAX_INITIAL_SCROBBLES_FETCH
} = {}
} = {},
options = {},
} = this.config;
this.initializeNowPlaying();
let initialLimit = refreshInitialCount;
if(refreshInitialCount > this.MAX_INITIAL_SCROBBLES_FETCH) {
this.logger.warn(`Defined initial scrobbles count (${refreshInitialCount}) higher than maximum allowed (${this.MAX_INITIAL_SCROBBLES_FETCH}). Will use max instead.`);
@@ -315,7 +460,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
const temporalComparison = comparePlayTemporally(x.play, playObj);
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
return hasAcceptableTemporalAccuracy(temporalComparison.match)
});
return [matchPlayDate, dtInvariantMatches];
@@ -403,9 +548,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const temporalComparison = comparePlayTemporally(x, playObj);
let timeMatch = 0;
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
timeMatch = 1;
} else if(temporalComparison.match === TA_FUZZY) {
} else if(hasAcceptableTemporalAccuracy(temporalComparison.match, [TA_FUZZY, TA_DURING])) {
timeMatch = 0.6;
}
@@ -775,6 +920,74 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
this.emitEvent('deadLetter', {dead: deadData});
}
queuePlayingNow = (data: PlayObject, source: SourceIdentifier) => {
const sourceId = `${source.name}-${source.type}`;
if(isDebugMode()) {
this.npLogger.debug(`Queueing ${buildTrackString(data, {include: ['artist', 'track', 'platform']})} from ${sourceId}`);
}
const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map();
platformPlays.set(genGroupIdStrFromPlay(data), {play: data, source});
this.nowPlayingQueue.set(sourceId, platformPlays);
}
processingPlayingNow = async (): Promise<void> => {
if(this.supportsNowPlaying && this.nowPlayingEnabled) {
const play = this.nowPlayingFilter(this.nowPlayingQueue);
if(play === undefined) {
return;
}
if(this.shouldUpdatePlayingNow(play)) {
try {
await this.doPlayingNow(play);
this.npLogger.debug(`Now Playing updated.`);
this.emitEvent('nowPlayingUpdated', play);
} catch (e) {
this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e}));
}
this.nowPlayingLastPlay = play;
this.nowPlayingLastUpdated = dayjs();
// only clear queue after we have updated Now Playing, this way we always have the latest and "most complete"
// set of Source Player updates available for filtering/sorting
this.nowPlayingQueue = new Map();
}
}
}
shouldUpdatePlayingNow = (data: PlayObject): boolean => {
if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing has not yet been set! Should update`);
}
return true;
}
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
// update if play *has* changed and time since last update is greater than min interval
// this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently
if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[0] < lastUpdateDiff) {
if(isDebugMode()) {
this.npLogger.debug(`New Play differs from previous Now Playing and time since update > ${lastUpdateDiff}s, should update`);
}
return true;
}
// update if play *has not* changed but last update is greater than max interval
// this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long
if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[1] < lastUpdateDiff) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing has not been updated in > ${lastUpdateDiff}s, should update`);
}
return true;
}
if(isDebugMode()) {
this.npLogger.debug(`Updated Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago, not updating`);
}
return false;
}
protected doPlayingNow = (data: PlayObject): Promise<any> => Promise.resolve(undefined)
public emitEvent = (eventName: string, payload: object) => {
this.emitter.emit(eventName, {
+27 -2
View File
@@ -1,7 +1,6 @@
import { Logger } from "@foxxmd/logging";
import dayjs from 'dayjs';
import EventEmitter from "events";
import { TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
import { NowPlayingResponse, TrackScrobbleResponse, UserGetRecentTracksResponse } from "lastfm-node-client";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
@@ -26,6 +25,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
this.api = new LastfmApiClient(name, config.data, {...options, logger})
// https://www.last.fm/api/show/user.getRecentTracks
this.MAX_INITIAL_SCROBBLES_FETCH = 200;
this.supportsNowPlaying = true;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options);
@@ -170,4 +170,29 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
this.logger.debug('Raw Payload: ', scrobblePayload);
}
}
doPlayingNow = async (data: PlayObject) => {
try {
const {timestamp, mbid, ...rest} = this.api.playToClientPayload(data);
const response = await this.api.callApi<NowPlayingResponse>((client: any) => client.trackUpdateNowPlaying(rest));
const {
nowplaying: {
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
} = {}
} = response;
if (ignoreCode > 0) {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`, {payload: rest});
}
return response;
} catch (e) {
if (!(e instanceof UpstreamError)) {
throw new UpstreamError('Error received from LastFM API', {cause: e, showStopper: false});
} else {
throw e;
}
}
}
}
@@ -11,12 +11,14 @@ import { ListenbrainzApiClient, ListenPayload } from "../common/vendor/Listenbra
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzClientConfig;
@@ -26,6 +28,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens
// 1000 is way too high. maxing at 100
this.MAX_INITIAL_SCROBBLES_FETCH = 100;
this.supportsNowPlaying = true;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options);
@@ -44,6 +47,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
this.isKoito = this.api.isKoito;
return true;
}
@@ -78,7 +82,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
} = playObj;
try {
await this.api.submitListen(playObj, true);
await this.api.submitListen(playObj, { log: isDebugMode()});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
@@ -91,4 +95,12 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
throw new UpstreamError(`Error occurred while making Listenbrainz API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
doPlayingNow = async (data: PlayObject) => {
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
} catch (e) {
throw new UpstreamError(`Error occurred while making Listenbrainz API Playing Now request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
}
}
}
+52 -16
View File
@@ -1,8 +1,8 @@
/* eslint-disable no-case-declarations */
import { childLogger, Logger } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { PlayObject } from "../../core/Atomic.js";
import { ClientType, clientTypes, ConfigMeta, isClientType } from "../common/infrastructure/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { ClientType, clientTypes, ConfigMeta, isClientType, REPORTED_PLAYER_STATUSES, SourceIdentifier } from "../common/infrastructure/Atomic.js";
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
@@ -10,7 +10,7 @@ import { ListenBrainzClientConfig } from "../common/infrastructure/config/client
import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { readJson, thresholdResultSummary } from "../utils.js";
import { isDebugMode, readJson } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js";
import { validateJson } from "../utils/ValidationUtils.js";
@@ -45,6 +45,12 @@ export default class ScrobbleClients {
this.localUrl = localUrl;
this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing) {
this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
}
});
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
await this.scrobble(payload.data, payload.options);
});
@@ -55,7 +61,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 +69,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;
}
@@ -118,6 +127,11 @@ export default class ScrobbleClients {
clientDefaults = cd;
for (const [index, c] of mainConfigClientConfigs.entries()) {
const {name = 'unnamed'} = c;
if(c.type === undefined) {
const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`;
this.logger.error(invalidMsgType);
continue;
}
if(!isClientType(c.type.toLocaleLowerCase())) {
const invalidTypeMsg = `Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`;
//this.emitter.emit('error', new Error(invalidTypeMsg));
@@ -284,7 +298,7 @@ ${sources.join('\n')}`);
try {
await this.addClient(c, clientDefaults, notifier);
} catch(e) {
const addError = new Error(`Client ${c.name} was not added because it had unrecoverable errors`, {cause: e});
const addError = new Error(`Client ${c.name} from ${c.source} was not added because it had unrecoverable errors`, {cause: e});
this.emitter.emit('error', addError);
this.logger.error(addError);
}
@@ -296,17 +310,17 @@ ${sources.join('\n')}`);
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}*/
const {type, name, enable = true, data: d = {}} = clientConfig;
const {type, name, enable = true, source, data: d = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) client was disabled by config`);
this.logger.warn({labels: [`${type} - ${name}`]}, `Client from ${source} was disabled by config`);
return;
}
// add defaults
const data = {...defaults, ...d};
let newClient;
this.logger.debug(`Constructing ${type} (${name}) client...`);
this.logger.debug({labels: [`${type} - ${name}`]}, `Constructing Client from ${source}`);
switch (type) {
case 'maloja':
newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.emitter, this.logger);
@@ -323,17 +337,39 @@ ${sources.join('\n')}`);
if(newClient === undefined) {
// really shouldn't get here!
throw new Error(`Client of type ${type} was not recognized??`);
throw new Error(`Client of type ${type} from ${source} was not recognized??`);
}
newClient.logger.info('Client Added');
newClient.logger.info(`Client Added from ${source}`);
this.clients.push(newClient);
}
/**
* @param {*} data
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
* @returns {Array}
*/
playingNow = async (data: (PlayObject | PlayObject[]), options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
scrobbleTo = [],
scrobbleFrom,
} = options;
if (this.clients.length === 0) {
this.logger.warn('Cannot update Now Playing! No clients are configured.');
}
for (const client of this.clients) {
if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
continue;
}
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
if(isDebugMode()) {
client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`);
}
continue;
}
for (const playObj of playObjs) {
client.queuePlayingNow(playObj, scrobbleFrom);
}
}
}
scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
+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) {
+43 -13
View File
@@ -2,7 +2,7 @@ import { childLogger, LogDataPretty } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import { FixedSizeList } from "fixed-size-list";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY } from "../../core/Atomic.js";
import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
import AbstractComponent from "../common/AbstractComponent.js";
import {
@@ -33,11 +33,12 @@ import {
sortByNewestPlayDate,
sortByOldestPlayDate,
} from "../utils.js";
import { comparePlayTemporally, temporalAccuracyIsAtLeast, todayAwareFormat } from "../utils/TimeUtils.js";
import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js";
import { getRoot } from '../ioc.js';
import { componentFileLogger } from '../common/logging.js';
import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';
import { messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js';
import { genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js';
export interface RecentlyPlayedOptions {
limit?: number
@@ -74,10 +75,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
supportsUpstreamRecentlyPlayed: boolean = false;
supportsUpstreamNowPlaying: boolean = false;
supportsManualListening: boolean = false;
manualListening?: boolean
emitter: EventEmitter;
protected SCROBBLE_BACKLOG_COUNT: number = 20;
protected SCROBBLE_BACKLOG_COUNT: number = 30;
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
@@ -103,6 +107,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[]> => {
@@ -120,7 +131,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
protected addPlayToDiscovered = (play: PlayObject) => {
const platformId = this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID;
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(30);
const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList<ProgressAwarePlayObject>(200);
list.add(play);
this.recentDiscoveredPlays.set(platformId, list);
this.tracksDiscovered++;
@@ -142,7 +153,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
return [];
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
protected getExistingDiscoveredLists = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject[][] => {
const lists: PlayObject[][] = [];
if(opts.checkAll !== true) {
lists.push(this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID));
@@ -158,19 +169,22 @@ export default abstract class AbstractSource extends AbstractComponent implement
}
});
}
return lists;
}
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
}
return undefined;
//const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : SINGLE_USER_PLATFORM_ID);
//return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play));
}
alreadyDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): boolean => {
@@ -195,10 +209,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 +258,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 +468,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 +482,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';
}
+279
View File
@@ -0,0 +1,279 @@
import dayjs from "dayjs";
import EventEmitter from "events";
import request, { Request, Response, SuperAgent } from 'superagent';
import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, TRANSFORM_HOOK } from "../common/infrastructure/Atomic.js";
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { parseRetryAfterSecsFromObj, playObjDataMatch, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { CookieJar, Cookie } from 'tough-cookie';
import { MixedCookieAgent } from 'http-cookie-agent/http';
import MemorySource from "./MemorySource.js";
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
interface DeezerHistoryResponse {
errors: []
results: {
data: DeezerInternalTrackData[]
error: string[]
}
}
export default class DeezerInternalSource extends MemorySource {
requiresAuth = true;
requiresAuthInteraction = false;
csrfToken?: string;
agent: request.SuperAgentStatic & request.Request
jar: CookieJar
declare config: DeezerInternalSourceConfig;
constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('deezer', name, config, internal, emitter);
const {
data: {
interval = 60,
...rest
} = {},
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
// @ts-expect-error not correct structure
this.config.data = {
...rest,
interval,
};
this.canPoll = true;
this.canBacklog = true;
this.supportsUpstreamRecentlyPlayed = true;
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
// https://developers.deezer.com/api/user/history
// https://stackoverflow.com/a/19497151/1469797
this.SCROBBLE_BACKLOG_COUNT = 50;
this.jar = new CookieJar();
const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
this.agent = request.agent().use((req) => req.agent(mixedAgent));
}
static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const play: PlayObject = {
data: {
artists: [obj.ART_NAME],
album: obj.ALB_TITLE,
track: obj.SNG_TITLE,
duration: obj.DURATION,
playDate: dayjs(obj.TS * 1000),
},
meta: {
source: 'Deezer',
trackId: obj.SNG_ID,
newFromSource,
url: {
web: `https://www.deezer.com/track/${obj.SNG_ID}`
}
}
};
if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
play.meta.art = {
album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
}
}
return play;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
if (this.config.data.arl === undefined) {
throw new Error('arl must be defined in configuration');
}
this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
return true;
}
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get('https://deezer.com');
return true;
} catch (e) {
throw e;
}
}
doAuthentication = async () => {
try {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'deezer.getUserData'
})
const resp = await this.callApi(req);
return true;
} catch (e) {
throw e;
}
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
.query({
method: 'user.getSongsHistory'
})
.set('Content-Type', 'application/json')
.send({
nb: 30,
start: 0
});
// returns listening history in descending order (newest to oldest)
const resp = (await this.callApi(req)) as DeezerHistoryResponse;
for(const e of resp.results.error) {
this.logger.warn(`Error returned in history response: ${e}`);
}
const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song');
if(nonSong.length > 0) {
const nonSongTypes = [];
for(const n of nonSong) {
if(!nonSongTypes.includes(n.__TYPE__)) {
nonSongTypes.push(n.__TYPE__);
}
}
this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
}
return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
callApi = async (req: request.SuperAgentRequest, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
} = this.config.options;
req.query({
input: 3,
api_version: '1.0',
api_token: this.csrfToken ?? ''
});
setRequestHeaders(req);
try {
const resp = await req;
const {
body: {
error,
results
} = {}
} = resp;
if('checkForm' in results) {
this.csrfToken = results.checkForm;
}
if (error !== undefined && error.length > 0) {
const err = new Error((error as string[]).join(' | '));
throw err;
}
return resp.body;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response,
} = e;
const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
throw new Error(msg, {cause: e});
}
}
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
existingDiscovered = (play: PlayObject, opts: {checkAll?: boolean} = {}): PlayObject | undefined => {
const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
for(const list of lists) {
const existing = list.find(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
}
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
const fuzzyIndex = list.findIndex(x => {
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
let temporalOptions: TemporalPlayComparisonOptions = {};
const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
temporalOptions = {
fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
duringReferences: ['duration', 'listenedFor', 'range']
}
temporalAccuracy.push(TA_DURING);
}
return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
});
if(fuzzyIndex !== -1) {
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
// always return fuzzy match as existing
// likely will make MS miss scrobbles for repeated plays
return list[fuzzyIndex];
}
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
// last discovered play was this one, or next played play was also this one
// so we'll assume this means the play is on repeat, don't count as existing
return undefined;
}
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
// so this is likely a duplicate deezer should not have reported
return list[fuzzyIndex];
}
}
}
return undefined;
}
}
const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
req
.set('Pragma', 'no-cache')
.set('Origin', 'https://www.deezer.com')
.set('Accept-Encoding', 'gzip, deflate, br')
.set('Accept-Language', 'en-US,en;q=0.9')
.set('User-Agent', userAgent)
.set('Accept', '*/*')
.set('Cache-Control', 'no-cache')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Connection', 'keep-alive')
.set('Referer', 'https://www.deezer.com/login')
.set('DNT', '1')
const ct = req.get('Content-Type');
if(ct === '' || ct === null || ct === undefined) {
req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
}
const buildInternalUrl = (method: string, token: string = ''): URL => {
const params = new URLSearchParams([
['api_version', '1.0'],
['input', '3']
]);
params.append('method', method);
params.append('api_token', token);
const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);
return u;
}
+1 -1
View File
@@ -91,7 +91,7 @@ export default class DeezerSource extends AbstractSource {
}
protected async doBuildInitData(): Promise<true | string | undefined> {
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. Refer to the MS documentation for a new Deezer Source implementation.');
try {
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
+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'
}
}
}
+25 -2
View File
@@ -46,7 +46,7 @@ import EventEmitter from "events";
import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { PlayObject } from "../../core/Atomic.js";
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
FormatPlayObjectOptions,
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
RunTimeTicks,
Type, // should be BaseItemKind.Audio
UserData,
ProviderIds = {}
} = obj;
return {
const meta: BrainzMeta = {};
if(ProviderIds.MusicBrainzAlbum !== undefined) {
meta.album = ProviderIds.MusicBrainzAlbum;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzArtist !== undefined) {
meta.artist = [ProviderIds.MusicBrainzArtist];
}
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
}
const play: PlayObject = {
data: {
artists: Artists,
album: Album,
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
source: 'Jellyfin',
}
}
if(Object.keys(meta).length > 0) {
play.data.meta = { brainz: meta };
}
return play;
}
getRecentlyPlayed = async (options = {}) => {
+4 -4
View File
@@ -1,7 +1,7 @@
import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT } from "../../core/Atomic.js";
import {
buildTrackString,
combinePartsToString,
@@ -14,12 +14,12 @@ import {
doubleReturnNewline,
isDebugMode,
parseBool,
parseDurationFromTimestamp,
playObjDataMatch,
} from "../utils.js";
import { parseDurationFromTimestamp } from '../utils/TimeUtils.js';
import {
comparePlayTemporally,
temporalAccuracyIsAtLeast,
hasAcceptableTemporalAccuracy,
temporalPlayComparisonSummary,
} from "../utils/TimeUtils.js";
import MemorySource from "./MemorySource.js";
@@ -313,7 +313,7 @@ export default class JellyfinSource extends MemorySource {
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
}
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
if(hasAcceptableTemporalAccuracy(temporalResult.match)) {
existingTracked = currPlay;
}
break;
+9 -1
View File
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
export default class ListenbrainzSource extends MemorySource {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzSourceConfig;
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get(this.api.url);
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
const isKoito = await this.api.checkKoito();
this.api.isKoito = isKoito;
this.isKoito = isKoito;
return true;
} catch (e) {
if(isNodeNetworkException(e)) {
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
if(this.isKoito) {
return await this.api.getRecentlyPlayedKoito(limit);
}
const now = await this.api.getPlayingNow();
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
return await this.api.getRecentlyPlayed(limit);
@@ -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)
}
+31 -14
View File
@@ -1,20 +1,17 @@
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";
import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import {
asPlayerStateData,
asPlayerStateDataMaybePlay,
CALCULATED_PLAYER_STATUSES,
InternalConfig,
PlayerStateData,
PlayerStateDataMaybePlay,
InternalConfig, PlayerStateDataMaybePlay,
PlayPlatformId,
ProgressAwarePlayObject,
SourceType,
SourceType
} from "../common/infrastructure/Atomic.js";
import { PollingOptions } from "../common/infrastructure/config/common.js";
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
@@ -27,7 +24,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 +49,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);
@@ -90,7 +89,12 @@ export default class MemorySource extends AbstractSource {
const stateHash = objectHash.sha1(state);
if(stateHash !== this.playerState.get(key)) {
this.playerState.set(key, stateHash);
this.emitEvent('playerUpdate', state);
this.emitEvent('playerUpdate', {
...state,
options: {
scrobbleTo: this.clients
}
});
}
if(this.config.options?.logPlayerState === true || isDebugMode()) {
player.logSummary();
@@ -116,11 +120,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 +153,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 +188,7 @@ export default class MemorySource extends AbstractSource {
return sessions[0];
}
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => {
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[], reportedTS?: Dayjs) => {
const {
options: {
@@ -231,7 +243,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;
@@ -254,7 +266,12 @@ export default class MemorySource extends AbstractSource {
}
const apiState = player.getApiState();
this.playerState.set(key, objectHash.sha1(apiState))
this.emitEvent('playerUpdate', apiState);
this.emitEvent('playerUpdate', {
...apiState,
options: {
scrobbleTo: this.clients
}
});
} else {
const playFromCleanup = this.cleanupPlayer(key);
if(playFromCleanup !== undefined) {
@@ -323,7 +340,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();
}
}
+197 -12
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 { DeezerData, DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig, DeezerInternalData } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
import { LastFMEndpointSourceConfig, LastFMEndpointData } from "../common/infrastructure/config/source/endpointlfm.js";
import {
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,9 @@ 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';
import DeezerInternalSource from './DeezerInternalSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -86,14 +98,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;
}
@@ -125,7 +140,16 @@ export default class ScrobbleSources {
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig");
break;
case 'deezer':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerSourceConfig");
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("DeezerCompatConfig");
break;
case 'endpointlz':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("ListenbrainzEndpointSourceConfig");
break;
case 'endpointlfm':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("LastFMEndpointSourceConfig");
break;
case 'icecast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("IcecastSourceConfig");
break;
case 'subsonic':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SubSonicSourceConfig");
@@ -163,17 +187,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,11 +262,17 @@ 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(c.type === undefined) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[sourceType]" must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
}
if(!isSourceType(c.type.toLocaleLowerCase())) {
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source type of '${c.type}'. Must be one of ${sourceTypes.join(' | ')}`;
const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid source "type" of "${c.type}". Must be one of ${sourceTypes.join(' | ')}`;
this.emitter.emit('error', new Error(invalidMsgType));
this.logger.error(invalidMsgType);
continue;
@@ -221,6 +296,8 @@ export default class ScrobbleSources {
configureAs: 'source' // override user value
});
}
} else {
sourceDefaults = this.buildSourceDefaults();
}
for (const sourceType of sourceTypes) {
@@ -341,6 +418,7 @@ export default class ScrobbleSources {
clientSecret: process.env.DEEZER_CLIENT_SECRET,
redirectUri: process.env.DEEZER_REDIRECT_URI,
accessToken: process.env.DEEZER_ACCESS_TOKEN,
arl: process.env.DEEZER_ARL,
};
if (!Object.values(d).every(x => x === undefined)) {
configs.push({
@@ -374,6 +452,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 +582,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 +646,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;
@@ -589,7 +753,7 @@ export default class ScrobbleSources {
try {
await this.addSource(c, sourceDefaults);
} catch(e) {
const addError = new Error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`, {cause: e});
const addError = new Error(`Source ${c.name} of type ${c.type} from source ${c.source} was not added because of unrecoverable errors`, {cause: e});
this.emitter.emit('error', addError);
this.logger.error(addError);
}
@@ -604,17 +768,17 @@ export default class ScrobbleSources {
// throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
// }
const {type, name, data: d = {}, enable = true, options: clientOptions = {}} = clientConfig;
const {type, name, data: d = {}, enable = true, source, options: clientOptions = {}} = clientConfig;
if(enable === false) {
this.logger.warn(`${type} (${name}) source was disabled by config`);
this.logger.warn({labels: [`${type} - ${name}`]},`Source from ${source} was disabled by config`);
return;
}
// add defaults
const compositeConfig: SourceConfig = {...clientConfig, data: d, options: {...defaults, ...clientOptions}};
this.logger.debug(`(${name}) Constructing ${type} source`);
this.logger.debug({labels: [`${type} - ${name}`]},`Constructing Source from ${source}...`);
let newSource: AbstractSource;
switch (type) {
case 'spotify':
@@ -646,7 +810,12 @@ export default class ScrobbleSources {
newSource = await new LastfmSource(name, compositeConfig as LastfmSourceConfig, this.internalConfig, this.emitter);
break;
case 'deezer':
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
const deezerConfig = compositeConfig as DeezerCompatConfig;
if(deezerConfig.data.arl !== undefined) {
newSource = await new DeezerInternalSource(name, compositeConfig as DeezerInternalSourceConfig, this.internalConfig, this.emitter);
} else {
newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, this.internalConfig, this.emitter);
}
break;
case 'ytmusic':
newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter);
@@ -660,6 +829,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,21 +853,28 @@ 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;
}
if(newSource === undefined) {
// really shouldn't get here!
this.logger.error(new Error(`Source of type ${type} was not recognized??`));
this.logger.error(new Error(`Source of type ${type} from ${source} was not recognized??`));
return;
}
this.sources.push(newSource);
newSource.logger.info(`Source Added from ${source}`);
}
}
+11 -2
View File
@@ -2,7 +2,7 @@ import dayjs, { Dayjs } from "dayjs";
import EventEmitter from "events";
import SpotifyWebApi from "spotify-web-api-node";
import request from 'superagent';
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js";
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
@@ -155,6 +155,7 @@ export default class SpotifySource extends MemoryPositionalSource {
}
const {
id: albumId,
name: albumName,
artists: albumArtists = [],
images = []
@@ -183,7 +184,15 @@ export default class SpotifySource extends MemoryPositionalSource {
track: name,
duration: duration_ms / 1000,
playDate: played_at,
playDateCompleted
playDateCompleted,
meta: {
spotify: {
track: id,
artist: artists.map(x => x.id),
albumArtist: actualAlbumArtists.map(x => x.id),
album: albumId
}
}
},
meta: {
deviceId: deviceId ?? `${NO_DEVICE}-${NO_USER}`,
+20 -1
View File
@@ -24,6 +24,8 @@ export class SubsonicSource extends MemorySource {
declare config: SubSonicSourceConfig;
usersAllow: string[] = [];
constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data: {
@@ -193,6 +195,22 @@ export class SubsonicSource extends MemorySource {
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
}
let usersAllowVal = this.config.data.usersAllow;
if(usersAllowVal !== undefined && usersAllowVal !== null) {
if(!Array.isArray(usersAllowVal)) {
usersAllowVal = [usersAllowVal];
}
if(usersAllowVal.filter(x => x.toString().trim() !== '').length > 0) {
this.usersAllow = usersAllowVal.map(x => x.toString().trim());
}
}
if(this.usersAllow.length === 0) {
this.logger.verbose('Will monitor plays by all users');
} else {
this.logger.verbose(`Will only monitor plays for the following users: ${this.usersAllow.join(', ')}`);
}
return true;
}
@@ -245,7 +263,8 @@ export class SubsonicSource extends MemorySource {
} = resp;
// sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays
const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj));
return this.processRecentPlays(deduped);
const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase()));
return this.processRecentPlays(userFiltered);
}
}
+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: {
+53 -2
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);
@@ -91,9 +92,59 @@ describe('Sample Configs', function () {
await copyFile(samplePath(componentType), `${componentType}.json`);
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
expect(clients.clients).length(1);
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
expect(clients.clients).length(expectedCount);
});
}
});
});
});
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]);
+14 -2
View File
@@ -4,15 +4,16 @@ import request from "superagent";
import { PlayObject } from "../../../core/Atomic.js";
import { Notifiers } from "../../notifier/Notifiers.js";
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.js";
import { CommonClientConfig, CommonClientOptions, NowPlayingOptions } from "../../common/infrastructure/config/client/index.js";
export class TestScrobbler extends AbstractScrobbleClient {
testRecentScrobbles: PlayObject[] = [];
constructor() {
constructor(config: CommonClientConfig = {name: 'test'}) {
const logger = loggerTest;
const notifier = new Notifiers(new EventEmitter(), new EventEmitter(), new EventEmitter(), logger);
super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger);
super('test', 'Test', {name: 'test', ...config}, notifier, new EventEmitter(), logger);
}
protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> {
@@ -49,3 +50,14 @@ export class TestAuthScrobbler extends TestScrobbler {
}
}
}
export type TestNowPlayingConfig = CommonClientConfig & {options?: CommonClientOptions & NowPlayingOptions};
export class NowPlayingScrobbler extends TestScrobbler {
declare config: TestNowPlayingConfig
constructor(config?: TestNowPlayingConfig) {
super(config);
this.supportsNowPlaying = true;
}
}
+236 -3
View File
@@ -7,13 +7,15 @@ import { after, before, describe, it } from 'mocha';
import { http, HttpResponse } from 'msw';
import pEvent from 'p-event';
import { PlayObject } from "../../../core/Atomic.js";
import { sleep } from "../../utils.js";
import { genGroupIdStr, sleep } from "../../utils.js";
import mixedDuration from '../plays/mixedDuration.json';
import withDuration from '../plays/withDuration.json';
import { MockNetworkError, withRequestInterception } from "../utils/networking.js";
import { asPlays, generatePlay, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import MockDate from 'mockdate';
import { TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
import { PlayPlatformId } from '../../common/infrastructure/Atomic.js';
chai.use(asPromised);
@@ -737,3 +739,234 @@ describe('Manages scrobble queue', function() {
await testScrobbler.tryStopScrobbling()
});
});
describe('Now Playing', function() {
describe('Filtering Aggregated Updates', function() {
it('When no Now Playing exists, chooses play based on sorted platform id', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('When Now Playing platform does not exist in queued plays, chooses play based on sorted platform id', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
npScrobbler.nowPlayingLastPlay = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
it('Chooses play based on existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
const pt = dayjs().subtract(15, 's');
const stickyNp = generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)});
npScrobbler.nowPlayingLastPlay = stickyNp
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(secondPlatform));
});
it('Sorts Sources alphabetically when using default Source sorting', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(a.meta.deviceId);
});
it('Sorts Sources based on user config', async function () {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(b.meta.deviceId);
});
it('Does not report if source is not in user config', async function () {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const c = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport).to.be.undefined;
});
});
describe('Updating', function () {
it('Should update if no existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.true;
});
it('Should update if previous Now Playing matches updated and last updated diff is greater than upper limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
expect(res).to.be.true;
});
it('Should NOT update if previous Now Playing matches updated and last updated diff is less than upper limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
expect(res).to.be.false;
});
it('Should update if previous Now Playing does NOT match updated and last updated diff is greater than lower limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.true;
});
it('Should NOT update if previous Now Playing does NOT match updated and last updated diff is less than than lower limit', async function () {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
expect(res).to.be.false;
});
});
describe('Scheduling', function () {
this.afterEach(() => {
MockDate.reset();
});
it('Should update when no existing Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
npScrobbler.nowPlayingTaskInterval = 10;
await npScrobbler.initialize();
npScrobbler.scheduler.startById('pn_task');
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(res).is.not.undefined;
});
it('Should update when updated does not match Now Playing', async function () {
const npScrobbler = new NowPlayingScrobbler();
npScrobbler.nowPlayingTaskInterval = 10;
await npScrobbler.initialize();
npScrobbler.scheduler.startById('pn_task');
const now = dayjs();
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(res).is.not.undefined;
MockDate.set(now.add(npScrobbler.nowPlayingThresholds[0] + 3, 's').toDate());
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
expect(resUpdate).is.not.undefined;
});
});
});
+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 {
}
+442 -2
View File
@@ -6,10 +6,21 @@ 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, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json';
import SpotifySource from "../../sources/SpotifySource.js";
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";
import DeezerInternalSource from "../../sources/DeezerInternalSource.js";
import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js";
chai.use(asPromised);
@@ -20,6 +31,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 +184,419 @@ 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;
});
});
const generateDeezerSource = (options: DeezerInternalSourceOptions = {}) => {
return new DeezerInternalSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter);
}
const firstPlayDate = dayjs().subtract(1, 'hour');
const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate});
const lastPlay = normalizedPlays[normalizePlays.length - 1];
describe('Deezer Internal Source', function() {
describe('When fuzzyDiscoveryIgnore is not defined or false', function () {
it('discovers fuzzy play', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource();
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
});
describe('When fuzzyDiscoveryIgnore is true', function () {
it('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('discovers fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(1);
});
it('discovers fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: true});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length);
});
});
describe('When fuzzyDiscoveryIgnore is aggressive', function () {
it('does not discover fuzzy play with interim plays', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover play found during duration of previous', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const duringPlay = clone(targetPlay);
duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([duringPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play with delay of up to 40 seconds', function() {
const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
const targetPlay = normalizedPlays[normalizedPlays.length - 2]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover([...normalizedPlays, interimPlay]);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('it does not discover fuzzy play when it is the last play ', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
source.discover(normalizedPlays);
const discovered = source.discover([fuzzyPlay]);
expect(discovered.length).to.eq(0);
});
it('does not discover fuzzy play when it is played consecutively', function() {
const targetPlay = normalizedPlays[normalizedPlays.length - 1]
const fuzzyPlay = clone(targetPlay);
fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});
const source = generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
const discovered = source.discover(morePlays);
expect(discovered.length).to.eq(morePlays.length - 1);
});
});
});
+23 -1
View File
@@ -1,4 +1,4 @@
import { faker } from '@faker-js/faker';
import { faker, fakerEL, FakerError } from '@faker-js/faker';
import dayjs, { Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration.js";
import isBetween from "dayjs/plugin/isBetween.js";
@@ -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: {
@@ -151,6 +167,12 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl
}
}
export const generatePlayPlatformId = (deviceId?: string, userId?: string): PlayPlatformId => {
const did = deviceId ?? [NO_DEVICE, faker.hacker.noun(), faker.hacker.noun()][faker.number.int({min: 0, max: 2})];
const uid = userId ?? [NO_USER, faker.internet.username(), faker.internet.username()][faker.number.int({min: 0, max: 2})];
return [did, uid];
}
export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject[] => {
return Array.from(Array(numberOfPlays), () => generatePlay(data, meta));
}
@@ -1,8 +1,8 @@
import { loggerTest } from "@foxxmd/logging";
import { assert } from 'chai';
import { assert, expect } from 'chai';
import clone from "clone";
import { describe, it } from 'mocha';
import { playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.js";
import { generatePlay, generatePlays } from "./PlayTestUtils.js";
import { PlayObject } from "../../../core/Atomic.js";
@@ -249,4 +249,37 @@ describe('Compare lists by order', function () {
});
});
describe('Source Play Comparisons', function() {
describe('Generic Source Comparison', function() {
it('matches identical plays', function() {
expect(genericSourcePlayMatch(newPlay, newPlay)).to.be.true;
});
it('matches identical plays with close timestamps', function() {
const closePlay = clone(newPlay);
closePlay.data.playDate = closePlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, closePlay)).to.be.true;
});
it('does not match unique plays', function() {
expect(genericSourcePlayMatch(newPlay, generatePlay())).to.be.false;
});
it('does not match unique plays with exact timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate;
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
it('does not match unique plays with close timestamps', function() {
const diffPlay = generatePlay();
diffPlay.data.playDate = newPlay.data.playDate.add(3, 's');
expect(genericSourcePlayMatch(newPlay, diffPlay)).to.be.false;
});
});
});
});
+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);
+94 -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();
@@ -105,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
return finalUrl;
}
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
let v4: string,
+7 -1
View File
@@ -1,6 +1,8 @@
import { getListDiff, ListDiff } from "@donedeal0/superdiff";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { playObjDataMatch } from "../utils.js";
import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
export const metaInvariantTransform = (play: PlayObject): PlayObject => {
@@ -221,3 +223,7 @@ export const humanReadableDiff = (aPlay: PlayObject[], bPlay: PlayObject[], resu
return `${a} => ${b}`;
}).join('\n');
}
export const genericSourcePlayMatch = (a: PlayObject, b: PlayObject, t?: TemporalAccuracy[], temporalOptions?: TemporalPlayComparisonOptions): boolean =>
playObjDataMatch(a, b)
&& hasAcceptableTemporalAccuracy(comparePlayTemporally(a, b, temporalOptions).match, t);
+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

Some files were not shown because too many files have changed in this diff Show More