mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03cbe7a0a | ||
|
|
91bd702deb | ||
|
|
ede22e10e9 | ||
|
|
c648ad4ae3 | ||
|
|
a0fbd1129a | ||
|
|
b90ea03924 | ||
|
|
88efe2ad87 | ||
|
|
39f933418e | ||
|
|
cc8c7d84e2 | ||
|
|
6d9783c5f5 | ||
|
|
c10d46b774 | ||
|
|
a5417277a8 | ||
|
|
5a1c931513 | ||
|
|
09f4503593 | ||
|
|
045cae66f5 | ||
|
|
e67ef5fb0f | ||
|
|
1677c0021f | ||
|
|
3400e747a8 | ||
|
|
c08ff4843c | ||
|
|
5e905eae17 | ||
|
|
7ebe73ccb3 | ||
|
|
574911cfa0 | ||
|
|
91a84dc4f4 | ||
|
|
9a578e54a8 | ||
|
|
1a8d5eded3 | ||
|
|
94765057db | ||
|
|
86c639ad2c | ||
|
|
f3045debb3 | ||
|
|
ffc826f04d | ||
|
|
e70a4bbf1c | ||
|
|
a9eca51bfa | ||
|
|
de4a0aecd5 | ||
|
|
f097fac8ea | ||
|
|
a20b35f7cd | ||
|
|
2d51a0e159 | ||
|
|
e524077242 | ||
|
|
a5b5fe8d2a | ||
|
|
ddb3e0e02d | ||
|
|
ec97ec428a | ||
|
|
49b834af5d | ||
|
|
6329aaf6a9 | ||
|
|
fd6e5ab084 | ||
|
|
1dbb1b9ba3 | ||
|
|
fc65a63d43 | ||
|
|
4d1ddde57b | ||
|
|
7d14fb5341 | ||
|
|
e310b3aec8 | ||
|
|
056457d028 | ||
|
|
73183e760e | ||
|
|
3fff729d32 | ||
|
|
19e199bd58 | ||
|
|
7b0f5801a1 | ||
|
|
4dbea91a5e | ||
|
|
548356aaa4 | ||
|
|
8b3f4c636c | ||
|
|
1b0212f3bb | ||
|
|
14e9ec0b45 | ||
|
|
dab113ab76 | ||
|
|
ad729769ec | ||
|
|
bb4ae549a3 | ||
|
|
0786642a38 | ||
|
|
77d5f5884e |
@@ -12,3 +12,6 @@ config/*.json
|
||||
config/*.bak
|
||||
/docs
|
||||
/logs
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
flatpak/.flatpak-builder
|
||||
|
||||
@@ -125,3 +125,5 @@ src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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?**
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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
@@ -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"
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJriver",
|
||||
"data": {
|
||||
"url": "0.0.0.0",
|
||||
"username": "auser",
|
||||
"password": "apassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyKodi",
|
||||
"data": {
|
||||
"url": "http://localhost:8080/jsonrpc",
|
||||
"username": "myUser",
|
||||
"password": "myPassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "brainz",
|
||||
"configureAs": "client"
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
|
||||
+15
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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!
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"logging": {
|
||||
"file": false
|
||||
}
|
||||
}
|
||||
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
|
||||
Generated
+454
-168
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user