diff --git a/src/backend/common/infrastructure/config/client/koito.ts b/src/backend/common/infrastructure/config/client/koito.ts new file mode 100644 index 00000000..ded4d756 --- /dev/null +++ b/src/backend/common/infrastructure/config/client/koito.ts @@ -0,0 +1,30 @@ +export interface ListensResponse { + items: ListenObjectResponse[] + total_record_count: number + items_per_page: number + has_next_page: boolean + current_page: number +} + +export interface ListenObjectResponse { + /** ISO8601 timestamp */ + time: string + track: TrackResponse +} + +export interface TrackResponse { + id: number + title: string + artists: ArtistResponse[] + musicbrainz_id: string | null + listen_count: number + duration: number + image: string | null + album_id: number + time_listened: number +} + +export interface ArtistResponse { + id: number + name: string +} \ No newline at end of file diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 60d3c7ec..8e7fb8e2 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -16,8 +16,10 @@ import { UpstreamError } from "../errors/UpstreamError.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js"; import AbstractApiClient from "./AbstractApiClient.js"; -import { joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; +import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; import { parseRegexSingleOrFail } from '../../utils.js'; +import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js' +import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; export interface ArtistMBIDMapping { @@ -120,6 +122,7 @@ export class ListenbrainzApiClient extends AbstractApiClient { declare config: ListenBrainzClientData; url: URLData; + isKoito: boolean = false; constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) { super('ListenBrainz', name, config, options); @@ -189,7 +192,8 @@ export class ListenbrainzApiClient extends AbstractApiClient { testConnection = async () => { try { - const resp = await this.callApi(request.get(this.url.url.toString())) + await isPortReachableConnect(this.url.port, {host: this.url.url.hostname}); + this.isKoito = await this.checkKoito(); return true; } catch (e) { if(e.status === 410 || e.message.includes('HTTP Status 410')) { @@ -208,6 +212,18 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } + async checkKoito(): Promise { + try { + const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`)); + this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!'); + this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.'); + return true; + } catch (e) { + this.logger.verbose('Listenbrainz Host does not look like a Koito server.'); + } + return false; + } + getUserListens = async (maxTracks: number, user?: string): Promise => { try { @@ -228,6 +244,24 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } + getUserListensKoito = async (maxTracks: number): Promise => { + try { + + const resp = await this.callApi(request + .get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`) + .query({ + period: 'all_time', + page: 0, + limit: maxTracks + }) + ); + const { body } = resp as any; + return body as KoitoListensResponse; + } catch (e) { + throw e; + } + } + getPlayingNow = async (user?: string): Promise => { try { @@ -249,6 +283,10 @@ export class ListenbrainzApiClient extends AbstractApiClient { } getRecentlyPlayed = async (maxTracks: number, user?: string): Promise => { + if(this.isKoito) { + return this.getRecentlyPlayedKoito(maxTracks) + } + try { const resp = await this.getUserListens(maxTracks, user); return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x)); @@ -258,6 +296,17 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } + getRecentlyPlayedKoito = async (maxTracks: number): Promise => { + try { + const resp = await this.getUserListensKoito(maxTracks); + return resp.items.map(x => listenObjectResponseToPlay(x)); + } catch (e) { + this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); + return []; + } + } + + submitListen = async (play: PlayObject, log: boolean = false) => { try { const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]}; diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts new file mode 100644 index 00000000..cba11e20 --- /dev/null +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -0,0 +1,38 @@ +import dayjs from "dayjs"; +import { PlayObject } from "../../../../core/Atomic.js"; +import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; +import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js"; +import AbstractApiClient from "../AbstractApiClient.js"; + + +export class KoitoApiClient extends AbstractApiClient { + + constructor(name: any, config: any, options: AbstractApiOptions) { + super('Koito', name, config, options); + } + +} + +export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => { + const play: PlayObject = { + data: { + track: obj.track.title, + artists: (obj.track.artists ?? []).map(x => x.name), + duration: obj.track.duration, + playDate: dayjs(obj.time) + }, + meta: { + source: 'Koito', + newFromSource: options.newFromSource ?? false, + trackId: obj.track.id.toString() + } + } + if(obj.track.musicbrainz_id !== null) { + play.data.meta = { + brainz: { + track: obj.track.musicbrainz_id + } + } + } + return play; +} \ No newline at end of file diff --git a/src/backend/scrobblers/ListenbrainzScrobbler.ts b/src/backend/scrobblers/ListenbrainzScrobbler.ts index e63117e5..534b02f8 100644 --- a/src/backend/scrobblers/ListenbrainzScrobbler.ts +++ b/src/backend/scrobblers/ListenbrainzScrobbler.ts @@ -17,6 +17,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { api: ListenbrainzApiClient; requiresAuth = true; requiresAuthInteraction = false; + isKoito: boolean = false; declare config: ListenBrainzClientConfig; @@ -44,6 +45,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { protected async doCheckConnection(): Promise { await this.api.testConnection(); + this.isKoito = this.api.isKoito; return true; } diff --git a/src/backend/sources/ListenbrainzSource.ts b/src/backend/sources/ListenbrainzSource.ts index 35898bd7..f46c70df 100644 --- a/src/backend/sources/ListenbrainzSource.ts +++ b/src/backend/sources/ListenbrainzSource.ts @@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import MemorySource from "./MemorySource.js"; +import { isPortReachableConnect } from "../utils/NetworkUtils.js"; export default class ListenbrainzSource extends MemorySource { api: ListenbrainzApiClient; requiresAuth = true; requiresAuthInteraction = false; + isKoito: boolean = false; declare config: ListenBrainzSourceConfig; @@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource { protected async doCheckConnection(): Promise { try { - await request.get(this.api.url); + await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname}); + const isKoito = await this.api.checkKoito(); + this.api.isKoito = isKoito; + this.isKoito = isKoito; return true; } catch (e) { if(isNodeNetworkException(e)) { @@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource { getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { const {limit = 20} = options; + if(this.isKoito) { + return await this.api.getRecentlyPlayedKoito(limit); + } const now = await this.api.getPlayingNow(); this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x))); return await this.api.getRecentlyPlayed(limit); diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index d426d488..bf3b0f45 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => { finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); return finalUrl; } + +export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`); + export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => { const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host; let v4: string,