import dayjs from "dayjs"; import type {PlayObject, PlayObjectMinimal, ScrobbleActionResult, URLData} from "../../../../core/Atomic.ts"; import type {AbstractApiOptions, PaginatedListensTimeRangeOptions, PaginatedTimeRangeListens, PaginatedTimeRangeListensResult} from "../../infrastructure/Atomic.ts"; import type {GetListensOptions, KoitoData, ListenObjectResponse, ListensResponse} from "../../infrastructure/config/client/koito.ts"; import AbstractApiClient from "../AbstractApiClient.ts"; import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.ts"; import type { Request, Response } from 'superagent'; import request from 'superagent'; import { UpstreamError } from "../../errors/UpstreamError.ts"; import { playToListenPayload } from '../listenbrainz/lzUtils.ts'; import type {SubmitPayload} from '../../../../core/vendor/listenbrainz/interfaces.ts'; import type {ListenType} from '../../../../core/vendor/listenbrainz/interfaces.ts'; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; import { AuthError, ScrobbleSubmitError } from "../../errors/MSErrors.ts"; import { tryApiCall } from "../../../utils/RequestUtils.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { artistNamesToCredits } from "../../../../core/StringUtils.ts"; import { findCauseByFunc } from "../../../utils/ErrorUtils.ts"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts"; interface SubmitOptions { log?: boolean listenType?: ListenType } const KOITO_LZ_PATH: RegExp = new RegExp(/^\/apis\/listenbrainz(\/?1?\/?)?$/); export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRangeListens { declare config: KoitoData; url: URLData; constructor(name: any, config: KoitoData, options: AbstractApiOptions) { super('Koito', name, config, options); const { url } = this.config; const u = normalizeWebAddress(url); if(u.url.pathname === '/') { this.url = u; } else if(parseRegexSingle(KOITO_LZ_PATH, u.url.pathname) !== undefined) { this.logger.verbose('Detected Koito Server URL path only contains listenbrainz prefix. Removing this for API calls so non-listenbrainz paths work correctly.'); this.url = normalizeWebAddress(getBaseFromUrl(u.url).toString()); } else { this.logger.verbose('It looks like Koito server URL contains non-standard parts (not "just" /apis/listenbrainz"). MS will assume this is the ROOT path for the Koito server.'); this.url = u; } this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`) } doCallApi = async (req: Request, retries = 0): Promise => { try { req.set('Authorization', `Token ${this.config.token}`); return await req as T; } catch (e) { const { message, err, status, response: { body = undefined, text = undefined, } = {} } = e; // TODO check err for network exception if (status !== undefined) { const statusMsg = `(HTTP Status ${status})`; const msgParts = []; // if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service const showStopper = status !== 400; if (body !== undefined) { if (typeof body === 'object') { if ('code' in body) { msgParts.push(`Code ${body.code}`); } if ('error' in body) { msgParts.push(`Error => ${body.error}`); } if ('message' in body) { msgParts.push(`Message => ${body.error}`); } } else if (typeof body === 'string') { msgParts.push(`Response => ${body}`); } } if (msgParts.length === 0 && text !== undefined && text.trim() !== '') { msgParts.push(`Response => ${text}`); } throw new UpstreamError(`Koito API Request Failed => ${[statusMsg, ...msgParts].join(' | ')}`, { cause: e, showStopper }); } throw e; } } callApi = async (reqFunc: () => Request, retries = 0): Promise => { const apiCall = async () => await reqFunc(); try { return await tryApiCall(() => this.doCallApi(reqFunc()), {...this.config, logger: this.logger}) as T; } catch (e) { throw e; } } testConnection = async () => { try { await isPortReachableConnect(this.url.port, { host: this.url.url.hostname }); } catch (e) { throw new Error(`Koito server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e }); } try { const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url, 'apis/web/v1/stats')}`)); if(resp.type !== 'application/json') { throw new Error(`Expected response from ${resp.request.url} to be 'application/json' but got ${resp.type}. Is the Normalized Koito URL correct?`); } } catch (e) { let allowedHint = ''; if(e.cause !== undefined && 'response' in e.cause) { if(e.cause.response.status === 403) { allowedHint = ` HINT: 403 usually means Koito env KOITO_ALLOWED_HOSTS is not configured correctly. Check Koito logs for warnings.` } } throw new Error(`A server exists at ${this.url.url.hostname}:${this.url.port} but is not responding to API calls as expected.${allowedHint}`, { cause: e }); } } testAuth = async () => { try { const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`)); return true; } catch (e) { const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)}); } } getUserListens = async (options: GetListensOptions): Promise => { const {page = 0} = options; try { const resp = await this.callApi(() => request .get(`${joinedUrl(this.url.url, '/apis/web/v1/listens')}`) .query({ period: 'all_time', page, ...options }) ); const { body } = resp as any; return body as ListensResponse; } catch (e) { throw e; } } getPaginatedTimeRangeListens = async (params: PaginatedListensTimeRangeOptions): Promise> => { let resp: ListensResponse; try { resp = await this.getUserListens({...params, page: params.cursor}); } catch (e) { throw new Error('Error occurred while getting Koito paginated listens', { cause: e }); } return { data: resp.items.map((x => listenObjectResponseToPlay(x, { url: this.url.url }))), meta: { ...params, total: resp.total_record_count, cursor: resp.current_page, more: resp.has_next_page } } } getPaginatedUnitOfTime(): dayjs.ManipulateType { return 'second'; } // getRecentlyPlayed = async (maxTracks: number): Promise => { // try { // const resp = await this.getUserListens({limit: maxTracks}); // 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 []; // } // } submitListen = async (play: PlayObject, options: SubmitOptions = {}): Promise => { const { log = false, listenType = 'single' } = options; const listenPayload: SubmitPayload = { listen_type: listenType, payload: [playToListenPayload(play)] }; try { if (listenType === 'playing_now') { delete listenPayload.payload[0].listened_at; } if (log) { this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`); } // response consists of {"status": "ok"} // so no useful information // https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens // TODO may we should make a call to recent-listens to get the parsed scrobble? const resp = await this.callApi(() => request.post(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/submit-listens')}`).type('json').send(listenPayload)); if (log) { this.logger.debug(`Submit Response: ${resp.text}`) } return {payload: listenPayload, response: resp.text, createdAt: dayjs().toISOString()}; } catch (e) { throw new ScrobbleSubmitError(`Error occurred while making Koito API submit request (listen_type ${listenPayload.listen_type})`, {cause: e, payload: listenPayload, response: e.response, responseBody: e.response?.text}); } } } export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => { const play: PlayObjectMinimal = { data: { track: obj.track.title, artists: artistNamesToCredits((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(), url: { web: options.url !== undefined ? joinedUrl(options.url, `/track/${obj.track.id.toString()}`).toString() : undefined } } } if (obj.track.musicbrainz_id !== null) { play.data.meta = { brainz: { recording: obj.track.musicbrainz_id } } } return baseFormatPlayObj(obj, play); }