Compare commits

...
62 Commits
Author SHA1 Message Date
FoxxMD b03cbe7a0a Merge branch 'develop' 2023-06-12 14:20:09 -04:00
FoxxMD 91bd702deb docs: Add multiple artist scrobbling limitation to FAQ 2023-06-12 12:23:49 -04:00
Matt Foxx ede22e10e9 Merge pull request #82 from Whhoesj/lastfm-fix 2023-06-12 12:10:05 -04:00
Wouter Habets c648ad4ae3 fix(lastfm): only scrobble primary artist to last.fm
When a track contains multiple artist, only the first artist should be send to Last.fm since it doesn't support multiple artists per track.
Previous behaviour was to combine all artists in a single string.

Issue: #81
2023-06-12 17:56:37 +02:00
FoxxMD a0fbd1129a chore: Add git-cliff template 2023-06-07 10:01:30 -04:00
FoxxMD b90ea03924 Merge branch 'develop' 2023-06-07 09:23:51 -04:00
FoxxMD 88efe2ad87 Bump versions for release 2023-06-07 09:23:40 -04:00
FoxxMD 39f933418e fix(ntfy): Include authorization object correctly in publish request data #80 2023-05-30 10:32:20 -04:00
FoxxMD cc8c7d84e2 fix(kodi): Fix hardcoded string #79
Probably leftover from debugging, oops
2023-05-25 12:18:29 -04:00
FoxxMD 6d9783c5f5 fix(tautulli): More response formatting safeguards #77
* Log warning if payload is empty
* Destructure request body with default empty object
2023-05-25 11:46:00 -04:00
FoxxMD c10d46b774 fix: Passing wrong argument to tautulli payload formatter #77 2023-05-24 11:47:29 -04:00
FoxxMD a5417277a8 feat: Use pre/post auth hooks for polling start to simplify logic
* Removes need to override poll() in child class and better defines when logic should be performed
* Fixes app crash when spotify credentials are present but invalid #76
2023-05-24 11:04:51 -04:00
FoxxMD 5a1c931513 fix: Re-initialize auth state after receiving auth callback
So we don't have to restart after authenticating in order to start polling

#76
2023-05-24 10:45:07 -04:00
FoxxMD 09f4503593 fix: Handle null/non-string values when truncating
#76
2023-05-19 12:25:22 -04:00
FoxxMD 045cae66f5 fix: Respect maloja response status and throw on error
Don't assume track scrobble correctly. Check body status prop and throw on error or log warnings if they are present

Maybe fixes #57
2023-04-18 10:51:25 -04:00
FoxxMD e67ef5fb0f fix: try to limit number of plays returned by deezer 2023-04-03 16:10:58 -04:00
FoxxMD 1677c0021f Update maloja track parsing in preparation for Maloja 3.2
Album structure changes
2023-04-03 15:59:29 -04:00
FoxxMD 3400e747a8 Fix listenbrainz example
Thanks @captainnapalm
2023-04-03 15:46:43 -04:00
FoxxMD c08ff4843c Bump flatpak build source 2023-03-27 10:02:49 -04:00
FoxxMD 5e905eae17 Merge branch 'develop' 2023-03-27 09:40:42 -04:00
FoxxMD 7ebe73ccb3 Update flatpak install instructions (official!) 2023-03-27 09:40:20 -04:00
FoxxMD 574911cfa0 Update flatpak instructions 2023-03-24 09:34:15 -04:00
FoxxMD 91a84dc4f4 Use files from repo rather than including separately to keep more things upstreams
https://github.com/flathub/flathub/pull/4002#pullrequestreview-1355412003
2023-03-23 16:09:18 -04:00
FoxxMD 9a578e54a8 Fix app id and other issues from review in flathub PR
https://github.com/flathub/flathub/pull/4002
2023-03-23 16:03:43 -04:00
FoxxMD 1a8d5eded3 Update metadata 2023-03-23 15:21:48 -04:00
FoxxMD 94765057db oops typo on icon name 2023-03-23 12:36:54 -04:00
FoxxMD 86c639ad2c Add actual MS icon
Commissioned from @mkalunivsky https://www.fiverr.com/mkalunivsky
2023-03-23 12:35:00 -04:00
FoxxMD f3045debb3 Scope dbus access to mpris 2023-03-23 12:16:28 -04:00
FoxxMD ffc826f04d Log message if running as foreground app with flatpak 2023-03-23 12:00:48 -04:00
FoxxMD e70a4bbf1c Update flatpak build with proper desktop integration
* Fix appstream (metainfo) missing properties and correct component types
* Add .desktop file that specifies terminal use
* Add placeholder icon
2023-03-23 11:56:53 -04:00
FoxxMD a9eca51bfa Implement user-defined scrobble thresholds #73
Add a scrobbleThresholds object with 'duration' and 'percent' values to all sources and sourceDefaults. If both are defined then first passing condition trigger scrobble.

* duration, in seconds, tests how long MS has been tracking the play (since discovery)
* percent, only used if source provides track duration, tests time MS has been tracking play (since discovery) is some % of track duration
2023-03-22 14:55:38 -04:00
FoxxMD de4a0aecd5 Add .flatpak-builder ignore in flatpak folder 2023-03-22 12:00:52 -04:00
FoxxMD f097fac8ea docs: Add systemd usage and add preview flatpak install instructions 2023-03-22 11:39:51 -04:00
FoxxMD a20b35f7cd More build improvements and provide config defaults to flatpak config dir
* Remove npm dev dependencies after app install/build
* Delete typescript files after build
* Provide a default config with file logging disabled and copy examples to config folder if empty
2023-03-22 10:32:29 -04:00
FoxxMD 2d51a0e159 Don't create rotating log file on init logger
We don't know if use wants files or there may not be permissions necessary...wait until we try to parse logging from config in appLogger
2023-03-22 10:31:04 -04:00
FoxxMD e524077242 Update flatpak build instructions to use npm 2023-03-21 16:54:23 -04:00
FoxxMD a5b5fe8d2a Use npm package-lock and update winston dependencies
* Update winston-duplex to remove hard winston dependency
* Use scoped @foxxmd/winston instead of git commit
  * Replace 'winston' with '@foxxmd/winston' in application
2023-03-21 16:53:42 -04:00
FoxxMD ddb3e0e02d Move into own folder and cleanup/instructions
Only blocking issue is needing to use yarn instead of npm
2023-03-21 11:09:25 -04:00
FoxxMD ec97ec428a Update permissions 2023-03-21 09:15:13 -04:00
FoxxMD 49b834af5d working config folder 2023-03-21 09:15:13 -04:00
FoxxMD 6329aaf6a9 Revert to git source for winston to fix typings problem 2023-03-21 09:15:12 -04:00
FoxxMD fd6e5ab084 Working example 2023-03-21 09:15:12 -04:00
FoxxMD 1dbb1b9ba3 Add network permission 2023-03-21 09:15:12 -04:00
FoxxMD fc65a63d43 Use yarn instead of npm 2023-03-21 09:15:12 -04:00
FoxxMD 4d1ddde57b NON working flatpak manifest
See comments in manifest

use npm published @foxxmd winston package
2023-03-21 09:15:12 -04:00
FoxxMD 7d14fb5341 Modify ioc and index so we can get port from config 2023-03-21 09:05:16 -04:00
FoxxMD e310b3aec8 Add PORT to aio config 2023-03-21 09:05:16 -04:00
FoxxMD 056457d028 Fix readJson logging usage 2023-03-21 09:05:16 -04:00
FoxxMD 73183e760e feat: Improve robustness of MPRIS data parsing
* Log warnings when any specific mpris info for a player isn't able to be parsed rather than failing entire poll attempt
* Fallback to undefined tracking progress if position cannot be parsed
2023-03-21 09:04:41 -04:00
FoxxMD 3fff729d32 Scrobble to Listenbrainz with MBID metadata if present
* Add play object data "meta" with a brainz property and common mbid tags
* Parse MBID data from jellyfin webhook
* Add MBID data from play object to 'additional_info' when scrobbling to listenbrainz

Closes #72
2023-03-20 22:37:54 -04:00
FoxxMD 19e199bd58 Pre-emptive adding flatpak folders to gitignore 2023-03-20 22:31:27 -04:00
FoxxMD 7b0f5801a1 Merge branch 'develop' 2023-03-20 11:06:38 -04:00
FoxxMD 4dbea91a5e docs: some words 2023-03-15 12:28:44 -04:00
FoxxMD 548356aaa4 feat: Implement Kodi as a Source 2023-03-15 12:26:35 -04:00
FoxxMD 8b3f4c636c Merge branch 'develop' 2023-03-15 09:13:20 -04:00
FoxxMD 1b0212f3bb feat: Improve polling retry strategy
* Increase default to 5 for all sources
* Use true exponential backoff for determining delay between polling
2023-03-15 09:06:26 -04:00
FoxxMD 14e9ec0b45 refactor: Remove superfluous 'Client' from scrobbler label
Already know what it is since it is prefixed by [Scrobblers] label
2023-03-15 08:30:18 -04:00
FoxxMD dab113ab76 docs: Missing jriver TOC entry 2023-03-13 14:01:40 -04:00
FoxxMD ad729769ec Implement JRiver Source #26 2023-03-13 13:59:24 -04:00
FoxxMD bb4ae549a3 Remove superfluous spotify label 2023-03-13 10:13:47 -04:00
FoxxMD 0786642a38 Increase default poll retries for Spotify
This seems to be a pain point so increasing ought to help.
2023-03-13 10:09:22 -04:00
FoxxMD 77d5f5884e Use error with cause for spotify api errors 2023-03-13 10:00:23 -04:00
63 changed files with 3192 additions and 493 deletions
+3
View File
@@ -12,3 +12,6 @@ config/*.json
config/*.bak
/docs
/logs
.flatpak-builder
flatpak/generated-sources.json
flatpak/.flatpak-builder
+2
View File
@@ -125,3 +125,5 @@ src/**/**.js
src/**/**.js.map
*.bak
.flatpak-builder
flatpak/generated-sources.json
+6 -1
View File
@@ -4,6 +4,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
<img src="/assets/icon.png" align="right"
alt="multi-scrobbler logo" width="180" height="180">
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
* Supports scrobbling from many **Sources**
@@ -17,6 +20,8 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Deezer](/docs/configuration.md#deezer)
* [MPRIS (Linux Desktop)](/docs/configuration.md#mpris)
* [Mopidy](/docs/configuration.md#mopidy)
* [JRiver](/docs/configuration.md#jriver)
* [Kodi](/docs/configuration.md#kodi)
* Supports scrobbling to many **Clients**
* [Maloja](/docs/configuration.md#maloja)
* [Last.fm](/docs/configuration.md#lastfm)
@@ -26,7 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Web server interface for stats, basic control, and detailed logs
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Docker images for x86/ARM
* Install using [Docker images for x86/ARM](/docs/installation.md#docker), [flatpak](/docs/installation.md#flatpak), or [locally with NodeJS](/docs/installation.md#nodejs)
**Why should I use this over a browser extension and/or mobile app scrobbler?**
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #231f20;
}
</style>
</defs>
<path class="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z"/>
<path class="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+67
View File
@@ -0,0 +1,67 @@
# configuration file for git-cliff (0.1.0)
[changelog]
# changelog header
header = """
# Changelog
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
- *({{commit.scope}})* {{ commit.message | upper_first }}
{%- if commit.breaking %}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
- *(No Category)* {{ commit.message | upper_first }}
{% if commit.breaking -%}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{% endif -%}
{% endif -%}
{% endfor -%}
{% endfor %}
"""
# remove the leading and trailing whitespaces from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# allow only conventional commits
# https://www.conventionalcommits.org
conventional_commits = true
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "MyJriver",
"data": {
"url": "0.0.0.0",
"username": "auser",
"password": "apassword"
}
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "MyKodi",
"data": {
"url": "http://localhost:8080/jsonrpc",
"username": "myUser",
"password": "myPassword"
}
}
]
+1 -1
View File
@@ -1,7 +1,7 @@
[
{
"name": "brainz",
"configureAs": "client"
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
+15 -1
View File
@@ -4,6 +4,9 @@
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
* [Configuration Issues](#configuration-issues)
* [Config could not be parsed](#config-could-not-be-parsed)
* [Scrobbling Issues](#scrobbling-issues)
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
# Connection Issues
@@ -19,7 +22,6 @@ multi-scrobbler will log information about any server that connects to it for th
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
```
It also logs if a server tries to connect to a URL that it does not recognize:
```
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
```
@@ -120,3 +122,15 @@ If you see something like this in your logs:
```
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
# Scrobbling Issues
## Last.fm does not scrobble tracks with multiple artists correctly
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
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
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)
+143 -2
View File
@@ -15,6 +15,8 @@
* [Youtube Music](#youtube-music)
* [MPRIS (Linux Desktop)](#mpris)
* [Mopidy](#mopidy)
* [JRiver](#jriver)
* [Kodi](#kodi)
* [Client Configurations](#client-configurations)
* [Maloja](#maloja)
* [Last.fm](#lastfm)
@@ -270,7 +272,7 @@ No support for ENV based for Last.fm as a client (only source)
See [`lastfm.json.example`](/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
# [Listenbrainz (Source)](https://listenbrainz.org)
## [Listenbrainz (Source)](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
@@ -404,7 +406,8 @@ Part => Default Value
* Port => `6680`
* Path => `/mopidy/ws/`
EX
<details>
<summary>URL Transform Examples</summary>
```json
{
@@ -430,6 +433,8 @@ MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/`
MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS`
</details>
#### URI Blacklist/Whitelist
@@ -460,6 +465,142 @@ EX:
If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist`
then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist`
## [JRiver](https://jriver.com/)
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
* Tools -> Options -> Media Network
* Check `Use Media Network to share this library...`
* If you have `Authentication` checked you will need to provide the **Username** and **Password** in the ENV/File configuration below.
#### URL
If you do not provide a URL then a default is used which assumes JRiver is installed on the same server as multi-scrobbler: `http://localhost:52199/MCWS/v1/`
* Make sure the port number matches what is found in `Advanced` section in the [Media Network](#jriver) options.
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `http://`
* Hostname => `localhost`
* Port => `52199`
* Path => `/MCWS/v1/`
<details>
<summary>URL Transform Examples</summary>
```json
{
"url": "jriver.mydomain.com"
}
```
MS transforms this to: `http://jriver.mydomain.com:52199/MCWS/v1/`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `http://192.168.0.101:3456/MCWS/v1/`
```json
{
"url": "mydomain.com:80/jriverReverse/MCWS/v1/"
}
```
MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
</details>
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|---------------------------------|------------------------------------------------|
| JRIVER_URL | Yes | http://localhost:52199/MCWS/v1/ | The URL of the JRiver server |
| JRIVER_USERNAME | No | | If authentication is enabled, the username set |
| JRIVER_PASSWORD | No | | If authenticated is enabled, the password set |
### File-Based
See [`jriver.json.example`](/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
## [Kodi](https://kodi.tv/)
In order for multi-scrobbler to communicate with Kodi you must have the [Web Interface](https://kodi.wiki/view/Web_interface) enabled. This can can be in the Kodi GUI:
* Settings -> Services -> Control
* Check `Allow remote control via HTTP`
* Ensure you have a **Username** and **Password** set, you will need to provide them in the ENV/File configuration below.
#### URL
If you do not provide a URL then a default is used which assumes Kodi is installed on the same server as multi-scrobbler: `http://localhost:8080/jsonrpc`
* Make sure the port number matches what is found in **Port** in the [Control](#kodi) section mentioned above.
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
Part => Default Value
* Protocol => `http://`
* Hostname => `localhost`
* Port => `8080`
* Path => `/jsonrpc`
<details>
<summary>URL Transform Examples</summary>
```json
{
"url": "kodi.mydomain.com"
}
```
MS transforms this to: `http://kodi.mydomain.com:8080/jsonrpc`
```json
{
"url": "192.168.0.101:3456"
}
```
MS transforms this to: `http://192.168.0.101:3456/jsonprc`
```json
{
"url": "mydomain.com:80/kodiReverse/jsonrpc"
}
```
MS transforms this to: `http://mydomain.com:80/kodiReverse/jsonrpc`
</details>
### ENV-Based
| Environmental Variable | Required | Default | Description |
|------------------------|----------|-------------------------------|----------------------------|
| KODI_URL | Yes | http://localhost:8080/jsonrpc | The URL of the Kodi server |
| KODI_USERNAME | No | | The username set |
| KODI_PASSWORD | No | | The password set |
### File-Based
See [`kodi.json.example`](/config/kodi.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FKodiSourceConfig/%23%2Fdefinitions%2FKodiData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
# Client Configurations
## [Maloja](https://github.com/krateng/maloja)
+39 -9
View File
@@ -1,10 +1,14 @@
# Installation
## Local
# Local
After installation see [service.md](/docs/service.md) to configure multi-scrobbler to run automatically in the background.
## Nodejs
Clone this repository somewhere and then install from the working directory
```bash
```shell
git clone https://github.com/FoxxMD/multi-scrobbler.git .
cd multi-scrobbler
nvm use # optional, to set correct Node version
@@ -13,23 +17,49 @@ npm build
npm start
```
### Local Usage Examples
### Usage Examples
* The web UI is served on port `9078`. This can be modified using the `PORT` environmental variable.
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
```bash
```shell
npm start
```
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
```bash
```shell
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
```
## [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
## Flatpak
You must have [Flatpak](https://flatpak.org/) installed on your system.
```shell
flatpak install flathub io.github.foxxmd.multiscrobbler
```
### Usage Examples
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
```shell
flatpak run io.github.multiscrobbler
```
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
```shell
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
```
# [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
Cross-platform images are built for x86 (Intel/AMD) and ARM (IE Raspberry Pi)
@@ -65,15 +95,15 @@ To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID
* `id -g` -- prints GID
### Docker Usage Examples
## Docker Usage Examples
#### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
### Using [env-based](/docs/configuration.md#env-based-configuration) configuration
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
```
#### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
### Using [file-based](/docs/configuration.md#file-based-configuration) configuration
```bash
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
+68
View File
@@ -0,0 +1,68 @@
If you have multi-scrobbler installed [locally](/docs/installation.md#local) you can enable it to run as a background service when you login.
Before running as a service you should run it at least once in the foreground to ensure it can start up correctly!
# [Systemd](https://systemd.io/)
Systemd is system and service manager that is used by [most popular linux distros](https://en.wikipedia.org/wiki/Systemd#Adoption) including [Ubuntu](https://wiki.ubuntu.com/SystemdForUpstartUsers), [Fedora](https://docs.fedoraproject.org/en-US/quick-docs/understanding-and-administering-systemd/), Pop!_OS, [Debian](https://wiki.debian.org/systemd), and [Arch](https://wiki.archlinux.org/title/systemd).
This setup will create a [user service](https://wiki.archlinux.org/title/systemd/User) that runs on login.
## Create A Unit File
Create a new service file for multi-scrobbler under your HOME config:
```console
mkdir -p ~/.config/systemd/user
touch ~/.config/systemd/user/multi-scrobbler.service
```
In a text editor add contents below to the file you created, `multi-scrobbler.service`:
```ini
[Unit]
Description=multi-scrobbler
After=network.target
[Service]
Type=simple
ExecStart=flatpak run io.github.multiscrobbler
Restart=no
[Install]
WantedBy=default.target
```
The above assumes you [installed multi-scrobbler using flatpak](/docs/installation.md#flatpak)
### Node.js Installs
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](/docs/installation.md#nodejs) you should modify the `[Service]`:
```ini
[Service]
Type=simple
WorkingDirectory=/path/to/multi-scrobbler/directory
ExecStart=node src/index.js
Restart=no
```
## Start the Service
Save the file then run:
```console
systemctl daemon-reload
systemctl --user enable multi-scrobbler.service
systemctl --user start multi-scrobbler.service
```
This will
* scan for new services and pickup our multi-scrobbler user service
* enable the service to run at login automatically
* start the service now
# Other Service Methods
Open a PR if you would like to document setting up multi-scrobbler for other service managers!
+41
View File
@@ -0,0 +1,41 @@
NOTE: This steps are for building the flatpak entirely locally, from source. If you want to install the application normally then [get it through flathub](/docs/installation.md#flatpak)
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
# 1. Install Requirements
## Flatpak and flatpak-builder
Install [Flatpak](https://flatpak.org/setup/)
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
## [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
# 2. Update Project source
Set the `branch` `tag` or `commit` to use for MS in the `git` source in [`io.github.foxxmd.multiscrobbler.yml`](/flatpak/io.github.foxxmd.multiscrobbler.yml)
# 3. Use `flatpak-node-generator` to generate sources
First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442)
Then, from MS project root:
```
flatpak-node-generator npm package-lock.json
```
Move `generated-sources.json` into [`/flatpak`](/flatpak)
# 4. Build flatpak
From MS project root:
```console
cd flatpak
flatpak-builder --repo=/home/yourUser/multi-scrobbler-repo /home/yourUser/multi-scrobbler-build io.github.foxxmd.multiscrobbler.yml --force-clean
```
Add `--install --user` to have the app installed immediately.
+5
View File
@@ -0,0 +1,5 @@
{
"logging": {
"file": false
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=multi-scrobbler
Comment=Scrobbles music from multiple sources to multiple clients
Icon=io.github.foxxmd.multiscrobbler
TryExec=multiscrobbler
Exec=multiscrobbler
Terminal=true
Categories=Utility;Music;ConsoleOnly;
X-Flatpak=io.github.foxxmd.multiscrobbler
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>io.github.foxxmd.multiscrobbler</id>
<provides>
<id>io.github.foxxmd.multiscrobbler.desktop</id>
</provides>
<launchable type="desktop-id">io.github.foxxmd.multiscrobbler.desktop</launchable>
<name>multi-scrobbler</name>
<summary>Scrobbles music from many sources to many clients</summary>
<url type="homepage">https://github.com/FoxxMD/multi-scrobbler</url>
<url type="help">https://github.com/FoxxMD/multi-scrobbler/blob/flatpak-clean/docs/configuration.md</url>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<description>
<p>
Track your music listening history from many different sources:
</p>
<ul>
<li>Spotify</li>
<li>Plex (Tautulli)</li>
<li>Any subsonic-based music player (Airsonic)</li>
<li>Jellyfin</li>
<li>Mopidy</li>
<li>Deezer</li>
<li>JRiver</li>
<li>Kodi</li>
<li>MPRIS (linux desktop)</li>
</ul>
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
<ul>
<li>Maloja</li>
<li>Last.fm</li>
<li>Listenbrainz</li>
</ul>
<p>multi-scrobbler requires some configuration before use. Please visit the website or help links below to get started.</p>
</description>
<screenshots>
<screenshot type="default">
<caption>Browser-based UI</caption>
<image type="source">https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/7b0f5801a1aa728986f440f4c67fe158cecd763d/assets/status-ui.jpg</image>
</screenshot>
</screenshots>
<releases>
<release version="0.4.6" date="2023-06-07"/>
<release version="0.4.4" date="2023-03-20"/>
</releases>
<categories>
<category>Utility</category>
<category>Music</category>
<category>ConsoleOnly</category>
</categories>
<content_rating type="oars-1.0">
<content_attribute id="language-profanity">mild</content_attribute>
</content_rating>
</component>
@@ -0,0 +1,77 @@
app-id: io.github.foxxmd.multiscrobbler
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
command: multiscrobbler
finish-args:
- --share=network
# used for access to MPRIS in order to scrobble
- --own-name=org.mpris.MediaPlayer2.*
sdk-extensions:
- org.freedesktop.Sdk.Extension.node18
modules:
- name: multiscrobbler
buildsystem: simple
build-options:
append-path: /usr/lib/sdk/node18/bin
env:
XDG_CACHE_HOME: /run/build/multiscrobbler/flatpak-node/cache
npm_config_cache: /run/build/multiscrobbler/flatpak-node/npm-cache
npm_config_nodedir: /usr/lib/sdk/node18
npm_config_offline: 'true'
build-commands:
# install npm dependencies
- npm install --offline
# build app
- npm run build
# makes node and npm available
- mkdir -p /app/bin /app/lib /app/lib/src
- cp -a /usr/lib/sdk/node18/bin/{node,npm} /app/bin
- cp -a /usr/lib/sdk/node18/lib/* /app/lib
- rm -r /app/lib/node_modules/npm/{docs,man}
# remove dev dependencies
- npm prune --production
# copy node_modules needed to run app
- cp -r node_modules/* /app/lib/node_modules
# delete typescript files (not needed for running app)
- find src/ -name "*.ts" -type f -delete
# copy app files to runtime dir
- cp -r src/* /app/lib/src
- cp -r config /app/lib/config
- cp flatpak/defaultConfig.json /app/lib/config/config.json
- install -Dm644 flatpak/${FLATPAK_ID}.desktop /app/share/applications/${FLATPAK_ID}.desktop
- install -Dm644 flatpak/icon.png /app/share/icons/hicolor/512x512/apps/${FLATPAK_ID}.png
- install -Dm644 flatpak/${FLATPAK_ID}.metainfo.xml /app/share/metainfo/${FLATPAK_ID}.metainfo.xml
- install -Dm755 ../run.sh /app/bin/multiscrobbler
subdir: main
sources:
- type: git
url: https://github.com/FoxxMD/multi-scrobbler
tag: 0.4.6
commit: 5e905eae1798482dbcef504500d26a028c2e15cb
dest: main
# Wrapper to launch the app
- type: script
dest-filename: run.sh
commands:
# Run config check and copy examples + default config (disable file logging)
- |
echo -e "Checking CONFIG_DIR for existing configs: ${XDG_CONFIG_HOME}"
if [ "$(ls -A ${XDG_CONFIG_HOME})" ]; then
echo "Directory is not empty! Will not add default config/examples"
else
echo "Directory is empty! Adding default config/examples"
cp -r /app/lib/config/. "${XDG_CONFIG_HOME}"/
#cp /app/lib/defaultConfig.json $XDG_CONFIG_HOME/config.json
fi
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true node src/index.js
- generated-sources.json
+454 -168
View File
@@ -10,6 +10,8 @@
"license": "MIT",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"@foxxmd/winston": "3.3.31",
"@kenyip/backoff-strategies": "^1.0.4",
"ajv": "^7.2.4",
"body-parser": "^1.19.0",
"compare-versions": "^4.1.2",
@@ -25,6 +27,7 @@
"gotify": "^1.1.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
"normalize-url": "^6.1.0",
@@ -38,11 +41,11 @@
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"triple-beam": "^1.3.0",
"winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
"winston-daily-rotate-file": "^4.5.0",
"winston-duplex": "^0.1.1",
"winston-duplex": "0.1.3",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"xml2js": "^0.4.23",
"youtube-music-ts-api": "^1.4.1"
},
"devDependencies": {
@@ -56,6 +59,7 @@
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^4.1.16",
"@types/triple-beam": "^1.3.2",
"@types/xml2js": "^0.4.11",
"ts-essentials": "^9.1.2",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.13.0",
@@ -108,6 +112,26 @@
"kuler": "^2.0.0"
}
},
"node_modules/@foxxmd/winston": {
"version": "3.3.31",
"resolved": "https://registry.npmjs.org/@foxxmd/winston/-/winston-3.3.31.tgz",
"integrity": "sha512-7Qz87xrYjYo9K8biVCYDFN7z2XTQEurlyVAcqGQBfko4CJCovTbBuNMXHQFiysF4zmB8w15thVR30wdpCXJHUA==",
"dependencies": {
"@dabh/diagnostics": "^2.0.2",
"async": "^3.1.0",
"is-stream": "^2.0.0",
"lodash.mergewith": "^4.6.2",
"logform": "^2.2.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.4.0"
},
"engines": {
"node": ">= 6.4.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -133,6 +157,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kenyip/backoff-strategies": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@kenyip/backoff-strategies/-/backoff-strategies-1.0.4.tgz",
"integrity": "sha512-vduQZw2ctS3kIuSnCSSRiE4J90Y8WShR9xVG+e1lvFWksU2aTxjdkArcQqJ+XLm22JS380OZmrIPY1U06TAsng=="
},
"node_modules/@nornagon/put": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz",
@@ -232,7 +261,6 @@
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
"integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
@@ -280,9 +308,9 @@
}
},
"node_modules/@types/express-session": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.6.tgz",
"integrity": "sha512-L6sB04HVA4HEZo1hDL65JXdZdBJtzZnCiw/P7MnO4w6746tJCNtXlHtzEASyI9ccn9zyOw6IbqQuhVa03VpO4w==",
"version": "1.17.7",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.7.tgz",
"integrity": "sha512-L25080PBYoRLu472HY/HNCxaXY8AaGgqGC8/p/8+BYMhG0RDOLQ1wpXOpAzr4Gi5TGozTKyJv5BVODM5UNyVMw==",
"dev": true,
"dependencies": {
"@types/express": "*"
@@ -329,9 +357,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "18.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz",
"integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ=="
"version": "18.15.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.5.tgz",
"integrity": "sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew=="
},
"node_modules/@types/passport": {
"version": "1.0.12",
@@ -363,9 +391,9 @@
}
},
"node_modules/@types/serve-static": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz",
"integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz",
"integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==",
"dev": true,
"dependencies": {
"@types/mime": "*",
@@ -373,9 +401,9 @@
}
},
"node_modules/@types/spotify-api": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/spotify-api/-/spotify-api-0.0.20.tgz",
"integrity": "sha512-HRBwiOthYAT0r/vUQlGlNTdXMnPg/CS3MzTuQKDy2PtalUDXhj0Ep9YZ4tXmL68K0j7FMcLFcYTzy7k7nVu5mg==",
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/spotify-api/-/spotify-api-0.0.21.tgz",
"integrity": "sha512-26dd63oO7sxKt48dnivb8c1hvHEtNmGWi/vesAFm55Z1NiI09ytYUyKPVY8tBMn1nNbglfGA3cGxXhlOYN6SAw==",
"dev": true
},
"node_modules/@types/spotify-web-api-node": {
@@ -402,6 +430,23 @@
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.2.tgz",
"integrity": "sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g=="
},
"node_modules/@types/ws": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
"integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.11.tgz",
"integrity": "sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -457,12 +502,12 @@
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"devOptional": true,
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"optional": true,
"engines": {
"node": ">=8"
"node": ">=0.10.0"
}
},
"node_modules/ansi-styles": {
@@ -479,6 +524,22 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-styles/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/ansi-styles/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/aproba": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
@@ -771,6 +832,50 @@
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -809,20 +914,17 @@
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
"color-name": "1.1.3"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/color-string": {
"version": "1.9.1",
@@ -833,19 +935,6 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
@@ -874,6 +963,11 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/compare-versions": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-4.1.4.tgz",
@@ -1047,6 +1141,17 @@
"node": ">=10"
}
},
"node_modules/delay": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
"integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1122,9 +1227,9 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/ejs": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz",
"integrity": "sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
"integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==",
"dependencies": {
"jake": "^10.8.5"
},
@@ -1139,7 +1244,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"devOptional": true
"dev": true
},
"node_modules/enabled": {
"version": "2.0.0",
@@ -1253,6 +1358,19 @@
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/es6-promisify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
"integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==",
"dependencies": {
"es6-promise": "^4.0.3"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -1408,6 +1526,14 @@
],
"optional": true
},
"node_modules/eyes": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
"integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==",
"engines": {
"node": "> 0.1.90"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1627,53 +1753,6 @@
"wide-align": "^1.1.0"
}
},
"node_modules/gauge/node_modules/ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gauge/node_modules/is-fullwidth-code-point": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
"optional": true,
"dependencies": {
"number-is-nan": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gauge/node_modules/string-width": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
"optional": true,
"dependencies": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
"strip-ansi": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gauge/node_modules/strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"optional": true,
"dependencies": {
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -1776,9 +1855,9 @@
}
},
"node_modules/graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"optional": true
},
"node_modules/har-schema": {
@@ -1953,12 +2032,15 @@
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"devOptional": true,
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
"optional": true,
"dependencies": {
"number-is-nan": "^1.0.0"
},
"engines": {
"node": ">=8"
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
@@ -2032,6 +2114,37 @@
"node": ">=10"
}
},
"node_modules/jayson": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/jayson/-/jayson-3.7.0.tgz",
"integrity": "sha512-tfy39KJMrrXJ+mFcMpxwBvFDetS8LAID93+rycFglIQM4kl3uNR3W4lBLE/FFhsoUCEox5Dt2adVpDm/XtebbQ==",
"dependencies": {
"@types/connect": "^3.4.33",
"@types/node": "^12.12.54",
"@types/ws": "^7.4.4",
"commander": "^2.20.3",
"delay": "^5.0.0",
"es6-promisify": "^5.0.0",
"eyes": "^0.1.8",
"isomorphic-ws": "^4.0.1",
"json-stringify-safe": "^5.0.1",
"JSONStream": "^1.3.5",
"lodash": "^4.17.20",
"uuid": "^8.3.2",
"ws": "^7.4.5"
},
"bin": {
"jayson": "bin/jayson.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jayson/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
},
"node_modules/jsbi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
@@ -2062,8 +2175,7 @@
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"optional": true
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"node_modules/json5": {
"version": "2.2.3",
@@ -2076,6 +2188,37 @@
"node": ">=6"
}
},
"node_modules/jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
"integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
"engines": [
"node >= 0.2.0"
]
},
"node_modules/jsonschema": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz",
"integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==",
"engines": {
"node": "*"
}
},
"node_modules/JSONStream": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz",
"integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==",
"dependencies": {
"jsonparse": "^1.2.0",
"through": ">=2.2.7 <3"
},
"bin": {
"JSONStream": "bin.js"
},
"engines": {
"node": "*"
}
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@@ -2099,6 +2242,17 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kodi-api": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/kodi-api/-/kodi-api-0.2.1.tgz",
"integrity": "sha512-FcPJyisjyYZCvtGRLaO/s+gwGgZMdDrowan5/axI43PDB5fxE8OyrLpiwMrSDJ1Fs0ImaSO8UEoeGP9zcKstrQ==",
"dependencies": {
"jayson": "^3.4.4",
"jsonschema": "^1.4.0",
"rpc-websocket-client": "^1.1.4",
"uuid": "^8.3.2"
}
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -2112,6 +2266,11 @@
"node": ">=8.10.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
@@ -2136,9 +2295,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/logform/node_modules/safe-stable-stringify": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.2.tgz",
"integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==",
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz",
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
"engines": {
"node": ">=10"
}
@@ -2261,9 +2420,9 @@
}
},
"node_modules/minipass": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.4.tgz",
"integrity": "sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==",
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz",
"integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==",
"optional": true,
"engines": {
"node": ">=8"
@@ -2580,9 +2739,9 @@
}
},
"node_modules/passport-oauth2": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.6.1.tgz",
"integrity": "sha512-ZbV43Hq9d/SBSYQ22GOiglFsjsD1YY/qdiptA+8ej+9C1dL1TVB+mBE5kDH/D4AJo50+2i8f4bx0vg4/yDDZCQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz",
"integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.9.x",
@@ -2744,9 +2903,9 @@
}
},
"node_modules/readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz",
"integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -2811,6 +2970,16 @@
"node": ">=0.6"
}
},
"node_modules/request/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"optional": true,
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -2854,6 +3023,28 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rpc-websocket-client": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rpc-websocket-client/-/rpc-websocket-client-1.1.4.tgz",
"integrity": "sha512-UDMVcsNDJ3WET+0EFdmseYX+60MOCA3BpgpZ5dbtiGsqHo+9uWfgNbPZ4iGDtNcX+EUhCt/Y7C0fTmUU3CkkWA==",
"dependencies": {
"isomorphic-ws": "^4.0.1",
"uuid": "^3.3.3",
"ws": "^7.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/rpc-websocket-client/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -3169,29 +3360,29 @@
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"devOptional": true,
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
"strip-ansi": "^3.0.0"
},
"engines": {
"node": ">=8"
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"devOptional": true,
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=8"
"node": ">=0.10.0"
}
},
"node_modules/strip-bom": {
@@ -3328,9 +3519,9 @@
"integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw=="
},
"node_modules/ts-essentials": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.3.0.tgz",
"integrity": "sha512-XeiCboEyBG8UqXZtXl59bWEi4ZgOqRsogFDI6WDGIF1LmzbYiAkIwjkXN6zZWWl4re/lsOqMlYfe8KA0XiiEPw==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.3.1.tgz",
"integrity": "sha512-9CChSvQMyVRo29Vb1A2jbs+LKo3d/bAf+ndSaX0T8cEiy/HChVaRN/HY5DqUryZ8hZ6uol9bEgCnGmnDbwBR9Q==",
"dev": true,
"peerDependencies": {
"typescript": ">=4.1.0"
@@ -3380,13 +3571,13 @@
}
},
"node_modules/tsconfig-paths": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
"integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
"integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==",
"dev": true,
"dependencies": {
"@types/json5": "^0.0.29",
"json5": "^1.0.1",
"json5": "^1.0.2",
"minimist": "^1.2.6",
"strip-bom": "^3.0.0"
}
@@ -3482,15 +3673,15 @@
}
},
"node_modules/typescript-json-schema/node_modules/@types/node": {
"version": "16.18.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.12.tgz",
"integrity": "sha512-vzLe5NaNMjIE3mcddFVGlAXN1LEWueUsMsOJWaT6wWMJGyljHAWHznqfnKUQWGzu7TLPrGvWdNAsvQYW+C0xtw==",
"version": "16.18.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.18.tgz",
"integrity": "sha512-fwGw1uvQAzabxL1pyoknPlJIF2t7+K90uTqynleKRx24n3lYcxWa3+KByLhgkF8GEAK2c7hC8Ki0RkNM5H15jQ==",
"dev": true
},
"node_modules/typescript-json-schema/node_modules/safe-stable-stringify": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.2.tgz",
"integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==",
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz",
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
"dev": true,
"engines": {
"node": ">=10"
@@ -3575,13 +3766,11 @@
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"optional": true,
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "bin/uuid"
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
@@ -3637,24 +3826,25 @@
}
},
"node_modules/winston": {
"version": "3.3.3",
"resolved": "git+ssh://git@github.com/FoxxMD/winston.git#fbab8de969ecee578981c77846156c7f43b5f01e",
"integrity": "sha512-OnunfctuocKBmG0uzkBkVYGSW13VYmzglQPwj4ZFOBEtV2e2ECqe65eCaYRYHSvKynGc8T5hiaEWAVR2hVj+Yg==",
"license": "MIT",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz",
"integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==",
"peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.1.0",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"lodash.mergewith": "^4.6.2",
"logform": "^2.2.0",
"logform": "^2.4.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.4.0"
"winston-transport": "^4.5.0"
},
"engines": {
"node": ">= 6.4.0"
"node": ">= 12.0.0"
}
},
"node_modules/winston-compat": {
@@ -3710,12 +3900,11 @@
}
},
"node_modules/winston-duplex": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/winston-duplex/-/winston-duplex-0.1.1.tgz",
"integrity": "sha512-p6Bd/wNYZnvfx9NQa6XKcEp3IidMuKrn5P+jxhxhW+fG+tqN/fk8lI8hjhLdrLi3LCBfrmh4au910PQ1D1WOOw==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/winston-duplex/-/winston-duplex-0.1.3.tgz",
"integrity": "sha512-zBMeRsuiVobqhpRQ1Jetk3i7cVR2Vm/VvWEq0pcnMb4abueW/WycomtKO1j94nLzi2+lgjZVrODSDgHWmRnPGA==",
"dependencies": {
"triple-beam": "^1.3.0",
"winston": "^3",
"winston-transport": "^4.4.0"
},
"engines": {
@@ -3759,6 +3948,15 @@
"node": ">= 6.4.0"
}
},
"node_modules/winston/node_modules/safe-stable-stringify": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz",
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -3776,15 +3974,59 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"engines": {
"node": ">=8.3.0"
},
@@ -3862,6 +4104,50 @@
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
@@ -3872,9 +4158,9 @@
}
},
"node_modules/youtube-music-ts-api": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/youtube-music-ts-api/-/youtube-music-ts-api-1.4.1.tgz",
"integrity": "sha512-6BB537n6czQiGE0LuBetLB40ZnM38TwHC880cXcTSxRYUgOFOv0afzK9iikUqdhxzK15Ql/Iy6/OX8w4uHAPlQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/youtube-music-ts-api/-/youtube-music-ts-api-1.5.0.tgz",
"integrity": "sha512-Ae2SLh8J2SuYNkEUIBaQeued2o+kh/yGVNB+sIeMpPJROUP3vGAplhfGp56ZJWTTzrmglVAckNGOLQkETswl6w==",
"dependencies": {
"sha1": "^1.1.1"
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.4.0",
"version": "0.4.6",
"description": "scrobble plays from multiple sources to multiple clients",
"main": "src/index.js",
"scripts": {
@@ -36,6 +36,8 @@
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"@foxxmd/winston": "3.3.31",
"@kenyip/backoff-strategies": "^1.0.4",
"ajv": "^7.2.4",
"body-parser": "^1.19.0",
"compare-versions": "^4.1.2",
@@ -51,6 +53,7 @@
"gotify": "^1.1.0",
"iti": "^0.6.0",
"json5": "^2.2.3",
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
"normalize-url": "^6.1.0",
@@ -64,11 +67,11 @@
"spotify-web-api-node": "^5.0.2",
"superagent": "^8.0.9",
"triple-beam": "^1.3.0",
"winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
"winston-daily-rotate-file": "^4.5.0",
"winston-duplex": "^0.1.1",
"winston-duplex": "0.1.3",
"winston-null": "^2.0.0",
"winston-transport": "^4.4.0",
"xml2js": "^0.4.23",
"youtube-music-ts-api": "^1.4.1"
},
"devDependencies": {
@@ -82,6 +85,7 @@
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^4.1.16",
"@types/triple-beam": "^1.3.2",
"@types/xml2js": "^0.4.11",
"ts-essentials": "^9.1.2",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.13.0",
+2 -2
View File
@@ -1,7 +1,7 @@
import {capitalize, mergeArr} from "../utils.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import winston from 'winston';
import winston from '@foxxmd/winston';
export default abstract class AbstractApiClient {
name: string;
+167
View File
@@ -0,0 +1,167 @@
import AbstractApiClient from "./AbstractApiClient.js";
import {JRiverData} from "../common/infrastructure/config/source/jriver.js";
import request, {Request, Response} from 'superagent';
import xml2js from 'xml2js';
import {ErrorWithCause} from "pony-cause";
const parser = new xml2js.Parser({'async': true});
export const PLAYER_STATE: Record<string, PLAYER_STATE> = {
STOPPED: '0',
PAUSED: '1',
PLAYING: '2'
}
export type PLAYER_STATE = '0' | '1' | '2';
interface JRiverResponseItem {
_: string
$: {
Name: string
}
}
interface JRiverResponse {
Response: {
'$': {
Status: string
},
Item: JRiverResponseItem[]
}
}
export interface JRiverTransformedResponse<T> {
status: string
data?: T
}
export interface Alive {
RuntimeGUID: string
LibraryVersion: string
ProgramName: string
ProgramVersion: string
FriendlyName: string
AccessKey: string
ProductVersion: string
Platform: string
}
// state 0 = nothing?
// 2 = playing
// 1 = paused
export interface Authenticate {
Token: string
ReadOnly: number
PreLicensed: boolean
}
export interface Info {
ZoneID: string
ZoneName: string
State: PLAYER_STATE
PositionMS: number
DurationMS: number
Artist: string
Album: string
Name: string
Status: string
FileKey: string
}
export interface Zones {
NumberZones: number
CurrentZoneID: string
CurrentZoneIndex: string
}
const jriverResponseTransform = <T>(val: JRiverResponse): JRiverTransformedResponse<T> => {
const status = val.Response.$.Status;
const items = val.Response.Item === undefined ? undefined : val.Response.Item.map(x => {
return [x.$.Name, x._];
});
return {
status,
data: items.reduce((acc, curr) => {
acc[curr[0]] = curr[1];
return acc;
}, {}) as T
};
}
export class JRiverApiClient extends AbstractApiClient {
declare config: JRiverData
url: string;
token?: string;
constructor(name: any, config: JRiverData, options = {}) {
super('JRiver', name, config, options);
const {
url = 'http://localhost:52199/MCWS/v1/'
} = config;
this.url = url;
}
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
} = this.config;
if (this.token !== undefined) {
req.query({token: this.token});
}
try {
const resp = await req as Response;
if (resp.text !== '') {
const rawBody = await parser.parseStringPromise(resp.text);
resp.body = <T>jriverResponseTransform(rawBody);
}
return resp;
} catch (e) {
throw e;
}
}
testConnection = async () => {
try {
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
const {body: { data } = {}} = resp;
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
return true;
} catch (e) {
this.logger.error(new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e}));
return false;
}
}
testAuth = async () => {
try {
let req = request.get(`${this.url}Authenticate`);
if (this.config.username !== undefined) {
req.auth(this.config.username, this.config.password);
}
const resp = await this.callApi<Authenticate>(req);
this.token = resp.body.data.Token;
return true;
} catch (e) {
let msg = 'Authentication failed.';
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 ErrorWithCause(msg, {cause: e}));
return false;
}
}
getInfo = async (zoneId: string = '-1') => {
return await this.callApi<Info>(request.get(`${this.url}Playback/Info`).query({Zone: zoneId}));
}
getZones = async () => {
return await this.callApi<Zones>(request.get(`${this.url}Playback/Zones`));
}
}
+187
View File
@@ -0,0 +1,187 @@
import AbstractApiClient from "./AbstractApiClient.js";
import {ErrorWithCause} from "pony-cause";
import {KodiData} from "../common/infrastructure/config/source/kodi.js";
import { KodiClient } from 'kodi-api'
import normalizeUrl from "normalize-url";
import {URL} from "url";
import {RecentlyPlayedOptions} from "../sources/AbstractSource.js";
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import dayjs from "dayjs";
interface KodiDuration {
hours: number
milliseconds: number
minutes: number
seconds: number
}
interface ActivePlayer {
playerid: number
playertype: string
type: string
}
interface PlayerInfo {
position: -1 | 0
time: KodiDuration
totaltime: KodiDuration
type: string
}
interface PlayerItem {
album: string
albumartist: string[]
artist: string[]
duration: number
id: number
label: string
title: string
type: string
}
export class KodiApiClient extends AbstractApiClient {
declare config: KodiData
url: URL;
token?: string;
client: KodiClient;
constructor(name: any, config: KodiData, options = {}) {
super('Kodi', name, config, options);
const {
url = 'http://localhost:8080/jsonrpc'
} = config;
this.url = KodiApiClient.parseConnectionUrl(url);
const auth = new Buffer(`${config.username}:${config.password}`).toString('base64');
this.client = new KodiClient({
clientType: this.url.protocol.replace(':', '') as ('http' | 'https'),
//parameterMode: 'object',
clientOptions: {
host: this.url.hostname,
port: this.url.port,
path: this.url.pathname,
headers: {
Authorization: `Basic ${auth}`
},
}
});
}
static parseConnectionUrl(val: string) {
const normal = normalizeUrl(val, {removeTrailingSlash: true, normalizeProtocol: true})
const url = new URL(normal);
if (url.port === null || url.port === '') {
url.port = '8080';
}
if (url.pathname === '/') {
url.pathname = '/jsonrpc';
} else if (url.pathname === '/jsonrpc/') {
url.pathname = '/jsonrpc';
}
return url;
}
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = true} = options;
const {
album: albumVal,
albumartist,
artist: artistVal,
duration,
id,
title,
time,
playerid,
} = obj;
let artists = artistVal === null || artistVal === undefined ? [] : artistVal;
let album = albumVal === null || albumVal === '' ? undefined : albumVal;
const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined;
return {
data: {
track: title,
album: album,
artists,
duration,
playDate: dayjs()
},
meta: {
source: 'kodi',
trackId: id.toString(),
newFromSource,
trackProgressPosition,
deviceId: playerid !== undefined ? `Player${playerid}` : undefined,
}
}
}
testConnection = async () => {
return true;
}
testAuth = async () => {
try {
await this.client.connect();
// https://kodi.wiki/view/JSON-RPC_API/v12#JSONRPC.Version
const jsonInfo = await this.client.JSONRPC.Version();
// https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties
const applicationInfo = await this.client.Application.GetProperties(['version']);
this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`);
return true;
} catch (e) {
let msg = 'Authentication failed.';
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 ErrorWithCause(msg, {cause: e}));
return false;
}
}
getPlayerInfo = async (id: number): Promise<PlayerInfo> => {
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetProperties
// @ts-ignore
const playerInfo = await this.client.Player.GetProperties(0, ["position","type","time","totaltime"])
return playerInfo;
}
getPlayerItem = async (id: number): Promise<{item: PlayerItem}> => {
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetItem
// @ts-ignore
const itemInfo = await this.client.Player.GetItem(0, ["title","artist","album","albumartist","starttime","endtime","duration","streamdetails","uniqueid"]);
return itemInfo as {item: PlayerItem};
}
getActivePlayers = async (): Promise<ActivePlayer[]> => {
// https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetActivePlayers
const players = await this.client.Player.GetActivePlayers();
return players as ActivePlayer[];
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
const active = await this.getActivePlayers();
if(active.length === 0) {
return [];
}
// get first active playing audio
const audioActive = active.find(x => x.type === 'audio');
if(audioActive === undefined) {
return [];
}
const playerInfo = await this.getPlayerInfo(audioActive.playerid);
const itemInfo = await this.getPlayerItem(audioActive.playerid);
const play = KodiApiClient.formatPlayObj({...itemInfo.item, ...playerInfo, ...audioActive});
return [play];
}
}
+12 -1
View File
@@ -191,13 +191,24 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
static playToListenPayload = (play: PlayObject): ListenPayload => {
const {
data: {
meta: {
brainz = {}
} = {}
}
} = play;
return {
listened_at: (play.data.playDate ?? dayjs()).unix(),
track_metadata: {
artist_name: play.data.artists[0],
track_name: play.data.track,
additional_info: {
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined
duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined,
track_mbid: brainz.track,
artist_mbids: brainz.artist !== undefined ? [brainz.artist] : undefined,
release_mbid: brainz.album,
release_group_mbid: brainz.releaseGroup
}
}
}
+82
View File
@@ -0,0 +1,82 @@
export interface MalojaV2ScrobbleData {
artists: string[]
title: string
album: string
/**
* Length of the track
* */
duration: number
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
}
export interface MalojaAlbumData {
name?: string
albumtitle?: string
artists: string[]
}
export interface MalojaV3ScrobbleData {
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
track: {
artists: string[]
title: string
album?: MalojaAlbumData | null
/**
* length of the track
* */
length: number
}
/**
* how long the track was listened to before it was scrobbled
* */
duration: number
}
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
key: string
title: string
album: string
time: number
length: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
artists: string[]
}
interface MalojaScrobbleWarning {
type: string
value: string[] | string
desc: string
}
export interface MalojaResponseV3CommonData {
status: 'failure' | 'error' | 'success' | 'ok'
error?: {
type: string
value?: string | object
desc: string
}
}
export interface MalojaScrobbleV3ResponseData extends MalojaResponseV3CommonData {
track?: {
title: string
artists: string[]
album?: MalojaAlbumData | null
}
desc: string
warnings?: MalojaScrobbleWarning[]
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
NOT_INITIALIZED,
PlayObject, ScrobbledPlayObject, TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import winston, {Logger} from "winston";
import winston, {Logger} from '@foxxmd/winston';
import {CommonClientConfig} from "../common/infrastructure/config/client/index.js";
import {ClientConfig} from "../common/infrastructure/config/client/clients.js";
import {Notifiers} from "../notifier/Notifiers.js";
@@ -52,7 +52,7 @@ export default abstract class AbstractScrobbleClient {
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, logger: Logger) {
this.type = type;
this.name = name;
const identifier = `Client ${capitalize(this.type)} - ${name}`;
const identifier = `${capitalize(this.type)} - ${name}`;
this.logger = logger.child({labels: [identifier]}, mergeArr);
this.notifier = notifier;
+11 -2
View File
@@ -18,7 +18,7 @@ import {
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
export default class LastfmScrobbler extends AbstractScrobbleClient {
@@ -138,8 +138,17 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
const sType = newFromSource ? 'New' : 'Backlog';
// LFM does not support multiple artists in scrobble payload
// https://www.last.fm/api/show/track.scrobble
let artist: string;
if (artists.length === 0) {
artist = "";
} else {
artist = artists[0];
}
const rawPayload = {
artist: artists.join(', '),
artist: artist,
duration,
track,
album,
+1 -1
View File
@@ -16,7 +16,7 @@ import {
TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js";
+104 -47
View File
@@ -14,18 +14,18 @@ import {
import {
FormatPlayObjectOptions,
INITIALIZING,
MalojaScrobbleData,
MalojaScrobbleRequestData,
MalojaScrobbleV2RequestData,
MalojaScrobbleV3RequestData,
MalojaV2ScrobbleData,
MalojaV3ScrobbleData,
PlayObject,
TrackStringOptions
} from "../common/infrastructure/Atomic.js";
import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js";
import {Notifiers} from "../notifier/Notifiers.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {
MalojaScrobbleData,
MalojaScrobbleRequestData,
MalojaScrobbleV2RequestData,
MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaV2ScrobbleData, MalojaV3ScrobbleData
} from "../apis/maloja/interfaces";
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
@@ -65,10 +65,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
track: {
artists: mArtists,
title: mTitle,
album: {
name: mAlbum,
artists: albumArtists
} = {},
album: mAlbum,
// length of the track
length: mLength,
} = {},
@@ -79,7 +76,14 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
time = mTime;
title = mTitle;
duration = mLength;
album = mAlbum;
if(mAlbum !== null) {
const {
albumtitle,
name: mAlbumName,
artists: albumArtists
} = mAlbum || {};
album = albumtitle ?? mAlbumName;
}
} else {
// scrobble data structure for v2 and below
const {
@@ -384,6 +388,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
length: duration,
};
let responseBody: MalojaScrobbleV3ResponseData;
try {
// 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
@@ -398,53 +404,77 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
.type('json')
.send(scrobbleData));
let scrobbleResponse = {};
let scrobbleResponse: any | undefined = undefined,
scrobbledPlay: PlayObject;
if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
responseBody = response.body;
const {
track,
status,
warnings = [],
} = responseBody;
if(status === 'success') {
if(track !== undefined) {
scrobbleResponse = {
time: playDate.unix(),
track: {
...track,
length: duration
},
}
if (album !== undefined) {
const {
album: malojaAlbum = {},
} = track;
scrobbleResponse.track.album = {
...malojaAlbum,
name: album
}
}
}
if(warnings.length > 0) {
for(const w of warnings) {
this.logger.warn(`Maloja Warning: ${w.desc} => ${JSON.stringify(w.value)}`)
}
}
} else {
throw new Error(buildErrorString(response));
}
} else {
const {
body: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
track,
} = {}
track: {
time: mTime = playDate.unix(),
duration: mDuration = duration,
album: mAlbum = album,
...rest
} = {}
} = {}
} = response;
scrobbleResponse = {
time: playDate.unix(),
track: {
...track,
length: duration
},
}
if(album !== undefined) {
const {
album: malojaAlbum = {},
} = track;
// @ts-expect-error TS(2339): Property 'track' does not exist on type '{}'.
scrobbleResponse.track.album = {
...malojaAlbum,
name: album
}
}
} else {
const {body: {
// @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
track: {
time: mTime = playDate.unix(),
duration: mDuration = duration,
album: mAlbum = album,
...rest
}
} = {}} = response;
scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
}
this.addScrobbledTrack(playObj, this.formatPlayObj(scrobbleResponse));
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
let warning = '';
if(scrobbleResponse === undefined) {
warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`;
scrobbledPlay = playObj;
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
scrobbledPlay = this.formatPlayObj(scrobbleResponse)
}
this.addScrobbledTrack(playObj, scrobbledPlay);
const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
if(warning !== '') {
this.logger.warn(`${scrobbleInfo} | ${warning}`);
this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`);
} else {
this.logger.info(scrobbleInfo);
}
} catch (e) {
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
if(responseBody !== undefined) {
this.logger.error('Raw Response:', responseBody);
}
throw e;
} finally {
this.logger.debug('Raw Payload:', scrobbleData);
@@ -453,3 +483,30 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
return true;
}
}
const buildErrorString = (body: MalojaScrobbleV3ResponseData) => {
let valString: string | undefined = undefined;
const {
status,
error: {
type,
value,
desc
} = {}
} = body;
if(value !== undefined && value !== null) {
if(typeof value === 'string') {
valString = value;
} else if(Array.isArray(value)) {
valString = value.map(x => {
if(typeof x === 'string') {
return x;
}
return JSON.stringify(x);
}).join(', ');
} else {
valString = JSON.stringify(value);
}
}
return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
}
+1 -1
View File
@@ -19,7 +19,7 @@ import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.
import {Notifiers} from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import {EventEmitter} from "events";
import winston from "winston";
import winston from '@foxxmd/winston';
import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js";
import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js";
import {ErrorWithCause} from "pony-cause";
+33 -57
View File
@@ -1,10 +1,10 @@
import {Dayjs} from "dayjs";
import {FixedSizeList} from 'fixed-size-list';
import {MESSAGE} from 'triple-beam';
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz'];
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi';
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi'];
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
@@ -48,6 +48,16 @@ export interface PlayData {
* The date the track was played at
* */
playDate?: Dayjs
meta?: {
brainz?: {
artist?: string
albumArtist?: string
album?: string
track?: string
releaseGroup?: string
}
}
}
export interface PlayMeta {
@@ -124,60 +134,6 @@ export interface ScrobbledPlayObject {
scrobble: PlayObject
}
export interface MalojaV2ScrobbleData {
artists: string[]
title: string
album: string
/**
* Length of the track
* */
duration: number
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
}
export interface MalojaV3ScrobbleData {
/**
* unix timestamp (seconds) scrobble was made at
* */
time: number
track: {
artists: string[]
title: string
album?: {
name: string
artists: string[]
}
/**
* length of the track
* */
length: number
}
/**
* how long the track was listened to before it was scrobbled
* */
duration: number
}
export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
export interface MalojaScrobbleRequestData {
key: string
title: string
album: string
time: number
length: number
}
export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
artist: string
}
export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
artists: string[]
}
export interface RemoteIdentityParts {
host: string,
@@ -235,3 +191,23 @@ export interface LogInfo {
labels?: string[]
transport?: string[]
}
// https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist#comment116238286_49725198
export type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];
export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 30;
export interface ScrobbleThresholdResult {
passes: boolean
duration: {
passes?: boolean
value?: number
threshold?: number
}
percent: {
passes?: boolean
value?: number
threshold?: number
}
}
+16 -2
View File
@@ -1,4 +1,4 @@
import {SourceRetryOptions} from "./source/index.js";
import {ScrobbleThresholds, SourceRetryOptions} from "./source/index.js";
import {RequestRetryOptions} from "./common.js";
import {SourceAIOConfig} from "./source/sources.js";
import {ClientAIOConfig} from "./client/clients.js";
@@ -6,13 +6,27 @@ import {WebhookConfig} from "./health/webhooks.js";
import {LogOptions} from "../Atomic.js";
export interface AIOConfig {
sourceDefaults?: SourceRetryOptions
sourceDefaults?: SourceRetryOptions &
{
/**
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
* */
scrobbleThresholds?: ScrobbleThresholds
}
clientDefaults?: RequestRetryOptions
sources?: SourceAIOConfig[]
clients?: ClientAIOConfig[]
webhooks?: WebhookConfig[]
/**
* Set the port the multi-scrobbler UI will be served from
*
* @default 9078
* @examples [9078]
* */
port?: number
logging?: LogOptions
}
@@ -4,13 +4,33 @@ export interface SourceRetryOptions extends RequestRetryOptions {
/**
* default # of automatic polling restarts on error
*
* @default 0
* @examples [1]
* @default 5
* @examples [5]
* */
maxPollRetries?: number
}
export interface ScrobbleThresholds {
/**
* The number of seconds before a track should be considered scrobbled.
*
* @default 30
* @examples [30]
* */
duration?: number
/**
* The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.
*
* NOTE: This should be used with care when the Source is a "polling" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not "seen" the track for long enough from first discovery, even if you have been playing the track for longer.
* */
percent?: number
}
export interface CommonSourceData extends CommonData, SourceRetryOptions {
/**
* Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled.
* */
scrobbleThresholds?: ScrobbleThresholds
}
export interface CommonSourceConfig extends CommonConfig {
@@ -0,0 +1,41 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface JRiverData extends CommonSourceData, PollingOptions {
/**
* URL of the JRiver HTTP server to connect to
*
* multi-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`
*
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
*
* Parts => [default value]
*
* * Protocol => `http://`
* * Hostname => `localhost`
* * Port => `52199`
* * Path => `/MCWS/v1/`
*
*
* @examples ["http://localhost:52199/MCWS/v1/"]
* @default "http://localhost:52199/MCWS/v1/"
* */
url: string
/**
* If you have enabled authentication, the username you set
* */
username?: string
/**
* If you have enabled authentication, the password you set
* */
password?: string
}
export interface JRiverSourceConfig extends CommonSourceConfig {
data: JRiverData
}
export interface JRiverSourceAIOConfig extends JRiverSourceConfig {
type: 'jriver'
}
@@ -0,0 +1,42 @@
import {CommonSourceConfig, CommonSourceData} from "./index.js";
import {PollingOptions} from "../common.js";
export interface KodiData extends CommonSourceData, PollingOptions {
/**
* URL of the Kodi HTTP server to connect to
*
* multi-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`
*
* The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.
*
* Parts => [default value]
*
* * Protocol => `http://`
* * Hostname => `localhost`
* * Port => `8080`
* * Path => `/jsonrpc`
*
*
* @examples ["http://localhost:8080/jsonrpc"]
* @default "http://localhost:8080/jsonrpc"
* */
url: string
/**
* The username set for Remote Control via Web Sever
* */
username: string
/**
* The password set for Remote Control via Web Sever
* */
password: string
}
export interface KodiSourceConfig extends CommonSourceConfig {
data: KodiData
}
export interface KodiSourceAIOConfig extends KodiSourceConfig {
type: 'kodi'
}
@@ -9,7 +9,9 @@ import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js";
import {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.js";
import {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.js";
import {ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig} from "./listenbrainz.js";
import {JRiverSourceAIOConfig, JRiverSourceConfig} from "./jriver.js";
import {KodiSourceAIOConfig, KodiSourceConfig} from "./kodi.js";
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig;
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig;
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig;
+3 -3
View File
@@ -1,13 +1,13 @@
import path from "path";
import {projectDir} from "./index.js";
import winston, {format, Logger} from "winston";
import winston, {format, Logger} from '@foxxmd/winston';
import {DuplexTransport} from "winston-duplex";
import {asLogOptions, LogConfig, LogInfo, LogLevel, LogOptions} from "./infrastructure/Atomic.js";
import process from "process";
import {fileOrDirectoryIsWriteable, truncateStringToLength} from "../utils.js";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {NullTransport} from 'winston-null';
import 'winston-daily-rotate-file';
import DailyRotateFile from 'winston-daily-rotate-file';
import dayjs from "dayjs";
import stringify from 'safe-stable-stringify';
import {SPLAT, LEVEL, MESSAGE} from 'triple-beam';
@@ -66,7 +66,7 @@ export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => {
];
if (file !== false) {
const rotateTransport = new winston.transports.DailyRotateFile({
const rotateTransport = new DailyRotateFile({
dirname: logPath,
createSymlink: true,
symlinkName: 'scrobble-current.log',
+299 -22
View File
@@ -26,10 +26,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -59,6 +59,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -103,13 +107,119 @@
],
"type": "object"
},
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "If you have enabled authentication, the password you set",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:52199/MCWS/v1/",
"description": "URL of the JRiver HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `52199`\n* Path => `/MCWS/v1/`",
"examples": [
"http://localhost:52199/MCWS/v1/"
],
"type": "string"
},
"username": {
"description": "If you have enabled authentication, the username you set",
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"JRiverSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/JRiverData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
},
"type": {
"enum": [
"jriver"
],
"type": "string"
}
},
"required": [
"data",
"type"
],
"type": "object"
},
"JellyData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -143,6 +253,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -220,6 +334,114 @@
],
"type": "object"
},
"KodiData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "The password set for Remote Control via Web Sever",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:8080/jsonrpc",
"description": "URL of the Kodi HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `8080`\n* Path => `/jsonrpc`",
"examples": [
"http://localhost:8080/jsonrpc"
],
"type": "string"
},
"username": {
"description": "The username set for Remote Control via Web Sever",
"type": "string"
}
},
"required": [
"password",
"url",
"username"
],
"type": "object"
},
"KodiSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/KodiData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
},
"type": {
"enum": [
"kodi"
],
"type": "string"
}
},
"required": [
"data",
"type"
],
"type": "object"
},
"LastFmSouceAIOConfig": {
"properties": {
"clients": {
@@ -276,10 +498,10 @@
"type": "string"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -310,6 +532,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
@@ -377,10 +603,10 @@
"ListenBrainzSourceData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -403,6 +629,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
@@ -452,10 +682,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -478,6 +708,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"whitelist": {
"anyOf": [
{
@@ -571,10 +805,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -597,6 +831,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"uriBlacklist": {
"description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.",
"items": {
@@ -715,10 +953,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -741,6 +979,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -786,6 +1028,23 @@
"Record<string,any>": {
"type": "object"
},
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"examples": [
30
],
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"type": "number"
}
},
"type": "object"
},
"SourceAIOConfig": {
"anyOf": [
{
@@ -820,16 +1079,22 @@
},
{
"$ref": "#/definitions/ListenBrainzSourceAIOConfig"
},
{
"$ref": "#/definitions/JRiverSourceAIOConfig"
},
{
"$ref": "#/definitions/KodiSourceAIOConfig"
}
]
},
"SourceRetryOptions": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -920,10 +1185,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -953,6 +1218,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -981,10 +1250,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1014,6 +1283,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"description": "URL of the subsonic media server to query",
"examples": [
@@ -1133,10 +1406,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1158,6 +1431,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
+321 -23
View File
@@ -90,10 +90,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -123,6 +123,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -210,13 +214,119 @@
],
"type": "object"
},
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "If you have enabled authentication, the password you set",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:52199/MCWS/v1/",
"description": "URL of the JRiver HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `52199`\n* Path => `/MCWS/v1/`",
"examples": [
"http://localhost:52199/MCWS/v1/"
],
"type": "string"
},
"username": {
"description": "If you have enabled authentication, the username you set",
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"JRiverSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/JRiverData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
},
"type": {
"enum": [
"jriver"
],
"type": "string"
}
},
"required": [
"data",
"type"
],
"type": "object"
},
"JellyData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -250,6 +360,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -327,6 +441,114 @@
],
"type": "object"
},
"KodiData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "The password set for Remote Control via Web Sever",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:8080/jsonrpc",
"description": "URL of the Kodi HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `8080`\n* Path => `/jsonrpc`",
"examples": [
"http://localhost:8080/jsonrpc"
],
"type": "string"
},
"username": {
"description": "The username set for Remote Control via Web Sever",
"type": "string"
}
},
"required": [
"password",
"url",
"username"
],
"type": "object"
},
"KodiSourceAIOConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/KodiData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
},
"type": {
"enum": [
"kodi"
],
"type": "string"
}
},
"required": [
"data",
"type"
],
"type": "object"
},
"LastFmSouceAIOConfig": {
"properties": {
"clients": {
@@ -383,10 +605,10 @@
"type": "string"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -417,6 +639,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
@@ -694,10 +920,10 @@
"ListenBrainzSourceData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -720,6 +946,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
@@ -808,10 +1038,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -834,6 +1064,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"whitelist": {
"anyOf": [
{
@@ -1053,10 +1287,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1079,6 +1313,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"uriBlacklist": {
"description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.",
"items": {
@@ -1245,10 +1483,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1271,6 +1509,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -1365,6 +1607,23 @@
},
"type": "object"
},
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"examples": [
30
],
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"type": "number"
}
},
"type": "object"
},
"SourceAIOConfig": {
"anyOf": [
{
@@ -1399,16 +1658,22 @@
},
{
"$ref": "#/definitions/ListenBrainzSourceAIOConfig"
},
{
"$ref": "#/definitions/JRiverSourceAIOConfig"
},
{
"$ref": "#/definitions/KodiSourceAIOConfig"
}
]
},
"SourceRetryOptions": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1499,10 +1764,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1532,6 +1797,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -1560,10 +1829,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1593,6 +1862,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"description": "URL of the subsonic media server to query",
"examples": [
@@ -1722,10 +1995,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1747,6 +2020,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -1803,8 +2080,29 @@
"logging": {
"$ref": "#/definitions/LogOptions"
},
"port": {
"default": 9078,
"description": "Set the port the multi-scrobbler UI will be served from",
"examples": [
9078
],
"type": "number"
},
"sourceDefaults": {
"$ref": "#/definitions/SourceRetryOptions"
"allOf": [
{
"$ref": "#/definitions/SourceRetryOptions"
},
{
"properties": {
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"type": "object"
}
]
},
"sources": {
"items": {
+283 -20
View File
@@ -33,6 +33,12 @@
},
{
"$ref": "#/definitions/ListenBrainzSourceConfig"
},
{
"$ref": "#/definitions/JRiverSourceConfig"
},
{
"$ref": "#/definitions/KodiSourceConfig"
}
],
"definitions": {
@@ -61,10 +67,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -94,6 +100,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -131,13 +141,112 @@
],
"type": "object"
},
"JRiverData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "If you have enabled authentication, the password you set",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:52199/MCWS/v1/",
"description": "URL of the JRiver HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:52199/MCWS/v1/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `52199`\n* Path => `/MCWS/v1/`",
"examples": [
"http://localhost:52199/MCWS/v1/"
],
"type": "string"
},
"username": {
"description": "If you have enabled authentication, the username you set",
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"JRiverSourceConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/JRiverData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
}
},
"required": [
"data"
],
"type": "object"
},
"JellyData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -171,6 +280,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -241,6 +354,107 @@
],
"type": "object"
},
"KodiData": {
"properties": {
"interval": {
"default": 30,
"description": "How long to wait before polling the source API for new tracks (in seconds)",
"examples": [
30
],
"type": "number"
},
"maxInterval": {
"default": 60,
"description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)",
"examples": [
60
],
"type": "number"
},
"maxPollRetries": {
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
5
],
"type": "number"
},
"maxRequestRetries": {
"default": 1,
"description": "default # of http request retries a source can make before error is thrown",
"examples": [
1
],
"type": "number"
},
"options": {
"$ref": "#/definitions/Record<string,any>"
},
"password": {
"description": "The password set for Remote Control via Web Sever",
"type": "string"
},
"retryMultiplier": {
"default": 1.5,
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
"examples": [
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"default": "http://localhost:8080/jsonrpc",
"description": "URL of the Kodi HTTP server to connect to\n\nmulti-scrobbler connects to the Web Service Interface endpoint that ultimately looks like this => `http://yourDomain:8080/jsonrpc`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `http://`\n* Hostname => `localhost`\n* Port => `8080`\n* Path => `/jsonrpc`",
"examples": [
"http://localhost:8080/jsonrpc"
],
"type": "string"
},
"username": {
"description": "The username set for Remote Control via Web Sever",
"type": "string"
}
},
"required": [
"password",
"url",
"username"
],
"type": "object"
},
"KodiSourceConfig": {
"properties": {
"clients": {
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
"examples": [
[
"MyMalojaConfigName",
"MyLastFMConfigName"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"data": {
"$ref": "#/definitions/KodiData"
},
"name": {
"description": "Unique identifier for this source.",
"type": "string"
}
},
"required": [
"data"
],
"type": "object"
},
"LastFmSourceData": {
"properties": {
"apiKey": {
@@ -251,10 +465,10 @@
"type": "string"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -285,6 +499,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"secret": {
"description": "Secret generated from Last.fm account",
"examples": [
@@ -384,10 +602,10 @@
"ListenBrainzSourceData": {
"properties": {
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -410,6 +628,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"token": {
"description": "User token for the user to scrobble for",
"examples": [
@@ -459,10 +681,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -485,6 +707,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"whitelist": {
"anyOf": [
{
@@ -571,10 +797,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -597,6 +823,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"uriBlacklist": {
"description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.",
"items": {
@@ -701,10 +931,10 @@
]
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -727,6 +957,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"servers": {
"anyOf": [
{
@@ -772,6 +1006,23 @@
"Record<string,any>": {
"type": "object"
},
"ScrobbleThresholds": {
"properties": {
"duration": {
"default": 30,
"description": "The number of seconds before a track should be considered scrobbled.",
"examples": [
30
],
"type": "number"
},
"percent": {
"description": "The percentage of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is.\n\nNOTE: This should be used with care when the Source is a \"polling\" type (has an 'interval' property). If the track is short and the interval is too high MS may ignore the track if percentage is high because it had not \"seen\" the track for long enough from first discovery, even if you have been playing the track for longer.",
"type": "number"
}
},
"type": "object"
},
"SpotifySourceConfig": {
"properties": {
"clients": {
@@ -833,10 +1084,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -866,6 +1117,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
@@ -922,10 +1177,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -955,6 +1210,10 @@
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
},
"url": {
"description": "URL of the subsonic media server to query",
"examples": [
@@ -1032,10 +1291,10 @@
"type": "number"
},
"maxPollRetries": {
"default": 0,
"default": 5,
"description": "default # of automatic polling restarts on error",
"examples": [
1
5
],
"type": "number"
},
@@ -1057,6 +1316,10 @@
1.5
],
"type": "number"
},
"scrobbleThresholds": {
"$ref": "#/definitions/ScrobbleThresholds",
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled."
}
},
"required": [
+24 -12
View File
@@ -1,7 +1,7 @@
import {addAsync, Router} from '@awaitjs/express';
import express from 'express';
import bodyParser from 'body-parser';
import {Logger} from 'winston';
import {Logger} from '@foxxmd/winston';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import isBetween from 'dayjs/plugin/isBetween.js';
@@ -49,12 +49,11 @@ dayjs.extend(duration);
const app = addAsync(express());
const router = Router();
const port = process.env.PORT ?? 9078;
let envPort = process.env.PORT ?? 9078;
(async function () {
const server = await app.listen(port)
const io = new Server(server);
let io: Server | undefined = undefined;
app.use(router);
app.use(bodyParser.json());
@@ -65,11 +64,13 @@ app.use(passport.session());
let output: LogInfo[] = []
const initLogger = getLogger({}, 'init');
const initLogger = getLogger({file: false}, 'init');
initLogger.stream().on('log', (log: LogInfo) => {
output.unshift(log);
output = output.slice(0, 301);
io.emit('log', formatLogToHtml(log[MESSAGE]));
if(io !== undefined) {
io.emit('log', formatLogToHtml(log[MESSAGE]));
}
});
let logger: Logger;
@@ -78,20 +79,25 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
try {
initLogger.debug(`Config Dir ENV: ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`)
// try to read a configuration file
let appConfigFail = false;
let appConfigFail: Error | undefined = undefined;
let config = {};
try {
config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
appConfigFail = true;
appConfigFail = e;
}
const {
webhooks = [],
port = envPort,
logging = {},
} = (config || {}) as AIOConfig;
const server = await app.listen(port);
io = new Server(server);
const logConfig: {level: LogLevel, sort: string, limit: number} = {
level: logging.level || (process.env.LOG_LEVEL || 'info') as LogLevel,
sort: 'descending',
@@ -107,10 +113,15 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
}
});
if(appConfigFail) {
logger.warn('App config file exists but could not be parsed!');
if(process.env.IS_LOCAL === 'true') {
logger.info('multi-scrobbler can be run as a background service! See: https://github.com/FoxxMD/multi-scrobbler/blob/develop/docs/service.md');
}
const root = createRoot();
if(appConfigFail !== undefined) {
logger.warn('App config file exists but could not be parsed!');
logger.warn(appConfigFail);
}
const root = createRoot(port);
const localUrl = root.get('localUrl');
const notifiers = root.get('notifiers');
@@ -233,7 +244,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
app.postAsync('/tautulli', async function(this: any, req, res) {
tauIngress.trackIngress(req, false);
const payload = TautulliSource.formatPlayObj(req.body, {newFromSource: true});
const payload = TautulliSource.formatPlayObj(req, {newFromSource: true});
// try to get config name from payload
if (req.body.scrobblerConfig !== undefined) {
const source = scrobbleSources.getByName(req.body.scrobblerConfig);
@@ -509,6 +520,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
const tokenResult = await source.handleAuthCodeCallback(req.query);
let responseContent = 'OK';
if (tokenResult === true) {
await source.testAuth();
source.poll();
} else {
responseContent = tokenResult;
+3 -3
View File
@@ -6,11 +6,11 @@ import ScrobbleSources from "./sources/ScrobbleSources.js";
import {Notifiers} from "./notifier/Notifiers.js";
import {EventEmitter} from "events";
import {logPath} from "./common/logging.js";
import {Container} from "winston";
import {Container} from '@foxxmd/winston';
//import ScrobbleClients from "./clients/ScrobbleClients.js";
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
const port = process.env.PORT ?? 9078;
//const port = process.env.PORT ?? 9078;
/*let logPath = path.resolve(projectDir, `./logs`);
if(typeof process.env.CONFIG_DIR === 'string') {
@@ -19,7 +19,7 @@ if(typeof process.env.CONFIG_DIR === 'string') {
//let root: Container;
export const createRoot = () => {
export const createRoot = (port) => {
return createContainer().add({
configDir: configDir,
logDir: logPath,
+1 -1
View File
@@ -1,5 +1,5 @@
import {GotifyConfig, NtfyConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {mergeArr} from "../utils.js";
export abstract class AbstractWebhookNotifier {
+1 -1
View File
@@ -3,7 +3,7 @@ import {GotifyConfig, PrioritiesConfig, WebhookPayload} from "../common/infrastr
import {gotify} from 'gotify';
import request from 'superagent';
import {HTTPError} from "got";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
+1 -1
View File
@@ -1,4 +1,4 @@
import winston, {config, format, Logger} from "winston";
import winston, {config, format, Logger} from '@foxxmd/winston';
import {mergeArr} from "../utils.js";
import {
GotifyConfig,
+11 -11
View File
@@ -6,7 +6,8 @@ import {
} from "../common/infrastructure/config/health/webhooks.js";
import {publish} from 'ntfy';
import request from "superagent";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {Config} from "ntfy/interfaces";
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
@@ -54,21 +55,20 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
doNotify = async (payload: WebhookPayload) => {
try {
let authorization = {};
if (this.config.username !== undefined) {
authorization = {
username: this.config.username,
password: this.config.password,
}
}
await publish({
const req: Config = {
message: payload.message,
topic: this.config.topic,
title: payload.title,
server: this.config.url,
priority: this.priorities[payload.priority],
...authorization,
});
};
if (this.config.username !== undefined) {
req.authorization = {
username: this.config.username,
password: this.config.password,
}
}
await publish(req);
this.logger.debug(`Pushed notification.`);
} catch (e: any) {
this.logger.error(`Failed to push notification: ${e.message}`)
+22 -8
View File
@@ -3,7 +3,7 @@ import {
buildTrackString,
capitalize, closePlayDate,
genGroupId, mergeArr,
playObjDataMatch,
playObjDataMatch, pollingBackoff,
sleep, sortByNewestPlayDate,
sortByOldestPlayDate
} from "../utils.js";
@@ -14,7 +14,7 @@ import {
PlayObject, ProgressAwarePlayObject,
SourceType
} from "../common/infrastructure/Atomic.js";
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import {SourceConfig} from "../common/infrastructure/config/source/sources.js";
import {EventEmitter} from "events";
import {FixedSizeList} from "fixed-size-list";
@@ -159,11 +159,18 @@ export default abstract class AbstractSource {
this.emitter.emit('notify', payload);
}
poll = async () => {
await this.startPolling();
onPollPreAuthCheck = async (): Promise<boolean> => {
return true;
}
startPolling = async () => {
onPollPostAuthCheck = async (): Promise<boolean> => {
return true;
}
poll = async () => {
if(!await this.onPollPreAuthCheck()) {
return;
}
if(this.requiresAuth && !this.authed) {
if(this.requiresAuthInteraction) {
this.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'});
@@ -174,13 +181,20 @@ export default abstract class AbstractSource {
}
return;
}
if(!await this.onPollPostAuthCheck()) {
return;
}
await this.startPolling();
}
startPolling = async () => {
// reset poll attempts if already previously run
this.pollRetries = 0;
const {
data: {
maxPollRetries = 0,
retryMultiplier = 1.5,
maxPollRetries = 5,
retryMultiplier = 1,
} = {},
} = this.config;
@@ -192,7 +206,7 @@ export default abstract class AbstractSource {
await this.doPolling();
} catch (e) {
if (this.pollRetries < maxRetries) {
const delayFor = (this.pollRetries + 1) * retryMultiplier;
const delayFor = pollingBackoff(this.pollRetries + 1, retryMultiplier);
this.logger.info(`Poll retries (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
this.notify({title: `${this.identifier} - Polling Retry`, message: `Encountered error while polling but retries (${this.pollRetries}) are less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'});
await sleep((delayFor) * 1000);
+1 -1
View File
@@ -102,7 +102,7 @@ export default class DeezerSource extends AbstractSource {
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history`));
const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history?limit=20`));
return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
}
+146
View File
@@ -0,0 +1,146 @@
import MemorySource from "./MemorySource.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import dayjs from "dayjs";
import {URL} from "url";
import normalizeUrl from 'normalize-url';
import {EventEmitter} from "events";
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import {JRiverSourceConfig} from "../common/infrastructure/config/source/jriver.js";
import {Info, JRiverApiClient, PLAYER_STATE} from "../apis/JRiverApiClient.js";
export class JRiverSource extends MemorySource {
declare config: JRiverSourceConfig;
url: URL;
client: JRiverApiClient;
clientReady: boolean = false;
constructor(name: any, config: JRiverSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data,
} = config;
const {
interval = 10,
maxInterval = 30,
...rest
} = data || {};
super('jriver', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
const {
data: {
url = 'http://localhost:52199/MCWS/v1/'
} = {},
} = config;
this.url = JRiverSource.parseConnectionUrl(url);
this.client = new JRiverApiClient(name, {...data, url: this.url.toString()});
this.requiresAuth = true;
this.canPoll = true;
this.multiPlatform = true;
}
static parseConnectionUrl(val: string) {
const normal = normalizeUrl(val, {removeTrailingSlash: true, normalizeProtocol: true})
const url = new URL(normal);
if (url.port === null || url.port === '') {
url.port = '52199';
}
if (url.pathname === '/') {
url.pathname = '/MCWS/v1/';
} else if (url.pathname === '/MCWS/v1') {
url.pathname = '/MCWS/v1/';
}
return url;
}
initialize = async () => {
const {
data: {
url
} = {}
} = this.config;
this.logger.debug(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.toString()}'`)
const connected = await this.client.testConnection();
if(connected) {
this.logger.info('Connection OK');
this.initialized = true;
return true;
} else {
this.logger.error(`Could not connect.`);
this.initialized = false;
return false;
}
}
testAuth = async () => {
const resp = await this.client.testAuth();
this.authed = resp;
this.clientReady = this.authed;
return this.authed;
}
static formatPlayObj(obj: Info, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = true} = options;
const {
Artist,
Album,
Name,
DurationMS,
PositionMS: trackProgressPosition,
FileKey,
ZoneID,
ZoneName,
} = obj;
let artists = Artist === null || Artist === undefined ? [] : [Artist];
let album = Album === null || Album === '' ? undefined : Album;
const length = Number.parseInt(DurationMS.toString()) / 1000;
return {
data: {
track: Name,
album: album,
artists,
duration: Math.round(length),
playDate: dayjs()
},
meta: {
source: 'mopidy',
trackId: FileKey,
newFromSource,
trackProgressPosition: trackProgressPosition !== undefined ? Math.round(Number.parseInt(trackProgressPosition.toString()) / 1000) : undefined,
deviceId: `Zone${ZoneID}${ZoneName !== undefined ? `-${ZoneName}` : ''}`,
}
}
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
if (!this.clientReady) {
this.logger.warn('Cannot actively poll since client is not connected.');
return [];
}
let play = [];
//should it use zones?
//const zoneResp = await this.client.getZones();
const infoResp = await this.client.getInfo();
const {
body: {
data,
} = {}
} = infoResp;
if(data !== undefined) {
const {State} = data;
if(State !== PLAYER_STATE.STOPPED) {
play = [JRiverSource.formatPlayObj(data)];
}
}
return this.processRecentPlays(play);
}
}
+14
View File
@@ -70,6 +70,11 @@ export default class JellyfinSource extends MemorySource {
DeviceId = '',
DeviceName,
ClientName,
Provider_musicbrainzalbumartist,
Provider_musicbrainzartist,
Provider_musicbrainzalbum,
Provider_musicbrainztrack,
Provider_musicbrainzreleasegroup
} = obj;
const dur = parseDurationFromTimestamp(RunTime);
@@ -94,6 +99,15 @@ export default class JellyfinSource extends MemorySource {
track: Name,
duration: dur !== undefined ? dur.as('seconds') : undefined,
playDate: dayjs(),
meta: {
brainz: {
artist: Provider_musicbrainzartist,
album: Provider_musicbrainzalbum,
albumArtist: Provider_musicbrainzalbumartist,
track: Provider_musicbrainztrack,
releaseGroup: Provider_musicbrainzreleasegroup
}
}
},
meta: {
event: NotificationType,
+75
View File
@@ -0,0 +1,75 @@
import MemorySource from "./MemorySource.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import {EventEmitter} from "events";
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import {KodiSourceConfig} from "../common/infrastructure/config/source/kodi.js";
import {KodiApiClient} from "../apis/KodiApiClient.js";
export class KodiSource extends MemorySource {
declare config: KodiSourceConfig;
client: KodiApiClient;
clientReady: boolean = false;
constructor(name: any, config: KodiSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data,
} = config;
const {
interval = 10,
maxInterval = 30,
...rest
} = data || {};
super('kodi', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
this.requiresAuth = true;
this.canPoll = true;
this.multiPlatform = true;
}
initialize = async () => {
const {
data: {
url
} = {}
} = this.config;
this.client = new KodiApiClient(this.name, this.config.data);
this.logger.debug(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.client.url.toString()}'`)
this.initialized = true;
return true;
/*const connected = await this.client.testConnection();
if(connected) {
//this.logger.info('Connection OK');
this.initialized = true;
return true;
} else {
this.logger.error(`Could not connect.`);
this.initialized = false;
return false;
}*/
}
testAuth = async () => {
const resp = await this.client.testAuth();
this.authed = resp;
this.clientReady = this.authed;
return this.authed;
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
return KodiApiClient.formatPlayObj(obj, options);
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
if (!this.clientReady) {
this.logger.warn('Cannot actively poll since client is not connected.');
return [];
}
let play = await this.client.getRecentlyPlayed(options);
return this.processRecentPlays(play);
}
}
+44 -21
View File
@@ -12,6 +12,7 @@ import MemorySource from "./MemorySource.js";
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import {removeDuplicates} from "../utils.js";
import EventEmitter from "events";
import {ErrorWithCause} from "pony-cause";
export class MPRISSource extends MemorySource {
@@ -109,41 +110,63 @@ export class MPRISSource extends MemorySource {
const playerInfos: PlayerInfo[] = [];
for (const playerName of list) {
let obj = await bus.getProxyObject(playerName, MPRIS_PATH);
const plainPlayerName = playerName.replace('org.mpris.MediaPlayer2.', '');
//let player = obj.getInterface(MPRIS_IFACE);
let props = obj.getInterface(PROPERTIES_IFACE);
try {
let obj = await bus.getProxyObject(playerName, MPRIS_PATH);
const pos = await this.getPlayerPosition(props);
const status = await this.getPlayerStatus(props);
if (status === PLAYBACK_STATUS_STOPPED && activeOnly) {
continue;
//let player = obj.getInterface(MPRIS_IFACE);
let props = obj.getInterface(PROPERTIES_IFACE);
// may not always have position available! can fallback to undefined for this
let pos: number | undefined;
try {
pos = await this.getPlayerPosition(props);
} catch (e) {
this.logger.warn(`Could not get Position info for player ${plainPlayerName}`);
}
const status = await this.getPlayerStatus(props);
if (status === PLAYBACK_STATUS_STOPPED && activeOnly) {
continue;
}
const metadata = await this.getPlayerMetadata(props);
playerInfos.push({
name: plainPlayerName,
status,
position: pos,
metadata
});
} catch (e) {
this.logger.warn(new ErrorWithCause(`Could not parse D-bus info for player ${plainPlayerName}`, {cause: e}));
}
const metadata = await this.getPlayerMetadata(props);
playerInfos.push({
name: plainPlayerName,
status,
position: pos,
metadata
});
}
return playerInfos;
}
protected getPlayerPosition = async (props: ClientInterface): Promise<number> => {
const pos = await props.Get(MPRIS_IFACE, 'Position');
return dayjs.duration({milliseconds: Number(pos.value / 1000n)}).asSeconds();
try {
const pos = await props.Get(MPRIS_IFACE, 'Position');
return dayjs.duration({milliseconds: Number(pos.value / 1000n)}).asSeconds();
} catch(e) {
throw new ErrorWithCause('Could not get player Position', {cause: e});
}
}
protected getPlayerStatus = async (props: ClientInterface): Promise<PlaybackStatus> => {
const status = await props.Get(MPRIS_IFACE, 'PlaybackStatus');
return status.value as PlaybackStatus;
try {
const status = await props.Get(MPRIS_IFACE, 'PlaybackStatus');
return status.value as PlaybackStatus;
} catch (e) {
throw new ErrorWithCause('Could not get player PlaybackStatus', {cause: e})
}
}
protected getPlayerMetadata = async (props: ClientInterface): Promise<MPRISMetadata> => {
const metadata = await props.Get(MPRIS_IFACE, 'Metadata');
return this.metadataToPlain(metadata.value);
try {
const metadata = await props.Get(MPRIS_IFACE, 'Metadata');
return this.metadataToPlain(metadata.value);
} catch(e) {
throw new ErrorWithCause('Could not get player Metadata', {cause: e});
}
}
metadataToPlain = (metadataVariant): MPRISMetadata => {
+24 -12
View File
@@ -5,10 +5,15 @@ import {
buildTrackString,
toProgressAwarePlayObject,
getProgress,
genGroupId
genGroupId, playPassesScrobbleThreshold, timePassesScrobbleThreshold, thresholdResultSummary
} from "../utils.js";
import dayjs from "dayjs";
import {GroupedPlays, PlayObject, ProgressAwarePlayObject} from "../common/infrastructure/Atomic.js";
import {
GroupedPlays,
PlayObject,
ProgressAwarePlayObject,
ScrobbleThresholdResult
} from "../common/infrastructure/Atomic.js";
export default class MemorySource extends AbstractSource {
/*
@@ -42,6 +47,12 @@ export default class MemorySource extends AbstractSource {
processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => {
const {
data: {
scrobbleThresholds = {}
} = {}
} = this.config;
let newStatefulPlays: PlayObject[] = [];
// if we can't trust existing play dates (like for subsonic where there is no timestamp) then
// we format new plays with locked play date IE the first time we have "seen" this track
@@ -103,11 +114,12 @@ export default class MemorySource extends AbstractSource {
//const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? [];
// now we check if all candidates pass tests for having been tracked long enough:
// * Has been tracked for at least 30 seconds
// * If it has playback position data then it must also have progressed at least 30 seconds since our initial tracking data
// * Has been tracked for at least [duration] seconds or [percentage] of track duration
// * If it has playback position data then it must also have progressed at least [duration] seconds or [percentage] of track duration progress since our initial tracking data
for(const candidate of cRecentlyPlayed) {
const {data: {playDate, track}} = candidate;
const firstSeenValid = playDate.isBefore(dayjs().subtract(30, 's'));
let thresholdResults: ScrobbleThresholdResult;
thresholdResults = playPassesScrobbleThreshold(candidate, scrobbleThresholds);
const {passes: firstSeenValid} = thresholdResults;
let progressValid = firstSeenValid;
if (firstSeenValid) {
// check if we can get progress as well
@@ -116,9 +128,9 @@ export default class MemorySource extends AbstractSource {
if (matchingLockedPlay !== undefined) {
const progress = getProgress(candidate, matchingLockedPlay);
if (progress !== undefined) {
if (progress < 30) {
progressValid = false;
}
thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, progress, candidate.data.duration);
const {passes: progressPasses} = thresholdResults;
progressValid = progressPasses;
}
}
}
@@ -129,7 +141,7 @@ export default class MemorySource extends AbstractSource {
const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`;
if(matchingRecent === undefined) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and not matching any prior plays`);
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`);
newStatefulPlays.push(candidate);
//sRecentlyPlayed.push(candidate);
} else {
@@ -138,7 +150,7 @@ export default class MemorySource extends AbstractSource {
if(!playDate.isSame(rplayDate)) {
if(duration !== undefined) {
if(playDate.isAfter(rplayDate.add(duration, 's'))) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and having a different timestamp than a prior play`);
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`);
newStatefulPlays.push(candidate);
//sRecentlyPlayed.push(candidate);
}
@@ -146,7 +158,7 @@ export default class MemorySource extends AbstractSource {
const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(candidate);
if(discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[0], candidate)) {
// if most recent stateful play is not this track we'll add it
this.logger.debug(`${stPrefix} added after being seen for 30 seconds. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
this.logger.debug(`${stPrefix} added after ${thresholdResultSummary(thresholdResults)}. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
newStatefulPlays.push(candidate);
}
//sRecentlyPlayed.push(candidate);
+1 -1
View File
@@ -7,7 +7,7 @@ import {URL} from "url";
import normalizeUrl from 'normalize-url';
import {EventEmitter} from "events";
import pEvent from 'p-event';
import winston from 'winston';
import winston from '@foxxmd/winston';
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import request from "superagent";
import {buildTrackString, removeDuplicates} from "../utils.js";
+1 -1
View File
@@ -11,7 +11,7 @@ import concatStream from 'concat-stream';
import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js";
import EventEmitter from "events";
import winston from "winston";
import winston from '@foxxmd/winston';
const shortDeviceId = truncateStringToLength(10, '');
+45 -1
View File
@@ -8,7 +8,7 @@ import LastfmSource from "./LastfmSource.js";
import DeezerSource from "./DeezerSource.js";
import {ConfigMeta, InternalConfig, SourceType, sourceTypes} from "../common/infrastructure/Atomic.js";
import {configDir as defaultConfigDir} from "../common/index.js";
import winston, {Logger} from "winston";
import winston, {Logger} from '@foxxmd/winston';
import {SourceAIOConfig, SourceConfig} from "../common/infrastructure/config/source/sources.js";
import {DeezerData, DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js";
import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js";
@@ -31,6 +31,10 @@ import {MopidySource} from "./MopidySource.js";
import {MopidySourceConfig} from "../common/infrastructure/config/source/mopidy.js";
import ListenbrainzSource from "./ListenbrainzSource.js";
import {ListenBrainzSourceConfig} from "../common/infrastructure/config/source/listenbrainz.js";
import {JRiverSource} from "./JRiverSource.js";
import {JRiverData, JRiverSourceConfig} from "../common/infrastructure/config/source/jriver.js";
import {KodiSource} from "./KodiSource.js";
import {KodiData, KodiSourceConfig} from "../common/infrastructure/config/source/kodi.js";
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -259,6 +263,40 @@ export default class ScrobbleSources {
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
defaultConfigureAs = 'client';
break;
case 'jriver':
const jr = {
url: process.env.JRIVER_URL,
username: process.env.JRIVER_USER,
password: process.env.JRIVER_PASSWORD
}
if (!Object.values(jr).every(x => x === undefined)) {
configs.push({
type: 'jriver',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: jr as JRiverData
});
}
break;
case 'kodi':
const ko = {
url: process.env.KODI_URL,
username: process.env.KODI_USER,
password: process.env.KODI_PASSWORD
}
if (!Object.values(ko).every(x => x === undefined)) {
configs.push({
type: 'kodi',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: ko as KodiData
});
}
break;
default:
break;
}
@@ -422,6 +460,12 @@ export default class ScrobbleSources {
case 'listenbrainz':
newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, internal, this.emitter);
break;
case 'jriver':
newSource = await new JRiverSource(name, compositeConfig as JRiverSourceConfig, internal, this.emitter);
break;
case 'kodi':
newSource = await new KodiSource(name, compositeConfig as KodiSourceConfig, internal, this.emitter);
break;
default:
break;
}
+13 -8
View File
@@ -16,6 +16,7 @@ import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified;
import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified;
import UserDevice = SpotifyApi.UserDevice;
import MemorySource from "./MemorySource.js";
import {ErrorWithCause} from "pony-cause";
const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position'];
const state = 'random';
@@ -218,7 +219,7 @@ export default class SpotifySource extends MemorySource {
await this.callApi<ReturnType<typeof this.spotifyApi.getMe>>(((api: any) => api.getMe()));
this.authed = true;
} catch (e) {
this.logger.error('Could not successfully communicate with Spotify API');
this.logger.error(new ErrorWithCause('Could not successfully communicate with Spotify API', {cause: e}));
this.authed = false;
}
return this.authed;
@@ -346,7 +347,7 @@ export default class SpotifySource extends MemorySource {
return await func(this.spotifyApi);
} catch (ee) {
this.logger.error('Refreshing access token encountered an error');
this.logger.error(ee, {label: 'Spotify'});
this.logger.error(ee);
throw ee;
}
} else if(maxRequestRetries > retries) {
@@ -356,18 +357,22 @@ export default class SpotifySource extends MemorySource {
return this.callApi(func, retries + 1);
} else {
this.logger.error(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`);
this.logger.error(e, {label: 'Spotify'});
throw e;
const error = new ErrorWithCause(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`, {cause: e});
this.logger.error(error);
throw error;
}
}
}
poll = async () => {
onPollPreAuthCheck = async () => {
if (this.spotifyApi === undefined) {
this.logger.warn('Cannot poll spotify without valid credentials configuration')
return;
return false;
}
return true;
}
onPollPostAuthCheck = async () => {
// test capabilities
try {
await this.getCurrentPlaybackState(false);
@@ -376,12 +381,12 @@ export default class SpotifySource extends MemorySource {
this.logger.warn('multi-scrobbler does not have sufficient permissions to access Spotify API "Get Playback State". MS will continue to work but accuracy for determining if/when a track played from a Spotify Connect device (smart device controlled through Spotify app) may be degraded. To fix this re-authenticate MS with Spotify and restart polling.');
}
// and record backlogged tracks
this.logger.info('Checking recently played API for tracks to backlog...');
const backlogPlays = await this.getPlayHistory({formatted: true});
this.scrobble(backlogPlays);
this.logger.info('Backlog complete.');
await this.startPolling();
return true;
}
}
+23 -21
View File
@@ -4,6 +4,7 @@ import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautu
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import {combinePartsToString, truncateStringToLength} from "../utils.js";
import EventEmitter from "events";
import {Request} from "express";
const shortDeviceId = truncateStringToLength(10, '');
@@ -13,29 +14,30 @@ export default class TautulliSource extends PlexSource {
constructor(name: any, config: TautulliSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super(name, config, internal, 'tautulli', emitter);
}
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
static formatPlayObj(obj: Request, options: FormatPlayObjectOptions = {}): PlayObject {
const {newFromSource = false} = options;
const {
artist_name,
track_name,
track_artist,
album_name,
media_type,
title,
library_name,
server,
version,
duration,
username,
library,
machine_id = '',
session_key,
action,
platform,
device,
player,
} = obj.body;
body :{
artist_name,
track_name,
track_artist,
album_name,
media_type,
title,
library_name,
server,
version,
duration,
username,
library,
machine_id = '',
session_key,
action,
platform,
device,
player,
} = {}
} = obj;
let artists = [artist_name];
if (track_artist !== undefined && track_artist !== artist_name) {
artists.push(track_artist);
+3 -3
View File
@@ -180,8 +180,8 @@ export default class YTMusicSource extends AbstractSource {
return this.authed;
}
poll = async () => {
if(this.authed && !this.polling) {
onPollPostAuthCheck = async () => {
if(!this.polling) {
this.logger.verbose('Hydrating initial recently played tracks for reference.');
const referencePlays = await this.getRecentlyPlayed();
const reversedPlays = [...referencePlays];
@@ -194,6 +194,6 @@ export default class YTMusicSource extends AbstractSource {
}
}
}
await this.startPolling();
return true;
}
}
@@ -1,4 +1,4 @@
import winston, {Logger} from "winston";
import winston, {Logger} from '@foxxmd/winston';
import {mergeArr, remoteHostIdentifiers, remoteHostStr} from "../../utils.js";
import {Request} from "express";
import {RemoteIdentityParts} from "../../common/infrastructure/Atomic.js";
@@ -16,7 +16,10 @@ export class TautulliNotifier extends IngressNotifier {
if(!this.seenServers.includes(playObj.meta.server)) {
this.seenServers.push(playObj.meta.server);
let msg = [`Received valid data from server ${playObj.meta.server} for the first time.`];
let msg = [`Received data from server ${playObj.meta.server} for the first time.`];
if(req.body === undefined) {
msg.push('WARNING: Payload was empty.');
}
if(playObj.meta.library === undefined) {
msg.push('WARNING: library was not defined in payload. If you want to filter plays by library this must be present in webhook payload.');
}
+76 -13
View File
@@ -1,23 +1,26 @@
import {promises, constants, accessSync} from "fs";
import dayjs, {Dayjs} from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import {Logger} from "winston";
import {Logger} from '@foxxmd/winston';
import JSON5 from 'json5';
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
import Ajv, {Schema} from 'ajv';
import {
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
lowGranularitySources,
PlayObject, ProgressAwarePlayObject,
RemoteIdentityParts,
RemoteIdentityParts, ScrobbleThresholdResult,
TrackStringOptions
} from "./common/infrastructure/Atomic.js";
import {Request} from "express";
import pathUtil from "path";
import {ErrorWithCause} from "pony-cause";
import backoffStrategies from '@kenyip/backoff-strategies';
import {ScrobbleThresholds} from "./common/infrastructure/config/source/index.js";
dayjs.extend(utc);
export async function readJson(this: any, path: any, {logErrors = true, throwOnNotFound = true} = {}) {
export async function readJson(this: any, path: any, {throwOnNotFound = true} = {}) {
try {
await promises.access(path, constants.R_OK);
const data = await promises.readFile(path);
@@ -26,18 +29,12 @@ export async function readJson(this: any, path: any, {logErrors = true, throwOnN
const {code} = e;
if (code === 'ENOENT') {
if (throwOnNotFound) {
if (logErrors) {
this.logger.warn('No file found at given path', {filePath: path});
}
throw e;
throw new ErrorWithCause(`No file found at given path: ${path}`, {cause: e});
} else {
return;
}
} else if (logErrors) {
this.logger.warn(`Encountered error while parsing file`, {filePath: path});
this.logger.error(e);
}
throw e;
throw new ErrorWithCause(`Encountered error while parsing file: ${path}`, {cause: e})
}
}
@@ -80,7 +77,13 @@ export const truncateStringArrToLength = (length: any, truncStr = '...') => {
const truncater = truncateStringToLength(length, truncStr);
return (strings: any) => strings.map(truncater);
}
export const truncateStringToLength = (length: any, truncStr = '...') => (str: any) => str.length > length ? `${str.slice(0, length)}${truncStr}` : str;
export const truncateStringToLength = (length: any, truncStr = '...') => (val: any = '') => {
if(val === null) {
return '';
}
const str = typeof val !== 'string' ? val.toString() : val;
return str.length > length ? `${str.slice(0, length)}${truncStr}` : str;
}
const defaultTransformer = (input: any) => input;
@@ -560,11 +563,57 @@ export const toProgressAwarePlayObject = (play: PlayObject): ProgressAwarePlayOb
export const getProgress = (initial: ProgressAwarePlayObject, curr: PlayObject): number | undefined => {
if(initial.meta.initialTrackProgressPosition !== undefined && curr.meta.trackProgressPosition !== undefined) {
return Math.abs(curr.meta.trackProgressPosition - initial.meta.initialTrackProgressPosition);
return Math.round(Math.abs(curr.meta.trackProgressPosition - initial.meta.initialTrackProgressPosition));
}
return undefined;
}
export const playPassesScrobbleThreshold = (play: PlayObject, thresholds: ScrobbleThresholds): ScrobbleThresholdResult => {
const progressed = Math.round(Math.abs(dayjs().diff(play.data.playDate, 's')));
return timePassesScrobbleThreshold(thresholds, progressed, play.data.duration);
}
export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, secondsTracked: number, playDuration?: number): ScrobbleThresholdResult => {
let durationPasses = undefined,
durationThreshold = (thresholds.duration ?? DEFAULT_SCROBBLE_DURATION_THRESHOLD),
percentPasses = undefined,
percent: number | undefined = undefined;
if (thresholds.percent !== undefined && playDuration !== undefined) {
percent = (secondsTracked / playDuration) * 100;
percentPasses = percent >= thresholds.percent;
}
if (thresholds.duration !== undefined || percentPasses === undefined) {
durationPasses = secondsTracked >= durationThreshold;
}
return {
passes: (durationPasses ?? false) || (percentPasses ?? false),
duration: {
passes: durationPasses,
threshold: durationThreshold,
value: secondsTracked
},
percent: {
passes: percentPasses,
value: percent,
threshold: thresholds.percent
}
}
}
export const thresholdResultSummary = (result: ScrobbleThresholdResult) => {
const parts: string[] = [];
if(result.duration.passes !== undefined) {
parts.push(`tracked time of ${result.duration.value}s (wanted ${result.duration.threshold}s)`);
}
if(result.percent.passes !== undefined) {
parts.push(`tracked percent of ${(result.percent.value).toFixed(2)}% (wanted ${result.percent.threshold})`)
}
return `${result.passes ? 'met' : 'did not meet'} thresholds with ${parts.join(' and')}`;
}
export function parseBool(value: any, prev: any = false): boolean {
let usedVal = value;
if (value === undefined || value === '') {
@@ -618,3 +667,17 @@ export const mergeArr = (objValue: [], srcValue: []): (any[] | undefined) => {
return objValue.concat(srcValue);
}
}
export const pollingBackoff = (attempt: number, scaleFactor: number = 1): number => {
const backoffStrat = backoffStrategies({
delay: 1000,
strategy: "exponential",
jitter: true,
minimumDelay: 1000,
scaleFactor
});
// first attempt delay is never enough so always add + 1
return Math.round(backoffStrat(attempt + 1) / 1000);
}