diff --git a/config/koito.json.example b/config/koito.json.example index a21e45fe..6c2ea492 100644 --- a/config/koito.json.example +++ b/config/koito.json.example @@ -1,16 +1,16 @@ [ - { - "name": "koito-source", - "configureAs": "source", + { + "name": "koito-client", + "configureAs": "client", "data": { "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", "username": "admin", "url": "http://192.168.0.100:4110" } }, - { - "name": "koito-client", - "configureAs": "client", + { + "name": "koito-source", + "configureAs": "source", "data": { "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", "username": "admin", diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index f6c1352a..fb639dc8 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -30,7 +30,8 @@ export type SourceType = | 'mpd' | 'vlc' | 'icecast' - | 'azuracast'; + | 'azuracast' + | 'koito'; export const sourceTypes: SourceType[] = [ 'spotify', @@ -55,7 +56,8 @@ export const sourceTypes: SourceType[] = [ 'mpd', 'vlc', 'icecast', - 'azuracast' + 'azuracast', + 'koito' ]; export const isSourceType = (data: string): data is SourceType => { diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index 7400ef1c..e7c3f736 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -146,7 +146,7 @@ export class KoitoApiClient extends AbstractApiClient { getRecentlyPlayed = async (maxTracks: number): Promise => { try { const resp = await this.getUserListens(maxTracks); - return resp.items.map(x => listenObjectResponseToPlay(x)); + return resp.items.map(x => listenObjectResponseToPlay(x, { url: this.url.url })); } catch (e) { this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); return []; @@ -179,7 +179,7 @@ export class KoitoApiClient extends AbstractApiClient { } -export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean } = {}): PlayObject => { +export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => { const play: PlayObject = { data: { track: obj.track.title, @@ -190,7 +190,10 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { meta: { source: 'Koito', newFromSource: options.newFromSource ?? false, - trackId: obj.track.id.toString() + trackId: obj.track.id.toString(), + url: { + web: options.url !== undefined ? joinedUrl(options.url, `/track/${obj.track.id.toString()}`).toString() : undefined + } } } if (obj.track.musicbrainz_id !== null) { diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index b1b65e74..09677ad0 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -143,7 +143,7 @@ export default class ScrobbleClients { this.logger.error(invalidTypeMsg); continue; } - if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) { + if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) { this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); continue; } @@ -262,8 +262,8 @@ export default class ScrobbleClients { continue; } for(const [i,rawConf] of rawClientConfigs.entries()) { - if(['lastfm','listenbrainz'].includes(clientType) && - ((rawConf as LastfmClientConfig | ListenBrainzClientConfig).configureAs === 'source')) + if(['lastfm','listenbrainz','koito'].includes(clientType) && + ((rawConf as LastfmClientConfig | ListenBrainzClientConfig | KoitoClientConfig).configureAs === 'source')) { this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`); continue; diff --git a/src/backend/sources/KoitoSource.ts b/src/backend/sources/KoitoSource.ts new file mode 100644 index 00000000..0692f923 --- /dev/null +++ b/src/backend/sources/KoitoSource.ts @@ -0,0 +1,77 @@ +import EventEmitter from "events"; +import request from "superagent"; +import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; +import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; +import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; +import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js"; +import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import MemorySource from "./MemorySource.js"; +import { isPortReachableConnect } from "../utils/NetworkUtils.js"; +import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; +import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js"; + +export default class KoitoSource extends MemorySource { + + api: KoitoApiClient; + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: KoitoSourceConfig; + + constructor(name: any, config: KoitoSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data: { + interval = 15, + maxInterval = 60, + ...restData + } = {} + } = config; + super('koito', name, {...config, data: {interval, maxInterval, ...restData}}, internal, emitter); + this.canPoll = true; + this.canBacklog = true; + this.api = new KoitoApiClient(name, config.data, {logger: this.logger}); + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + this.supportsUpstreamRecentlyPlayed = true + this.SCROBBLE_BACKLOG_COUNT = 100; + this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`) + } + + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return listenObjectResponseToPlay(obj, options); } + + protected async doCheckConnection(): Promise { + await this.api.testConnection(); + return true; + } + + doAuthentication = async () => { + if(this.config.data.token === undefined) { + throw new Error('Must provide a User Token in configuration'); + } + try { + return await this.api.testAuth(); + } catch (e) { + if(isNodeNetworkException(e)) { + this.logger.error('Could not communicate with Koito API'); + } + throw e; + } + } + + + getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { + const {limit = 20} = options; + this.processRecentPlays([]); + return await this.api.getRecentlyPlayed(limit); + } + + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + try { + return await this.api.getRecentlyPlayed(20); + } catch (e) { + throw e; + } + } + + protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) +} diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 7f535f54..e8e68c08 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -65,6 +65,8 @@ import PlexApiSource from './PlexApiSource.js'; import { nonEmptyStringOrDefault } from '../../core/StringUtils.js'; import { IcecastSource } from './IcecastSource.js'; import DeezerInternalSource from './DeezerInternalSource.js'; +import KoitoSource from './KoitoSource.js'; +import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -199,6 +201,9 @@ export default class ScrobbleSources { case 'azuracast': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig"); break; + case 'koito': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("KoitoSourceConfig"); + break; } } return this.schemaDefinitions[type]; @@ -277,7 +282,7 @@ export default class ScrobbleSources { this.logger.error(invalidMsgType); continue; } - if(['lastfm','listenbrainz'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) + if(['lastfm','listenbrainz','koito'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source')) { this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`); continue; @@ -692,8 +697,8 @@ export default class ScrobbleSources { continue; } for (const [i,rawConf] of sourceConfigs.entries()) { - if(['lastfm','listenbrainz'].includes(sourceType) && - ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs !== 'source')) + if(['lastfm','listenbrainz','koito'].includes(sourceType) && + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source')) { this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); continue; @@ -865,6 +870,9 @@ export default class ScrobbleSources { case 'azuracast': newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter); break; + case 'koito': + newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter); + break; default: break; }