diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index e04e9885..63da05f9 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -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>(((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; @@ -356,8 +357,9 @@ 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; } } } -- 2.51.2 From 0786642a3836573b63c6b423d2b64f3d61fd0bc9 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 13 Mar 2023 10:09:11 -0400 Subject: [PATCH 2/7] Increase default poll retries for Spotify This seems to be a pain point so increasing ought to help. --- src/sources/SpotifySource.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index 63da05f9..002a8d2b 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -40,6 +40,7 @@ export default class SpotifySource extends MemorySource { const { data: { interval = 30, + maxPollRetries = 5 } = {} } = config; @@ -48,6 +49,7 @@ export default class SpotifySource extends MemorySource { } this.config.data.interval = interval; + this.config.data.maxPollRetries = maxPollRetries; this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; -- 2.51.2 From bb4ae549a37734f7dd22bbf9a8f8336a3506c93b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 13 Mar 2023 10:12:28 -0400 Subject: [PATCH 3/7] Remove superfluous spotify label --- src/sources/SpotifySource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index 002a8d2b..886d2dff 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -349,7 +349,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) { -- 2.51.2 From ad729769ec0712afb80cf11d3ff44691fc845a27 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 13 Mar 2023 13:59:24 -0400 Subject: [PATCH 4/7] Implement JRiver Source #26 --- README.md | 1 + config/jriver.json.example | 10 ++ docs/configuration.md | 75 +++++++- package-lock.json | 11 ++ package.json | 2 + src/apis/JRiverApiClient.ts | 167 ++++++++++++++++++ src/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/source/jriver.ts | 41 +++++ .../infrastructure/config/source/sources.ts | 5 +- src/common/schema/aio-source.json | 105 +++++++++++ src/common/schema/aio.json | 105 +++++++++++ src/common/schema/source.json | 98 ++++++++++ src/sources/JRiverSource.ts | 146 +++++++++++++++ src/sources/ScrobbleSources.ts | 22 +++ 14 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 config/jriver.json.example create mode 100644 src/apis/JRiverApiClient.ts create mode 100644 src/common/infrastructure/config/source/jriver.ts create mode 100644 src/sources/JRiverSource.ts diff --git a/README.md b/README.md index 2f7f9aaf..4da686a6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ 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) * Supports scrobbling to many **Clients** * [Maloja](/docs/configuration.md#maloja) * [Last.fm](/docs/configuration.md#lastfm) diff --git a/config/jriver.json.example b/config/jriver.json.example new file mode 100644 index 00000000..6f6ab3f3 --- /dev/null +++ b/config/jriver.json.example @@ -0,0 +1,10 @@ +[ + { + "name": "MyJriver", + "data": { + "url": "0.0.0.0", + "username": "auser", + "password": "apassword" + } + } +] diff --git a/docs/configuration.md b/docs/configuration.md index 55552a49..ea1b1e7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -270,7 +270,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 +404,8 @@ Part => Default Value * Port => `6680` * Path => `/mopidy/ws/` -EX +
+URL Transform Examples ```json { @@ -430,6 +431,8 @@ MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/` MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS` +
+ #### URI Blacklist/Whitelist @@ -460,6 +463,74 @@ 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/` + +
+URL Transform Examples + +```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/` + +
+ +### 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) + # Client Configurations ## [Maloja](https://github.com/krateng/maloja) diff --git a/package-lock.json b/package-lock.json index 57f6616b..59136b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "winston-duplex": "^0.1.1", "winston-null": "^2.0.0", "winston-transport": "^4.4.0", + "xml2js": "^0.4.23", "youtube-music-ts-api": "^1.4.1" }, "devDependencies": { @@ -56,6 +57,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", @@ -402,6 +404,15 @@ "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/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", diff --git a/package.json b/package.json index 34f3954c..1db2a7f6 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "winston-duplex": "^0.1.1", "winston-null": "^2.0.0", "winston-transport": "^4.4.0", + "xml2js": "^0.4.23", "youtube-music-ts-api": "^1.4.1" }, "devDependencies": { @@ -82,6 +83,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", diff --git a/src/apis/JRiverApiClient.ts b/src/apis/JRiverApiClient.ts new file mode 100644 index 00000000..6678ede9 --- /dev/null +++ b/src/apis/JRiverApiClient.ts @@ -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 = { + 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 { + 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 = (val: JRiverResponse): JRiverTransformedResponse => { + 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 (req: Request, retries = 0): Promise => { + 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 = jriverResponseTransform(rawBody); + } + return resp; + } catch (e) { + throw e; + } + } + + testConnection = async () => { + try { + const resp = await this.callApi(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(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(request.get(`${this.url}Playback/Info`).query({Zone: zoneId})); + } + + getZones = async () => { + return await this.callApi(request.get(`${this.url}Playback/Zones`)); + } +} diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 72ceab8b..e165e4b5 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -3,8 +3,8 @@ import {FixedSizeList} from 'fixed-size-list'; import {MESSAGE} from 'triple-beam'; import {Logger} from "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'; +export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver']; export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; diff --git a/src/common/infrastructure/config/source/jriver.ts b/src/common/infrastructure/config/source/jriver.ts new file mode 100644 index 00000000..d2517b8b --- /dev/null +++ b/src/common/infrastructure/config/source/jriver.ts @@ -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' +} diff --git a/src/common/infrastructure/config/source/sources.ts b/src/common/infrastructure/config/source/sources.ts index 0b3d9f05..1ea3e7e5 100644 --- a/src/common/infrastructure/config/source/sources.ts +++ b/src/common/infrastructure/config/source/sources.ts @@ -9,7 +9,8 @@ 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"; -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; -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; diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index 2b2f214b..faec45a3 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -103,6 +103,108 @@ ], "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": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "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" + }, + "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" + }, + "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": { @@ -820,6 +922,9 @@ }, { "$ref": "#/definitions/ListenBrainzSourceAIOConfig" + }, + { + "$ref": "#/definitions/JRiverSourceAIOConfig" } ] }, diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index ba208272..69aaa352 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -210,6 +210,108 @@ ], "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": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "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" + }, + "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" + }, + "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": { @@ -1399,6 +1501,9 @@ }, { "$ref": "#/definitions/ListenBrainzSourceAIOConfig" + }, + { + "$ref": "#/definitions/JRiverSourceAIOConfig" } ] }, diff --git a/src/common/schema/source.json b/src/common/schema/source.json index a3107114..f89bbbed 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -33,6 +33,9 @@ }, { "$ref": "#/definitions/ListenBrainzSourceConfig" + }, + { + "$ref": "#/definitions/JRiverSourceConfig" } ], "definitions": { @@ -131,6 +134,101 @@ ], "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": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "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" + }, + "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" + }, + "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": { diff --git a/src/sources/JRiverSource.ts b/src/sources/JRiverSource.ts new file mode 100644 index 00000000..2141ae73 --- /dev/null +++ b/src/sources/JRiverSource.ts @@ -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); + } + +} diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index 453f6dab..1b3283e5 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -31,6 +31,8 @@ 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"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -259,6 +261,23 @@ 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; default: break; } @@ -422,6 +441,9 @@ 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; default: break; } -- 2.51.2 From dab113ab76e39f75d3f051ab0e165471ffbec68d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 13 Mar 2023 14:01:40 -0400 Subject: [PATCH 5/7] docs: Missing jriver TOC entry --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index ea1b1e7c..1f93f4f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ * [Youtube Music](#youtube-music) * [MPRIS (Linux Desktop)](#mpris) * [Mopidy](#mopidy) + * [JRiver](#jriver) * [Client Configurations](#client-configurations) * [Maloja](#maloja) * [Last.fm](#lastfm) -- 2.51.2 From 14e9ec0b45037c51fc27163e9a97bf0c49b9eac5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 15 Mar 2023 08:30:18 -0400 Subject: [PATCH 6/7] refactor: Remove superfluous 'Client' from scrobbler label Already know what it is since it is prefixed by [Scrobblers] label --- src/clients/AbstractScrobbleClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index d5fc2a91..7cecdaf7 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -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; -- 2.51.2 From 1b0212f3bba249a52683a5b10358fcbae96045d1 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 15 Mar 2023 09:06:26 -0400 Subject: [PATCH 7/7] feat: Improve polling retry strategy * Increase default to 5 for all sources * Use true exponential backoff for determining delay between polling --- package-lock.json | 6 +++ package.json | 1 + .../infrastructure/config/source/index.ts | 4 +- src/common/schema/aio-source.json | 48 +++++++++---------- src/common/schema/aio.json | 48 +++++++++---------- src/common/schema/source.json | 44 ++++++++--------- src/sources/AbstractSource.ts | 8 ++-- src/sources/SpotifySource.ts | 2 - src/utils.ts | 16 +++++++ 9 files changed, 99 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59136b7b..429d1e92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@awaitjs/express": "^0.6.3", + "@kenyip/backoff-strategies": "^1.0.4", "ajv": "^7.2.4", "body-parser": "^1.19.0", "compare-versions": "^4.1.2", @@ -135,6 +136,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", diff --git a/package.json b/package.json index 1db2a7f6..e3caff36 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "homepage": "https://github.com/FoxxMD/multi-scrobbler#readme", "dependencies": { "@awaitjs/express": "^0.6.3", + "@kenyip/backoff-strategies": "^1.0.4", "ajv": "^7.2.4", "body-parser": "^1.19.0", "compare-versions": "^4.1.2", diff --git a/src/common/infrastructure/config/source/index.ts b/src/common/infrastructure/config/source/index.ts index cfc37249..3d2efc6f 100644 --- a/src/common/infrastructure/config/source/index.ts +++ b/src/common/infrastructure/config/source/index.ts @@ -4,8 +4,8 @@ export interface SourceRetryOptions extends RequestRetryOptions { /** * default # of automatic polling restarts on error * - * @default 0 - * @examples [1] + * @default 5 + * @examples [5] * */ maxPollRetries?: number } diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index faec45a3..841ca9f3 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -26,10 +26,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -122,10 +122,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -208,10 +208,10 @@ "JellyData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -378,10 +378,10 @@ "type": "string" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -479,10 +479,10 @@ "ListenBrainzSourceData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -554,10 +554,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -673,10 +673,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -817,10 +817,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -931,10 +931,10 @@ "SourceRetryOptions": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1025,10 +1025,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1086,10 +1086,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1238,10 +1238,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index 69aaa352..90d63ab4 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -90,10 +90,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -229,10 +229,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -315,10 +315,10 @@ "JellyData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -485,10 +485,10 @@ "type": "string" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -796,10 +796,10 @@ "ListenBrainzSourceData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -910,10 +910,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1155,10 +1155,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1347,10 +1347,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1510,10 +1510,10 @@ "SourceRetryOptions": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1604,10 +1604,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1665,10 +1665,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1827,10 +1827,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, diff --git a/src/common/schema/source.json b/src/common/schema/source.json index f89bbbed..5d5e3d5a 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -64,10 +64,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -153,10 +153,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -232,10 +232,10 @@ "JellyData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -349,10 +349,10 @@ "type": "string" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -482,10 +482,10 @@ "ListenBrainzSourceData": { "properties": { "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -557,10 +557,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -669,10 +669,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -799,10 +799,10 @@ ] }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -931,10 +931,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1020,10 +1020,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, @@ -1130,10 +1130,10 @@ "type": "number" }, "maxPollRetries": { - "default": 0, + "default": 5, "description": "default # of automatic polling restarts on error", "examples": [ - 1 + 5 ], "type": "number" }, diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 6a2f3ccd..3940083f 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -3,7 +3,7 @@ import { buildTrackString, capitalize, closePlayDate, genGroupId, mergeArr, - playObjDataMatch, + playObjDataMatch, pollingBackoff, sleep, sortByNewestPlayDate, sortByOldestPlayDate } from "../utils.js"; @@ -179,8 +179,8 @@ export default abstract class AbstractSource { const { data: { - maxPollRetries = 0, - retryMultiplier = 1.5, + maxPollRetries = 5, + retryMultiplier = 1, } = {}, } = this.config; @@ -192,7 +192,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); diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index 886d2dff..e5d53a05 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -40,7 +40,6 @@ export default class SpotifySource extends MemorySource { const { data: { interval = 30, - maxPollRetries = 5 } = {} } = config; @@ -49,7 +48,6 @@ export default class SpotifySource extends MemorySource { } this.config.data.interval = interval; - this.config.data.maxPollRetries = maxPollRetries; this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; diff --git a/src/utils.ts b/src/utils.ts index fef3e924..dc6608dc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,6 +14,8 @@ import { import {Request} from "express"; import pathUtil from "path"; import {ErrorWithCause} from "pony-cause"; +import backoffStrategies from '@kenyip/backoff-strategies'; +import bstrat from "@kenyip/backoff-strategies"; dayjs.extend(utc); @@ -618,3 +620,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); +}