Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
7.4 kB · 205 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206import dayjs from "dayjs";import { KodiClient } from 'kodi-api'import normalizeUrl from "normalize-url";import { URL } from "url";import type {PlayObject, PlayObjectMinimal} from "../../../core/Atomic.ts";import type {RecentlyPlayedOptions} from "../../sources/AbstractSource.ts";import type {AbstractApiOptions, FormatPlayObjectOptions} from "../infrastructure/Atomic.ts";import type {KodiData} from "../infrastructure/config/source/kodi.ts";import AbstractApiClient from "./AbstractApiClient.ts";import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";import { nameToCredit } from "../../../core/MusicMetadata.ts";import { namesToCredits } from "../../../core/MusicMetadata.ts";import { AuthError } from "../errors/MSErrors.ts";
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;
version?: string;
declare client: KodiClient;
constructor(name: any, config: KodiData, options: AbstractApiOptions) { super('Kodi', name, config, options); const { url = 'http://localhost:8080/jsonrpc' } = config; this.url = KodiApiClient.parseConnectionUrl(url); const auth = new Buffer(`${config.username}:${config.password}`).toString('base64'); this.client = new KodiClient({ clientType: this.url.protocol.replace(':', '') as ('http' | 'https'), //parameterMode: 'object', clientOptions: { host: this.url.hostname, port: this.url.port, path: this.url.pathname, headers: { Authorization: `Basic ${auth}` }, } }); }
static parseConnectionUrl(val: string) { const normal = normalizeUrl(val, {removeTrailingSlash: true, normalizeProtocol: true}) const url = new URL(normal);
if (url.port === null || url.port === '') { url.port = '8080'; } if (url.pathname === '/') { url.pathname = '/jsonrpc'; } else if (url.pathname === '/jsonrpc/') { url.pathname = '/jsonrpc'; } return url; }
static formatPlayObj(obj: (PlayerItem & Partial<PlayerInfo> & { playerid?: number }), options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject { const {newFromSource = true} = options;
const { album: albumVal, albumartist, artist: artistVal, duration, id, title, time, playerid, } = obj;
const artists = artistVal === null || artistVal === undefined ? [] : artistVal; const album = albumVal === null || albumVal === '' ? undefined : albumVal; const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined;
const play: PlayObjectMinimal = { data: { track: nameToCredit(title), album: nameToCredit(album), albumArtists: namesToCredits(albumartist), artists: namesToCredits(artistVal), duration, playDate: dayjs() }, meta: { source: 'kodi', mediaPlayerName: 'Kodi', mediaPlayerVersion: options.version, trackId: id.toString(), newFromSource, trackProgressPosition, deviceId: playerid !== undefined ? `Player${playerid}` : undefined, } } return baseFormatPlayObj(obj, play); }
testConnection = async () => { return true; }
testAuth = async () => { try {
await this.client.connect(); // https://kodi.wiki/view/JSON-RPC_API/v12#JSONRPC.Version const jsonInfo = await requireKodiNamespace(this.client.JSONRPC, 'JSONRPC').Version();
// https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties const applicationInfo = await requireKodiNamespace(this.client.Application, 'Application').GetProperties(['version']);
this.version = `${applicationInfo.version.major}.${applicationInfo.version.minor}`; 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?'; } throw new AuthError(msg, {cause: e}); } }
getPlayerInfo = async (id: number): Promise<PlayerInfo> => { // https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetProperties // @ts-expect-error types are wrong 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-expect-error types are wrong 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 requireKodiNamespace(this.client.Player, '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}, {version: this.version});
return [play]; }}
/** kodi client namespaces are only populated after the client has connected */const requireKodiNamespace = <T>(ns: T | undefined, name: string): T => { if (ns === undefined) { throw new Error(`Kodi client ${name} namespace is not available, is the client connected?`); } return ns;}