Compare commits

...
231 Commits
Author SHA1 Message Date
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
FoxxMD 6a9e538845 chore: Bump version for release 2025-01-21 15:04:38 +00:00
Matt Foxx 130538cd9a Merge pull request #255 from FoxxMD/GH-254/playCompletedOnCleanup
fix(spotify): Generate played object from cleanup with correct TS
2025-01-18 12:57:51 -05:00
FoxxMD 1486613744 fix(spotify): Generate played object from player cleanup with correct timestamp
Missing arg to pass to getPlayedObject to ensure the timestamp used for playDate is the same as normally discovered tracks. Without the arg two tracks played with the second being scrobbled on stale would cause the latter to have the same TS as the former

fixes #254
2025-01-17 18:55:35 +00:00
Matt Foxx f2516206c0 ci: Refactor OCI image workflows to use multi-runner for cross-arch builds (#246)
Replaces `build-push-action` buildx QEMU building for multiple `arch` with a multi-runner matrix that builds each image on native runners and combines digests before pushing to registries.

* Use arm public preview runner
* Refactor publish image workflow to use multi-runner
* Refactor PR workflow to use multi-runner
2025-01-17 09:35:11 -05:00
FoxxMD f8902e05a6 ci: Update sanity test to install/build concurrently 2025-01-16 16:15:00 -05:00
Matt Foxx c59b47c801 Merge pull request #252 from FoxxMD/paralellBuild
feat(docker): Paralellize npm install and build processes
2025-01-16 16:06:27 -05:00
FoxxMD 0e4845f2e2 feat(docker): Paralellize npm install and build processes 2025-01-16 20:48:05 +00:00
FoxxMD e2775cd9de chore(docker): Update node version and reduce npm verbosity
* Update to latest node 20.x minor version
* Remove `--verbose` and enable `--no-audit` for npm install to reduce install time
  * These were left over from debugging ARM builds from Feb 2024
2025-01-16 14:43:29 -05:00
Matt Foxx 816bec88c8 Merge pull request #251 from FoxxMD/docusaurus36
docs: Update to Docusaurs 3.6 to improve build times
2025-01-16 14:25:19 -05:00
FoxxMD d724bef4e4 docs: Update to Docusaurs 3.6 to improve build times 2025-01-16 14:07:47 -05:00
FoxxMD cac851f120 chore: Bump version for release 2025-01-15 15:16:26 +00:00
FoxxMD c1df74ee27 docs: Strikeout tautulli in readme 2025-01-15 15:16:01 +00:00
FoxxMD 0c7b51c622 docs: Update bug report template to correct debug mode link 2025-01-15 15:12:26 +00:00
FoxxMD 56ecad24d4 docs: Update kitchensink with newer jellyfin/plex examples 2025-01-15 15:09:38 +00:00
FoxxMD 6bb600648a docs: Add final deprecation notices for Plex/Tautull/Jellyfin legacy sources 2025-01-15 15:00:44 +00:00
FoxxMD fe2610c35a docs: Add debug mode information 2025-01-15 14:36:48 +00:00
FoxxMD 84bcbc9cea docs(ytm): Update docs with more debugging options 2025-01-15 14:02:50 +00:00
FoxxMD d741377e5a feat(ytm): Debug mode enables log diffs 2025-01-15 13:50:47 +00:00
Matt Foxx 1ea106970d Merge pull request #248 from FoxxMD/GH-227/ytmMusicalChairs
fix(ytm): Improve history handling
2025-01-15 08:27:55 -05:00
Matt Foxx a879b7aded Merge pull request #249 from FoxxMD/GH-223/plexPlatformCleanup
fix(plex): Improve handling of orphaned API sessions
2025-01-14 08:19:12 -05:00
FoxxMD 30ee000676 fix(plex): Refactor final range position to be configurable
* Keep default 3 seconds grace period
* Set grace period for plex to API viewoffset period (rtdrift) of 17 seconds #223
2025-01-09 18:42:09 +00:00
FoxxMD 703cf80d5a fix(source): Implement player cleanup with play discovery for stale platforms
* Do play cleanup during polling for new plays so that we can discover tracks from abandoned players
* Fixes missed scrobbles from Plex API when Source stops returning a session due to player queue end #223
2025-01-09 18:01:12 +00:00
FoxxMD 34289f0176 docs: defer script load 2025-01-03 19:24:51 +00:00
FoxxMD 8b8f32de02 feat(ytm): Use durations to generate play dates for interim tracks
#226
2025-01-03 18:28:55 +00:00
FoxxMD 095e5cc0d9 feat(ytm): Conservative skip detection
#226
2025-01-03 17:56:07 +00:00
FoxxMD 0c46c0a079 refactor(ytm): Clean up tests and history parsing functions 2025-01-03 16:25:33 +00:00
FoxxMD dd4b2ebb8c fix(ytm): Compare recent changed history to detect outdated responses
Store responses when history changes and then check if YTM returns an outdated response after previously returning new tracks.

#227
2025-01-02 19:03:38 +00:00
FoxxMD d30ee0c35a feat(source): Ensure delay on close-interval for scrobbling is less than interval 2025-01-02 18:29:34 +00:00
FoxxMD 484fb16bba fix(ytm): Use diff type to restrict valid history changes
* Diff type should always be prepend since we are checking for new tracks
  * Warn and log if another type is detected
* Warn if more than one track diffed for discovery
* Refactor and and make YTM history parsing testable

#227
2025-01-02 18:28:59 +00:00
FoxxMD f320e6cef0 feat: Include comment in play diff when present 2025-01-02 18:25:27 +00:00
FoxxMD ea2421bc6d chore: Bump plexjs version
Includes originalTitle spec addition so we can remove patch
2025-01-02 16:59:36 +00:00
FoxxMD f43c31fb9a docs: Add lnav config 2024-12-27 18:49:29 +00:00
FoxxMD 6825ad55e3 feat(ui): Disable minifiying frontend identifiers
No need to minify to save space since this is not a high-traffic site and preserving identifiers helps with debug issues
2024-12-27 18:49:16 +00:00
FoxxMD 92841f8bb3 fix: Default positional listen range to 0 position if none is provided
* doesn't make sense to use undefined as start. if we are providing a position it should be 0, if unknown

#245
2024-12-27 18:43:51 +00:00
FoxxMD 38e5e4c9a9 feat: Add init log message for debug mode status 2024-12-20 13:47:54 -05:00
FoxxMD 1bcfed05af feat: Log player state when using debug mode 2024-12-20 18:28:21 +00:00
FoxxMD e00ec4098a feat: Reduce polling log noise 2024-12-20 18:10:22 +00:00
FoxxMD 5d44953435 revert ts comment causing failing build 2024-12-20 17:46:04 +00:00
FoxxMD 80ad5a3518 feat: Fully implement use of DEBUG_MODE 2024-12-20 14:02:21 +00:00
FoxxMD b5683ba4b8 fix(jellyfin): Fix missing check for undefined play
#239
2024-12-17 20:53:54 +00:00
FoxxMD 884b3963d3 fix(plex): Use track artist from response data when album artist is present
* Fixes #241 by using the track artist as the primary artist when album artist is present
* Bug is reported in LukeHagar/plexjs#27 but will need be patched at runtime for the foreeable future due to npm packages not being published since September LukeHagar/plexjs#26
2024-12-16 20:11:50 +00:00
Matt Foxx 8de2296aaf Merge pull request #221 from FoxxMD/improveInitUsage
refactor: bubble init usage and errors to simplify recovery
2024-12-16 09:43:53 -05:00
Matt Foxx c1869f469c Merge pull request #240 from Fratt/fix-spotify-album-artists
fix(spotify): Album artists no longer missing
2024-12-16 09:34:29 -05:00
FoxxMD 86196b3122 test(spotify): Test album artist parsing 2024-12-16 14:31:10 +00:00
Fratt fd03e42240 fix(spotify): Album artists no longer missing 2024-12-14 13:08:00 +01:00
FoxxMD 3e230b0d68 fix(ui): Fix card header right-side alignment 2024-12-11 20:41:06 +00:00
FoxxMD 133d7f56e5 fix(ui): Wording for non-polling sources start/restart 2024-12-11 20:35:22 +00:00
FoxxMD 78d048ba62 fix: missing implemented methods for test class 2024-12-11 18:24:42 +00:00
FoxxMD 323004a730 feat: Implement start/restart/force init from UI regardless of status 2024-12-11 18:20:12 +00:00
FoxxMD 626bca9951 feat: Initialize usage refactored everywhere
* try-catch and bubble up depending on usage
* replace poll/monitor code on startup with scheduler task usage
* better notification messages with truncated cause chain
2024-12-11 17:30:08 +00:00
FoxxMD 3ae0d4ff45 feat: Add stage Error classes and cause chain for notification 2024-12-10 21:17:44 +00:00
FoxxMD 9d85df54e1 refactor: Normalize all testAuth implementation to throw on error 2024-12-10 20:56:31 +00:00
FoxxMD 0ff1c95fd1 TODO partial refactor of init usage to throw errors 2024-12-10 20:34:33 +00:00
FoxxMD b93274fd53 Merge remote-tracking branch 'origin/master' 2024-12-10 15:19:55 -05:00
FoxxMD 0f0d1d64f9 feat(jellyfin): More robust connection check and logging 2024-12-10 20:19:30 +00:00
FoxxMD d77e6c6f22 feat(jellyfin): Bump client major version 2024-12-10 20:19:10 +00:00
Matt Foxx 83ff5348c6 Merge pull request #216 from FoxxMD/GH-215/notifiersRelax
feat(notifications): Make init more granular and always complete init
2024-12-10 11:54:59 -05:00
FoxxMD 65012d40ed feat(notifications): Make init more granular and always complete init
* Notifications are not mission critical so its okay if they fail on init because service outage may be transient. Always init notification services and always attempt to push (we catch failures anyway)
* Parse more info from notification URL and test simple reachability of host/port to make troubleshooting easier

Fixes #215
2024-12-10 11:33:33 -05:00
FoxxMD feb02bfa85 docs(ytmusic): Clearer wording for troubleshooting content
Also increase non-erroneous diff logging from DEBUG to VERBOSE to raise signal a bit.
2024-12-10 15:55:20 +00:00
FoxxMD 276e51b8ea docs: Fix broken links 2024-12-10 15:40:15 +00:00
FoxxMD c83d77c02a docs: Fix bad transform links 2024-12-10 15:28:25 +00:00
FoxxMD 0cdb48a71a ci: Generate schema files for docsite in GH docs deploy workflow 2024-12-10 10:13:06 -05:00
FoxxMD 1556d7ac69 chore: Generate schema files during docker build for docsite 2024-12-10 10:08:02 -05:00
Matt Foxx 1e65abd962 Merge pull request #236 from FoxxMD/ytOauthRedux
feat(ytm): Re-implement auth with cookie and oauth
2024-12-10 09:16:14 -05:00
FoxxMD 9f11d799ac feat(source): Add experimental file logging 2024-12-09 18:44:43 +00:00
FoxxMD 5c0f419e7e feat(ytm): Implement ENV config 2024-12-06 19:28:32 +00:00
FoxxMD c33d42808a docs(ytmusic): Add new auth docs and update FAQ 2024-12-06 19:09:32 +00:00
FoxxMD e8716a45b2 feat(ytmusic): Custom redirectUri checks 2024-12-06 15:56:32 +00:00
FoxxMD 570dee30e3 fix(ytmusic): Add missing logging for redirect URI 2024-12-06 15:33:02 +00:00
FoxxMD 97222ed233 refactor(ytmusic): Improve auth handling for reauthentication 2024-12-06 15:18:07 +00:00
FoxxMD e2a7fff6f9 chore: Bump youtubei.js major version
* Fixes parsing warning for history response
2024-12-06 15:17:18 +00:00
FoxxMD efc8246a76 chore: Add docker-compose dev file and vscode task 2024-12-05 09:37:58 -05:00
FoxxMD b4a95e0cf9 fix(ytmusic): query string on redirect uri incorrectly encoded 2024-12-05 09:18:05 -05:00
FoxxMD c66d5d4627 docs: Improve schema generation and compatibility
* Patch generator to generate definition titles until vega/ts-json-schema-generator#2127 is resolved
* Add launch config for debugging schema generation
* Disallow topRef generation so docusarus json viewer works
* Write to docsite static assets on generation
2024-12-04 14:46:40 -05:00
FoxxMD 4de28e0390 feat(ytmusic): Add more innertube options to user configuration 2024-12-04 19:38:54 +00:00
FoxxMD f74721b366 fix(ytmusic): Fix missing api prefix for redirectUri 2024-12-04 15:04:36 +00:00
Matt Foxx 4fb16e7098 Merge pull request #232 from CodeWithMa/remove-deprecated-version
fix: remove deprecated version from docker-compose.yml
2024-12-03 16:35:46 -05:00
Matt Foxx a76c351206 Merge pull request #235 from CodeWithMa/fix_case
fix: fix WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match
2024-12-03 16:35:03 -05:00
FoxxMD 7faf94cfab feat(ytm): Re-implement auth with cookie and oauth
Youtube TV seems no longer have scope for reading history or account details?

Cookie may work and custom oauth seems most stable based on reporting from LuanRT/YouTube.js#803
2024-12-03 15:58:08 -05:00
Ma 3a1e29f682 fix: fix WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match 2024-11-28 15:57:17 +01:00
Ma 88cb287df8 fix: remove deprecated version from docker-compose.yml 2024-11-20 17:16:14 +01:00
dependabot[bot] eb26ee0cd4 Merge pull request #194 from FoxxMD/dependabot/npm_and_yarn/npm_and_yarn-ac4cac0c7a 2024-10-31 12:44:16 +00:00
dependabot[bot] 844833f348 chore(deps): bump the npm_and_yarn group across 2 directories with 3 updates
Bumps the npm_and_yarn group with 1 update in the / directory: [xml2js](https://github.com/Leonidas-from-XIV/node-xml2js).
Bumps the npm_and_yarn group with 2 updates in the /docsite directory: [micromatch](https://github.com/micromatch/micromatch) and [webpack](https://github.com/webpack/webpack).


Updates `xml2js` from 0.6.0 to 0.6.1
- [Commits](https://github.com/Leonidas-from-XIV/node-xml2js/compare/0.6.0...0.6.1)

Updates `micromatch` from 4.0.7 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8)

Updates `webpack` from 5.92.1 to 5.95.0
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.92.1...v5.95.0)

---
updated-dependencies:
- dependency-name: xml2js
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: micromatch
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: webpack
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-31 12:40:57 +00:00
FoxxMD 38b02cf771 Update dependencies 2024-10-31 12:38:04 +00:00
Matt Foxx 1706d5cab9 Merge pull request #203 from FoxxMD/GH-195/ytoauth
feat(youtube)!: Use oauth-based api library for better stability
2024-10-31 08:30:48 -04:00
Matt Foxx 640d2ff28b Merge pull request #219 from FoxxMD/GH-218/plexLocal
fix(plex): Handle plex local user
2024-10-30 13:42:55 -04:00
FoxxMD 312b6ce987 fix(plex): Handle plex local user
* Identify when session is for the local user (no user is logged in)
* Add Local User as default allowed when no usersAllow settings
* Allow user config to allow/block local user
* Update plex docs

Fixes #218
2024-10-30 16:44:51 +00:00
FoxxMD 489f70929e docs: Fix spelling mistake 2024-10-29 13:47:38 -04:00
FoxxMD 904ddfa5c6 docs(tautulli): Add deprecation notice and discussion link 2024-10-29 16:57:59 +00:00
FoxxMD 3ed6840607 feat: Improved filtering for platform players with multiple states
* Add session key dervied from source data to Play and PlayerState
* Discriminate states based on a session key, if possible
  * Log which states are being dropped based on session key (plex)
* Add session key to important logging (new player play, in UI for player info)
2024-10-29 14:59:55 +00:00
FoxxMD 93125dd7c8 feat: Reduce drop reason logging noise for jellyfin/plex API sources
Only log the first occurrence of a unique session drop so that subsequent drops don't make logging noisy
2024-10-28 18:33:52 +00:00
FoxxMD 34356c6c56 fix(youtube): Fix signout check 2024-10-28 18:03:10 +00:00
FoxxMD 05466fed2b feat(youtube): Add config option to always log diff 2024-10-28 18:03:10 +00:00
FoxxMD 535b818a22 fix(youtube): Implement bump-only list change detection
Should fix new way YTM orders recent history #195
2024-10-28 18:03:10 +00:00
FoxxMD e62e04dd14 feat(youtube)!: Use oauth-based api library for better stability
* Fixes #195 missing (404) history endpoint which only appear when using cookies
* Might fix #158 due to using a different authentication method
2024-10-28 18:03:10 +00:00
FoxxMD 61cd5fcf7b fix: Bump @foxxmd/logging to fix DST rolling file issue
#214
2024-10-28 18:02:04 +00:00
Matt Foxx f308f71b5d Merge pull request #212 from FoxxMD/playerStateCleanup
feat(player): Real time positional player state
2024-10-25 14:54:23 -04:00
FoxxMD c498bde1fd feat(ui): Show indeterminate state for non-positional players 2024-10-25 15:57:30 +00:00
FoxxMD 0c9b496137 feat(player): Implement real-time positional state tracking
* Introduce positional and non-positional data structures
  * Refactor state player into types of each
  * Refactor listen ranges and progress into types of each
* Implment (internal) real-time player and base positional player seeked/repeat on real-time drift instead of just reported position
* Refactor tests to use (emulated) real-time components
2024-10-24 20:37:41 +00:00
FoxxMD 9682d4f8f9 refactor(player): Cleaner player update signature 2024-10-24 14:53:30 +00:00
Matt Foxx da76945f5e Merge pull request #211 from FoxxMD/plexApi
feat(plex): Initial Plex API Source implementation
2024-10-24 09:45:17 -04:00
FoxxMD 51074a58f4 Add deprecation warning to plex webhook source 2024-10-24 13:41:10 +00:00
FoxxMD 3cc735b9c8 fix(plex): Refactor player state to allow for drift and tracking pause based on Source behavior
Plex only updates player position every 15 seconds (of played track) so player state needs to be adapted to not detect this as a pause or seek.

* Allow per-source drift allowed before triggering seek
* Allow per-source pause detection
2024-10-23 20:30:33 +00:00
FoxxMD b74f31c26d feat(plex): Implement album art for UI 2024-10-23 18:42:55 +00:00
FoxxMD 4a96cbfb0e docs(plex): Clean up api/webhook sections and add migration guide 2024-10-23 15:18:13 +00:00
FoxxMD 77a3c352b1 feat(plex): Implement workaround for getting plex token details
Ignore sdk validation error and get raw value in order to parse authenticated user
2024-10-23 10:59:36 -04:00
FoxxMD 598ae01082 feat(plex): Add track progress tracking to api source 2024-10-23 13:40:50 +00:00
FoxxMD 54e11899a0 Fix missing comma 2024-10-22 18:29:11 +00:00
FoxxMD dc4962ade1 feat(plex): Initial Plex API Source implementation 2024-10-22 18:21:28 +00:00
FoxxMD ccf317c06f docs: Fix Jellyfin API migration version 2024-10-17 10:40:54 -04:00
FoxxMD aff5717f19 fix(ui): Fix album art ordering 2024-10-16 16:26:23 +00:00
FoxxMD fa39209272 feat(ui): Implement album art for Jellyfin and Spotify 2024-10-16 16:09:24 +00:00
FoxxMD d63082a889 fix(ui): Skip lg breakpoint for cleaner interface 2024-10-16 16:09:05 +00:00
FoxxMD a28d46ecbe feat(ui): Add album art support in player 2024-10-16 16:08:41 +00:00
FoxxMD 3bc72ce881 feat(ui): Improve player ui when player is stopping 2024-10-16 13:56:05 +00:00
FoxxMD 314210117b fix(player): Fix missing calculated state on player stop 2024-10-16 13:55:35 +00:00
FoxxMD 57a8e48d3a fix(jellyfin): Fix memory player hanging on end of state
Was not passing stopped state due to undefined play but need to do this to let last play actually be scrobbled.

Refactor jellyfin session validation and types for MemorySource to correctly allow processing player state with no plays
2024-10-16 13:55:05 +00:00
FoxxMD 2d3ab86238 fix: Fix string splitting 2024-10-16 13:03:37 +00:00
FoxxMD b76edbf7de fix: Revert including platform for track dispay by default 2024-10-16 12:47:43 +00:00
Matt Foxx b2efc34b80 Merge pull request #210 from FoxxMD/GH-209/jellyfinCleanup
fix(jellyfin): Enforcing media type and session handling fixes
2024-10-15 14:36:56 -04:00
FoxxMD dee8d6bc61 chore(vscode): Add markdown extension to devcontainer 2024-10-15 17:42:04 +00:00
FoxxMD 3e5576481d feat(jellyfin): Support and enforce library collection type
* Default disallow any activity not from a library of collectionType 'music'
* Allow user config for allow/block by library name
* Allow user config for allowing additional library collection types
* Update docs and example config
2024-10-15 17:41:33 +00:00
FoxxMD ffd74aea6f docs: Enable parsing json5 for AIO examples 2024-10-15 17:39:49 +00:00
FoxxMD 145af33239 feat(jellyfin): More item type validation 2024-10-15 14:25:51 +00:00
FoxxMD 3851b2226a fix(jellyfin): Detect theme song extras play and discard
Fixes #209
2024-10-15 13:13:36 +00:00
FoxxMD bb132101b5 fix(jellyfin): Use correct id for tracking first-time seen media 2024-10-14 17:19:41 -04:00
FoxxMD 1e6251edfb ci: Fix sanity test checkout ref and shorten APP_VERSION sha usage 2024-10-14 13:07:12 -04:00
FoxxMD e251da9b8e test(jellyfin): Update valid activity tests 2024-10-14 15:42:54 +00:00
FoxxMD 335ee0cd60 fix(jellyfin): Set multi-platform in constructor 2024-10-14 11:15:59 -04:00
FoxxMD dc9f6c22ad feat(jellyfin): Add debug options for media data troubleshooting 2024-10-14 11:11:58 -04:00
FoxxMD 5568b54c99 fix(jellyfin): Enforce media type when determining session validity
Fixes #209 for erroneous scrobbling of video
2024-10-14 11:01:48 -04:00
FoxxMD e6e35e052a feat: Implement platform output when building track info 2024-10-14 11:01:11 -04:00
Matt Foxx 9c21db8172 Merge pull request #205 from FoxxMD/GH-200/jfPlayFormat
fix(jellyfin): Gaurd against undefined playback data
2024-10-11 15:57:57 -04:00
FoxxMD 77c9141d92 fix(jellyfin): Gaurd against active session without now playing data 2024-10-11 13:47:39 -04:00
FoxxMD 68e86f4f90 fix(jellyfin): Gaurd against undefined playback data
SDK types say AlbumArtists etc. should be defined but #200 shows that's not always the case, for some reason.

Check for undefined value on playback data that requires additional transformation to prevent errors.

Fixes #200
2024-10-11 13:47:39 -04:00
Matt Foxx 5e95f60461 Merge pull request #208 from FoxxMD/vegaSchema
fix: Replace typescript-json-schema with ts-json-schema-generator
2024-10-11 13:32:52 -04:00
FoxxMD 908d7d4177 fix: Replace typescript-json-schema with ts-json-schema-generator
Fixes a lot of headaches:

* vega is more up to date and extensible
  * no more hacky regex for reference names
  * complete control over parsing and formatting (const as enum)
  * does not duplicate typescript as dependency
  * should properly throw errors on schema generation instead of just logging to console
* move schema static files into programmatic creation instead of package scripts
* fix types being removed in prod which caused schema generation errors
2024-10-11 12:28:55 -04:00
FoxxMD de1a9c35ec fix: Add missing express types for prod dependencies
Required for ts-schema generator to correctly work since we use it in config definitions
2024-10-11 09:28:01 -04:00
FoxxMD f7fa9cd330 fix: Guard against ts-schema generation errors
* Check if generator is null and throw
* Check if schema is null before trying to validate, will make errors less cryptic
2024-10-11 09:27:12 -04:00
FoxxMD ee8b371850 fix: Missing tsconfig and other root files for some reason?? 2024-10-11 09:23:02 -04:00
FoxxMD 601228a369 fix: Fix boolean options in issue template forms 2024-10-10 15:12:24 -04:00
FoxxMD 5dcaefc460 chore: Rename config template 2024-10-10 15:06:21 -04:00
FoxxMD 026e94fc1b chore: Add issue template config 2024-10-10 15:05:24 -04:00
FoxxMD fd7c8467b4 chore: Add templates for feature request and new source/client 2024-10-10 15:03:31 -04:00
FoxxMD 06e651a844 chore: Migrate bug report template to form 2024-10-10 14:41:38 -04:00
FoxxMD 1b0e7a3dfd ci: Make test workflow reusable and add run sanity check
* Add sanity run steps to check for obvious errors when running in production
* Refactor test job to be reusable
2024-10-10 12:05:16 -04:00
FoxxMD abc5d1861a fix: Specify ts-json-schema as NON dev dep
Not enough to include it in both dependencies and devDependencies -- it needs to be NOT in devDependencies to prevent `npm ci --omit=dev` from purging it.
2024-10-10 12:02:06 -04:00
FoxxMD 4b8129fd19 fix: missing schema package in regular deps and freeze ts version 2024-10-10 11:36:23 -04:00
FoxxMD f610f385bd test: Try timeout in test instead of before() 2024-10-09 16:15:21 -04:00
FoxxMD 6994635fe4 test: Try another timeout and destroy scheduler 2024-10-09 16:13:15 -04:00
FoxxMD 61525e1351 test: Add long timeout to first config parse due to slow schema generation
Not a problem on my machine but causing timeout on github actions
2024-10-09 15:55:06 -04:00
FoxxMD a50070e133 ci: Add ACT run for just backend test 2024-10-09 15:51:58 -04:00
Matt Foxx db632424b1 Merge pull request #202 from FoxxMD/validateGranular
feat: Improve client/source schema validation generation and error handling
2024-10-09 12:52:47 -04:00
FoxxMD 6b92920f50 fix: Use silent logger for source config tests 2024-10-09 12:20:10 -04:00
FoxxMD a30d362695 fix: Fix error logging line numbers
Don't use ts-node for json-schema
2024-10-09 12:18:23 -04:00
FoxxMD d105e62f18 feat: Refactor validation errors to emit an event instead of throwing
* Prevents app from crashing due to only one thing going wrong
* Will be used for testing error handling in the future
2024-10-09 12:18:23 -04:00
FoxxMD d5ad796339 fix: Fix example configs so they pass validation tests
* Add missing sources to dual configs
* Fix slug type for webscrobbler example
2024-10-09 12:18:23 -04:00
FoxxMD c0aabefc72 test: Add tests for example configs and test parsing/validation
* Add suite for testing client/source parsing and validation
* Additionally, use example configs for two-birds-one-stone checking they are valid
* Refactor sources init as separate step from component building
2024-10-09 12:18:23 -04:00
FoxxMD 259d321f3d chore: Remove tests folder from tsconfig exclude to get autocomplete 2024-10-09 12:18:23 -04:00
FoxxMD d2375d92e6 test: Force mocha to exit 2024-10-09 12:18:23 -04:00
FoxxMD f8a63c5df1 fix: Add missing glob package 2024-10-09 12:18:23 -04:00
FoxxMD 5004d99473 chore: Add tmp testing folders to ignore files 2024-10-09 12:18:23 -04:00
FoxxMD 5b26cc73e8 feat: Improve client/source schema validation generation and error handling
* Use runtime-generated schemas
  * Ensures validated schemas always match code
  * Can use schemas for individual source/clients without having to generate files
* catch invididual validation errors and log instead of crashing
* refactor validation error output into chained error instead of as individual log statements to improve clarity
2024-10-09 12:18:23 -04:00
FoxxMD 72a4c4542b fix: Fix some source schemas to reflect documentation WRT optional properties 2024-10-09 12:18:23 -04:00
Matt Foxx fdaeb8f6b8 Merge pull request #201 from FoxxMD/GH-199/ytschema
fix(ytmusic): Allow authUser to be number or string
2024-10-09 08:28:25 -04:00
FoxxMD f2f550dae7 Update devcontainer usage with docusarus and docs 2024-10-08 19:53:50 +00:00
FoxxMD 6780d72296 Improve testing, linting, and node usage in devcontainer
* Swtch to devcontainer for node 18.x and remove terminal/launch settings for version (removes nvm dependency in container)
* Update eslint config to use proper config for include/ignore files
* Add spec for mocha and mocha test runner extension + settings
2024-10-08 19:33:47 +00:00
FoxxMD f1784f3c21 fix(ytmusic): Allow authUser to be number or string
Fixes #199
2024-10-07 10:38:55 -04:00
FoxxMD 22d17ba621 docs: Fix link typo for flatpak 2024-10-03 09:18:26 -04:00
FoxxMD 4b4cc1c438 docs: Update/fix docker-compose usage
* Remove bad direct links to non-docsite docker-compose and replace with inline file
* Fix quotes around base url ENV
* Simplify config volume mapping
2024-10-03 09:11:56 -04:00
FoxxMD 7aac664916 docs: Update jellyfin instructions in quickstart to use API Source 2024-10-03 08:55:36 -04:00
193 changed files with 23875 additions and 21159 deletions
+20 -4
View File
@@ -3,19 +3,35 @@
{
"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-22-bookworm",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [9078]
"forwardPorts": [9078,3000],
// Use 'postCreateCommand' to run commands after the container is created.
//"postCreateCommand": "./.devcontainer/postCreateCommand.sh"
"postCreateCommand": "npm install",
// Configure tool-specific properties.
// "customizations": {},
"customizations": {
"vscode": {
"extensions": [
"hbenl.vscode-mocha-test-adapter",
"dbaeumer.vscode-eslint",
"unifiedjs.vscode-mdx"
]
}
},
"portsAttributes": {
"3000": {
"label": "Docusaurus"
},
"9078": {
"label": "App"
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
+3 -1
View File
@@ -25,4 +25,6 @@ build
*.secrets
.devcontainer
.vscode
**/act/*
**/act/*
tmp-*
.env
+67
View File
@@ -0,0 +1,67 @@
name: Bug Report
description: Report a reproducible problem
title: "bug: "
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Please check existing knowledge before opening an issue
options:
- label: I have [checked the FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ/) and [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
required: true
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
required: true
- type: markdown
attributes:
value: |
If this a how-do-I question, troublehooting, or generally **not** a bug/error with multi-scrobbler please consider [**starting a discussion**](https://github.com/FoxxMD/multi-scrobbler/discussions) instead of opening an issue.
- type: textarea
id: what-happened
attributes:
label: Describe the Bug
description: Describe what you expected to happen and what happened instead. Please provide as much detail as possible.
placeholder: The app should have scrobbled from Spotify but instead it crashed with an error about undefined properties, etc...
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: What platform are you running multi-scrobbler on?
options:
- Docker
- Local (NodeJS)
- Flatpak
default: 0
validations:
required: true
- type: textarea
attributes:
label: Versions
description: What version of multi-scrobbler and issue-related sources/clients are running?
placeholder: |
- multi-scrobbler: 0.8.3 on docker
- maloja 3.1.4
- jellyfin 10.8.9
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
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
description: Add any other context about the problem here.
@@ -0,0 +1,31 @@
name: Feature Request
description: Suggest a new feature or functionality
title: "feat: "
labels: ["enhancement"]
body:
- type: checkboxes
attributes:
label: Please check existing knowledge before opening an issue
options:
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
required: true
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
required: true
- type: textarea
attributes:
label: Describe the new functionality
description: Please describe the feature or functionality you want multi-scrobbler to have. Use as much detail as possible about the exact behavior you wish to see.
validations:
required: true
- type: textarea
attributes:
label: Configuration Example
description: |
If the functionality involves changes to configuration files please provide an example here
render: json5
- type: checkboxes
attributes:
label: Will you help implement it?
options:
- label: I am willing to contribute code towards completing this feature
- label: I am willing to help test preview builds for this feature
@@ -0,0 +1,75 @@
name: Request a new Source or Client
description: Request a new Source or Client to be implemented
title: "feat: "
labels: ["enhancement"]
body:
- type: checkboxes
attributes:
label: Please check existing knowledge before opening an issue
options:
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
required: true
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
required: true
- type: dropdown
attributes:
label: What Type?
description: Is this a Source or a Client?
options:
- Source
- Client
default: 1
validations:
required: true
- type: input
attributes:
label: Name
description: "What is the name of the new service/software?"
placeholder: "My Cool Player"
validations:
required: true
- type: input
attributes:
label: Website
description: "Website for the service/software. Preferably a github repo or other source-code homepage"
validations:
required: true
- type: dropdown
attributes:
label: API Available?
description: Does the service have an API or other protocol that multi-scrobbler can use to communicate? **Without this the likelyhood of implementation is very low.**
options:
- 'Yes'
- 'No'
default: 1
validations:
required: true
- type: dropdown
attributes:
label: Free To Use?
description: Is this service/software freely available or does it require purchasing/subscription?
options:
- 'Yes'
- 'No'
default: 1
validations:
required: true
- type: dropdown
attributes:
label: Willing to finance development?
description: If the software is **non-free** are you will to provide a paid account/subscription to multi-scrobbler developer's in order to facilitate development/testing?
options:
- 'Yes'
- 'No'
validations:
required: false
- type: checkboxes
attributes:
label: Will you help implement it?
options:
- label: I am willing to contribute code towards completing this feature
- label: I am willing to help test preview builds for this feature
- type: textarea
attributes:
label: Additional Context
description: Add any other context about the new Source/Client here
-40
View File
@@ -1,40 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
[Please check the FAQ](https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite/docs/FAQ.md) before submitting a bug report.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
If possible reproduce the issue with [debug logging ON](https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite/docs/FAQ.md#turn-on-debug-logging)
```
Copy and paste as much log data as possible related to this issue here.
```
**Versions (please complete the following information):**
Provide version information for any related sources/clients.
- multi-scrobbler: [e.g. 0.8.3 on docker]
- maloja [e.g. 3.1.4]
- jellyfin [e.g. 10.8.9]
**Additional context**
Add any other context about the problem here.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+8 -2
View File
@@ -12,7 +12,13 @@ docker pull ghcr.io/jefuller/artifact-server:latest
docker run -d --name artifact-server -p 8082:8080 --add-host host.docker.internal:host-gateway -e AUTH_KEY=foo ghcr.io/jefuller/artifact-server:latest
```
Run the following **from this directory** to make use of `.actrc` and proper working directoy.
Run the following **from this directory** to make use of `.actrc` and proper working directory.
### Test Branch Test Suite
```shell
act -W '.github/act/testSuite.yml' -e '.github/act/actBranchEvent.json'
```
### Test Branch Push
@@ -44,4 +50,4 @@ act -W '.github/act/multiRunnerTest.yml' -e '.github/act/actBranchEvent.json'
```shell
act -W '.github/act/multiRunnerBakeTest.yml' -e '.github/act/actBranchEvent.json'
```
```
+1 -1
View File
@@ -1,4 +1,4 @@
{
"ref": "refs/heads/master",
"sha": "005dae76ab51799d3d55112738e301cb1af0dafd"
"sha": "14d5eb3313110f2cfd1aa8fe9c229c21901c1eeb"
}
+164
View File
@@ -0,0 +1,164 @@
{
"action": "synchronize",
"number": 210,
"pull_request": {
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/pulls/210",
"id": 2123337348,
"node_id": "PR_kwDOEpP-x85-j46E",
"number": 210,
"state": "open",
"locked": false,
"merge_commit_sha": "349bea1f4580b9e61aa22446eae73f8b1225bbb7",
"labels": [
{
"id": 2494982061,
"node_id": "MDU6TGFiZWwyNDk0OTgyMDYx",
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/bug",
"name": "bug",
"color": "d73a4a",
"default": true,
"description": "Something isn't working"
},
{
"id": 7131135993,
"node_id": "LA_kwDOEpP-x88AAAABqQx_-Q",
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/safe%20to%20test",
"name": "safe to test",
"color": "D4C5F9",
"default": false,
"description": "trusted to build image"
}
],
"milestone": null,
"draft": false,
"head": {
"label": "FoxxMD:GH-209/jellyfinCleanup",
"ref": "GH-209/jellyfinCleanup",
"sha": "e251da9b8eecfcb3aba8756393e5075d0092794b",
"user": {
"login": "FoxxMD",
"id": 4663766,
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/FoxxMD",
"html_url": "https://github.com/FoxxMD",
"followers_url": "https://api.github.com/users/FoxxMD/followers",
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
"repos_url": "https://api.github.com/users/FoxxMD/repos",
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
"type": "User",
"site_admin": false
},
"repo": {
"id": 311688903,
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
"name": "multi-scrobbler",
"full_name": "FoxxMD/multi-scrobbler",
"private": false,
"owner": {
"login": "FoxxMD",
"id": 4663766,
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/FoxxMD",
"html_url": "https://github.com/FoxxMD",
"followers_url": "https://api.github.com/users/FoxxMD/followers",
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
"repos_url": "https://api.github.com/users/FoxxMD/repos",
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
"type": "User",
"site_admin": false
},
"html_url": "https://github.com/FoxxMD/multi-scrobbler",
"description": "Scrobble plays from multiple sources to multiple clients",
"fork": false,
"created_at": "2020-11-10T14:45:38Z",
"updated_at": "2024-10-14T09:21:37Z",
"pushed_at": "2024-10-14T15:43:00Z",
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"visibility": "public",
"forks": 16,
"open_issues": 19,
"watchers": 369,
"default_branch": "master",
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"allow_auto_merge": false,
"delete_branch_on_merge": false,
"allow_update_branch": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_message": "COMMIT_MESSAGES",
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE"
}
},
"base": {
"label": "FoxxMD:master",
"ref": "master",
"sha": "9c21db8172b6beff9d321e831e9353bf17e92a37",
"repo": {
"id": 311688903,
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
"name": "multi-scrobbler",
"full_name": "FoxxMD/multi-scrobbler",
"private": false,
"fork": false,
"created_at": "2020-11-10T14:45:38Z",
"updated_at": "2024-10-14T09:21:37Z",
"pushed_at": "2024-10-14T15:43:00Z",
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"visibility": "public",
"forks": 16,
"open_issues": 19,
"watchers": 369,
"default_branch": "master",
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"allow_auto_merge": false,
"delete_branch_on_merge": false,
"allow_update_branch": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_message": "COMMIT_MESSAGES",
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE"
}
},
"author_association": "OWNER",
"auto_merge": null,
"active_lock_reason": null,
"merged": false,
"mergeable": null,
"rebaseable": null,
"mergeable_state": "unknown",
"merged_by": null,
"comments": 0,
"review_comments": 0,
"maintainer_can_modify": false,
"commits": 5,
"additions": 84,
"deletions": 28,
"changed_files": 5
},
"before": "335ee0cd601f5a82e36643c812507b2b53e7daf0",
"after": "e251da9b8eecfcb3aba8756393e5075d0092794b"
}
+34 -31
View File
@@ -37,10 +37,10 @@ jobs:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set short git commit SHA
id: appvars
@@ -64,17 +64,17 @@ jobs:
run: |
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_SLUG }}
${{ env.GHCR_SLUG }}
labels: |
org.opencontainers.image.title=Multi-Scrobbler
org.opencontainers.image.description=Scrobble from many sources to many clients
org.opencontainers.image.vendor=FoxxMD
# - name: Extract metadata (tags, labels) for Docker
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: |
# ${{ env.DOCKERHUB_SLUG }}
# ${{ env.GHCR_SLUG }}
# labels: |
# org.opencontainers.image.title=Multi-Scrobbler
# org.opencontainers.image.description=Scrobble from many sources to many clients
# org.opencontainers.image.vendor=FoxxMD
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -138,6 +138,19 @@ jobs:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -155,7 +168,7 @@ jobs:
type=edge
# maybe re-enable branch-named tags in the futures
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
type=match,pattern=\d.\d.\d$,priority=901
@@ -169,27 +182,17 @@ jobs:
org.opencontainers.image.description=Scrobble from many sources to many clients
org.opencontainers.image.vendor=FoxxMD
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.DOCKERHUB_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_SLUG }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.GHCR_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
# - name: Inspect image
# run: |
# docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
+10
View File
@@ -0,0 +1,10 @@
name: PR Workflow
on:
push:
branches:
- 'master'
jobs:
test:
uses: ./.github/workflows/testAndSanity.yml
+2 -20
View File
@@ -11,24 +11,6 @@ on:
jobs:
test:
name: Run Tests and Build (sanity)
name: Tests / Build / Sanity Run
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Build Backend
run: 'npm run build:backend'
- name: Test Backend
run: npm run test
- name: Install Docs Deps
run: npm run docs:install
- name: Build
run: npm run build
uses: ./.github/workflows/testAndSanity.yml
+6 -1
View File
@@ -18,7 +18,12 @@ jobs:
node-version: 20
cache: npm
- name: Install dependencies
- name: Install app dev dependencies
run: npm ci
- name: Generate schema files
run: npm run schema
- name: Install docusaurus dependencies
run: npm ci
working-directory: ./docsite
- name: Build website
+153 -52
View File
@@ -1,5 +1,9 @@
name: PR Workflow
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha }}
cancel-in-progress: true
on:
pull_request_target:
types:
@@ -12,29 +16,16 @@ on:
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Build Backend
run: 'npm run build:backend'
- name: Test Backend
run: npm run test
uses: ./.github/workflows/testAndSanity.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
release-snapshot:
name: Release snapshot
runs-on: ubuntu-latest
build-snapshot:
name: Build OCI snapshot
needs: test
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
runs-on: ${{ matrix.os }}
permissions:
packages: write
contents: read
@@ -42,62 +33,172 @@ jobs:
fail-fast: false
matrix:
include:
- dockerfile: ./Dockerfile
suffix: ''
platforms: 'linux/amd64,linux/arm64'
# - dockerfile: ./alpine.Dockerfile
# suffix: '-alpine'
# platforms: 'linux/amd64,linux/arm64'
- os: ubuntu-latest
arch: amd64
platform: linux/amd64
- os: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
# list all registries to push to and join all non-empty with comma
# https://unix.stackexchange.com/a/693165/116849
# https://stackoverflow.com/a/9429887/1469797
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
- name: Log in to Docker Hub
# 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: |
short=$(echo "${{ github.event.pull_request.head.sha }}" | head -c7)
echo "COMMIT_SHORT_SHA=$short" >> $GITHUB_ENV
- name: Get App Version
id: appversion
env:
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, env.COMMIT_SHORT_SHA ) }}
run: |
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
- name: Login to Docker Hub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ vars.GHCR_SLUG != '' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
# metadata extract for docker labels/image names is done in merge job
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
# for caching
- name: Build and push by digest
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 }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
release-snapshot:
name: Merge OCI Images and Push
if: ${{ vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '' }}
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs:
- build-snapshot
- test
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ vars.GHCR_SLUG != '' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
foxxmd/multi-scrobbler
ghcr.io/foxxmd/multi-scrobbler
${{ vars.DOCKERHUB_SLUG }}
${{ vars.GHCR_SLUG }}
# generate Docker tags based on the following events/attributes
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
tags: |
type=ref,event=pr,suffix=${{ matrix.suffix }}
type=ref,event=pr
flavor: |
latest=false
labels: |
org.opencontainers.image.title=Multi-Scrobbler
org.opencontainers.image.description=Scrobble from many sources to many clients
org.opencontainers.image.vendor=FoxxMD
- name: Create manifest list and push dockerhub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
- name: Create manifest list and push gchr
if: ${{ vars.GHCR_SLUG != '' }}
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
- name: Inspect image dockerhub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
run: |
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Inspect image ghcr
if: ${{ vars.GHCR_SLUG != '' }}
run: |
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
env:
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, github.event.pull_request.head.sha ) }}
with:
context: .
build-args: |
APP_BUILD_VERSION=${{env.APP_VERSION}}
file: ${{ matrix.dockerfile }}
push: ${{ !env.ACT}}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms }}
combine-and-comment:
name: Leave comment
+158 -53
View File
@@ -1,5 +1,11 @@
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
name: Publish Docker image to Dockerhub
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
push:
@@ -18,41 +24,54 @@ on:
# release:
# types: [ published ]
# define in GH Repository -> Actions -> Variables (or act .variables) to enable pushing to registries
# -- will only push to registries that are defined
# EX
# DOCKERHUB_SLUG=foxxmd/multi-scrobbler
# GHCR_SLUG=ghcr.io/foxxmd/multi-scrobbler
jobs:
test:
name: Run Tests
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Build Backend
run: 'npm run build:backend'
- name: Test Backend
run: npm run test
uses: ./.github/workflows/testAndSanity.yml
push_to_registry:
name: Build and push container images
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
build:
name: Build OCI Images
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
needs: test
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
runs-on: ${{ matrix.os }}
permissions:
packages: write
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
platform: linux/amd64
- os: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
# list all registries to push to and join all non-empty with comma
# https://unix.stackexchange.com/a/693165/116849
# https://stackoverflow.com/a/9429887/1469797
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
joined_string=$(IFS=, ; echo "${strings[*]}")
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v4
- name: Set short git commit SHA
id: vars
id: appvars
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
# short sha available under env.COMMIT_SHORT_SHA
run: |
@@ -61,33 +80,116 @@ jobs:
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
- name: Log in to Docker Hub
- name: Get App Version
id: appversion
env:
# use release instead of tags once version is correctly parsed
#APP_VERSION: ${{ github.event.release.tag_name }}
# https://github.com/actions/runner/issues/409#issuecomment-752775072
# https://stackoverflow.com/a/69919067/1469797
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
run: |
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
- name: Login to Docker Hub
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
# metadata extract for docker labels/image names is done in merge job
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
# for caching
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
build-args: |
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge OCI Images and Push
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs:
- build
- test
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
foxxmd/multi-scrobbler
ghcr.io/foxxmd/multi-scrobbler
${{ vars.DOCKERHUB_SLUG }}
${{ vars.GHCR_SLUG }}
# generate Docker tags based on the following events/attributes
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
tags: |
type=edge
# maybe re-enable branch-named tags in the futures
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
# push with branch name as tag if not master/main
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
type=match,pattern=\d.\d.\d$,priority=901
@@ -96,28 +198,31 @@ jobs:
type=semver,pattern={{version}}
# flavor: |
# latest=false
labels: |
org.opencontainers.image.title=Multi-Scrobbler
org.opencontainers.image.description=Scrobble from many sources to many clients
org.opencontainers.image.vendor=FoxxMD
- name: Create manifest list and push dockerhub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
- name: Create manifest list and push gchr
if: ${{ vars.GHCR_SLUG != '' }}
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
- name: Inspect image dockerhub
if: ${{ vars.DOCKERHUB_SLUG != '' }}
run: |
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
env:
# use release instead of tags once version is correctly parsed
#APP_VERSION: ${{ github.event.release.tag_name }}
# https://github.com/actions/runner/issues/409#issuecomment-752775072
# https://stackoverflow.com/a/69919067/1469797
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
uses: docker/build-push-action@v5
with:
context: .
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
build-args: |
APP_BUILD_VERSION=${{env.APP_VERSION}}
push: ${{ !env.ACT }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Inspect image ghcr
if: ${{ vars.GHCR_SLUG != '' }}
run: |
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
+65
View File
@@ -0,0 +1,65 @@
name: Tests and Sanity Run
on:
workflow_call:
inputs:
node-version:
description: "Node version"
required: false
default: '20.x'
type: string
ref:
description: "Ref to checkout"
required: false
type: string
jobs:
test:
name: Tests / Build / Sanity Run
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install concurrently
run: npm install -g concurrently
- name: Install dev dependencies
run: npm run install:parallel
- name: Test Backend
run: npm run test
- name: Build
run: npm run build:parallel
# remove modules that might include dev stuff
# so that in the next step we are sure that prod-only runs work correctly
- name: Install Prod Deps
run: |
rm -rf node_modules && \
rm -rf docsite/node_modules && \
NODE_ENV=production npm ci --omit=dev
# run app for 10 seconds as sanity check to see if it errors for any reason
# easy testcase for missing packages and init errors
- name: Sanity Run
run: |
set +e
export NODE_ENV=production
timeout --preserve-status 10s node node_modules/.bin/tsx src/backend/index.ts
exitcode="$?"
if [[ "$exitcode" -eq 143 ]] || [[ "$exitcode" -eq 137 ]]; then
echo "App stayed up long enough and exited with expected status"
exit 0
else
echo "App exited with unexpected code $exitcode"
exit "$exitcode"
fi
+3 -1
View File
@@ -118,6 +118,7 @@ dist
.pnp.*
config/*.json
config/yti-*
*.txt
.idea/
@@ -133,4 +134,5 @@ flatpak/generated-sources.json
build
!setupProxy.js
**/generated-sources.*
.github/act/out
.github/act/out
tmp-*
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
"error_log": {
"title": "Multi-Scrobbler Log",
"description": "Logs from Multi Scrobbler",
"regex": {
"std": {
"pattern": "^\\[(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?)(?:.*?)\\] (?<level>[A-Z]*) : (?<body>.*)"
}
},
"level-field": "level",
"level": {
"info": "^(?i)(?:info)$",
"debug": "^(?i)(?:debug)$",
"error": "^(?i)(error)$",
"warning": "^(?i)(warn)$",
"trace": "^(?i)verbose$",
"critical": "^(?i)(?:crit|Cr)$",
"fatal": "^(?i)(?:alert|fatal|panic|Al|Em)$"
},
"sample": [
{
"line": "[2024-12-26 01:58:48.261 -0500] DEBUG : [App] [Sources] [Jellyfin - unnamed] Last activity was at 01:58:48-05:00 | Next check in 10.00s | No new tracks discovered",
"level": "debug"
}
]
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
{
"reporter": "dot",
"extension": "ts",
"import": "tsx/esm"
"import": "tsx/esm",
"spec": "./src/backend/tests/**/*.test.ts",
"exit": true
}
+20 -5
View File
@@ -4,9 +4,8 @@
{
"name": "dev",
"type": "node",
"runtimeVersion": "18.19.1",
"request": "launch",
// Debug current file in VSCode
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
@@ -20,7 +19,6 @@
{
"name": "tsx",
"type": "node",
"runtimeVersion": "18.19.1",
"request": "launch",
// Debug current file in VSCode
"program": "${file}",
@@ -33,6 +31,25 @@
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "schema",
"type": "node",
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/utils/SchemaStaticUtil.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**",
// Ignore all dependencies (optional)
//"${workspaceFolder}/node_modules/**",
],
"resolveSourceMapLocations": [
"${workspaceFolder}/src/**/*",
"${workspaceFolder}/node_modules/**/*"
]
},
{
"args": [
"-u",
@@ -47,7 +64,6 @@
"--recursive",
"${workspaceFolder}/src/backend/tests/**/*.test.ts"
],
"runtimeVersion": "18.19.1",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"internalConsoleOptions": "openOnSessionStart",
"name": "Mocha Tests",
@@ -72,7 +88,6 @@
"--config", "${workspaceRoot}/.mocharc.json",
"${file}"
],
"runtimeVersion": "18.19.1",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"internalConsoleOptions": "openOnSessionStart",
"name": "Mocha Test on File",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"debug.javascript.terminalOptions": {
"runtimeVersion": "18.19.1"
}
"mochaExplorer.require": "tsx/esm",
"mochaExplorer.timeout": 1200000,
"mochaExplorer.exit": true
}
+21 -1
View File
@@ -12,6 +12,26 @@
"isDefault": true
},
"label": "tsc: build - src/backend/tsconfig.json"
}
},
{
"type": "shell",
"label": "compose-up-dev",
"command": "docker-compose -f docker-compose.dev.yml up",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"type": "shell",
"label": "compose-build-dev",
"command": "docker-compose -f docker-compose.dev.yml build",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}
+15 -13
View File
@@ -1,7 +1,7 @@
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
FROM ghcr.io/linuxserver/baseimage-debian:bookworm AS base
ENV TZ=Etc/GMT
ENV NODE_VERSION 20.11.1
ENV NODE_VERSION=20.18.1
# borrowing openssl header removal trick from offical docker-node
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
@@ -49,6 +49,8 @@ RUN \
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN npm install -g concurrently
ARG data_dir=/config
VOLUME $data_dir
ENV CONFIG_DIR=$data_dir
@@ -57,10 +59,11 @@ COPY docker/root /
WORKDIR /app
FROM base as build
FROM base AS build
COPY --chown=abc:abc package*.json tsconfig.json ./
COPY --chown=abc:abc patches ./patches
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
# for debugging, so the build fails faster when timing out (arm64)
#RUN npm config set fetch-retries 1 && \
@@ -74,21 +77,20 @@ COPY --chown=abc:abc patches ./patches
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
RUN npm ci \
--verbose \
# --no-audit \
&& chown -R root:root node_modules
RUN npm run install:parallel \
&& chown -R root:root node_modules \
&& chown -R root:root docsite/node_modules
COPY --chown=abc:abc . /app
# need to set before build so server/client build is optimized and has constants (if needed)
ENV NODE_ENV=production
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
FROM base as app
FROM base AS app
COPY --chown=abc:abc package*.json ./
COPY --chown=abc:abc *.json *.js *.ts index.html ./
COPY --chown=abc:abc patches ./patches
COPY --from=build --chown=abc:abc /app/dist /app/dist
COPY --from=build --chown=abc:abc /app/src /app/src
@@ -103,11 +105,11 @@ ENV IS_DOCKER=true
ARG APP_BUILD_VERSION
ENV APP_VERSION=$APP_BUILD_VERSION
RUN npm ci --omit=dev \
RUN npm ci --omit=dev --no-audit \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
&& rm -rf node_modules/@types \
/root/.cache
&& rm -rf /root/.cache
ARG webPort=9078
ENV PORT=$webPort
+8 -3
View File
@@ -13,13 +13,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Supports scrobbling from many **Sources**
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [Tautulli](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
@@ -29,6 +31,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
* Supports scrobbling to many **Clients**
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
@@ -39,8 +44,8 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* 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](https://foxxmd.github.io/multi-scrobbler/docs/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/installationr#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
* 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)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
+13
View File
@@ -0,0 +1,13 @@
[
{
"type": "azuracast",
"enable": true,
"name": "azura",
"data": {
"url": "ws://192.168.0.101",
"station": "my-station-name",
"monitorWhenLive": true,
"monitorWhenListeners": 1
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myLfm",
"enable": true,
"data": {
"slug": "mySlug"
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myLz",
"enable": true,
"data": {
"token": "myToken"
}
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "myIcy",
"enable": true,
"data": {
"url": "http://myIcecast.com/radio"
}
}
]
+8 -1
View File
@@ -7,12 +7,19 @@
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
// everything below is optional
"usersAllow": ["FoxxMD","SomeOtherUser"],
"usersBlock": ["AnotherUser"],
"devicesAllow": ["firefox"],
"devicesBlock": ["google-home"]
"devicesBlock": ["google-home"],
"librariesAllow": ["GoodMusic"],
"librariesBlock": ["BadMusic"],
"additionalAllowedLibraryTypes": ["musicvideos"],
"allowUnknown": false,
},
"options": {
"logPayload": true,
"logFilterFailure": "debug"
}
}
+11 -1
View File
@@ -1,6 +1,6 @@
[
{
"name": "myLastFm",
"name": "myLastFmClient",
"enable": true,
"configureAs": "client",
"data": {
@@ -8,5 +8,15 @@
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/lastfm/callback"
}
},
{
"name": "myLastFmSource",
"enable": true,
"configureAs": "source",
"data": {
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/lastfm/callback"
}
}
]
+10 -1
View File
@@ -1,11 +1,20 @@
[
{
"name": "brainz",
"name": "brainzClient",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
},
{
"name": "brainzSource",
"enable": true,
"configureAs": "source",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
}
]
+9
View File
@@ -0,0 +1,9 @@
[
{
"name": "myYamaha",
"enable": true,
"data": {
"url": "192.168.0.101"
}
}
]
+13 -7
View File
@@ -1,15 +1,21 @@
[
{
"name": "MyPlex",
"name": "MyPlexApi",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
"libraries": ["music","my podcasts"],
"servers": ["myServer","anotherServer"],
"options": {
"logFilterFailure": "warn"
"token": "1234",
"url": "http://192.168.0.120:32400",
"usersAllow": ["FoxxMD","SomeOtherUser"],
"usersBlock": ["AnotherUser"],
"devicesAllow": ["firefox"],
"devicesBlock": ["google-home"],
"librariesAllow": ["GoodMusic"],
"librariesBlock": ["BadMusic"]
},
"options": {
"logPayload": true,
"logFilterFailure": "debug"
}
}
}
]
+17
View File
@@ -0,0 +1,17 @@
[
// DEPRECATED, use API Source instead
// rename files to plex.json to use
{
"name": "MyPlex",
"enable": true,
"clients": [],
"data": {
"user": ["username@gmail.com","anotherUser@gmail.com"],
"libraries": ["music","my podcasts"],
"servers": ["myServer","anotherServer"],
"options": {
"logFilterFailure": "warn"
}
}
}
]
+1 -1
View File
@@ -2,7 +2,7 @@
{
"name": "MyWebScrobbler",
"data": {
"slug": null,
"slug": "MyOptionalSlug",
"whitelist": [],
"blacklist": []
}
+10 -2
View File
@@ -4,8 +4,16 @@
"enable": true,
"clients": [],
"data": {
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
"authUser": "0"
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......",
// either cookie or id/secret needs to be provided
"clientId": "891098404....apps.googleusercontent.com",
"clientSecret": "GOCS..."
// optional
//"redirectUri": "http://my.custom.tld/api/ytmusic/callback?name=MyYTMusic"
},
"options": {
"logDiff": true
}
}
]
+29
View File
@@ -0,0 +1,29 @@
version: '3.7'
services:
multi-scrobbler:
build:
context: .
env_file:
- path: ./.env
required: false
#environment:
#- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
#- SPOTIFY_CLIENT_ID=
#- SPOTIFY_CLIENT_SECRET=
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
#- MALOJA_URL=http://maloja:42010
#- MALOJA_API_KEY=
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
#- BASE_URL=http://MyHostIP:9078
volumes:
- "./config:/config"
#networks:
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
ports:
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI and API on
restart: unless-stopped
+1 -3
View File
@@ -1,5 +1,3 @@
version: '3.7'
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
@@ -18,7 +16,7 @@ services:
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
#- BASE_URL=http://MyHostIP:9078
volumes:
- /path_on_host/multi-scrobbler-config:/config
- "./config:/config"
#networks:
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
ports:
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};
+103 -33
View File
@@ -5,7 +5,7 @@ toc_max_heading_level: 5
## Connection Issues
### Plex/Tautulli/Jellyfin/Webscrobbler don't connect
### Plex (Webhook)/Tautulli/Jellyfin(Webhook)/Webscrobbler don't connect
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
@@ -30,12 +30,7 @@ Check or try all these steps before submitting an issue:
##### Turn on Debug Logging
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
* using node `LOG_LEVEL=debug ... node src/index.js`
* using docker `docker run -e LOG_LEVEL=debug ... foxxmd/multi-scrobbler`
Check the output for any additional information.
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `DEBUG_MODE=true`. Then, check the output for any additional information.
##### Check Host name and URL
@@ -67,7 +62,7 @@ Administration -> Dashboard -> Advanced -> Logs
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
### Jellyfin has warnings about undefined or missing data
### Jellyfin (Webhook) has warnings about undefined or missing data
Make sure you have
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
@@ -75,26 +70,11 @@ Make sure you have
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
You can verify the payload sent from the webhook by modifying your jellyfin configuration to include `logPayload: true` which will output the raw payload to DEBUG level logging:
```json
[
{
"name": "MyJellyfin",
"clients": [],
"data": {
"users": ["FoxxMD"]
},
"options": {
"logPayload": true
}
}
]
```
You can verify the payload sent from the webhook by enabling Debug Mode with the env `DEBUG_MODE=true` which will output the raw payload to DEBUG level logging.
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
### Jellyfin has warnings about missing headers
### Jellyfin (Webhook) has warnings about missing headers
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
@@ -130,15 +110,25 @@ Deezer has discontinued support for their API and the Deezer Source is now [**de
### Youtube Music fails after some time
The Youtube Music library relies on scraping the YTM site (pretending to be a browser) by using cookies/auth from your actual browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control.
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
```
Error: Could not send the specified request to browse. Status code: 401
```
then YTM has invalidated your access. [Follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](configuration/configuration.mdx#youtube-music) and then restart MS to potentially resolve the problem. See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/158) for further discussion of the problem.
then YTM has invalidated your authentication.
First, ensure you are NOT using [YoutubeTV authentication.](configuration/configuration.mdx?ytmAuth=ytt#youtube-music) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
#### When using OAuth Client Authentication
Refresh your authentication by using the **(Re)authenticate** link from MS's web dashboard.
#### When using Cookies Authentication
The library MS uses relies on scraping the YTM site by using cookies from your actual browser to pretend it is a browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control. You can help limit the chance of your session being invalidated by [getting the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) and then immediately closing the browser afterwards.
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](configuration/configuration.mdx?ytmAuth=cookie#youtube-music) and then restart MS to potentially resolve the problem.
## Configuration Issues
@@ -162,10 +152,12 @@ This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scr
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
### Jellyfin does not scrobble tracks with multiple artists correctly
### Jellyfin (Webhook) does not scrobble tracks with multiple artists correctly
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
You can avoid this entirely by using the newer [Jellyfin API Source](configuration/configuration.mdx#jellyfin) which correctly handles multiple artists.
### Google Cast track information is missing/incorrect or MS player has weird times
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
@@ -194,10 +186,88 @@ Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tr
Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](configuration/configuration.mdx#vlc-information-reporting)
### Youtube Music misses scrobbles
### Youtube Music misses or duplicates scrobbles
In order for multi-scrobbler to accurately determine if a song has been scrobbled it needs **a source of truth.** For YTM this is a "history" list scraped from the YTM website. Unfortunately, the data in this list can be (often) inconsistent which makes it hard for multi-scrobbler to "trust" that it is correct and determine when/if new scrobbles occur. This inconsistency is not something multi-scrobbler can control -- it is a side-effect of having to use an unofficial method to access YTM (scraping).
<details>
In order to compensate for this multi-scrobbler resets when it considers this list the "source of truth" based on if the list changes in an inconsistent way between consecutive checks. New scrobbles can only be detected when this list is "OK" as a source of truth for N+1 checks. Therefore, any new tracks that appear when the list is inconsistent will be ignored.
<summary>How MS detects YTM history</summary>
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/156#issuecomment-2312533486) for further discussion and a more detailed explanation of why this is happening and how multi-scrobbler compensates for it.
In order for multi-scrobbler to accurately determine if a song has been scrobbled it needs **a source of truth.** For YTM this is a "history" list scraped from the YTM website. Unfortunately, the data in this list can be (often) inconsistent which makes it hard for multi-scrobbler to "trust" that it is correct and determine when/if new scrobbles occur. This inconsistency is not something multi-scrobbler can control -- it is a side-effect of having to use an unofficial method to access YTM (scraping).
To compensate for this multi-scrobbler resets when it considers this list the "source of truth" based on if the list changes in an inconsistent way between consecutive checks. New scrobbles can only be detected when this list is "OK" as a source of truth for N+1 checks. Therefore, any new tracks that appear when the list is inconsistent will be ignored.
Duplicate scrobbles can also occur if the change between two checks is technically consistent. For instance, if you listen to a track twice in some period, separated by other music, YTM will sometimes "remove" the track from the earlier time (further down in your history) and "re-add" it at the top of the history.
</details>
#### Reporting YTM scrobble issues
If you experience scrobble detection issues you can help improve MS's YTM heureistic by providing thorough feedback as [an issue.](https://github.com/FoxxMD/multi-scrobbler/issues/new?assignees=&labels=bug&projects=&template=01-bug-report.yml&title=bug%3A+) **Please do the following to provide the most useful report:**
##### Turn on Change Detection
In your YTM configuration (`ytmusic.json`) add `logDiff` under `options` like this:
```json
{
"type": "ytmusic",
"name": "MyYTM",
"data": { ... },
"options": {
"logDiff": true
}
}
```
or set either ENVs:
* `YTM_LOG_DIFF=true`
* [`DEBUG_MODE=true`](configuration/configuration.mdx?#debug-mode)
This will cause MS to log YTM history changes similar to this:
```
[Ytmusic - MyYTM] Changes from last seen list:
1. (tuhe1CpHRxY) KNOWER - Im The President --- undefined => Moved - Originally at 6
2. (Mtg8V6Xa2nc) Vulfpeck - Romanian Drinking Song --- Schvitz => Moved - Originally at 1
3. (rxbCaiyYSXM) Nightmares On Wax - You Wish --- In A Space Outta Sound => Moved - Originally at 2
4. (tMt_YXr90AM) Gorillaz - O Green World --- undefined => Moved - Originally at 3
...
```
Which are essential to troubleshooting this behavior.
##### Turn on File Logging
Optionally, if your logs are busy due to many other sources, you can log **only** YTM logs to a file. Turn this on with the `logToFile` config option:
```json
{
"type": "ytmusic",
"name": "MyYTM",
"data": { ... },
"options": {
"logDiff": true,
"logToFile": true
}
}
```
This will cause MS to write YTM logs to a file in your `logs` folder named `ytmusic-MyYTM.log` (based on your source name).
##### Provide Detail and Context
Provide a detailed account of how you were using YTM when the issue occurred, including things like:
* the platform listening on (desktop, mobile, 3rd party client, etc...)
* any changes in platform
* > I switched from desktop to listening on my phone...
* how you were listening to music
* > I was playing an album start to finish
* > I listened to two songs in a row, then browsed for a new song in library by artist, then went back to a song in the queue...
Explain the expected behavior (it should have scrobbled songs x, y, then z) and what actually happened (it scrobbled songs x, then y, then x again, then z)
Provide ALL logs from the time when the issue occurred including logs from BEFORE (ideally 2-3 minutes of logs) and AFTER the issue.
+773 -38
View File
@@ -10,19 +10,25 @@ import SchemaLink from "../../src/components/SchemaLink";
import AIOExample from "../../src/components/AIOExample";
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
import KodiConfig from '!!raw-loader!../../../config/kodi.json.example';
import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example';
import LastfmEndpointConfig from '!!raw-loader!../../../config/endpointlfm.json.example';
import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example';
import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example';
import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example';
import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example';
import MprisConfig from '!!raw-loader!../../../config/mpris.json.example';
import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example';
import MusiccastConfig from '!!raw-loader!../../../config/musiccast.json.example';
import MPDConfig from '!!raw-loader!../../../config/mpd.json.example';
import PlexConfig from '!!raw-loader!../../../config/plex.json.example';
import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example';
import SpotifyConfig from '!!raw-loader!../../../config/spotify.json.example';
import SubsonicConfig from '!!raw-loader!../../../config/subsonic.json.example';
import TautulliConfig from '!!raw-loader!../../../config/tautulli.json.example';
@@ -151,7 +157,7 @@ These options affect multi-scrobbler's behavior and are not specific to any sour
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify) and [lastfm](#lastfm) -- as well as some logging hints.
* Default => `http://localhost:9078`
* Set with [ENV](./configuration?configType=env#configuration-types) `BASE_URL` or `baseUrl` [all-in-one configuration](./configuration?configType=aio#configuration-types)
* Set with [ENV](./?configType=env#configuration-types) `BASE_URL` or `baseUrl` [all-in-one configuration](./?configType=aio#configuration-types)
* If protocol is `http` or no protocol is specified MS will try to use port `9078` -- to override this explicitly set the port or use `https`
Useful when running with [docker](../installation/installation.mdx#docker) so that you do not need to specify redirect URLs for each configuration.
@@ -177,20 +183,102 @@ Useful when running with [docker](../installation/installation.mdx#docker) so th
</details>
#### Debug Mode
Turning on Debug Mode will
* override and enable all optional "debugging" options found in configuration
* set log output to DEBUG
Use this as a shortcut for enabling output that can be used for troubleshooting and issue reporting. Be aware that logs will likely be VERY noisy while Debug Mode is on. You should only have this mode on while gathering logs for troubleshooting and then turn it off afterwards.
To set debug mode either add it to [AIO `config.json`](./?configType=aio#configuration-types)
```json5 title="config.json"
{
"debugMode": true,
"sources": [...],
// ...
}
```
or set the ENV `DEBUG_MODE=true`
#### Disable Web
If you do not need the dashboard and/or ingress sources, or have security concerns about ingress and cannot control your hosting environment, the web server and API can be disabled.
:::warning
Any **ingress-based sources will be unusable** (Plex, Tautulli, etc...) if this is disabled.
Any **ingress-based sources will be unusable** (Tautulli, Webscrobbler, etc...) if this is disabled.
:::
Disable using either:
* ENV `DISABLE_WEB=true`
* In [All-in-One File](./configuration?configType=aio#configuration-types) use the top-level property `"disableWeb": 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
@@ -240,10 +328,133 @@ If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-
### [Plex](https://plex.tv)
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
<Tabs groupId="plexType" queryString>
<TabItem value="api" label="API">
Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) and make note of the **URL** and **Port** used to connect to your Plex instance.
<details>
<summary>Allowed Users and Defaults</summary>
**Multi-scrobbler will automatically scrobble for these users by default:**
* The User authenticated with the Plex Token
* and the **Local User**
The Local User (`PLEX_LOCAL_USER`) is how Plex identifies anyone directly accessing the Plex UI from a local IP (who does not need to login).
To allow MS to scrobble for other users use `usersAllow` or `PLEX_USERS_ALLOW` (env) from the below configuration docs. However, because you are overriding the default settings you must also explicitly list the authenticated user and the Local User if you want them to also be able to scrobble.
<details>
<summary>Examples</summary>
###### Defaults
If `usersallow` and `PLEX_USERS_ALLOW` are not defined then the Plex Token authenticated User and Local User will be scrobbled for.
###### Only A Specific User
* `"usersallow": ["SomeUser"]` or
* `PLEX_USERS_ALLOW: SomeUser`
Only the Plex user `SomeUser` will be scrobbled for. The Plex Token authenticated user and the Local User will not be scrobbled for.
###### A Specific User + Defaults
(Assuming the plex authenticated user is `FoxxMD`)
* `"usersallow": ["FoxxMD", "PLEX_LOCAL_USER", "SomeUser"]` or
* `PLEX_USERS_ALLOW: FoxxMD,PLEX_LOCAL_USER,SomeUser`
The Plex user SomeUser, the Plex Token authenticated user (FoxxMD) and the Local User will be scrobbled for.
</details>
</details>
<details>
<summary>Allowed Libraries and Defaults</summary>
By default multi-scrobbler will only scrobble media found in Plex libraries that are labelled as **Music.**
* `librariesAllow` or `PLEX_LIBRARIES_ALLOW` will override this
</details>
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| ---------------------- | --------- | ------- | ---------------------------------------------------------------------- |
| `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:32400` |
| `PLEX_TOKEN` | **Yes** | | The **Plex Token** to use with the API |
| `PLEX_USERS_ALLOW` | No | | Comma-separated list of usernames (from Plex) to scrobble for |
| `PLEX_USERS_BLOCK` | No | | Comma-separated list of usernames (from Plex) to disallow scrobble for |
| `PLEX_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
| `PLEX_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
| `PLEX_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
| `PLEX_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="PlexApiSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={PlexConfig} name="plex"/>
</details>
or <SchemaLink lower objectName="PlexApiSourceConfig"/>
</TabItem>
</Tabs>
</TabItem>
<TabItem value="webhook" label="Webhook (Deprecated)">
:::warning[Deprecated]
Multi-scrobbler < 0.8.7 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. This approach has been deprecated in favor of using Plex's API directly which has many benefits including **not requiring Plex Pass.**
The Plex Webhook Source will be removed in an upcoming release. You should migrate to the API source as soon as possible.
:::
<details>
<summary>Migrating to API</summary>
* Follow the instructions in the API tab
* The `user` (`PLEX_USER`) setting has been renamed `usersAllow` (`PLEX_USERS_ALLOW`)
* If you were using this filter to ensure only scrobbles from yourself were registered then you no longer need this setting -- by default MS will only scrobble for the user the Plex Token is from.
* The `servers` setting is no longer available as MS only scrobbles from the server the Plex token is from.
* If you need to scrobble for multiple servers set up each server as a separate Plex API source with a separate token.
* The `libraries` setting has been renamed to `librariesAllow`
</details>
* In the Plex dashboard Navigate to your **Account/Settings** and find the **Webhooks** page
* Click **Add Webhook**
* URL -- `http://localhost:9078/plex` (substitute your domain if different than the default)
* **Save Changes**
##### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required | Default | Description |
@@ -256,7 +467,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexWebhookConfig}</CodeBlock>
</details>
@@ -267,7 +478,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
<summary>Example</summary>
<AIOExample data={PlexConfig} name="plex"/>
<AIOExample data={PlexWebhookConfig} name="plex"/>
</details>
@@ -275,8 +486,19 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
</TabItem>
</Tabs>
</TabItem>
</Tabs>
### [Tautulli](https://tautulli.com)
:::warning[Deprecated]
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](./?plexType=api#plex) and removes the need to use Tautulli since it does not require Plex Pass.
The Tautuilli Source will be removed in an upcoming release. You should migrate to the Plex API source as soon as possible.
:::
Check the [instructions](plex.md) on how to setup a notification agent.
#### Configuration
@@ -359,16 +581,19 @@ Can use this source for any application that implements the [Subsonic API](http:
### [Jellyfin](https://jellyfin.org/)
:::warning[Webhook Method Deprecated]
In multi-scrobbler **below v0.8.4** communication with Jellyfin was done using Jellyfin's **Webhook** plugin. This has been deprecated in favor of directly using Jeyllfin's API for a better experience in multi-scrobbler.
Webhook functionality will be removed in an upcoming release of MS. If you are still using the Webhook method you should migrate to the API method as soon as possible.
:::
<details>
<summary>Migrating from Webhook (multi-scrobbler below `v0.9.0`) to API</summary>
<summary>Migrating from Webhook (multi-scrobbler below `v0.8.4`) to API</summary>
In multi-scrobbler **below v0.9.0** communication with Jellyfin was done using Jellyfin's **Webhook** plugin.
This has been deprecated in favor of directly using Jeyllfin's API for a better experience in multi-scrobbler.
### To Migrate:
* Remove the **Webhook** plugin (if not using it for anything else)
* Remove the Jellyfin **Webhook** plugin (if not using it for anything else)
* Follow the new instructions below (outside this section) to setup API usage
* The `servers` (`JELLYFIN_SERVERS`) setting is no longer available as MS only scrobbles from the server using the API anyways.
* If you need to scrobble for multiple servers set up each server as a separate Jellyfin source
@@ -387,24 +612,36 @@ Must be using Jellyfin 10.7 or greater
It is **recommended** to use API Key + username but if you are not an admin for your Jellyfin instance you can also authenticate with your Jellyfin username and **password.**
#### Configuration
:::tip[Important Defaults]
:::tip
By default...
By default, multi-scrobbler will only scrobble for the user authenticated with the API. Allowed Users are only necessary if you want to scrobble for additional users.
* multi-scrobbler will **only** scrobble for the user authenticated with the API.
* Allowed Users (`usersAllow` or `JELLYFIN_USERS_ALLOW`) are only necessary if you want to scrobble for additional users.
* multi-scrobbler will **only** scrobble media found in Jellyfin libraries that were labelled as **Music.**
* `librariesAllow` or `JELLYFIN_LIBRARIES_ALLOW` will override this
* OR use `additionalAllowedLibraryTypes` to allow more types (like `mixed` or `book` for audiobooks)
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio.**
* To force multi-scrobbler to scrobble when media is detected as **Unknown** use `"allowUnknown": true` in file/aio configuration.
:::
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|---------|--------------------------------------------------------------------------------------------|
| -------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
| `JELLYFIN_URL` | **Yes** | | The URL of the Jellyfin server IE `http://localhost:8096` |
| `JELLYFIN_USER` | **Yes** | | The user to authenticate with the API |
| `JELLYFIN_APIKEY` | No | | The API Key to use for authentication **(Must provide either apikey or password)** |
| `JELLYFIN_PASSWORD` | No | | The password of the user to authenticate for. **(Must provide either apikey or password)** |
| `JELLYFIN_USERS_ALLOW` | No | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_USERS_BLOCK` | No | | Comma-separated list of usernames (from Jellyfin) to disallow scrobble for |
| `JELLYFIN_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
| `JELLYFIN_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
| `JELLYFIN_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
| `JELLYFIN_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
</TabItem>
<TabItem value="file" label="File">
<details>
@@ -415,7 +652,7 @@ By default, multi-scrobbler will only scrobble for the user authenticated with t
</details>
or <SchemaLink lower objectName="JellySourceConfig"/>
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
@@ -467,8 +704,67 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions.
</TabItem>
</Tabs>
### [Last.fm (Endpoint)](https://www.last.fm)
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server.
##### URL
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
```
http://localhost:9078/2.0/
```
which is the "standard" Last.fm server path for scrobbling.
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
If a slug is used then the URL will be:
```
http://localhost:9078/api/lastfm/mySlug
```
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
| `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/endpointlfm.json" language="json5">{LastfmEndpointConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={LastfmEndpointConfig} name="endpointlfm"/>
</details>
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
</TabItem>
</Tabs>
### [Listenbrainz (Source)](https://listenbrainz.org)
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
@@ -508,6 +804,73 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
</TabItem>
</Tabs>
### [Listenbrainz (Endpoint)](https://listenbrainz.org)
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server. An example of this would be configuring [pano-scrobbler](https://github.com/kawaiiDango/pano-scrobbler) with a custom Listenbrainz URL to point to multi-scrobbler.
##### URL
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
```
http://localhost:9078/1/submit-listens
```
which is the "standard" Listenbrainz server path for scrobbling.
Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens.
If a slug is used then the URL will be:
```
http://localhost:9078/api/listenbrainz/mySlug
```
:::note
Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
:::
##### Token
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/endpointlz.json" language="json5">{ListenbrainzEndpointConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={ListenbrainzEndpointConfig} name="endpointlz"/>
</details>
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
</TabItem>
</Tabs>
### [~~Deezer~~](https://deezer.com/)
:::warning
@@ -576,34 +939,190 @@ After starting multi-scrobbler with credentials in-place open the dashboard (`ht
### [Youtube Music](https://music.youtube.com)
:::note
:::warning
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps below to get new credentials. [See the FAQ](../FAQ.md#youtube-music-fails-after-some-time) for a more detailed explanation.
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
* Due to this scrobble history from YTM is often inconsistent and can cause missed scrobbles. [See the FAQ](../FAQ.md#youtube-music-misses-scrobbles) for a more detailed explanation.
Communication with YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
:::
Credentials for YT Music are obtained from a browser request to https://music.youtube.com **once you are logged in.** [Specific requirements are here and summarized below:](https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate)
:::tip[Scrobble Troubleshooting]
* Open a new tab
* Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
* Go to https://music.youtube.com and ensure you are logged in
Due to monitoring being unofficial, listening history from YTM can be inconsistent and can cause missed scrobbles.
Then...
[**See the FAQ**](../FAQ.md#youtube-music-misses-or-duplicates-scrobbles) for a detailed explanation, how to see more details about MS's detection of tracks, and how to properly report an issue.
1. Find and select an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you dont see the request, try scrolling down a bit or clicking on the library button in the top bar.
2. **Make sure **Headers** pane is selected and open
3. In the **Request Headers** section find and copy the **entire value** found after `Cookie:` and use this as the `cookie` value in your multi-scrobbler config
4. If present, in the **Request Headers** section find and copy the number found in `X-google-AuthUser` and use this as the value for `authUser` in your multi-scrobbler config
:::
![Google Headers](google-header.jpg)
#### Authentication
Only one of these methods needs to be used. **Cookies** are easier but **OAuth Client** may be more stable.
<Tabs groupId="ytmAuth" queryString>
<TabItem value="cookie" label="Cookies">
:::info
If cookies stop working for you or are being invalidated often try switching to **OAuth Client** authentication.
:::
Use instructions from
* https://github.com/patrickkfkan/Volumio-YouTube.js/wiki/How-to-obtain-Cookie or
* https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html#copy-authentication-headers
to get the **Cookie** value from a browser.
It is highly recommended to [get the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) to limit the chance the session is invalidated from normal browsing.
Add the cookie to your `ytmusic.json` config in `data` or as an ENV:
```json
{
"type": "ytmusic",
"enable": true,
"name": "MyYTM",
"data": {
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......"
},
"options": {
"logAuthUpdateChanges": true,
"logDiff": true
}
}
```
If MS gives you authentication errors (session invalidated) at some point in the future follow the same instructions to get new cookies.
</TabItem>
<TabItem value="oauth" label="OAuth Client">
:::note
This is likely to be the most stable method and least likely to be blocked or have authentication invalidated after an extended period. It requires more setup but is worth the effort.
:::
[Based on the instructions from here...](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2479689924)
* Login to [Google Cloud console](https://console.cloud.google.com/) (create an account, if necessary)
* [Create a new project](https://console.cloud.google.com/projectcreate)
* Go to APIs and services.
* Configure the OAuth consent screen
* Use the old experience if possible
* If new is unavoidable then do not fill out any branding and under Authorized Domains you can delete the empty one (in order to save)
* Add yourself as an authorized user
* Navigate to Credentials
* Create Credentials -> choose "OAuth client ID"
* Application Type is **Web Application**
* **Name** is whatever you want, leave Authorization Javascript origins blank
* Authorized redirect URIs
* This must be **exactly** the same as what is displayed in MS! For now leave it blank so we can generate it from MS first
* Create
* In the newly created client popup save the **Client ID** and **Client Secret**, then copy them into `ytmusic.json` or appropriate ENVs:
```json
{
"type": "ytmusic",
"enable": true,
"name": "MyYTM",
"data": {
"clientId": "8910....6jqupl.apps.googleusercontent.com",
"clientSecret": "GOCSPX-WGXL6BSuQ343..."
},
"options": {
"logAuthUpdateChanges": true,
"logDiff": true
}
}
```
Now, start MS and during the YTMusic startup it will log something like this:
```
Will use custom OAuth Client:
Client ID: ...
Client Secret: ...
Redirect URI: http://localhost:9078/api/ytmusic/callback?name=MyYTM
```
If the beginning of the Redirect URI (before `api`) is EXACTLY how you would reach the MS dashboard from your browser (EX `http://localhost:9078`) then edit your google oauth client section for `Authorized redirect URIs` and add the URL MS has displayed.
If it is NOT EXACTLY the same you either need to set MS's [base url](https://foxxmd.github.io/multi-scrobbler/docs/configuration/#base-url) or you can provide your own (Custom) Redirect URI for MS to use by setting it in `ytmusic.json` or ENV.
<details>
<summary>Using a Custom Redirect URI</summary>
The three parts of the URL that must be the same:
* it must start with `api` (after domain or subdirectory IE `my.domain.tld/api...` or `whatever.tld/subDir/api...`
* it must end in `ytmusic/callback`
* It must include `name=[NameOfSource]` in the query string
Remember to add your custom URL to the `Authorized redirect URIs` section in the google oauth client!
```json
{
"type": "ytmusic",
"enable": true,
"name": "MyYTM",
"data": {
"clientId": "8910....6jqupl.apps.googleusercontent.com",
"clientSecret": "GOCSPX-WGXL6BSuQ343...",
"redirectUri": "http://my.custom.domain/api/ytmusic/callback?name=MyYTM"
},
"options": {
"logAuthUpdateChanges": true,
"logDiff": true
}
}
```
</details>
AFTER changing the Authorized redirect URIs on Google Cloud console you may need to wait a few minutes for it to take affect. Then restart MS. From the dashboard click `(Re)authenticate` on the YTmusic source card and follow the auth flow:
* On the screen about "testing" make sure you hit **Continue** (not Back To Safety)
* Make sure to select ALL scopes/permissions/grants it asks you about
* `Select what [YourAppName] can access` -> Select all
Once the flow is finished MS will get the credentials and start polling automatically. You should not need to re-authenticate again after restarting MS as it saves the credentials to the `/config` folder.
</TabItem>
<TabItem value="ytt" label="YoutubeTV">
:::warning
Using the built-in YoutubeTV authentication is unlikely to work due to [Google restricting what permissions TV clients can have](https://github.com/yt-dlp/yt-dlp/issues/11462#issuecomment-2471703090). This authentication method should not be used.
:::
To authenticate start multi-scrobbler with an empty YT Music configuration. An authentication URL/code will be logged in additon to being available from the dashboard.
```
ERROR: Sign in with the code 'CLV-KFA-BVKY' using the authentication link on the dashboard or https://www.google.com/device
```
Visit the authentication URL and enter the code that was provided (also available on the dashboard). After completing the setup flow MS will log `Auth success` and the YT Music dashboard card will display as **Idle** after refreshing. Click the **Start** link to begin monitoring.
</TabItem>
</Tabs>
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
No ENV support
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|-----------------------------------------------|
| YTM_COOKIE | No | | Value for Cookie Authentication |
| YTM_CLIENT_ID | No | | Client ID for OAuth Athentication |
| YTM_CLIENT_SECRET | No | | Client Secret for OAuth Athentication |
| YTM_REDIRECT_URI | No | | A custom redirect URI for OAuth Athentication |
| YTM_LOG_DIFF | No | false | Log YTM history changes |
</TabItem>
<TabItem value="file" label="File">
<details>
@@ -1008,7 +1527,7 @@ Multi-scrobbler is **not** designed to be publicly accessible from the internet.
##### Multiple Users
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](./configuration?configType=file#webscrobbler-config)
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](./?configType=file#webscrobbler-config)
<details>
@@ -1211,7 +1730,7 @@ MS can be forced to track media from an application regardless of media type. Th
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue.
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue. Either enable in config using the below example OR enable [Debug Mode.](#debug-mode)
<details>
@@ -1402,9 +1921,7 @@ 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:](./configuration?configType=file#webscrobbler-config)
Example usage in [a file-based config:](./?configType=file#webscrobbler-config)
```json5 title="config/vlc.json" {10-15}
[
@@ -1446,6 +1963,224 @@ If you find that VLC is incorrectly reporting track information (in its interfac
]
```
### [Azuracast](https://www.azuracast.com/)
:::tip[Scrobbling Threshold]
Azuracast may not correctly report track length or position. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
:::
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
##### URL
The URL used by MS to connect to Azuracast has the syntax:
```
[ws|wss]://HOST:[PORT]
```
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
```
URL From Config => MS Uses
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
```
##### Scrobble Behavior
Multi-scrobbler will set the default [**scrobbling behavior**](#should-scrobble-behavior) based on if either of these settings is configured:
* live status (`AZURA_LIVE` or `monitorWhenLive`) or
* listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`)
If either is configured then MS will automatically scrobble based on when these conditions are met.
If neither is configured automatic scrobble defaults to **off** and you should either [manually initiate it or explicitly set the default behavior.](#should-scrobble-behavior)
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
| `AZ_STATION` | Yes | | The station name shown on the public page |
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={AzuracastConfig} name="azuracast"/>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
</Tabs>
### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html)
Monitor Musiccast device/receivers for music played on Network/USB/CD inputs.
:::tip[Scrobbling Threshold]
Musiccast may not correctly report track length. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
:::
##### URL
If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use.
EX
| `url` | Transformed URL |
| --------------------------------------- | :---------------------------------------------- |
| `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` |
| `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` |
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | :----------------------- |
| `MCAST_URL` | Yes | | The Musiccast device URL |
</TabItem>
<TabItem value="file" label="File">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/musiccast.json" language="json5">{MusiccastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
Change `configureAs` to `source`
<summary>Example</summary>
<AIOExample data={MusiccastConfig} name="musiccast"/>
</details>
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
</TabItem>
</Tabs>
### [Icecast](https://icecast.org)
Parse metadata from any Icecast Station (streams) to use for scrobbling.
:::note
There is no standard format for presenting track information in an Icecast stream and the majority of streams that do include metadata only use a single string (`StreamTitle`) from which metadata can be parsed.
Multi-scrobbler will do its best to parse Artist/Album/Track information from this data but the quality and consistency for this is solely based on how the Icecast broadcaster chooses to format their tracks.
:::
:::tip[Scrobbling Behavior]
Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic scrobbling for Icecast Sources is off by default.**
Use [Should Scrobble Behavior](#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
:::
:::tip[Scrobbling Threshold]
Icecast does not report track lengths so multi-scrobbler cannot determine if it should scrobble based on percent played. You should instead set a shorter "listened to" **duration** using [Scrobble Thresholds.](#scrobble-thresholds)
:::
##### URL and Sources
The URL should be the URL you would use to stream audio.
Additional sources are supported based on those found in [`icecast-metadata-stats`](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#supported-status-apis). To use these with automatic parsing [based on the regular URL](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) include a `sources` array in your config the same way as shown in `icecast-metadata-stats`:
<details>
<summary>Example</summary>
```json5 title="config.json"
[
{
"name": "myIcy",
"enable": true,
"data": {
"url": "http://myIcecast.com/radio",
"sources": ["icy", "ogg", "sevenhtml"]
}
}
]
```
</details>
To manually set these sources include the same [`*Endpoint` options](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) found in `icecast-metadata-stats`.
If no `sources` are configured for multi-scrobbler it will automatically try to use `icy` and `ogg`.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ----------- |
| `ICECAST_URL` | Yes | | Icecast URL |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={IcecastConfig} name="icecast"/>
</details>
or <SchemaLink lower objectName="IcecastSourceConfig"/>
</TabItem>
</Tabs>
## Client Configurations
### [Maloja](https://github.com/krateng/maloja)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

+28 -12
View File
@@ -69,7 +69,9 @@ Scenario:
"name": "fredPlex",
"clients": ["fredMaloja"],
"data": {
"user": ["fred@email.com"]
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"usersallow": ["fredUser"]
}
},
{
@@ -77,8 +79,10 @@ Scenario:
"name": "maryPlex",
"clients": ["maryMaloja"],
"data": {
"user": ["mary@email.com"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
"libraries": ["podcasts"]
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"usersallow": ["maryUser"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
"librariesAllow": ["podcasts"]
}
},
{
@@ -86,7 +90,9 @@ Scenario:
"name": "partyPlex",
// omitting clients (or making it empty) will make this Source scrobble to all Clients
"data": {
"libraries": ["party"],
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"librariesAllow": ["party"]
}
},
{
@@ -94,7 +100,10 @@ Scenario:
"name": "FredJelly",
// omitting clients (or making it empty) will make this Source scrobble to all Clients
"data": {
"user": ["fred@email.com"]
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
"usersAllow": ["fredUser"]
}
},
{
@@ -113,7 +122,6 @@ Scenario:
"clients": ["foxxMaloja"],
"data": {
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
"authUser": 1
}
},
],
@@ -195,21 +203,27 @@ Scenario:
"name": "fredPlex",
"clients": ["fredMaloja"],
"data": {
"user": ["fred@email.com"]
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"usersallow": ["fredUser"]
}
},
{
"name": "maryPlex",
"clients": ["maryMaloja"],
"data": {
"user": ["mary@email.com"],
"libraries": ["podcasts"]
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"usersallow": ["maryUser"],
"librariesAllow": ["podcasts"]
}
},
{
"name": "partyPlex",
"data": {
"libraries": ["party"]
"token": "JtRnwQWD__XMJF8sT3jc",
"url": "192.168.0.233:32400",
"librariesAllow": ["party"]
}
}
]
@@ -220,7 +234,10 @@ Scenario:
{
"name": "FredJelly",
"data": {
"user": ["fred@email.com"]
"url": "http://localhost:8096",
"user": "FoxxMD",
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
"usersAllow": ["fredUser"]
}
}
]
@@ -234,7 +251,6 @@ Scenario:
"clients": ["foxxMaloja"],
"data": {
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
"authUser": 1
}
}
]
+9 -1
View File
@@ -1,8 +1,16 @@
---
sidebar_position: 2
title: Plex/Tautulli
title: Plex/Tautulli (Deprecated)
---
:::warning[Deprecated]
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](configuration/configuration.mdx?plexType=api#plex) which removes the need to use Tautulli since it does not require Plex Pass.
The Tautulli source **and** Plex Webhook source are deprecated and will be removed in an upcoming release. Existing users should migrate to the API source as soon as possible.
:::
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
+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>
+10 -3
View File
@@ -14,14 +14,20 @@ Multi-scrobbler is written entirely in [Typescript](https://www.typescriptlang.o
## Project Setup
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system. It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system.
:::tip
When running locally (not with a devcontainer) you can use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
:::
Clone this repository somewhere and then install from the working directory
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
nvm use # optional, sets correct node version when running without devcontainer
npm install
npm run start
```
@@ -32,8 +38,9 @@ This repository contains [workspace settings](https://github.com/FoxxMD/multi-sc
* Run/Debug [Launch configurations](https://code.visualstudio.com/Docs/editor/debugging#_launch-configurations) for the application and tests
* [Devcontainer](https://code.visualstudio.com/docs/devcontainers/containers) for development with all dependencies already installed
* Useful extensions for linting and running tests
To use the Devcontainer simple open the repository in VSCode and "Use Devcontainer" when the notification is presented. You will still need to run `nvm use` and `npm install` the first time it is used.
To use the Devcontainer simple open the repository in VSCode and "Use Devcontainer" when the notification is presented. `npm install` will be run when a new container is created.
## Common Development
+14 -4
View File
@@ -5,6 +5,8 @@ title: 'Overview'
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import ComposeStack from '!!raw-loader!../../../docker-compose.yml';
:::tip
@@ -160,7 +162,15 @@ Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
:::
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
<details>
<summary>`docker-compose.yml`</summary>
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
</details>
Recommended configuration steps for docker or docker-compose usage:
@@ -253,7 +263,7 @@ set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the
multi-scrobbler:
# ...
environment:
- BASE_URL="http://hostMachineIP"
- BASE_URL=http://hostMachineIP
```
</TabItem>
</Tabs>
@@ -316,7 +326,7 @@ The example scenario:
</Tabs>
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
See [`docker-compose.yml`](../../../docker-compose.yml) file for more options and annotations.
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
```yaml title="docker-compose.yml"
services:
@@ -327,7 +337,7 @@ The example scenario:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- SPOTIFY_CLIENT_ID=yourId
- SPOTIFY_CLIENT_SECRET=yourSecret
- BASE_URL="http://192.168.0.100:9078"
- BASE_URL=http://192.168.0.100:9078
- MALOJA_URL=http://domain.tld:42010
- MALOJA_API_KEY=1234
- PUID=1000
+7 -6
View File
@@ -30,7 +30,7 @@ services:
container_name: multi-scrobbler
environment:
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- BASE_URL="http://192.168.0.100:9078"
- BASE_URL=http://192.168.0.100:9078
# all Environmental Variables in below examples go here!
volumes:
@@ -46,13 +46,14 @@ services:
<Tabs>
<TabItem value="jellyfin" label="Jellyfin">
Follow the Jellyfin configuration [instructions for setting up a **webhook**.](./configuration/configuration.mdx#jellyfin)
Follow the Jellyfin configuration [instructions for setting up an **API Key**.](./configuration/configuration.mdx#jellyfin)
After webhook is setup add **at least one** of these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
Then, add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- JELLYFIN_USER=myUserName # comma-separated list of users to monitor
- JELLYFIN_SERVER=myServerName # comma-separated list of servers that should be monitored
- JELLYFIN_URL=192.168.0.110:8096 # URL to Jellyfin UI
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b # Api Key created in previous step
- JELLYFIN_USER=MyUser # Your username in Jellyfin
```
</TabItem>
<TabItem value="spotify" label="Spotify">
@@ -109,7 +110,7 @@ services:
Finally, add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
```yaml title="~/msData/docker-compose.yml"
- MALOJA_URL="http://192.168.0.100:42010"
- MALOJA_URL=http://192.168.0.100:42010
- MALOJA_API_KEY=myApiKey
```
</TabItem>
+4
View File
@@ -4,6 +4,9 @@ import * as themes from 'prism-react-renderer';
//import sidebars from './sidebars';
const config: Config = {
future: {
experimental_faster: true
},
title: 'Multi-Scrobbler',
tagline: 'Scrobble all the things',
favicon: 'img/favicon.ico',
@@ -175,6 +178,7 @@ const config: Config = {
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
const script = {
src: process.env.ANALYTICS,
defer: true
}
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
+5669 -2365
View File
File diff suppressed because it is too large Load Diff
+17 -15
View File
@@ -3,24 +3,26 @@
"version": "0.0.0",
"private": true,
"scripts": {
"start": "npx docusaurus start",
"build": "npx docusaurus build",
"swizzle": "npx docusaurus swizzle",
"deploy": "npx docusaurus deploy",
"clear": "npx docusaurus clear",
"serve": "npx docusaurus serve",
"write-translations": "npx docusaurus write-translations",
"write-heading-ids": "npx docusaurus write-heading-ids",
"start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.4.0",
"@docusaurus/preset-classic": "^3.4.0",
"@easyops-cn/docusaurus-search-local": "^0.44.3",
"@docusaurus/core": "^3.6.3",
"@docusaurus/faster": "^3.7.0",
"@docusaurus/preset-classic": "^3.6.3",
"@easyops-cn/docusaurus-search-local": "^0.48.3",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-json-schema-plugin": "^1.12.1",
"docusaurus-theme-github-codeblock": "^2.0.2",
"json5": "^2.2.3",
"micromark-extension-directive": "^3.0.1",
"prism-react-renderer": "^2.3.0",
"raw-loader": "^4.0.2",
@@ -28,11 +30,11 @@
"react-dom": "^18.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.4.0",
"@docusaurus/tsconfig": "^3.4.0",
"@docusaurus/types": "^3.4.0",
"@docusaurus/module-type-aliases": "^3.6.3",
"@docusaurus/tsconfig": "^3.6.3",
"@docusaurus/types": "^3.6.3",
"@types/react": "^18.2.29",
"typescript": "~5.2.2"
"typescript": "~5.6.2"
},
"browserslist": {
"production": [
+2 -1
View File
@@ -5,6 +5,7 @@ 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 AIOProps {
data: string
@@ -22,7 +23,7 @@ const AIOExample = (props: AIOProps) => {
let configObj;
// eslint-disable-next-line prefer-const
try {
configObj = JSON.parse(data);
configObj = json5.parse(data);
} catch (e) {
console.error(e);
return <Admonition type="danger" title="Unexpected Error">
+7 -2
View File
@@ -14,13 +14,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Supports scrobbling from many **Sources**
* [Spotify](docs/configuration#spotify)
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
* [Jellyfin](docs/configuration#jellyfin)
* [WebScrobbler](docs/configuration#webscrobbler)
* [Youtube Music](docs/configuration#youtube-music)
* [Last.fm](docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
* [Mopidy](docs/configuration#mopidy)
@@ -30,6 +32,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Musikcube](docs/configuration#musikcube)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* [VLC](docs/configuration#vlc)
* [Azuracast](docs/configuration#azuracast)
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
* [Icecast](docs/configuration#icecast)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
@@ -40,7 +45,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* 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/transforms)
* 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)
[**Quick Start Guide**](docs/quickstart)
+1 -2972
View File
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
+29 -36
View File
@@ -4,40 +4,33 @@ import eslint from '@eslint/js';
import tsEslint from 'typescript-eslint';
import arrow from 'eslint-plugin-prefer-arrow-functions';
export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.recommended,
// use to enable typed linting (way more errors) https://typescript-eslint.io/linting/typed-linting
/* ...tsEslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
project: true,
tsconfigDirName: import.meta.dirname,
},
},
},*/
{
files: ['src/backend/**/*.ts'],
ignores: ['eslint.config.js'],
plugins: {
"prefer-arrow-functions": arrow
},
rules: {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
"allowNamedFunctions": false,
"classPropertiesAllowed": false,
"disallowPrototype": false,
"returnStyle": "unchanged",
"singleReturnOnly": false
}
],
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
}
export default tsEslint.config({
files: ['src/backend/**/*.ts'],
plugins: {
"prefer-arrow-functions": arrow
},
ignores: [
'eslint.config.js',
'src/backend/tests/**/*.ts'
],
extends: [
eslint.configs.recommended,
...tsEslint.configs.recommended,
],
rules: {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
"allowNamedFunctions": false,
"classPropertiesAllowed": false,
"disallowPrototype": false,
"returnStyle": "unchanged",
"singleReturnOnly": false
}
],
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
}
);
});
@@ -51,6 +51,8 @@
</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"/>
<release version="0.8.2" date="2024-08-20"/>
+1483 -925
View File
File diff suppressed because it is too large Load Diff
+28 -18
View File
@@ -1,27 +1,24 @@
{
"name": "multi-scrobbler",
"version": "0.8.4",
"version": "0.9.2",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
"schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
"schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
"schema": "tsx src/backend/utils/SchemaStaticUtil.ts",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "npm run -s test:backend",
"test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts",
"test:backend": "mocha --reporter spec",
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
"start": "NODE_ENV=production tsx src/backend/index.ts",
"build:frontend": "vite build",
"build:backend": "tsc -p src/backend",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
"docs:install": "cd docsite && npm ci",
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
"docs:install": "cd docsite && npm ci --no-audit",
"docs:start": "cd docsite && npm start",
"docs:build": "cd docsite && npm run build",
"postinstall": "patch-package"
"docs:build": "npm run schema && cd docsite && npm run build",
"postinstall": "patch-package",
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\""
},
"exports": {
".": {
@@ -54,14 +51,17 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.1",
"@foxxmd/logging": "^0.2.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@foxxmd/string-sameness": "^0.4.0",
"@jellyfin/sdk": "^0.10.0",
"@gr2m/net-interceptor": "^1.0.0",
"@jellyfin/sdk": "^0.11.0",
"@kenyip/backoff-strategies": "^1.0.4",
"@lukehagar/plexjs": "^0.32.1",
"@react-nano/use-event-source": "^0.13.0",
"@reduxjs/toolkit": "^1.9.5",
"@supercharge/promise-pool": "^3.0.0",
"@types/express-serve-static-core": "^4.19.6",
"address": "^1.2.2",
"ajv": "^8.12.0",
"autoprefixer": "^10.4.17",
@@ -82,7 +82,10 @@
"fast-deep-equal": "^3.1.3",
"fixed-size-list": "^0.3.0",
"formidable": "^3.5",
"glob": "^11.0.0",
"google-auth-library": "^9.15.0",
"gotify": "^1.1.0",
"icecast-metadata-stats": "^0.1.12",
"iso-websocket": "^0.3.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
@@ -108,11 +111,12 @@
"superagent": "^8.0.9",
"tailwindcss": "^3.4.4",
"toad-scheduler": "^3.0.0",
"ts-json-schema-generator": "^2.3.0",
"tsx": "^4.7.0",
"vite-express": "^0.16.0",
"vlc-client": "^1.1.1",
"xml2js": "0.6.0",
"youtube-music-ts-api": "^1.7.0"
"xml2js": "0.6.1",
"youtubei.js": "^13.1.0"
},
"devDependencies": {
"@dbus-types/notifications": "^0.0.5",
@@ -151,13 +155,14 @@
"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",
"typescript": "^5.5.4",
"typescript": "5.5.4",
"typescript-eslint": "^7.0.1",
"typescript-json-schema": "^0.61.0",
"vite": "^5.2.12"
"vite": "^5.2.12",
"with-local-tmp-dir": "^5.1.1"
},
"browserslist": {
"production": [
@@ -174,6 +179,11 @@
"overrides": {
"spotify-web-api-node": {
"superagent": "$superagent"
},
"with-local-tmp-dir": {
"tmp-promise": {
"tmp": "0.2.1"
}
}
}
}
@@ -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(() => {
@@ -0,0 +1,39 @@
diff --git a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
index 107dc62..d8cda3c 100644
--- a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
+++ b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
@@ -49,7 +49,7 @@ class ExtendedAnnotationsReader extends BasicAnnotationsReader_js_1.BasicAnnotat
.replace(/\r/g, "")
.trim();
const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim();
- return this.markdownDescription ? { description, markdownDescription } : { description };
+ return this.markdownDescription ? { description: markdownDescription } : { description };
}
getTypeAnnotation(node) {
const symbol = (0, symbolAtNode_js_1.symbolAtNode)(node);
diff --git a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
index e3b7761..93f914a 100644
--- a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
+++ b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
@@ -95,7 +95,7 @@ class SchemaGenerator {
children.reduce((definitions, child) => {
const name = child.getName();
if (!(name in definitions)) {
- definitions[name] = this.typeFormatter.getDefinition(child.getType());
+ definitions[name] = {...this.typeFormatter.getDefinition(child.getType()), title: name};
}
return definitions;
}, childDefinitions);
diff --git a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
index ba8fd57..806396c 100644
--- a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
+++ b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
@@ -57,7 +57,7 @@ export class ExtendedAnnotationsReader extends BasicAnnotationsReader {
const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim();
- return this.markdownDescription ? { description, markdownDescription } : { description };
+ return this.markdownDescription ? { description: markdownDescription } : { description };
}
private getTypeAnnotation(node: ts.Node): Annotations | undefined {
const symbol = symbolAtNode(node);
@@ -1,13 +0,0 @@
diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
index 5b5ecec..1e48e77 100644
--- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js
+++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
@@ -55,7 +55,7 @@ var crypto_1 = require("crypto");
var ts = require("typescript");
var path_equal_1 = require("path-equal");
var vm = require("vm");
-var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
+var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
var REGEX_TSCONFIG_NAME = /^.*\.json$/;
var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
File diff suppressed because one or more lines are too long
+82 -18
View File
@@ -5,7 +5,7 @@ import {
import deepEqual from 'fast-deep-equal';
import { Simulate } from "react-dom/test-utils";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
import {
configPartsToStrongParts, countRegexes,
@@ -23,6 +23,9 @@ import {
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
import play = Simulate.play;
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
import { AuthCheckError, BuildDataError, ConnectionCheckError, PostInitError, TransformRulesError } from "./errors/MSErrors.js";
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
export default abstract class AbstractComponent {
requiresAuth: boolean = false;
@@ -41,37 +44,73 @@ export default abstract class AbstractComponent {
regexCache!: ReturnType<typeof cacheFunctions>;
logger: Logger;
componentLogger?: Logger;
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
this.config = config;
}
initialize = async () => {
public abstract notify(payload: WebhookPayload): Promise<void>;
protected abstract getIdentifier(): string;
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
const {force = false, notify = false, notifyTitle = 'Init Error'} = options;
this.logger.debug('Attempting to initialize...');
try {
this.initializing = true;
await this.buildInitData();
if(this.componentLogger === undefined) {
await this.buildComponentLogger();
}
await this.buildInitData(force);
this.buildTransformRules();
await this.checkConnection();
await this.testAuth();
await this.checkConnection(force);
await this.testAuth(force);
this.logger.info('Fully Initialized!');
try {
await this.postInitialize();
} catch (e) {
this.logger.warn(new Error('Error occurred during post-initialization hook but was caught', {cause: e}));
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
}
return true;
} catch(e) {
this.logger.error(new Error('Initialization failed', {cause: e}));
return false;
if(notify) {
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
}
throw new Error('Initialization failed', {cause: e});
} finally {
this.initializing = false;
}
}
public async buildInitData() {
private async buildComponentLogger() {
await this.doBuildComponentLogger();
return;
}
protected async doBuildComponentLogger() {
return;
}
tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
if(this.initializing) {
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`)
}
try {
return await this.initialize(options);
} catch (e) {
throw e;
}
}
public async buildInitData(force: boolean = false) {
if(this.buildOK) {
return;
if(!force) {
return;
}
this.logger.debug('Build OK but step was forced');
}
try {
const res = await this.doBuildInitData();
@@ -88,7 +127,7 @@ export default abstract class AbstractComponent {
this.buildOK = true;
} catch (e) {
this.buildOK = false;
throw new Error('Building required data for initialization failed', {cause: e});
throw new BuildDataError('Building required data for initialization failed', {cause: e});
}
}
@@ -109,13 +148,13 @@ export default abstract class AbstractComponent {
this.doBuildTransformRules();
} catch (e) {
this.buildOK = false;
throw new Error('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
throw new TransformRulesError('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
}
try {
const ruleCount = countRegexes(this.transformRules);
this.regexCache = cacheFunctions(ruleCount);
} catch (e) {
this.logger.warn(new Error('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
this.logger.warn(new TransformRulesError('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
}
}
@@ -179,7 +218,13 @@ export default abstract class AbstractComponent {
}
}
public async checkConnection() {
public async checkConnection(force: boolean = false) {
if(this.connectionOK) {
if(!force) {
return;
}
this.logger.debug('Connection OK but step was forced')
}
try {
const res = await this.doCheckConnection();
if (res === undefined) {
@@ -194,7 +239,7 @@ export default abstract class AbstractComponent {
this.connectionOK = true;
} catch (e) {
this.connectionOK = false;
throw new Error('Communicating with upstream service failed', {cause: e});
throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e});
}
}
@@ -214,6 +259,8 @@ export default abstract class AbstractComponent {
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
protected doAuthentication = async (): Promise<boolean> => this.authed
// default init function, should be overridden if auth stage is required
@@ -221,8 +268,21 @@ export default abstract class AbstractComponent {
if(!this.requiresAuth) {
return;
}
if(this.authed && !force) {
return;
if(this.authed) {
if(!force) {
return;
}
this.logger.debug('Auth OK but step was forced');
}
if(this.authFailure) {
if(!force) {
if(this.requiresAuthInteraction) {
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
}
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
}
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
}
try {
@@ -232,7 +292,7 @@ export default abstract class AbstractComponent {
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
this.authed = false;
this.logger.error(new Error(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
}
}
@@ -318,4 +378,8 @@ export default abstract class AbstractComponent {
return play;
}
}
public additionalApiData(): Record<string, any> {
return {};
}
}
+25
View File
@@ -0,0 +1,25 @@
export abstract class NamedError extends Error {
public abstract name: string;
}
export abstract class StageError extends NamedError {}
export class BuildDataError extends StageError {
name = 'Init Build Data';
}
export class TransformRulesError extends StageError {
name = 'Transform Rules';
}
export class ConnectionCheckError extends StageError {
name = 'Conenction Check';
}
export class AuthCheckError extends StageError {
name = 'Authentication Check';
}
export class PostInitError extends StageError {
name = 'Post Initialization';
}
+31 -8
View File
@@ -4,7 +4,7 @@ import { Dayjs } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
export type SourceType =
@@ -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,9 +53,15 @@ export const sourceTypes: SourceType[] = [
'chromecast',
'musikcube',
'mpd',
'vlc'
'vlc',
'icecast',
'azuracast'
];
export const isSourceType = (data: string): data is SourceType => {
return sourceTypes.includes(data as SourceType);
}
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export type ClientType =
@@ -59,6 +73,9 @@ export const clientTypes: ClientType[] = [
'lastfm',
'listenbrainz'
];
export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
}
export type InitState = 0 | 1 | 2;
export const NOT_INITIALIZED: InitState = 0;
@@ -103,17 +120,23 @@ export interface ConfigMeta {
export type SourceData = (PlayObject | PlayerStateData);
export interface PlayerStateData {
platformId: PlayPlatformId
export interface PlayerStateData extends PlayerStateDataMaybePlay {
play: PlayObject
}
export interface PlayerStateDataMaybePlay {
platformId: PlayPlatformId
/** The ID/Key for individual sessions on a device/platform */
sessionId?: string
play?: PlayObject
status?: ReportedPlayerStatus
position?: number
timestamp?: Dayjs
}
export const asPlayerStateData = (obj: object): obj is PlayerStateData => {
return 'platformId' in obj && 'play' in obj;
}
export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play)
export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => 'platformId' in obj
export interface FormatPlayObjectOptions {
newFromSource?: boolean
@@ -5,6 +5,7 @@ import { RequestRetryOptions } from "./common.js";
import { WebhookConfig } from "./health/webhooks.js";
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
import { SourceAIOConfig } from "./source/sources.js";
import { ClientType, SourceType } from "../Atomic.js";
export interface SourceDefaults extends CommonSourceOptions {
@@ -70,7 +71,22 @@ export interface AIOClientConfig {
clients?: ClientAIOConfig[]
}
export interface AIOClientRelaxedConfig {
clientDefaults?: RequestRetryOptions
clients?: object[]
}
export interface AIOSourceConfig {
sourceDefaults?: SourceRetryOptions
sources?: SourceAIOConfig[]
}
export interface AIOSourceRelaxedConfig {
sourceDefaults?: SourceRetryOptions
sources?: object[]
}
export interface TypedConfig<T = string> {
type: T
// [key: string]: any
}
@@ -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'
}
@@ -19,7 +19,7 @@ export interface DeezerData extends CommonSourceData {
* @default "http://localhost:9078/deezer/callback"
* @examples ["http://localhost:9078/deezer/callback"]
* */
redirectUri: string
redirectUri?: string
/**
* optional, how long to wait before calling spotify for new tracks (in seconds)
*
@@ -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'
}
@@ -1,3 +1,4 @@
import { FileLogOptions, LogLevel } from "@foxxmd/logging";
import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js";
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
@@ -42,8 +43,8 @@ export interface ScrobbleThresholds {
export interface CommonSourceOptions extends SourceRetryOptions {
/**
* If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)
* then setting this option to true will make MS log the payload JSON to DEBUG output
* * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output
* * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen
*
* @default false
* @examples [false]
@@ -72,6 +73,13 @@ export interface CommonSourceOptions extends SourceRetryOptions {
* */
logPlayerState?: boolean
/**
* **Exprimental:** Log to a separate file for this Source.
*
* Useful for debugging long-running Sources
*/
logToFile?: true | LogLevel | FileLogOptions
/**
* If this source
*
@@ -101,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 {
}
@@ -1,4 +1,8 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import {
// @ts-expect-error weird typings?
CollectionType
} from "@jellyfin/sdk/lib/generated-client/index.js";
export interface JellyData extends CommonSourceData {
/**
@@ -54,23 +58,38 @@ export interface JellyApiData extends CommonSourceData {
/**
* Only scrobble if device or application name contains strings from this list (case-insensitive)
*
* Note: This only applies to real-time scrobbling as JF does not track device info in user activity history
* */
devicesAllow?: string | string[]
/**
* Do not scrobble if device or application name contains strings from this list (case-insensitive)
*
* Note: This only applies to real-time scrobbling as JF does not track device info in user activity history
* */
devicesBlock?: string | string[]
/**
* Only scrobble if library name contains string from this list (case-insensitive)
* */
librariesAllow?: string | string[]
/**
* Do not scrobble if library name contains strings from this list (case-insensitive)
* */
librariesBlock?: string | string[]
/**
* Allow MS to scrobble audio media in libraries classified other than 'music'
*
* `librariesAllow` will achieve the same result as this but this is more convenient if you do not want to explicitly list every library name or are only using `librariesBlock`
*/
additionalAllowedLibraryTypes?: CollectionType[]
/**
* Force media with a type of "Unknown" to be counted as Audio
*
* @default false
*/
allowUnknown?: boolean
}
export interface JellyApiOptions extends CommonSourceOptions {
/* /!**
* Set a persistent device id suffix
* *!/
deviceId?: string*/
}
export interface JellySourceConfig extends CommonSourceConfig {
@@ -89,3 +108,6 @@ export interface JellyApiSourceConfig extends CommonSourceConfig {
export interface JellyApiSourceAIOConfig extends JellyApiSourceConfig {
type: 'jellyfin'
}
export type JellyfinCompatConfig = JellyApiSourceConfig | JellySourceConfig;
@@ -0,0 +1,87 @@
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
export interface MusicCastResponse {
response_code: number
}
export interface DeviceInfoResponse extends MusicCastResponse {
model_name: string
device_id: string
system_version: number
api_version: number
}
export interface DeviceStatusResponse extends MusicCastResponse {
power: 'on' | 'standby'
}
/** use with /netusb/getPlayInfo or /cd/getPlayInfo */
export interface PlayInfoCDResponse extends MusicCastResponse {
device_status: 'open' | 'close' | 'ready' | 'not_ready'
playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
/** in seconds */
play_time: number
/** in seconds */
total_time: number
artist: string
album: string
track: string
}
export interface PlayInfoNetResponse extends PlayInfoCDResponse {
input: string
}
export const MusicCastResponseCodes = new Map<number, string>([
[0, 'Success'],
[1, 'Initializing'],
[2, 'Internal Error'],
[3, 'Invalid Request'],
[4, 'Invalid Parameter'],
[5, 'Guarded (Unable to setup in current status)'],
[6, 'Time out'],
[100, 'Access Error'],
[101, 'Other Error'],
[107, 'Service Maintenance'],
[109, 'License Error'],
[110, 'Read Only Mode'],
[112, 'Access Denied'],
[115, 'Simultaneous logins has reached the upper limit'],
[200, 'Linking in progress'],
[201, 'Unlinking in progress']
]);
export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => {
switch(pb) {
case 'play':
case 'fast_forward':
case 'fast_reverse':
return REPORTED_PLAYER_STATUSES.playing;
case 'pause':
return REPORTED_PLAYER_STATUSES.paused;
case 'stop':
return REPORTED_PLAYER_STATUSES.stopped;
default:
return REPORTED_PLAYER_STATUSES.unknown;
}
}
export interface MusicCastData extends CommonSourceData {
/**
* The host or URL of the YamahaExtendedControl endpoint to use
*
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
* */
url: string
}
export interface MusicCastSourceConfig extends CommonSourceConfig {
data: MusicCastData
}
export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig {
type: 'musiccast'
}
@@ -1,4 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface PlexSourceData extends CommonSourceData {
/**
@@ -34,3 +35,54 @@ export interface PlexSourceConfig extends CommonSourceConfig {
export interface PlexSourceAIOConfig extends PlexSourceConfig {
type: 'plex'
}
export interface PlexApiData extends CommonSourceData, PollingOptions {
token?: string
/**
* http(s)://HOST:PORT of the Plex server to connect to
* */
url: string
/**
* Only scrobble for specific users (case-insensitive)
*
* If `true` MS will scrobble activity from all users
* */
usersAllow?: string | true | string[]
/**
* Do not scrobble for these users (case-insensitive)
* */
usersBlock?: string | string[]
/**
* Only scrobble if device or application name contains strings from this list (case-insensitive)
* */
devicesAllow?: string | string[]
/**
* Do not scrobble if device or application name contains strings from this list (case-insensitive)
* */
devicesBlock?: string | string[]
/**
* Only scrobble if library name contains string from this list (case-insensitive)
* */
librariesAllow?: string | string[]
/**
* Do not scrobble if library name contains strings from this list (case-insensitive)
* */
librariesBlock?: string | string[]
}
export interface PlexApiOptions extends CommonSourceOptions {
}
export interface PlexApiSourceConfig extends CommonSourceConfig {
data: PlexApiData
options: PlexApiOptions
}
export interface PlexApiSourceAIOConfig extends PlexApiSourceConfig {
type: 'plex'
}
export type PlexCompatConfig = PlexApiSourceConfig | PlexSourceConfig;
@@ -1,4 +1,7 @@
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
@@ -9,20 +12,25 @@ 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 { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.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";
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 =
SpotifySourceConfig
| PlexSourceConfig
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| ListenbrainzEndpointSourceConfig
| LastFMEndpointSourceConfig
| SubSonicSourceConfig
| JellySourceConfig
| JellyApiSourceConfig
@@ -36,14 +44,20 @@ export type SourceConfig =
| WebScrobblerSourceConfig
| ChromecastSourceConfig
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
| VLCSourceConfig;
| VLCSourceConfig
| IcecastSourceConfig
| AzuracastSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
| PlexSourceAIOConfig
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| ListenbrainzEndpointSourceAIOConfig
| LastFMEndpointSourceAIOConfig
| SubsonicSourceAIOConfig
| JellySourceAIOConfig
| JellyApiSourceAIOConfig
@@ -57,5 +71,8 @@ export type SourceAIOConfig =
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
| VLCSourceAIOConfig;
| VLCSourceAIOConfig
| IcecastSourceAIOConfig
| AzuracastSourceAIOConfig;
@@ -20,7 +20,7 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
* @default "http://localhost:9078/callback"
* @examples ["http://localhost:9078/callback"]
* */
redirectUri: string
redirectUri?: string
/**
* How long to wait before polling the source API for new tracks (in seconds)
*
@@ -25,7 +25,7 @@ export interface VLCSourceOptions extends CommonSourceOptions {
*
* Used when VLC reports only the filename for the current audio track
* */
filenamePatterns?: string[]
filenamePatterns?: string | string[]
/**
* Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns
*
@@ -1,32 +1,94 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
export interface YTMusicCredentials {
export interface InnertubeOptions {
/**
* Proof of Origin token
*
* May be required if YTM starts returning 403
*
* @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide
*/
po_token?: string
/**
* Visitor ID value found in VISITOR_INFO1_LIVE or visitorData cookie
*
* May be required if YTM starts returning 403
*
* @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide
*/
visitor_data?: string
/**
* If account login results in being able to choose multiple account, use a zero-based index to choose which one to monitor
*
* @examples [0,1]
*/
account_index?: number
location?: string
lang?: string
generate_session_locally?: boolean
device_category?: string
client_type?: string
timezone?: string
}
export interface YTMusicData extends CommonSourceData, PollingOptions {
/**
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
*
* See https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.
* See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html#copy-authentication-headers for how to retrieve this value.
*
* @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."]
* */
cookie: string
cookie?: string
/**
* If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included
*
* @example [0]
* */
authUser?: number
* Google Cloud Console project OAuth Client ID
*
* Generated from a custom OAuth Client, see docs
*/
clientId?: string
/**
* Google Cloud Console project OAuth Client Secret
*
* Generated from a custom OAuth Client, see docs
*/
clientSecret?: string
/**
* Google Cloud Console project OAuth Client Authorized redirect URI
*
* Generated from a custom OAuth Client, see docs. multi-scrobbler will generate a default based on BASE_URL.
* Only specify this if the default does not work for you.
*/
redirectUri?: string
/**
* Additional options for authorization and tailoring YTM client
*/
innertubeOptions?: InnertubeOptions
}
export interface YTMusicData extends YTMusicCredentials, CommonSourceData, PollingOptions {
}
export interface YTMusicSourceConfig extends CommonSourceConfig {
data: YTMusicData
data?: YTMusicData
options?: CommonSourceOptions & {
/**
* When true MS will log to DEBUG what parts of the cookie are updated by YTM
* When true MS will log to DEBUG all of the credentials data it receives from YTM
* */
logAuthUpdateChanges?: boolean
logAuth?: boolean
/**
* Always log history diff
*
* By default MS will log to `WARN` if history diff is inconsistent but does not log if diff is expected (on new tracks found)
* Set this to `true` to ALWAYS log diff on new tracks. Expected diffs will log to `DEBUG` and inconsistent diffs will continue to log to `WARN`
*
* @default false
*/
logDiff?: boolean
}
}
@@ -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: {
+45 -3
View File
@@ -1,9 +1,10 @@
import { childLogger, Logger, loggerAppRolling, LogOptions, parseLogOptions, } from '@foxxmd/logging';
import { buildDestinationJsonPrettyStream, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
import { childLogger, FileLogOptions, Logger, loggerAppRolling, LogLevel, LogLevelStreamEntry, LogOptions, parseLogOptions } from '@foxxmd/logging';
import { buildDestinationJsonPrettyStream, buildDestinationRollingFile, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
import { PassThrough, Transform } from "node:stream";
import path from "path";
import process from "process";
import { projectDir } from "./index.js";
import { isDebugMode } from '../utils.js';
export let logPath = path.resolve(projectDir, `./logs`);
if (typeof process.env.CONFIG_DIR === 'string') {
@@ -22,7 +23,8 @@ export const initLogger = (): [Logger, Transform] => {
export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassThrough]> => {
const stream = new PassThrough({objectMode: true});
const opts = parseLogOptions(config)
const { file } = config;
const opts = parseLogOptions(isDebugMode() ? {...config, file: typeof file === 'object' ? {...file, level: 'debug'} : 'debug', console: 'debug', level: 'debug'} : config);
const logger = await loggerAppRolling(config, {
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
logDefaultPath: './logs/scrobble.log',
@@ -32,6 +34,46 @@ export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassT
});
return [logger, stream];
}
export const componentFileLogger = async (type: string, name: string, fileConfig: true | LogLevel | FileLogOptions, config: LogOptions = {}): Promise<Logger> => {
const opts = parseLogOptions(config, {
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
logDefaultPath: './logs/scrobble.log'
});
const base = path.dirname(typeof opts.file.path === 'function' ? opts.file.path() : opts.file.path);
const componentLogPath = path.join(base, `${type}-${name}.log`);
const componentConfig: LogOptions = {
level: opts.level ?? 'debug'
};
if (fileConfig === true) {
componentConfig.file = {
path: componentLogPath,
}
} else if (typeof fileConfig === 'string') {
componentConfig.file = {
level: fileConfig as LogLevel,
path: componentLogPath
}
} else {
componentConfig.file = fileConfig;
}
const strongOpts = parseLogOptions(componentConfig);
const streams: LogLevelStreamEntry[] = [];
if(strongOpts.file.level !== false) {
const file = await buildDestinationRollingFile(componentConfig.file.level ?? componentConfig.level, {...strongOpts.file})
streams.push(file);
return buildLogger('debug' as LogLevel, streams);
} else {
throw new Error('File must be set');
}
}
export class MaybeLogger {
logger?: Logger
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -151,8 +151,7 @@ export class JRiverApiClient extends AbstractApiClient {
if(this.config.username === undefined || this.config.password === undefined) {
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
}
this.logger.error(new Error(msg, {cause: e}));
return false;
throw new Error(msg, {cause: e});
}
}
+1 -2
View File
@@ -142,8 +142,7 @@ export class KodiApiClient extends AbstractApiClient {
if(this.config.username === undefined || this.config.password === undefined) {
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
}
this.logger.error(new Error(msg, {cause: e}));
return false;
throw new Error(msg, {cause: e});
}
}
+69 -3
View File
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import LastFm, {
AuthGetSessionResponse,
LastfmTrackUpdateRequest,
NowPlayingPayload,
NowPlayingResponse,
TrackObject,
TrackScrobblePayload,
@@ -8,13 +10,15 @@ import LastFm, {
} from "lastfm-node-client";
import { PlayObject } from "../../../core/Atomic.js";
import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js";
import { joinedUrl, readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
import { readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
import { joinedUrl } from "../../utils/NetworkUtils.js";
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.js";
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',
@@ -223,13 +227,18 @@ export default class LastfmApiClient extends AbstractApiClient {
artist = artists[0];
}
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
if(duration !== 0) {
additionalRichPayload.duration = duration;
}
const rawPayload: TrackScrobblePayload = {
artist: artist,
duration,
track,
album,
timestamp: getScrobbleTsSOCDate(playObj).unix(),
mbid,
...additionalRichPayload
};
// LFM does not support multiple artists in scrobble payload
@@ -274,3 +283,60 @@ export default class LastfmApiClient extends AbstractApiClient {
}
}
}
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
const {
artist,
track,
duration,
album,
albumArtist,
} = obj;
let ts: Dayjs | undefined;
if('timestamp' in obj) {
ts = dayjs.unix(obj.timestamp);
}
const mbid = 'mbid' in obj ? obj.mbid : undefined;
let artists: string[] = [];
const credits = parseArtistCredits(artist);
if(credits !== undefined) {
artists.push(credits.primary);
if(credits.secondary !== undefined) {
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
if(nonEmptyArtists.length > 0) {
artists = [...artists, ...nonEmptyArtists];
}
}
} else {
artists = [artist];
}
const play: PlayObject = {
data: {
track,
album: nonEmptyStringOrDefault(album),
albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined,
duration: typeof duration === 'string' ? parseInt(duration, 10) : duration,
playDate: ts,
artists
},
meta: {
source: 'lastfm',
nowPlaying: obj.method === 'track.updateNowPlaying'
}
};
if(nonEmptyStringOrDefault(mbid) !== undefined) {
play.data.meta = {
brainz: {
track: mbid
}
};
}
return play;
}
+39 -3
View File
@@ -2,8 +2,7 @@ import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject } from "../../../core/Atomic.js";
import { slice } from "../../../core/StringUtils.js";
import { combinePartsToString } from "../../utils.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
normalizeStr,
@@ -82,7 +81,7 @@ export interface ListenPayload {
}
export interface SubmitPayload {
listen_type: 'single',
listen_type: 'single' | 'playing_now',
payload: [ListenPayload]
}
@@ -301,6 +300,43 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
const {
listened_at = dayjs().unix(),
track_metadata: {
artist_name,
track_name,
additional_info: {
duration,
track_mbid,
artist_mbids,
release_mbid,
release_group_mbid
} = {}
} = {},
} = payload;
return {
data: {
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
track: track_name,
artists: [artist_name],
duration,
meta: {
brainz: {
artist: artist_mbids !== undefined ? artist_mbids : undefined,
album: release_mbid,
albumArtist: release_group_mbid,
track: track_mbid
}
}
},
meta: {
nowPlaying,
}
}
}
static listenResponseToPlay(listen: ListenResponse): PlayObject {
const {
listened_at,
@@ -0,0 +1,85 @@
import { childLogger } from "@foxxmd/logging";
import { URLData } from "../../../../core/Atomic.js";
import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
export class AzuracastApiClient extends AbstractApiClient {
declare config: AzuracastData
urlData: URLData;
wsNowPlaying: AzuraStationResponse
wsCurrenTime: number = 0;
socket!: WS;
constructor(name: any, config: AzuracastData, options: AbstractApiOptions) {
super('Azuracast API', name, config, options);
this.urlData = normalizeWSAddress(config.url);
}
connectWS() {
const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket');
const socket = new WebSocket(url);
socket.onopen = (e) => {
socket.send(JSON.stringify({
subs: {
[`station:${this.config.station}`]: {"recover": true}
}
}));
};
socket.onerror = (e) => {
this.logger.error(e);
}
// Handle a now-playing event from a station. Update your now-playing data accordingly.
function handleSseData(ssePayload, useTime = true) {
const jsonData = ssePayload.data;
if (useTime && 'current_time' in jsonData) {
this.wsCurrenTime = jsonData.current_time;
}
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
}
socket.onmessage = (e) => {
const jsonData = JSON.parse(e.data as string);
if ('connect' in jsonData) {
const connectData = jsonData.connect;
if ('data' in connectData) {
// Legacy SSE data
connectData.data.forEach(
(initialRow) => handleSseData(initialRow)
);
} else {
// New Centrifugo time format
if ('time' in connectData) {
this.wsCurrenTime = Math.floor(connectData.time / 1000);
}
// New Centrifugo cached NowPlaying initial push.
for (const subName in connectData.subs) {
const sub = connectData.subs[subName];
if ('publications' in sub && sub.publications.length > 0) {
sub.publications.forEach((initialRow) => handleSseData(initialRow, false));
}
}
}
} else if ('pub' in jsonData) {
handleSseData(jsonData.pub);
}
};
}
}
+38 -30
View File
@@ -4,6 +4,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration.js';
import isBetween from 'dayjs/plugin/isBetween.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import isToday from 'dayjs/plugin/isToday.js';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
import * as path from "path";
@@ -15,14 +16,19 @@ import { getRoot, parseVersion } from "./ioc.js";
import { initServer } from "./server/index.js";
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
import { parseBool, readJson, sleep } from "./utils.js";
import { isDebugMode, parseBool, readJson, retry, sleep } from "./utils.js";
import { createVegaGenerator } from './utils/SchemaUtils.js';
import ScrobbleClients from './scrobblers/ScrobbleClients.js';
import ScrobbleSources from './sources/ScrobbleSources.js';
dayjs.extend(utc)
dayjs.extend(isBetween);
dayjs.extend(relativeTime);
dayjs.extend(duration);
dayjs.extend(timezone);
dayjs.extend(isToday);
// eslint-disable-next-line prefer-arrow-functions/prefer-arrow-functions
(async function () {
const scheduler = new ToadScheduler()
@@ -75,14 +81,20 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
process.env.DEBUG_MODE = b.toString();
}
initLogger.info(`Debug Mode: ${isDebugMode() ? 'YES' : 'NO'}`);
await parseVersion();
const [aLogger, appLoggerStream] = await appLogger(logging)
logger = childLogger(aLogger, 'App');
const root = getRoot({...config, logger});
const root = getRoot({...config, logger, loggingConfig: logging, loggerStream: appLoggerStream});
initLogger.info(`Version: ${root.get('version')}`);
initLogger.info('Generating schema definitions...');
createVegaGenerator()
initLogger.info('Schema definitions generated');
initServer(logger, appLoggerStream, output);
if(process.env.IS_LOCAL === 'true') {
@@ -100,18 +112,12 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
/*
* setup clients
* */
const scrobbleClients = root.get('clients');
const scrobbleClients = root.get('clients') as ScrobbleClients;
await scrobbleClients.buildClientsFromConfig(notifiers);
if (scrobbleClients.clients.length === 0) {
logger.warn('No scrobble clients were configured!')
} else {
logger.info('Starting scrobble clients...');
}
for(const client of scrobbleClients.clients) {
await client.initScrobbleMonitoring();
}
const scrobbleSources = root.get('sources');
/*
* setup sources
* */
const scrobbleSources = root.get('sources') as ScrobbleSources;
await scrobbleSources.buildSourcesFromConfig([]);
// check ambiguous client/source types like this for now
@@ -124,27 +130,29 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
}
let anyNotReady = false;
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
if(source.isReady()) {
source.poll();
} else {
anyNotReady = true;
}
}
if (anyNotReady) {
logger.info(`Some sources are not ready, open the dashboard to continue`);
const clientTask = createHeartbeatClientsTask(scrobbleClients, logger);
clientTask.execute();
try {
await retry(() => {
if(clientTask.isExecuting) {
throw new Error('Waiting')
}
return true;
},{retries: scrobbleClients.clients.length + 1, retryIntervalMs: 2000});
} catch (e) {
logger.warn('Waited too long for clients to start! Moving ahead with sources init...');
}
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, clientTask, {id: 'clients_heart'}));
const sourceTask = createHeartbeatSourcesTask(scrobbleSources, logger);
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
minutes: 20,
runImmediately: false
}, createHeartbeatClientsTask(scrobbleClients, logger)));
runImmediately: true
}, sourceTask, {id: 'sources_heart'}));
logger.info('Scheduler started.');
} catch (e) {
+21 -5
View File
@@ -1,5 +1,5 @@
import { getVersion } from "@foxxmd/get-version";
import { Logger } from "@foxxmd/logging";
import { Logger, LogOptions } from "@foxxmd/logging";
import { EventEmitter } from "events";
import { createContainer } from "iti";
import path from "path";
@@ -8,7 +8,9 @@ import { WildcardEmitter } from "./common/WildcardEmitter.js";
import { Notifiers } from "./notifier/Notifiers.js";
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
import ScrobbleSources from "./sources/ScrobbleSources.js";
import { generateBaseURL } from "./utils.js";
import { generateBaseURL } from "./utils/NetworkUtils.js";
import { PassThrough } from "stream";
let version: string = 'unknown';
@@ -23,13 +25,17 @@ export interface RootOptions {
port?: string | number
logger: Logger
disableWeb?: boolean
loggerStream?: PassThrough
loggingConfig?: LogOptions
}
const createRoot = (options?: RootOptions) => {
const {
port = 9078,
baseUrl = process.env.BASE_URL,
disableWeb: dw
disableWeb: dw,
loggerStream,
loggingConfig
} = options || {};
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
let disableWeb = dw;
@@ -37,15 +43,25 @@ const createRoot = (options?: RootOptions) => {
disableWeb = process.env.DISABLE_WEB === 'true';
}
const cEmitter = new WildcardEmitter();
// do nothing, just catch
cEmitter.on('error', (e) => null);
const sEmitter = new WildcardEmitter();
sEmitter.on('error', (e) => {
const f = e;
});
return createContainer().add({
version,
configDir: configDir,
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
port: process.env.PORT ?? port,
disableWeb,
clientEmitter: () => new WildcardEmitter(),
sourceEmitter: () => new WildcardEmitter(),
clientEmitter: () => cEmitter,
sourceEmitter: () => sEmitter,
notifierEmitter: () => new EventEmitter(),
loggerStream,
loggingConfig,
}).add((items) => {
const localUrl = generateBaseURL(baseUrl, items.port)
return {
+17 -9
View File
@@ -11,6 +11,8 @@ import {
WebhookPayload
} from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { URLData } from "../../core/Atomic.js";
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
const shortKey = truncateStringToLength(10);
@@ -20,6 +22,8 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
priorities: PrioritiesConfig;
protected endpoint: URLData;
urls: string[];
keys: string[];
@@ -42,18 +46,24 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
}
initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.host);
this.logger.verbose(`Config URL: '${this.config.host}' => Normalized: '${this.endpoint.normal}'`)
this.initialized = true; // always set as ready to go. Server issues may be transient.
try {
await request.get(this.config.host);
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.error(new Error('Failed to contact Apprise server', {cause: e}));
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}
if (this.keys.length > 0) {
let anyOk = false;
for (const key of this.keys) {
try {
const resp = await request.get(`${this.config.host}/json/urls/${key}`);
const resp = await request.get(joinedUrl(this.endpoint.url, `/json/urls/${key}`).toString());
if (resp.statusCode === 204) {
this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`);
} else {
@@ -64,9 +74,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
}
}
if (!anyOk) {
this.logger.error('No Apprise Configs were valid!');
this.initialized = false;
return;
this.logger.warn('No Apprise Configs were valid!');
}
}
this.initialized = true;
@@ -83,7 +91,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
if (this.keys.length > 0) {
for (const key of this.keys) {
try {
const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`)
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, `/notify/${key}`).toString())
.type('json')
.send(body));
anyOk = true;
@@ -99,7 +107,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
body.urls = this.urls.join(',')
}
try {
const resp = await this.callApi(request.post(`${this.config.host}/notify`)
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, '/notify').toString())
.type('json')
.send(body));
anyOk = true;
+23 -8
View File
@@ -4,6 +4,8 @@ import { gotify } from 'gotify';
import request from 'superagent';
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";
import { URLData } from "../../core/Atomic.js";
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
@@ -11,6 +13,8 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
priorities: PrioritiesConfig;
protected endpoint: URLData;
constructor(defaultName: string, config: GotifyConfig, logger: Logger) {
super('Gotify', defaultName, config, logger);
this.requiresAuth = true;
@@ -29,14 +33,25 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
}
initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.url);
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)
this.initialized = true; // always set as ready to go. Server issues may be transient.
try {
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}
try {
const url = this.config.url;
const resp = await request.get(`${url}/version`);
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
this.initialized = true;
this.logger.verbose(`Found Server version ${resp.body.version}`);
} catch (e) {
this.logger.error(`Failed to contact server | Error: ${e.message}`);
this.logger.warn(new Error('Server was reachable but could not determine version', { cause: e }));
}
}
@@ -48,19 +63,19 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
doNotify = async (payload: WebhookPayload) => {
try {
await gotify({
server: this.config.url,
server: this.endpoint.normal,
app: this.config.token,
message: payload.message,
title: payload.title,
priority: this.priorities[payload.priority]
});
this.logger.verbose(`Pushed notification.`);
} catch (e: any) {
} catch (e) {
if(e instanceof HTTPError && e.response.statusCode === 401) {
this.logger.warn(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
this.logger.warn(new Error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier \n Response Error => ${e.response.body}`, {cause: e}));
this.authed = false;
} else {
this.logger.warn(`Failed to push notification | Error => ${e.message}`);
this.logger.warn(new Error('Failed to push notification', {cause: e}));
}
}
}
+22 -7
View File
@@ -3,6 +3,8 @@ import { Config, publish } from 'ntfy';
import request from "superagent";
import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
import { URLData } from "../../core/Atomic.js";
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
@@ -10,6 +12,8 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
priorities: PrioritiesConfig;
protected endpoint: URLData;
constructor(defaultName: string, config: NtfyConfig, logger: Logger) {
super('Ntfy', defaultName, config, logger);
const {
@@ -27,24 +31,35 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
}
initialize = async () => {
// check url is correct
// check url is correct as a courtesy
this.endpoint = normalizeWebAddress(this.config.url);
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)
this.initialized = true; // always set as ready to go. Server issues may be transient.
try {
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
} catch (e) {
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
return;
}
try {
const url = this.config.url;
const resp = await request.get(`${url}/v1/health`);
if(resp.body !== undefined && typeof resp.body === 'object') {
const {health} = resp.body;
if(health === false) {
this.logger.error('Found Ntfy server but it responded that it was not ready.')
this.logger.warn('Found server but it responded that it was not ready.')
return;
}
} else {
this.logger.error(`Found Ntfy server but expected a response with 'health' in payload. Found => ${resp.body}`);
this.logger.warn(`Found server but expected a response with 'health' in payload. Found => ${resp.body}`);
return;
}
this.logger.info('Initialized. Found Ntfy server');
this.initialized = true;
this.logger.info('Found Ntfy server');
} catch (e) {
this.logger.error(`Failed to contact Ntfy server | Error: ${e.message}`);
this.logger.error(new Error('Failed to contact server', {cause: e}));
}
}
@@ -54,7 +69,7 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
message: payload.message,
topic: this.config.topic,
title: payload.title,
server: this.config.url,
server: this.endpoint.normal,
priority: this.priorities[payload.priority],
};
if (this.config.username !== undefined) {

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