feat: Implement Kodi as a Source

This commit is contained in:
FoxxMD
2023-03-15 12:26:35 -04:00
parent 1b0212f3bb
commit 548356aaa4
14 changed files with 880 additions and 8 deletions
+1
View File
@@ -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)
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "MyKodi",
"data": {
"url": "http://localhost:8080/jsonrpc",
"username": "myUser",
"password": "myPassword"
}
}
]
+69
View File
@@ -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`
<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)
+154 -4
View File
@@ -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"
}
+1
View File
@@ -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",
+187
View File
@@ -0,0 +1,187 @@
import AbstractApiClient from "./AbstractApiClient.js";
import {ErrorWithCause} from "pony-cause";
import {KodiData} from "../common/infrastructure/config/source/kodi.js";
import { KodiClient } from 'kodi-api'
import normalizeUrl from "normalize-url";
import {URL} from "url";
import {RecentlyPlayedOptions} from "../sources/AbstractSource.js";
import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js";
import dayjs from "dayjs";
interface KodiDuration {
hours: number
milliseconds: number
minutes: number
seconds: number
}
interface ActivePlayer {
playerid: number
playertype: string
type: string
}
interface PlayerInfo {
position: -1 | 0
time: KodiDuration
totaltime: KodiDuration
type: string
}
interface PlayerItem {
album: string
albumartist: string[]
artist: string[]
duration: number
id: number
label: string
title: string
type: string
}
export class KodiApiClient extends AbstractApiClient {
declare config: KodiData
url: URL;
token?: string;
client: KodiClient;
constructor(name: any, config: KodiData, options = {}) {
super('Kodi', name, config, options);
const {
url = 'http://localhost:8080/jsonrpc'
} = config;
this.url = KodiApiClient.parseConnectionUrl('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<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];
}
}
+2 -2
View File
@@ -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'];
@@ -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'
}
@@ -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;
+107
View File
@@ -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<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"
},
"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"
}
]
},
+107
View File
@@ -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<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"
},
"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"
}
]
},
+100
View File
@@ -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<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"
},
"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": {
+75
View File
@@ -0,0 +1,75 @@
import MemorySource from "./MemorySource.js";
import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js";
import {EventEmitter} from "events";
import {RecentlyPlayedOptions} from "./AbstractSource.js";
import {KodiSourceConfig} from "../common/infrastructure/config/source/kodi.js";
import {KodiApiClient} from "../apis/KodiApiClient.js";
export class KodiSource extends MemorySource {
declare config: KodiSourceConfig;
client: KodiApiClient;
clientReady: boolean = false;
constructor(name: any, config: KodiSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data,
} = config;
const {
interval = 10,
maxInterval = 30,
...rest
} = data || {};
super('kodi', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter);
this.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);
}
}
+22
View File
@@ -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;
}