diff --git a/src/backend/common/infrastructure/config/client/maloja.ts b/src/backend/common/infrastructure/config/client/maloja.ts index 59d9170e..48bdedb3 100644 --- a/src/backend/common/infrastructure/config/client/maloja.ts +++ b/src/backend/common/infrastructure/config/client/maloja.ts @@ -1,7 +1,7 @@ import { RequestRetryOptions } from "../common.js"; import { CommonClientConfig, CommonClientData } from "./index.js"; -export interface MalojaClientData extends RequestRetryOptions, CommonClientData { +export interface MalojaData extends RequestRetryOptions { /** * URL for maloja server * @@ -16,7 +16,18 @@ export interface MalojaClientData extends RequestRetryOptions, CommonClientData apiKey: string } +export interface MalojaClientData extends MalojaData, CommonClientData { + +} + export interface MalojaClientConfig extends CommonClientConfig { + /** + * Should always be `client` when using Maloja as a client + * + * @default client + * @examples ["client"] + * */ + configureAs?: 'client' | 'source' data: MalojaClientData } diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts new file mode 100644 index 00000000..976df99d --- /dev/null +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -0,0 +1,422 @@ +import dayjs from 'dayjs'; +import request, { SuperAgentRequest, Response } from 'superagent'; +import compareVersions from "compare-versions"; +import AbstractApiClient from "../AbstractApiClient.js"; +import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; +import { MalojaData } from "../../infrastructure/config/client/maloja.js"; +import { PlayObject, URLData } from "../../../../core/Atomic.js"; +import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js"; +import { isNodeNetworkException } from "../../errors/NodeErrors.js"; +import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; +import { parseRetryAfterSecsFromObj, sleep } from "../../../utils.js"; +import { UpstreamError } from "../../errors/UpstreamError.js"; +import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; +import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; +import { buildTrackString } from '../../../../core/StringUtils.js'; + + + +export class MalojaApiClient extends AbstractApiClient { + + declare config: MalojaData; + url: URLData; + serverVersion: any; + + constructor(name: any, config: MalojaData, options: AbstractApiOptions) { + super('Maloja', name, config, options); + + const { + url + } = this.config; + + const u = normalizeWebAddress(url); + this.url = u; + + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`) + } + + callApi = async (req: SuperAgentRequest, retries = 0): Promise => { + const { + maxRequestRetries = 1, + retryMultiplier = DEFAULT_RETRY_MULTIPLIER + } = this.config; + + try { + return await req as T; + } catch (e) { + if ((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) { + if (retries < maxRequestRetries) { + const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); + this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); + await sleep(retryAfter * 1000); + return await this.callApi(req, retries + 1) + } else { + throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, { cause: e, showStopper: true }); + } + } else if (isSuperAgentResponseError(e)) { + const { + message, + response: { + status, + body, + } = {}, + response, + } = e; + if (isMalojaAPIErrorBody(body)) { + throw new UpstreamError(buildMalojaErrorString(body), { cause: e }) + } else { + throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, { cause: e }) + } + } else { + throw new Error('Unexpected error occurred during API call', { cause: e }); + } + } + } + + testConnection = async () => { + try { + await isPortReachableConnect(this.url.port, { host: this.url.url.hostname }); + } catch (e) { + throw new Error(`Maloja server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e }); + } + + try { + const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`)); + const { + statusCode, + body: { + version = [], + versionstring = '', + } = {}, + } = serverInfoResp; + + if (statusCode >= 300) { + throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`); + } + + this.logger.info('Communication test succeeded.'); + + if (version.length === 0) { + throw new Error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. Maloja versions below 3.0.0 are not supported.'); + } else { + this.logger.info(`Maloja Server Version: ${versionstring}`); + this.serverVersion = versionstring; + if (compareVersions(versionstring, '3.0.0') < 0) { + throw new Error(`Maloja versions below 3.0.0 are not supported.`); + } else if (compareVersions(versionstring, '3.2.0') < 0) { + this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`); + } + } + return true; + } catch (e) { + throw new Error('Communication test failed', { cause: e }) + } + + } + + testHealth = async () => { + + try { + const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`), 0); + const { + statusCode, + body: { + db_status: { + healthy = false, + rebuildinprogress = false, + complete = false, + } + } = {}, + } = serverInfoResp; + + if (statusCode >= 300) { + throw new Error(`Server responded with NOT OK status: ${statusCode}`); + } + + if (rebuildinprogress) { + throw new Error(`Server is rebuilding database`); + } + + if (!healthy) { + throw new Error('Server responded that it is not healthy'); + } + + return true + } catch (e) { + throw new Error('Error encountered while testing server health', { cause: e }); + } + } + + testAuth = async () => { + try { + const resp = await this.callApi(request + .get(`${this.url.url}/apis/mlj_1/test`) + .query({ key: this.config.apiKey })); + + const { + status, + body: { + status: bodyStatus, + } = {}, + body = {}, + text = '', + } = resp; + if (bodyStatus.toLocaleLowerCase() === 'ok') { + this.logger.info('Auth test passed!'); + return true; + } else { + this.logger.error('Maloja API Response', { + status, + body, + text: text.slice(0, 50) + }); + throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) }) + } + } catch (e) { + throw e; + } + } + + getRecentScrobbles = async (limit: number) => { + const resp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/scrobbles?perpage=${limit}`)); + const { + body: { + list = [], + } = {}, + } = resp; + return list.map(formatPlayObj); + } + + scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => { + + const { + data: { + album, + albumArtists = [], + duration, + } = {}, + meta: { + newFromSource = false, + } = {} + } = playObj; + + const sType = newFromSource ? 'New' : 'Backlog'; + + const pd = getScrobbleTsSOCDate(playObj); + + const scrobbleData = playToScrobblePayload(playObj, this.config.apiKey); + + try { + + + const response = await this.callApi(request.post(`${this.url.url}/apis/mlj_1/newscrobble`) + .type('json') + .send(scrobbleData)); + + let scrobbleResponse: MalojaScrobbleData, + scrobbledPlay: PlayObject; + + let responseBody: MalojaScrobbleV3ResponseData; + let warnStr: string; + + responseBody = response.body; + const { + track, + status, + warnings = [], + } = responseBody; + if (status === 'success') { + if (track !== undefined) { + scrobbleResponse = { + time: pd.unix(), + track: { + ...track, + length: duration + }, + } + if (album !== undefined) { + const { + album: malojaAlbum = {}, + } = track; + scrobbleResponse.track.album = { + name: album, + artists: albumArtists, + ...malojaAlbum, + } + } + } + if (warnings.length > 0) { + for (const w of warnings) { + warnStr = builMalojadWarningString(w); + if (warnStr.includes('The submitted scrobble was not added')) { + throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false }); + } + this.logger.warn(`Maloja Warning: ${warnStr}`); + } + } + } else { + throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false }); + } + + return [scrobbleResponse, responseBody, warnStr] + } catch (e) { + this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData }); + const responseError = getMalojaResponseError(e); + if (responseError !== undefined) { + if (responseError.status < 500 && e instanceof UpstreamError) { + e.showStopper = false; + } + if (responseError.response?.text !== undefined) { + this.logger.error('Raw Response:', { text: responseError.response?.text }); + } + } + throw e; + } + } +} + +export const buildMalojaErrorString = (body: MalojaResponseV3CommonData) => { + let valString: string | undefined = undefined; + const { + status, + error: { + type, + value, + desc + } = {} + } = body; + if (value !== undefined && value !== null) { + if (typeof value === 'string') { + valString = value; + } else if (Array.isArray(value)) { + valString = value.map(x => { + if (typeof x === 'string') { + return x; + } + return JSON.stringify(x); + }).join(', '); + } else { + valString = JSON.stringify(value); + } + } + return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`; +} + +export const builMalojadWarningString = (w: MalojaScrobbleWarning): string => { + const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`]; + let vals: string[] = []; + if (w.value !== null && w.value !== undefined) { + if (Array.isArray(w.value)) { + vals = w.value; + } else { + vals.push(w.value); + } + } + if (vals.length > 0) { + parts.push(vals.join(' | ')); + } + return parts.join(' => '); +} + +export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject => { + let artists, + title, + album, + duration, + time, + listenedFor; + + const { url } = options; + + // scrobble data structure changed for v3 + const { + // when the track was scrobbled + time: mTime, + track: { + artists: mArtists = [], + title: mTitle, + album: mAlbum, + // length of the track + length: mLength, + } = {}, + // how long the track was listened to before it was scrobbled + duration: mDuration, + } = obj; + + artists = mArtists; + time = mTime; + title = mTitle; + duration = mLength; + listenedFor = mDuration; + if (mAlbum !== null) { + const { + albumtitle, + name: mAlbumName, + artists: albumArtists = [] + } = mAlbum || {}; + album = albumtitle ?? mAlbumName; + } + + const artistStrings = artists.reduce((acc: any, curr: any) => { + let aString; + if (typeof curr === 'string') { + aString = curr; + } else if (typeof curr === 'object') { + aString = curr.name; + } + const aStrings = aString.split(','); + return [...acc, ...aStrings]; + }, []); + const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); + return { + data: { + artists: [...new Set(artistStrings)] as string[], + track: title, + album, + duration, + listenedFor, + playDate: dayjs.unix(time), + }, + meta: { + source: 'Maloja', + url: { + web: `${url}/track?${urlParams.toString()}` + } + } + } +} + +export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => { + + const { + data: { + artists = [], + albumArtists = [], + album, + track, + duration, + listenedFor + } = {} + } = playObj; + + const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj); + + const scrobbleData: MalojaScrobbleV3RequestData = { + title: track, + artists, + album, + key: apiKey, + time: pd.unix(), + // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135 + length: duration, + }; + if (listenedFor !== undefined && listenedFor > 0) { + scrobbleData.duration = listenedFor; + } + + if (albumArtists.length > 0) { + scrobbleData.albumartists = albumArtists; + } + + return scrobbleData; +} \ No newline at end of file diff --git a/src/backend/common/vendor/maloja/interfaces.ts b/src/backend/common/vendor/maloja/interfaces.ts index 5f53bf6a..87ba7167 100644 --- a/src/backend/common/vendor/maloja/interfaces.ts +++ b/src/backend/common/vendor/maloja/interfaces.ts @@ -3,20 +3,6 @@ import { ResponseError } from "superagent"; import { findCauseByFunc } from "../../../utils/ErrorUtils.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; -export interface MalojaV2ScrobbleData { - artists: string[] - title: string - album: string - /** - * Length of the track - * */ - duration: number - /** - * unix timestamp (seconds) scrobble was made at - * */ - time: number -} - export interface MalojaAlbumData { name?: string albumtitle?: string @@ -40,14 +26,14 @@ export interface MalojaV3ScrobbleData { /** * how long the track was listened to before it was scrobbled * */ - duration: number + duration?: number } -export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData; +export type MalojaScrobbleData = MalojaV3ScrobbleData; export interface MalojaScrobbleRequestData { /** The auth key used to scrobble */ - key: string + key?: string /** name of the track */ title: string /** name of the album */ @@ -60,16 +46,11 @@ export interface MalojaScrobbleRequestData { duration?: number } -export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData { - /** comma-separated list of artists for this track */ - artist: string -} - export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData { /** a list of artists for this track */ artists: string[] /** a list of artists for the album the track is on */ - albumartists: string[] + albumartists?: string[] /** skip server-side metadata parsing */ nofix?: boolean } diff --git a/src/backend/scrobblers/MalojaScrobbler.ts b/src/backend/scrobblers/MalojaScrobbler.ts index a9f07361..4f454827 100644 --- a/src/backend/scrobblers/MalojaScrobbler.ts +++ b/src/backend/scrobblers/MalojaScrobbler.ts @@ -1,32 +1,17 @@ -import { Logger } from "@foxxmd/logging"; -import compareVersions from 'compare-versions'; -import dayjs from 'dayjs'; +import { childLogger, Logger } from "@foxxmd/logging"; import EventEmitter from "events"; import normalizeUrl from "normalize-url"; -import request, { SuperAgentRequest } from 'superagent'; import { PlayObject } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; -import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; -import { UpstreamError } from "../common/errors/UpstreamError.js"; -import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; +import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js"; import { - getMalojaResponseError, - isMalojaAPIErrorBody, - MalojaResponseV3CommonData, - MalojaScrobbleData, MalojaScrobbleRequestData, - MalojaScrobbleV2RequestData, - MalojaScrobbleV3RequestData, - MalojaScrobbleV3ResponseData, MalojaScrobbleWarning, - MalojaV2ScrobbleData, - MalojaV3ScrobbleData, } from "../common/vendor/maloja/interfaces.js"; import { Notifiers } from "../notifier/Notifiers.js"; -import { parseRetryAfterSecsFromObj, sleep } from "../utils.js"; -import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; +import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js"; const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"]; @@ -36,208 +21,20 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { serverVersion: any; webUrl: string; + api: MalojaApiClient; + declare config: MalojaClientConfig constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { - super('maloja', name, config, notifier, emitter,logger); + super('maloja', name, config, notifier, emitter, logger); + this.api = new MalojaApiClient(name, this.config.data, { logger: childLogger(this.logger, 'API') }); this.MAX_INITIAL_SCROBBLES_FETCH = 100; } - static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject { - let artists, - title, - album, - duration, - time, - listenedFor; - - const {serverVersion, url} = options; - - if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) { - // scrobble data structure changed for v3 - const { - // when the track was scrobbled - time: mTime, - track: { - artists: mArtists = [], - title: mTitle, - album: mAlbum, - // length of the track - length: mLength, - } = {}, - // how long the track was listened to before it was scrobbled - duration: mDuration, - } = obj as MalojaV3ScrobbleData; - artists = mArtists; - time = mTime; - title = mTitle; - duration = mLength; - listenedFor = mDuration; - if(mAlbum !== null) { - const { - albumtitle, - name: mAlbumName, - artists: albumArtists = [] - } = mAlbum || {}; - album = albumtitle ?? mAlbumName; - } - } else { - // scrobble data structure for v2 and below - const { - artists: mArtists = [], - title: mTitle, - album: mAlbum, - duration: mDuration, - time: mTime, - } = obj as MalojaV2ScrobbleData; - artists = mArtists; - title = mTitle; - album = mAlbum; - duration = mDuration; - time = mTime; - } - const artistStrings = artists.reduce((acc: any, curr: any) => { - let aString; - if (typeof curr === 'string') { - aString = curr; - } else if (typeof curr === 'object') { - aString = curr.name; - } - const aStrings = aString.split(','); - return [...acc, ...aStrings]; - }, []); - const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); - return { - data: { - artists: [...new Set(artistStrings)] as string[], - track: title, - album, - duration, - listenedFor, - playDate: dayjs.unix(time), - }, - meta: { - source: 'Maloja', - url: { - web: `${url}/track?${urlParams.toString()}` - } - } - } - } - - formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl}); - - callApi = async (req: SuperAgentRequest, retries = 0) => { - const { - maxRequestRetries = 1, - retryMultiplier = DEFAULT_RETRY_MULTIPLIER - } = this.config.data; - - try { - return await req; - } catch (e) { - if((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) { - if(retries < maxRequestRetries) { - const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); - this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); - await sleep(retryAfter * 1000); - return await this.callApi(req, retries + 1) - } else { - throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, {cause : e, showStopper: true}); - } - } else if(isSuperAgentResponseError(e)) { - const { - message, - response: { - status, - body, - } = {}, - response, - } = e; - if(isMalojaAPIErrorBody(body)) { - throw new UpstreamError(buildErrorString(body), {cause: e}) - } else { - throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e}) - } - } else { - throw new Error('Unexpected error occurred during API call', {cause : e}); - } - } - } - - testConnection = async () => { - - const {url} = this.config.data; - try { - const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`)); - const { - statusCode, - body: { - version = [], - versionstring = '', - } = {}, - } = serverInfoResp; - - if (statusCode >= 300) { - throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`); - } - - this.logger.info('Communication test succeeded.'); - - if (version.length === 0) { - this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old. multi-scrobbler will most likely not work with this server.'); - } else { - this.logger.info(`Maloja Server Version: ${versionstring}`); - this.serverVersion = versionstring; - if(compareVersions(versionstring, '3.0.0') < 0) { - this.logger.warn(`Support for Maloja versions below 3.0.0 is DEPRECATED and will be removed in a future minor release.`); - } else if(compareVersions(versionstring, '3.2.0') < 0) { - this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`); - } - } - return true; - } catch (e) { - throw new Error('Communication test failed', {cause: e}) - } - } - - testHealth = async () => { - - const {url} = this.config.data; - try { - const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0); - const { - statusCode, - body: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message - db_status: { - healthy = false, - rebuildinprogress = false, - complete = false, - } - } = {}, - } = serverInfoResp; - - if (statusCode >= 300) { - throw new Error(`Server responded with NOT OK status: ${statusCode}`); - } - - if(rebuildinprogress) { - throw new Error(`Server is rebuilding database`); - } - - if(!healthy) { - throw new Error('Server responded that it is not healthy'); - } - - return true - } catch (e) { - throw new Error('Error encountered while testing server health', {cause: e}); - } - } + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => formatMalojaScrobbleToPlay(obj, { url: this.webUrl }); protected async doBuildInitData(): Promise { - const {data: {url, apiKey} = {}} = this.config; + const { data: { url, apiKey } = {} } = this.config; if (apiKey === undefined) { throw new Error("'apiKey' not found in config!"); } @@ -249,291 +46,83 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } protected async doCheckConnection(): Promise { - await this.testConnection(); - await this.testHealth(); - return true; + + try { + await this.api.testConnection(); + await this.api.testHealth(); + return true; + } catch (e) { + throw e; + } + } doAuthentication = async () => { - const {url, apiKey} = this.config.data; + const { data: { url, apiKey } = {} } = this.config; + if (apiKey === undefined) { + throw new Error("'apiKey' not found in config!"); + } try { - const resp = await this.callApi(request - .get(`${url}/apis/mlj_1/test`) - .query({key: apiKey})); - - const { - status, - body: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message - status: bodyStatus, - } = {}, - body = {}, - text = '', - } = resp; - if (bodyStatus.toLocaleLowerCase() === 'ok') { - this.logger.info('Auth test passed!'); - return true; - } else { - this.logger.error('Maloja API Response', { - status, - body, - text: text.slice(0, 50) - }); - throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {cause: new Error(`Maloja API Response was ${status}: ${text.slice(0,50)}`)}) - } + await this.api.testAuth(); + return true; } catch (e) { - if(e instanceof UpstreamError) { - if((e?.cause as any)?.status === 403) { - // may be an older version that doesn't support auth readiness before db upgrade - // and if it was before api was accessible during db build then test would fail during testConnection() - if(compareVersions(this.serverVersion, '2.12.19') < 0) { - if(!(await this.isReady())) { - throw new UpstreamError(`Could not test auth because server is not ready`, {showStopper: false}); - } - } - } + if (isNodeNetworkException(e)) { + this.logger.error('Could not communicate with Maloja API'); } throw e; } } getScrobblesForRefresh = async (limit: number) => { - const {url} = this.config.data; - const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`)); - const { - body: { - list = [], - } = {}, - } = resp; - return list.map((x: any) => this.formatPlayObj(x)); - } - - cleanSourceSearchTitle = (playObj: PlayObject) => { - const { - data: { - track, - artists: sourceArtists = [], - } = {}, - } = playObj; - let lowerTitle = track.toLocaleLowerCase(); - lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle); - // also remove [artist] from the track if found since that gets removed as well - const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase()); - lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle); - - // remove any whitespace in parenthesis - lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '') - // replace parenthesis - .replace('()', '') - .replace('( )', '') - .trim(); - - return lowerTitle; + return await this.api.getRecentScrobbles(limit); } alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData { - const {apiKey} = this.config.data; - - const { - data: { - artists = [], - albumArtists = [], - album, - track, - duration, - listenedFor - } = {} - } = playObj; - - const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj); - - const scrobbleData: MalojaScrobbleRequestData = { - title: track, - album, - key: apiKey, - time: pd.unix(), - // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135 - length: duration, - }; - if(listenedFor !== undefined && listenedFor > 0) { - scrobbleData.duration = listenedFor; - } + const { apiKey } = this.config.data; - // 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo - if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) { - (scrobbleData as MalojaScrobbleV3RequestData).artists = artists; - if(albumArtists.length > 0) { - (scrobbleData as MalojaScrobbleV3RequestData).albumartists = albumArtists; - } - } else { - // maloja seems to detect this deliminator much better than commas - // also less likely artist has a forward slash in their name than a comma - (scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / '); - } - - return scrobbleData; + return playToScrobblePayload(playObj); } doScrobble = async (playObj: PlayObject) => { - const {url, apiKey} = this.config.data; - const { - data: { - album, - duration, - playDate, - } = {}, meta: { source, newFromSource = false, } = {} } = playObj; - const pd = getScrobbleTsSOCDate(playObj); - - const sType = newFromSource ? 'New' : 'Backlog'; - - const scrobbleData = this.playToClientPayload(playObj); + const scrobbleData = playToScrobblePayload(playObj); - let responseBody: MalojaScrobbleV3ResponseData; + let scrobbledPlay: PlayObject; try { - const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`) - .type('json') - .send(scrobbleData)); + const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj); - let scrobbleResponse: any | undefined = undefined, - scrobbledPlay: PlayObject; - - if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) { - responseBody = response.body; - const { - track, - status, - warnings = [], - } = responseBody; - if(status === 'success') { - if(track !== undefined) { - scrobbleResponse = { - time: pd.unix(), - track: { - ...track, - length: duration - }, - } - if (album !== undefined) { - const { - album: malojaAlbum = {}, - } = track; - scrobbleResponse.track.album = { - ...malojaAlbum, - name: album - } - } - } - if(warnings.length > 0) { - for(const w of warnings) { - const warnStr = buildWarningString(w); - if(warnStr.includes('The submitted scrobble was not added')) { - throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false}); - } - this.logger.warn(`Maloja Warning: ${warnStr}`); - } - } - } else { - throw new UpstreamError(buildErrorString(response), {showStopper: false}); - } - } else { - const { - body: { - track: { - time: mTime = pd.unix(), - duration: mDuration = duration, - album: mAlbum = album, - ...rest - } = {} - } = {} - } = response; - scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration}; - } let warning = ''; - if(scrobbleResponse === undefined) { + if (scrobbleResp === undefined) { warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`; scrobbledPlay = playObj; } else { - scrobbledPlay = this.formatPlayObj(scrobbleResponse) + scrobbledPlay = this.formatPlayObj(scrobbleResp) } const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`; - if(warning !== '') { + if (warning !== '') { this.logger.warn(`${scrobbleInfo} | ${warning}`); - this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`); + this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`); } else { this.logger.info(scrobbleInfo); } return scrobbledPlay; } catch (e) { - await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); - this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData}); - const responseError = getMalojaResponseError(e); - if(responseError !== undefined) { - if(responseError.status < 500 && e instanceof UpstreamError) { - e.showStopper = false; - } - if(responseError.response?.text !== undefined) { - this.logger.error('Raw Response:', { text: responseError.response?.text }); - } - } + await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' }); throw e; } finally { this.logger.debug('Raw Payload:', scrobbleData); } } -} - -const buildErrorString = (body: MalojaResponseV3CommonData) => { - let valString: string | undefined = undefined; - const { - status, - error: { - type, - value, - desc - } = {} - } = body; - if(value !== undefined && value !== null) { - if(typeof value === 'string') { - valString = value; - } else if(Array.isArray(value)) { - valString = value.map(x => { - if(typeof x === 'string') { - return x; - } - return JSON.stringify(x); - }).join(', '); - } else { - valString = JSON.stringify(value); - } - } - return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`; -} - -const buildWarningString = (w: MalojaScrobbleWarning): string => { - const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`]; - let vals: string[] = []; - if(w.value !== null && w.value !== undefined) { - if(Array.isArray(w.value)) { - vals = w.value; - } else { - vals.push(w.value); - } - } - if(vals.length > 0) { - parts.push(vals.join(' | ')); - } - return parts.join(' => '); -} +} \ No newline at end of file diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 09677ad0..9eb5b741 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -270,7 +270,6 @@ export default class ScrobbleClients { } try { const validConfig = validateJson(rawConf, this.getSchemaByType(clientType), this.logger); - // @ts-expect-error configureAs should exist const {configureAs = defaultConfigureAs} = validConfig; if (configureAs === 'client') { const parsedConfig: ParsedConfig = { -- 2.51.2 From abf532c6bb4afee1e792b3c4c164891c1a848f8a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 6 Aug 2025 16:48:39 +0000 Subject: [PATCH 2/4] feat(maloja): Add Maloja Source #295 --- config/maloja.json.example | 12 ++- docsite/docs/configuration/configuration.mdx | 75 +++++++++++++++++++ docsite/src/pages/index.mdx | 1 + src/backend/common/infrastructure/Atomic.ts | 2 + .../infrastructure/config/source/maloja.ts | 21 ++++++ .../infrastructure/config/source/sources.ts | 3 + src/backend/sources/MalojaSource.ts | 75 +++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 12 ++- 8 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 src/backend/common/infrastructure/config/source/maloja.ts create mode 100644 src/backend/sources/MalojaSource.ts diff --git a/config/maloja.json.example b/config/maloja.json.example index 62f64295..00cd2e14 100644 --- a/config/maloja.json.example +++ b/config/maloja.json.example @@ -1,7 +1,17 @@ [ { - "name": "myMaloja", + "name": "myMaloja-client", "enable": true, + "configureAs": "client", + "data": { + "url": "http://localhost:42010", + "apiKey": "myMalojaKey" + } + }, + { + "name": "myMaloja-source", + "enable": true, + "configureAs": "source", "data": { "url": "http://localhost:42010", "apiKey": "myMalojaKey" diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 18bf579a..f0bbbebe 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -1326,6 +1326,55 @@ Most Listenbrainz clients require a token (Authentication Token) to be provided +### [Maloja (Source)](https://github.com/krateng/maloja) + +This Source monitors a Maloja server's scrobble history and then re-scrobbles discovered tracks to configured [Clients.](#client-configurations) + +:::tip[Other Uses] + +To _scrobble to_ a Maloja server, create a [Maloja (Client)](#maloja) + +::: + +See the [Maloja (Client)](#maloja) configuration for general setup. The only difference for **Source** configuration: + +* Cannot be setup with ENV config +* [File/AIO config](./?configType=file#configuration-types) must include `"configureAs": "source"` + +#### Configuration + + + + :::note + You cannot use ENV variables shown in the [Maloja Client config](#maloja) -- multi-scrobbler assumes Maloja ENVs are always used for the **client** configuration. You must use the [File or AIO](./?configType=file#configuration-types) config to setup Maloja as a Source. + ::: + + +
+ Change `configureAs` to `source` + + Example + + + +
+ + or +
+ +
+ Change `configureAs` to `source` + + Example + + + +
+ + or +
+
+ ### [Mopidy](https://mopidy.com/) Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/) @@ -2686,6 +2735,32 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token ### [Maloja](https://github.com/krateng/maloja) +Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this. + +
+ + Maloja Setup Intructions + + Using Maloja's example `docker-compose.yml`: + + ```yaml reference title="~/malojaData/docker-compose.yml" + https://github.com/krateng/maloja/blob/master/example-compose.yml + ``` + + Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password + + Start the container: + + ```shell title="~/malojaData" + docker compose up -d + ``` +
+ +* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://myMalojaServerIP/admin_apikeys) + * Create a **New Key** and then copy the generated key value + +Finally, add the Maloja server URL and API Key to the configuration type you choose to use, below. + #### Configuration diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index 90d01386..2271cb44 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -25,6 +25,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint) * [ListenBrainz](docs/configuration#listenbrainz-source) * [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint) + * [Maloja](docs/configuration#maloja-source) * [Mopidy](docs/configuration#mopidy) * [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon) * [MPRIS (Linux Desktop)](docs/configuration#mpris) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index cd25effc..90902dcb 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -26,6 +26,7 @@ export type SourceType = | 'kodi' | 'webscrobbler' | 'chromecast' + | 'maloja' | 'musikcube' | 'mpd' | 'vlc' @@ -52,6 +53,7 @@ export const sourceTypes: SourceType[] = [ 'kodi', 'webscrobbler', 'chromecast', + 'maloja', 'musikcube', 'mpd', 'vlc', diff --git a/src/backend/common/infrastructure/config/source/maloja.ts b/src/backend/common/infrastructure/config/source/maloja.ts new file mode 100644 index 00000000..dd383f39 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/maloja.ts @@ -0,0 +1,21 @@ +import { MalojaData } from "../client/maloja.js"; +import { PollingOptions } from "../common.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions { +} + +export interface MalojaSourceConfig extends CommonSourceConfig { + /** + * When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client. + * + * @default source + * @examples ["source"] + * */ + configureAs?: 'source' + data: MalojaSourceData +} + +export interface MalojaSourceAIOConfig extends MalojaSourceConfig { + type: 'maloja' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 35817364..7958c99b 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -22,6 +22,7 @@ import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscro import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; +import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; export type SourceConfig = @@ -45,6 +46,7 @@ export type SourceConfig = | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig + | MalojaSourceConfig | MusikcubeSourceConfig | MusicCastSourceConfig | MPDSourceConfig @@ -74,6 +76,7 @@ export type SourceAIOConfig = | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig + | MalojaSourceAIOConfig | MusikcubeSourceAIOConfig | MusicCastSourceAIOConfig | MPDSourceAIOConfig diff --git a/src/backend/sources/MalojaSource.ts b/src/backend/sources/MalojaSource.ts new file mode 100644 index 00000000..f0e32b19 --- /dev/null +++ b/src/backend/sources/MalojaSource.ts @@ -0,0 +1,75 @@ +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 { RecentlyPlayedOptions } from "./AbstractSource.js"; +import MemorySource from "./MemorySource.js"; +import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; +import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js"; +import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js"; +import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js"; + +export default class MalojaSource extends MemorySource { + + api: MalojaApiClient; + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: MalojaSourceConfig; + + constructor(name: any, config: MalojaSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data: { + interval = 15, + maxInterval = 60, + ...restData + } = {} + } = config; + super('maloja', name, { ...config, data: { interval, maxInterval, ...restData } }, internal, emitter); + this.canPoll = true; + this.canBacklog = true; + this.api = new MalojaApiClient(name, config.data, { logger: this.logger }); + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + this.supportsUpstreamRecentlyPlayed = true + this.SCROBBLE_BACKLOG_COUNT = 20; + 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.`) + } + + protected async doCheckConnection(): Promise { + await this.api.testConnection(); + return true; + } + + doAuthentication = async () => { + if (this.config.data.apiKey === undefined) { + throw new Error(`Must provide 'apiKey' in configuration`); + } + try { + return await this.api.testAuth(); + } catch (e) { + if (isNodeNetworkException(e)) { + this.logger.error('Could not communicate with Maloja API'); + } + throw e; + } + } + + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const { limit = 20 } = options; + this.processRecentPlays([]); + return await this.api.getRecentScrobbles(limit); + } + + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => { + try { + return await this.api.getRecentScrobbles(20); + } catch (e) { + throw e; + } + } + + protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({ formatted: true, ...options }) + +} \ No newline at end of file diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index e8e68c08..88f89565 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -25,6 +25,7 @@ import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js"; import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js"; import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js"; import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js"; +import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js"; import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js"; import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; @@ -67,6 +68,7 @@ import { IcecastSource } from './IcecastSource.js'; import DeezerInternalSource from './DeezerInternalSource.js'; import KoitoSource from './KoitoSource.js'; import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; +import MalojaSource from './MalojaSource.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -165,6 +167,9 @@ export default class ScrobbleSources { case 'ytmusic': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig"); break; + case 'maloja': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaSourceConfig"); + break; case 'mpris': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig"); break; @@ -697,8 +702,8 @@ export default class ScrobbleSources { continue; } for (const [i,rawConf] of sourceConfigs.entries()) { - if(['lastfm','listenbrainz','koito'].includes(sourceType) && - ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source')) + if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) && + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source')) { this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); continue; @@ -873,6 +878,9 @@ export default class ScrobbleSources { case 'koito': newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter); break; + case 'maloja': + newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter); + break; default: break; } -- 2.51.2 From 24df22781f0da59179c64d90846dca1c03153570 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 18 Aug 2025 14:23:35 +0000 Subject: [PATCH 3/4] fix: Don't removed undefined keys from nested objects Don't want to break 3p libraries (dayjs) --- src/backend/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 9e95d41b..3d5b1fa6 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -285,7 +285,9 @@ export const removeUndefinedKeys = >(obj: T): T | if(Array.isArray(obj[key])) { newObj[key] = obj[key]; } else if (obj[key] === Object(obj[key])) { - newObj[key] = removeUndefinedKeys(obj[key]); + // dumb assign nested objects + // bc they may be third party library-objects that use prototyping and we don't want to mess with + newObj[key] = obj[key]; } else if (obj[key] !== undefined) { newObj[key] = obj[key]; } -- 2.51.2 From 4f6fb4ffea5901d4a905aef24d8b0972940c0f2d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 18 Aug 2025 14:24:43 +0000 Subject: [PATCH 4/4] fix(maloja): Remove null duration/length --- .../common/vendor/maloja/MalojaApiClient.ts | 10 +++--- src/backend/utils.ts | 35 +++++++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index 976df99d..87aa27bc 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -8,7 +8,7 @@ import { PlayObject, URLData } from "../../../../core/Atomic.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js"; import { isNodeNetworkException } from "../../errors/NodeErrors.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; -import { parseRetryAfterSecsFromObj, sleep } from "../../../utils.js"; +import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep } from "../../../utils.js"; import { UpstreamError } from "../../errors/UpstreamError.js"; import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; @@ -346,8 +346,8 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject artists = mArtists; time = mTime; title = mTitle; - duration = mLength; - listenedFor = mDuration; + duration = getNonEmptyVal(mLength); + listenedFor = getNonEmptyVal(mDuration); if (mAlbum !== null) { const { albumtitle, @@ -369,14 +369,14 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject }, []); const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); return { - data: { + data: removeUndefinedKeys({ artists: [...new Set(artistStrings)] as string[], track: title, album, duration, listenedFor, playDate: dayjs.unix(time), - }, + }), meta: { source: 'Maloja', url: { diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 3d5b1fa6..646ea1bf 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -611,23 +611,36 @@ export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayOb return eArtists.length > 1 || cArtists.length > 1; } -export const getFirstNonEmptyVal = (values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable | undefined => { +export interface NonEmptyOptions { + ofType?: string, + test?: (val: T) => boolean +} +export const getFirstNonEmptyVal = (values: unknown[], options: NonEmptyOptions = {}): NonNullable | undefined => { for(const v of values) { - if(v === undefined || v === null) { - continue; - } - if(options.ofType !== undefined && typeof v !== options.ofType) { - continue; - } - if(options.test !== undefined && options.test(v as T) === false) { - continue; + const nonEmptyVal = getNonEmptyVal(v, options); + if(nonEmptyVal !== undefined) { + return nonEmptyVal as T; } - return v as T; } return undefined; } -export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); +export const getNonEmptyVal = (value: unknown, options: NonEmptyOptions = {}): NonNullable | undefined => { + if (value === undefined || value === null) { + return undefined; + } + if (options.ofType !== undefined && typeof value !== options.ofType) { + return undefined; + } + if (options.test !== undefined && options.test(value as T) === false) { + return undefined; + } + return value as T; +} + +const nonEmptyStringOpts: NonEmptyOptions = { ofType: 'string', test: (v) => v.trim() !== '' }; +export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, nonEmptyStringOpts); +export const getNonEmptyString = (value: unknown) => getNonEmptyVal(value, nonEmptyStringOpts); /** * Runs the function `fn`