import 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 { artistNamesToCredits } from "../../../core/StringUtils.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 & { 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: title, album: album, albumArtists: artistNamesToCredits(albumartist), artists: artistNamesToCredits(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 this.client.JSONRPC.Version(); // https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties const applicationInfo = await this.client.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 Error(msg, {cause: e}); } } getPlayerInfo = async (id: number): Promise => { // 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 => { // 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}, {version: this.version}); return [play]; } }