diff --git a/README.md b/README.md
index 4da686a6..53b564b0 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [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)
diff --git a/config/kodi.json.example b/config/kodi.json.example
new file mode 100644
index 00000000..3167eef6
--- /dev/null
+++ b/config/kodi.json.example
@@ -0,0 +1,10 @@
+[
+ {
+ "name": "MyKodi",
+ "data": {
+ "url": "http://localhost:8080/jsonrpc",
+ "username": "myUser",
+ "password": "myPassword"
+ }
+ }
+]
diff --git a/docs/configuration.md b/docs/configuration.md
index 1f93f4f3..34c120e8 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -16,6 +16,7 @@
* [MPRIS (Linux Desktop)](#mpris)
* [Mopidy](#mopidy)
* [JRiver](#jriver)
+ * [Kodi](#kodi)
* [Client Configurations](#client-configurations)
* [Maloja](#maloja)
* [Last.fm](#lastfm)
@@ -532,6 +533,74 @@ MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
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 JRiver 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 JRiver 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`
+
+
+URL Transform Examples
+
+```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`
+
+
+
+### 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)
diff --git a/package-lock.json b/package-lock.json
index 429d1e92..ea559b10 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,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",
@@ -240,7 +241,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": "*"
}
@@ -410,6 +410,14 @@
"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",
@@ -891,6 +899,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",
@@ -1064,6 +1077,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",
@@ -1270,6 +1294,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",
@@ -1425,6 +1462,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",
@@ -2049,6 +2094,45 @@
"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/jayson/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/jsbi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
@@ -2079,8 +2163,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",
@@ -2093,6 +2176,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",
@@ -2116,6 +2230,25 @@
"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/kodi-api/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -2129,6 +2262,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",
@@ -2871,6 +3009,19 @@
"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/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -3596,7 +3747,6 @@
"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"
}
diff --git a/package.json b/package.json
index e3caff36..4e1186bb 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,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",
diff --git a/src/apis/KodiApiClient.ts b/src/apis/KodiApiClient.ts
new file mode 100644
index 00000000..f1e0e367
--- /dev/null
+++ b/src/apis/KodiApiClient.ts
@@ -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('http://localhost:8080');
+ 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 & { 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 => {
+ // 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 => {
+ // 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 => {
+ 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];
+ }
+}
diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts
index e165e4b5..7909e0f8 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' | 'jriver';
-export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver'];
+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'];
diff --git a/src/common/infrastructure/config/source/kodi.ts b/src/common/infrastructure/config/source/kodi.ts
new file mode 100644
index 00000000..634928e0
--- /dev/null
+++ b/src/common/infrastructure/config/source/kodi.ts
@@ -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'
+}
diff --git a/src/common/infrastructure/config/source/sources.ts b/src/common/infrastructure/config/source/sources.ts
index 1ea3e7e5..293f0160 100644
--- a/src/common/infrastructure/config/source/sources.ts
+++ b/src/common/infrastructure/config/source/sources.ts
@@ -10,7 +10,8 @@ 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 | JRiverSourceConfig;
+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 | JRiverSourceAIOConfig;
+export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig;
diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json
index 841ca9f3..7b699228 100644
--- a/src/common/schema/aio-source.json
+++ b/src/common/schema/aio-source.json
@@ -322,6 +322,110 @@
],
"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"
+ },
+ "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"
+ },
+ "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": {
@@ -925,6 +1029,9 @@
},
{
"$ref": "#/definitions/JRiverSourceAIOConfig"
+ },
+ {
+ "$ref": "#/definitions/KodiSourceAIOConfig"
}
]
},
diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json
index 90d63ab4..0cf3b2af 100644
--- a/src/common/schema/aio.json
+++ b/src/common/schema/aio.json
@@ -429,6 +429,110 @@
],
"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"
+ },
+ "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"
+ },
+ "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": {
@@ -1504,6 +1608,9 @@
},
{
"$ref": "#/definitions/JRiverSourceAIOConfig"
+ },
+ {
+ "$ref": "#/definitions/KodiSourceAIOConfig"
}
]
},
diff --git a/src/common/schema/source.json b/src/common/schema/source.json
index 5d5e3d5a..44399a3b 100644
--- a/src/common/schema/source.json
+++ b/src/common/schema/source.json
@@ -36,6 +36,9 @@
},
{
"$ref": "#/definitions/JRiverSourceConfig"
+ },
+ {
+ "$ref": "#/definitions/KodiSourceConfig"
}
],
"definitions": {
@@ -339,6 +342,103 @@
],
"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"
+ },
+ "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"
+ },
+ "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": {
diff --git a/src/sources/KodiSource.ts b/src/sources/KodiSource.ts
new file mode 100644
index 00000000..2a51052a
--- /dev/null
+++ b/src/sources/KodiSource.ts
@@ -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.client = new KodiApiClient(name, data);
+ this.requiresAuth = true;
+ this.canPoll = true;
+ this.multiPlatform = true;
+ }
+
+ initialize = async () => {
+ const {
+ data: {
+ url
+ } = {}
+ } = this.config;
+ 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);
+ }
+
+}
diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts
index 1b3283e5..d95afd7a 100644
--- a/src/sources/ScrobbleSources.ts
+++ b/src/sources/ScrobbleSources.ts
@@ -33,6 +33,8 @@ 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[]};
@@ -278,6 +280,23 @@ export default class ScrobbleSources {
});
}
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;
}
@@ -444,6 +463,9 @@ export default class ScrobbleSources {
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;
}
--
2.51.2
From 4dbea91a5e2aa36f1107c79590ef6fc8cbd717e8 Mon Sep 17 00:00:00 2001
From: FoxxMD
Date: Wed, 15 Mar 2023 12:28:44 -0400
Subject: [PATCH 2/2] docs: some words
---
docs/configuration.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 34c120e8..708b2ba1 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -535,7 +535,7 @@ See [`jriver.json.example`](/config/jriver.json.example) or [explore the schema
## [Kodi](https://kodi.tv/)
-In order for multi-scrobbler to communicate with JRiver you must have the [Web Interface](https://kodi.wiki/view/Web_interface) enabled. This can can be in the Kodi GUI:
+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`
@@ -543,7 +543,7 @@ In order for multi-scrobbler to communicate with JRiver you must have the [Web I
#### 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:8080/jsonrpc`
+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.