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.