···13261326 </TabItem>
13271327</Tabs>
1328132813291329+### [Maloja (Source)](https://github.com/krateng/maloja)
13301330+13311331+This Source monitors a Maloja server's scrobble history and then re-scrobbles discovered tracks to configured [Clients.](#client-configurations)
13321332+13331333+:::tip[Other Uses]
13341334+13351335+To _scrobble to_ a Maloja server, create a [Maloja (Client)](#maloja)
13361336+13371337+:::
13381338+13391339+See the [Maloja (Client)](#maloja) configuration for general setup. The only difference for **Source** configuration:
13401340+13411341+* Cannot be setup with ENV config
13421342+* [File/AIO config](./?configType=file#configuration-types) must include `"configureAs": "source"`
13431343+13441344+#### Configuration
13451345+13461346+<Tabs groupId="configType" queryString>
13471347+ <TabItem value="env" label="ENV">
13481348+ :::note
13491349+ 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.
13501350+ :::
13511351+ </TabItem>
13521352+ <TabItem value="file" label="File">
13531353+ <details>
13541354+ Change `configureAs` to `source`
13551355+13561356+ <summary>Example</summary>
13571357+13581358+ <FileExample title="CONFIG_DIR/maloja.json" data={MalojaConfig}/>
13591359+13601360+ </details>
13611361+13621362+ or <SchemaLink lower objectName="MalojaSourceConfig"/>
13631363+ </TabItem>
13641364+ <TabItem value="aio" label="AIO">
13651365+ <details>
13661366+ Change `configureAs` to `source`
13671367+13681368+ <summary>Example</summary>
13691369+13701370+ <AIOExample data={MalojaConfig} name="maloja"/>
13711371+13721372+ </details>
13731373+13741374+ or <SchemaLink lower objectName="MalojaSourceConfig"/>
13751375+ </TabItem>
13761376+</Tabs>
13771377+13291378### [Mopidy](https://mopidy.com/)
1330137913311380Mopidy 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/)
···26852734</Tabs>
2686273526872736### [Maloja](https://github.com/krateng/maloja)
27372737+27382738+Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
27392739+27402740+<details>
27412741+27422742+ <summary>Maloja Setup Intructions</summary>
27432743+27442744+ Using Maloja's example `docker-compose.yml`:
27452745+27462746+ ```yaml reference title="~/malojaData/docker-compose.yml"
27472747+ https://github.com/krateng/maloja/blob/master/example-compose.yml
27482748+ ```
27492749+27502750+ Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password
27512751+27522752+ Start the container:
27532753+27542754+ ```shell title="~/malojaData"
27552755+ docker compose up -d
27562756+ ```
27572757+</details>
27582758+27592759+* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://myMalojaServerIP/admin_apikeys)
27602760+ * Create a **New Key** and then copy the generated key value
27612761+27622762+Finally, add the Maloja server URL and API Key to the configuration type you choose to use, below.
2688276326892764#### Configuration
26902765
···11+import dayjs from 'dayjs';
22+import request, { SuperAgentRequest, Response } from 'superagent';
33+import compareVersions from "compare-versions";
44+import AbstractApiClient from "../AbstractApiClient.js";
55+import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
66+import { MalojaData } from "../../infrastructure/config/client/maloja.js";
77+import { PlayObject, URLData } from "../../../../core/Atomic.js";
88+import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js";
99+import { isNodeNetworkException } from "../../errors/NodeErrors.js";
1010+import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
1111+import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep } from "../../../utils.js";
1212+import { UpstreamError } from "../../errors/UpstreamError.js";
1313+import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js";
1414+import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js';
1515+import { buildTrackString } from '../../../../core/StringUtils.js';
1616+1717+1818+1919+export class MalojaApiClient extends AbstractApiClient {
2020+2121+ declare config: MalojaData;
2222+ url: URLData;
2323+ serverVersion: any;
2424+2525+ constructor(name: any, config: MalojaData, options: AbstractApiOptions) {
2626+ super('Maloja', name, config, options);
2727+2828+ const {
2929+ url
3030+ } = this.config;
3131+3232+ const u = normalizeWebAddress(url);
3333+ this.url = u;
3434+3535+ this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
3636+ }
3737+3838+ callApi = async <T = Response>(req: SuperAgentRequest, retries = 0): Promise<T> => {
3939+ const {
4040+ maxRequestRetries = 1,
4141+ retryMultiplier = DEFAULT_RETRY_MULTIPLIER
4242+ } = this.config;
4343+4444+ try {
4545+ return await req as T;
4646+ } catch (e) {
4747+ if ((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
4848+ if (retries < maxRequestRetries) {
4949+ const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
5050+ this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
5151+ await sleep(retryAfter * 1000);
5252+ return await this.callApi(req, retries + 1)
5353+ } else {
5454+ throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, { cause: e, showStopper: true });
5555+ }
5656+ } else if (isSuperAgentResponseError(e)) {
5757+ const {
5858+ message,
5959+ response: {
6060+ status,
6161+ body,
6262+ } = {},
6363+ response,
6464+ } = e;
6565+ if (isMalojaAPIErrorBody(body)) {
6666+ throw new UpstreamError(buildMalojaErrorString(body), { cause: e })
6767+ } else {
6868+ throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, { cause: e })
6969+ }
7070+ } else {
7171+ throw new Error('Unexpected error occurred during API call', { cause: e });
7272+ }
7373+ }
7474+ }
7575+7676+ testConnection = async () => {
7777+ try {
7878+ await isPortReachableConnect(this.url.port, { host: this.url.url.hostname });
7979+ } catch (e) {
8080+ throw new Error(`Maloja server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e });
8181+ }
8282+8383+ try {
8484+ const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`));
8585+ const {
8686+ statusCode,
8787+ body: {
8888+ version = [],
8989+ versionstring = '',
9090+ } = {},
9191+ } = serverInfoResp;
9292+9393+ if (statusCode >= 300) {
9494+ throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
9595+ }
9696+9797+ this.logger.info('Communication test succeeded.');
9898+9999+ if (version.length === 0) {
100100+ 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.');
101101+ } else {
102102+ this.logger.info(`Maloja Server Version: ${versionstring}`);
103103+ this.serverVersion = versionstring;
104104+ if (compareVersions(versionstring, '3.0.0') < 0) {
105105+ throw new Error(`Maloja versions below 3.0.0 are not supported.`);
106106+ } else if (compareVersions(versionstring, '3.2.0') < 0) {
107107+ this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
108108+ }
109109+ }
110110+ return true;
111111+ } catch (e) {
112112+ throw new Error('Communication test failed', { cause: e })
113113+ }
114114+115115+ }
116116+117117+ testHealth = async () => {
118118+119119+ try {
120120+ const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`), 0);
121121+ const {
122122+ statusCode,
123123+ body: {
124124+ db_status: {
125125+ healthy = false,
126126+ rebuildinprogress = false,
127127+ complete = false,
128128+ }
129129+ } = {},
130130+ } = serverInfoResp;
131131+132132+ if (statusCode >= 300) {
133133+ throw new Error(`Server responded with NOT OK status: ${statusCode}`);
134134+ }
135135+136136+ if (rebuildinprogress) {
137137+ throw new Error(`Server is rebuilding database`);
138138+ }
139139+140140+ if (!healthy) {
141141+ throw new Error('Server responded that it is not healthy');
142142+ }
143143+144144+ return true
145145+ } catch (e) {
146146+ throw new Error('Error encountered while testing server health', { cause: e });
147147+ }
148148+ }
149149+150150+ testAuth = async () => {
151151+ try {
152152+ const resp = await this.callApi(request
153153+ .get(`${this.url.url}/apis/mlj_1/test`)
154154+ .query({ key: this.config.apiKey }));
155155+156156+ const {
157157+ status,
158158+ body: {
159159+ status: bodyStatus,
160160+ } = {},
161161+ body = {},
162162+ text = '',
163163+ } = resp;
164164+ if (bodyStatus.toLocaleLowerCase() === 'ok') {
165165+ this.logger.info('Auth test passed!');
166166+ return true;
167167+ } else {
168168+ this.logger.error('Maloja API Response', {
169169+ status,
170170+ body,
171171+ text: text.slice(0, 50)
172172+ });
173173+ 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)}`) })
174174+ }
175175+ } catch (e) {
176176+ throw e;
177177+ }
178178+ }
179179+180180+ getRecentScrobbles = async (limit: number) => {
181181+ const resp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/scrobbles?perpage=${limit}`));
182182+ const {
183183+ body: {
184184+ list = [],
185185+ } = {},
186186+ } = resp;
187187+ return list.map(formatPlayObj);
188188+ }
189189+190190+ scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => {
191191+192192+ const {
193193+ data: {
194194+ album,
195195+ albumArtists = [],
196196+ duration,
197197+ } = {},
198198+ meta: {
199199+ newFromSource = false,
200200+ } = {}
201201+ } = playObj;
202202+203203+ const sType = newFromSource ? 'New' : 'Backlog';
204204+205205+ const pd = getScrobbleTsSOCDate(playObj);
206206+207207+ const scrobbleData = playToScrobblePayload(playObj, this.config.apiKey);
208208+209209+ try {
210210+211211+212212+ const response = await this.callApi(request.post(`${this.url.url}/apis/mlj_1/newscrobble`)
213213+ .type('json')
214214+ .send(scrobbleData));
215215+216216+ let scrobbleResponse: MalojaScrobbleData,
217217+ scrobbledPlay: PlayObject;
218218+219219+ let responseBody: MalojaScrobbleV3ResponseData;
220220+ let warnStr: string;
221221+222222+ responseBody = response.body;
223223+ const {
224224+ track,
225225+ status,
226226+ warnings = [],
227227+ } = responseBody;
228228+ if (status === 'success') {
229229+ if (track !== undefined) {
230230+ scrobbleResponse = {
231231+ time: pd.unix(),
232232+ track: {
233233+ ...track,
234234+ length: duration
235235+ },
236236+ }
237237+ if (album !== undefined) {
238238+ const {
239239+ album: malojaAlbum = {},
240240+ } = track;
241241+ scrobbleResponse.track.album = {
242242+ name: album,
243243+ artists: albumArtists,
244244+ ...malojaAlbum,
245245+ }
246246+ }
247247+ }
248248+ if (warnings.length > 0) {
249249+ for (const w of warnings) {
250250+ warnStr = builMalojadWarningString(w);
251251+ if (warnStr.includes('The submitted scrobble was not added')) {
252252+ throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false });
253253+ }
254254+ this.logger.warn(`Maloja Warning: ${warnStr}`);
255255+ }
256256+ }
257257+ } else {
258258+ throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false });
259259+ }
260260+261261+ return [scrobbleResponse, responseBody, warnStr]
262262+ } catch (e) {
263263+ this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData });
264264+ const responseError = getMalojaResponseError(e);
265265+ if (responseError !== undefined) {
266266+ if (responseError.status < 500 && e instanceof UpstreamError) {
267267+ e.showStopper = false;
268268+ }
269269+ if (responseError.response?.text !== undefined) {
270270+ this.logger.error('Raw Response:', { text: responseError.response?.text });
271271+ }
272272+ }
273273+ throw e;
274274+ }
275275+ }
276276+}
277277+278278+export const buildMalojaErrorString = (body: MalojaResponseV3CommonData) => {
279279+ let valString: string | undefined = undefined;
280280+ const {
281281+ status,
282282+ error: {
283283+ type,
284284+ value,
285285+ desc
286286+ } = {}
287287+ } = body;
288288+ if (value !== undefined && value !== null) {
289289+ if (typeof value === 'string') {
290290+ valString = value;
291291+ } else if (Array.isArray(value)) {
292292+ valString = value.map(x => {
293293+ if (typeof x === 'string') {
294294+ return x;
295295+ }
296296+ return JSON.stringify(x);
297297+ }).join(', ');
298298+ } else {
299299+ valString = JSON.stringify(value);
300300+ }
301301+ }
302302+ return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
303303+}
304304+305305+export const builMalojadWarningString = (w: MalojaScrobbleWarning): string => {
306306+ const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
307307+ let vals: string[] = [];
308308+ if (w.value !== null && w.value !== undefined) {
309309+ if (Array.isArray(w.value)) {
310310+ vals = w.value;
311311+ } else {
312312+ vals.push(w.value);
313313+ }
314314+ }
315315+ if (vals.length > 0) {
316316+ parts.push(vals.join(' | '));
317317+ }
318318+ return parts.join(' => ');
319319+}
320320+321321+export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject => {
322322+ let artists,
323323+ title,
324324+ album,
325325+ duration,
326326+ time,
327327+ listenedFor;
328328+329329+ const { url } = options;
330330+331331+ // scrobble data structure changed for v3
332332+ const {
333333+ // when the track was scrobbled
334334+ time: mTime,
335335+ track: {
336336+ artists: mArtists = [],
337337+ title: mTitle,
338338+ album: mAlbum,
339339+ // length of the track
340340+ length: mLength,
341341+ } = {},
342342+ // how long the track was listened to before it was scrobbled
343343+ duration: mDuration,
344344+ } = obj;
345345+346346+ artists = mArtists;
347347+ time = mTime;
348348+ title = mTitle;
349349+ duration = getNonEmptyVal(mLength);
350350+ listenedFor = getNonEmptyVal(mDuration);
351351+ if (mAlbum !== null) {
352352+ const {
353353+ albumtitle,
354354+ name: mAlbumName,
355355+ artists: albumArtists = []
356356+ } = mAlbum || {};
357357+ album = albumtitle ?? mAlbumName;
358358+ }
359359+360360+ const artistStrings = artists.reduce((acc: any, curr: any) => {
361361+ let aString;
362362+ if (typeof curr === 'string') {
363363+ aString = curr;
364364+ } else if (typeof curr === 'object') {
365365+ aString = curr.name;
366366+ }
367367+ const aStrings = aString.split(',');
368368+ return [...acc, ...aStrings];
369369+ }, []);
370370+ const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
371371+ return {
372372+ data: removeUndefinedKeys({
373373+ artists: [...new Set(artistStrings)] as string[],
374374+ track: title,
375375+ album,
376376+ duration,
377377+ listenedFor,
378378+ playDate: dayjs.unix(time),
379379+ }),
380380+ meta: {
381381+ source: 'Maloja',
382382+ url: {
383383+ web: `${url}/track?${urlParams.toString()}`
384384+ }
385385+ }
386386+ }
387387+}
388388+389389+export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => {
390390+391391+ const {
392392+ data: {
393393+ artists = [],
394394+ albumArtists = [],
395395+ album,
396396+ track,
397397+ duration,
398398+ listenedFor
399399+ } = {}
400400+ } = playObj;
401401+402402+ const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
403403+404404+ const scrobbleData: MalojaScrobbleV3RequestData = {
405405+ title: track,
406406+ artists,
407407+ album,
408408+ key: apiKey,
409409+ time: pd.unix(),
410410+ // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
411411+ length: duration,
412412+ };
413413+ if (listenedFor !== undefined && listenedFor > 0) {
414414+ scrobbleData.duration = listenedFor;
415415+ }
416416+417417+ if (albumArtists.length > 0) {
418418+ scrobbleData.albumartists = albumArtists;
419419+ }
420420+421421+ return scrobbleData;
422422+}
+4-23
src/backend/common/vendor/maloja/interfaces.ts
···33import { findCauseByFunc } from "../../../utils/ErrorUtils.js";
44import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
5566-export interface MalojaV2ScrobbleData {
77- artists: string[]
88- title: string
99- album: string
1010- /**
1111- * Length of the track
1212- * */
1313- duration: number
1414- /**
1515- * unix timestamp (seconds) scrobble was made at
1616- * */
1717- time: number
1818-}
1919-206export interface MalojaAlbumData {
217 name?: string
228 albumtitle?: string
···4026 /**
4127 * how long the track was listened to before it was scrobbled
4228 * */
4343- duration: number
2929+ duration?: number
4430}
45314646-export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData;
3232+export type MalojaScrobbleData = MalojaV3ScrobbleData;
47334834export interface MalojaScrobbleRequestData {
4935 /** The auth key used to scrobble */
5050- key: string
3636+ key?: string
5137 /** name of the track */
5238 title: string
5339 /** name of the album */
···6046 duration?: number
6147}
62486363-export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData {
6464- /** comma-separated list of artists for this track */
6565- artist: string
6666-}
6767-6849export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData {
6950 /** a list of artists for this track */
7051 artists: string[]
7152 /** a list of artists for the album the track is on */
7272- albumartists: string[]
5353+ albumartists?: string[]
7354 /** skip server-side metadata parsing */
7455 nofix?: boolean
7556}
+38-449
src/backend/scrobblers/MalojaScrobbler.ts
···11-import { Logger } from "@foxxmd/logging";
22-import compareVersions from 'compare-versions';
33-import dayjs from 'dayjs';
11+import { childLogger, Logger } from "@foxxmd/logging";
42import EventEmitter from "events";
53import normalizeUrl from "normalize-url";
66-import request, { SuperAgentRequest } from 'superagent';
74import { PlayObject } from "../../core/Atomic.js";
85import { buildTrackString, capitalize } from "../../core/StringUtils.js";
99-import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js";
106import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
1111-import { UpstreamError } from "../common/errors/UpstreamError.js";
1212-import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
77+import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
138import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js";
149import {
1515- getMalojaResponseError,
1616- isMalojaAPIErrorBody,
1717- MalojaResponseV3CommonData,
1818- MalojaScrobbleData,
1910 MalojaScrobbleRequestData,
2020- MalojaScrobbleV2RequestData,
2121- MalojaScrobbleV3RequestData,
2222- MalojaScrobbleV3ResponseData, MalojaScrobbleWarning,
2323- MalojaV2ScrobbleData,
2424- MalojaV3ScrobbleData,
2511} from "../common/vendor/maloja/interfaces.js";
2612import { Notifiers } from "../notifier/Notifiers.js";
2727-import { parseRetryAfterSecsFromObj, sleep } from "../utils.js";
2828-import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js";
2913import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
1414+import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js";
30153116const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
3217···3621 serverVersion: any;
3722 webUrl: string;
38232424+ api: MalojaApiClient;
2525+3926 declare config: MalojaClientConfig
40274128 constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
4242- super('maloja', name, config, notifier, emitter,logger);
2929+ super('maloja', name, config, notifier, emitter, logger);
3030+ this.api = new MalojaApiClient(name, this.config.data, { logger: childLogger(this.logger, 'API') });
4331 this.MAX_INITIAL_SCROBBLES_FETCH = 100;
4432 }
45334646- static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject {
4747- let artists,
4848- title,
4949- album,
5050- duration,
5151- time,
5252- listenedFor;
5353-5454- const {serverVersion, url} = options;
5555-5656- if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) {
5757- // scrobble data structure changed for v3
5858- const {
5959- // when the track was scrobbled
6060- time: mTime,
6161- track: {
6262- artists: mArtists = [],
6363- title: mTitle,
6464- album: mAlbum,
6565- // length of the track
6666- length: mLength,
6767- } = {},
6868- // how long the track was listened to before it was scrobbled
6969- duration: mDuration,
7070- } = obj as MalojaV3ScrobbleData;
7171- artists = mArtists;
7272- time = mTime;
7373- title = mTitle;
7474- duration = mLength;
7575- listenedFor = mDuration;
7676- if(mAlbum !== null) {
7777- const {
7878- albumtitle,
7979- name: mAlbumName,
8080- artists: albumArtists = []
8181- } = mAlbum || {};
8282- album = albumtitle ?? mAlbumName;
8383- }
8484- } else {
8585- // scrobble data structure for v2 and below
8686- const {
8787- artists: mArtists = [],
8888- title: mTitle,
8989- album: mAlbum,
9090- duration: mDuration,
9191- time: mTime,
9292- } = obj as MalojaV2ScrobbleData;
9393- artists = mArtists;
9494- title = mTitle;
9595- album = mAlbum;
9696- duration = mDuration;
9797- time = mTime;
9898- }
9999- const artistStrings = artists.reduce((acc: any, curr: any) => {
100100- let aString;
101101- if (typeof curr === 'string') {
102102- aString = curr;
103103- } else if (typeof curr === 'object') {
104104- aString = curr.name;
105105- }
106106- const aStrings = aString.split(',');
107107- return [...acc, ...aStrings];
108108- }, []);
109109- const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]);
110110- return {
111111- data: {
112112- artists: [...new Set(artistStrings)] as string[],
113113- track: title,
114114- album,
115115- duration,
116116- listenedFor,
117117- playDate: dayjs.unix(time),
118118- },
119119- meta: {
120120- source: 'Maloja',
121121- url: {
122122- web: `${url}/track?${urlParams.toString()}`
123123- }
124124- }
125125- }
126126- }
127127-128128- formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl});
129129-130130- callApi = async (req: SuperAgentRequest, retries = 0) => {
131131- const {
132132- maxRequestRetries = 1,
133133- retryMultiplier = DEFAULT_RETRY_MULTIPLIER
134134- } = this.config.data;
135135-136136- try {
137137- return await req;
138138- } catch (e) {
139139- if((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) {
140140- if(retries < maxRequestRetries) {
141141- const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
142142- this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
143143- await sleep(retryAfter * 1000);
144144- return await this.callApi(req, retries + 1)
145145- } else {
146146- throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, {cause : e, showStopper: true});
147147- }
148148- } else if(isSuperAgentResponseError(e)) {
149149- const {
150150- message,
151151- response: {
152152- status,
153153- body,
154154- } = {},
155155- response,
156156- } = e;
157157- if(isMalojaAPIErrorBody(body)) {
158158- throw new UpstreamError(buildErrorString(body), {cause: e})
159159- } else {
160160- throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e})
161161- }
162162- } else {
163163- throw new Error('Unexpected error occurred during API call', {cause : e});
164164- }
165165- }
166166- }
167167-168168- testConnection = async () => {
169169-170170- const {url} = this.config.data;
171171- try {
172172- const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
173173- const {
174174- statusCode,
175175- body: {
176176- version = [],
177177- versionstring = '',
178178- } = {},
179179- } = serverInfoResp;
180180-181181- if (statusCode >= 300) {
182182- throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`);
183183- }
184184-185185- this.logger.info('Communication test succeeded.');
186186-187187- if (version.length === 0) {
188188- 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.');
189189- } else {
190190- this.logger.info(`Maloja Server Version: ${versionstring}`);
191191- this.serverVersion = versionstring;
192192- if(compareVersions(versionstring, '3.0.0') < 0) {
193193- this.logger.warn(`Support for Maloja versions below 3.0.0 is DEPRECATED and will be removed in a future minor release.`);
194194- } else if(compareVersions(versionstring, '3.2.0') < 0) {
195195- this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`);
196196- }
197197- }
198198- return true;
199199- } catch (e) {
200200- throw new Error('Communication test failed', {cause: e})
201201- }
202202- }
203203-204204- testHealth = async () => {
205205-206206- const {url} = this.config.data;
207207- try {
208208- const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0);
209209- const {
210210- statusCode,
211211- body: {
212212- // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
213213- db_status: {
214214- healthy = false,
215215- rebuildinprogress = false,
216216- complete = false,
217217- }
218218- } = {},
219219- } = serverInfoResp;
220220-221221- if (statusCode >= 300) {
222222- throw new Error(`Server responded with NOT OK status: ${statusCode}`);
223223- }
224224-225225- if(rebuildinprogress) {
226226- throw new Error(`Server is rebuilding database`);
227227- }
228228-229229- if(!healthy) {
230230- throw new Error('Server responded that it is not healthy');
231231- }
232232-233233- return true
234234- } catch (e) {
235235- throw new Error('Error encountered while testing server health', {cause: e});
236236- }
237237- }
3434+ formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => formatMalojaScrobbleToPlay(obj, { url: this.webUrl });
2383523936 protected async doBuildInitData(): Promise<true | string | undefined> {
240240- const {data: {url, apiKey} = {}} = this.config;
3737+ const { data: { url, apiKey } = {} } = this.config;
24138 if (apiKey === undefined) {
24239 throw new Error("'apiKey' not found in config!");
24340 }
···24946 }
2504725148 protected async doCheckConnection(): Promise<true | string | undefined> {
252252- await this.testConnection();
253253- await this.testHealth();
254254- return true;
4949+5050+ try {
5151+ await this.api.testConnection();
5252+ await this.api.testHealth();
5353+ return true;
5454+ } catch (e) {
5555+ throw e;
5656+ }
5757+25558 }
256592576025861 doAuthentication = async () => {
25962260260- const {url, apiKey} = this.config.data;
6363+ const { data: { url, apiKey } = {} } = this.config;
6464+ if (apiKey === undefined) {
6565+ throw new Error("'apiKey' not found in config!");
6666+ }
26167 try {
262262- const resp = await this.callApi(request
263263- .get(`${url}/apis/mlj_1/test`)
264264- .query({key: apiKey}));
265265-266266- const {
267267- status,
268268- body: {
269269- // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message
270270- status: bodyStatus,
271271- } = {},
272272- body = {},
273273- text = '',
274274- } = resp;
275275- if (bodyStatus.toLocaleLowerCase() === 'ok') {
276276- this.logger.info('Auth test passed!');
277277- return true;
278278- } else {
279279- this.logger.error('Maloja API Response', {
280280- status,
281281- body,
282282- text: text.slice(0, 50)
283283- });
284284- 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)}`)})
285285- }
6868+ await this.api.testAuth();
6969+ return true;
28670 } catch (e) {
287287- if(e instanceof UpstreamError) {
288288- if((e?.cause as any)?.status === 403) {
289289- // may be an older version that doesn't support auth readiness before db upgrade
290290- // and if it was before api was accessible during db build then test would fail during testConnection()
291291- if(compareVersions(this.serverVersion, '2.12.19') < 0) {
292292- if(!(await this.isReady())) {
293293- throw new UpstreamError(`Could not test auth because server is not ready`, {showStopper: false});
294294- }
295295- }
296296- }
7171+ if (isNodeNetworkException(e)) {
7272+ this.logger.error('Could not communicate with Maloja API');
29773 }
29874 throw e;
29975 }
30076 }
3017730278 getScrobblesForRefresh = async (limit: number) => {
303303- const {url} = this.config.data;
304304- const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
305305- const {
306306- body: {
307307- list = [],
308308- } = {},
309309- } = resp;
310310- return list.map((x: any) => this.formatPlayObj(x));
311311- }
312312-313313- cleanSourceSearchTitle = (playObj: PlayObject) => {
314314- const {
315315- data: {
316316- track,
317317- artists: sourceArtists = [],
318318- } = {},
319319- } = playObj;
320320- let lowerTitle = track.toLocaleLowerCase();
321321- lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
322322- // also remove [artist] from the track if found since that gets removed as well
323323- const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase());
324324- lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle);
325325-326326- // remove any whitespace in parenthesis
327327- lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
328328- // replace parenthesis
329329- .replace('()', '')
330330- .replace('( )', '')
331331- .trim();
332332-333333- return lowerTitle;
7979+ return await this.api.getRecentScrobbles(limit);
33480 }
3358133682 alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined
3378333884 public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData {
33985340340- const {apiKey} = this.config.data;
8686+ const { apiKey } = this.config.data;
34187342342- const {
343343- data: {
344344- artists = [],
345345- albumArtists = [],
346346- album,
347347- track,
348348- duration,
349349- listenedFor
350350- } = {}
351351- } = playObj;
352352-353353- const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj);
354354-355355- const scrobbleData: MalojaScrobbleRequestData = {
356356- title: track,
357357- album,
358358- key: apiKey,
359359- time: pd.unix(),
360360- // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135
361361- length: duration,
362362- };
363363- if(listenedFor !== undefined && listenedFor > 0) {
364364- scrobbleData.duration = listenedFor;
365365- }
366366-367367- // 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo
368368- if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) {
369369- (scrobbleData as MalojaScrobbleV3RequestData).artists = artists;
370370- if(albumArtists.length > 0) {
371371- (scrobbleData as MalojaScrobbleV3RequestData).albumartists = albumArtists;
372372- }
373373- } else {
374374- // maloja seems to detect this deliminator much better than commas
375375- // also less likely artist has a forward slash in their name than a comma
376376- (scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / ');
377377- }
378378-379379- return scrobbleData;
8888+ return playToScrobblePayload(playObj);
38089 }
3819038291 doScrobble = async (playObj: PlayObject) => {
383383- const {url, apiKey} = this.config.data;
384384-38592 const {
386386- data: {
387387- album,
388388- duration,
389389- playDate,
390390- } = {},
39193 meta: {
39294 source,
39395 newFromSource = false,
39496 } = {}
39597 } = playObj;
39698397397- const pd = getScrobbleTsSOCDate(playObj);
9999+ const scrobbleData = playToScrobblePayload(playObj);
398100399399- const sType = newFromSource ? 'New' : 'Backlog';
400400-401401- const scrobbleData = this.playToClientPayload(playObj);
402402-403403- let responseBody: MalojaScrobbleV3ResponseData;
101101+ let scrobbledPlay: PlayObject;
404102405103 try {
406406- const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
407407- .type('json')
408408- .send(scrobbleData));
104104+ const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj);
409105410410- let scrobbleResponse: any | undefined = undefined,
411411- scrobbledPlay: PlayObject;
412412-413413- if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) {
414414- responseBody = response.body;
415415- const {
416416- track,
417417- status,
418418- warnings = [],
419419- } = responseBody;
420420- if(status === 'success') {
421421- if(track !== undefined) {
422422- scrobbleResponse = {
423423- time: pd.unix(),
424424- track: {
425425- ...track,
426426- length: duration
427427- },
428428- }
429429- if (album !== undefined) {
430430- const {
431431- album: malojaAlbum = {},
432432- } = track;
433433- scrobbleResponse.track.album = {
434434- ...malojaAlbum,
435435- name: album
436436- }
437437- }
438438- }
439439- if(warnings.length > 0) {
440440- for(const w of warnings) {
441441- const warnStr = buildWarningString(w);
442442- if(warnStr.includes('The submitted scrobble was not added')) {
443443- throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false});
444444- }
445445- this.logger.warn(`Maloja Warning: ${warnStr}`);
446446- }
447447- }
448448- } else {
449449- throw new UpstreamError(buildErrorString(response), {showStopper: false});
450450- }
451451- } else {
452452- const {
453453- body: {
454454- track: {
455455- time: mTime = pd.unix(),
456456- duration: mDuration = duration,
457457- album: mAlbum = album,
458458- ...rest
459459- } = {}
460460- } = {}
461461- } = response;
462462- scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration};
463463- }
464106 let warning = '';
465465- if(scrobbleResponse === undefined) {
107107+ if (scrobbleResp === undefined) {
466108 warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`;
467109 scrobbledPlay = playObj;
468110 } else {
469469- scrobbledPlay = this.formatPlayObj(scrobbleResponse)
111111+ scrobbledPlay = this.formatPlayObj(scrobbleResp)
470112 }
471113 const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`;
472472- if(warning !== '') {
114114+ if (warning !== '') {
473115 this.logger.warn(`${scrobbleInfo} | ${warning}`);
474474- this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`);
116116+ this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`);
475117 } else {
476118 this.logger.info(scrobbleInfo);
477119 }
478120 return scrobbledPlay;
479121 } catch (e) {
480480- await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
481481- this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData});
482482- const responseError = getMalojaResponseError(e);
483483- if(responseError !== undefined) {
484484- if(responseError.status < 500 && e instanceof UpstreamError) {
485485- e.showStopper = false;
486486- }
487487- if(responseError.response?.text !== undefined) {
488488- this.logger.error('Raw Response:', { text: responseError.response?.text });
489489- }
490490- }
122122+ await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' });
491123 throw e;
492124 } finally {
493125 this.logger.debug('Raw Payload:', scrobbleData);
494126 }
495127 }
496496-}
497497-498498-const buildErrorString = (body: MalojaResponseV3CommonData) => {
499499- let valString: string | undefined = undefined;
500500- const {
501501- status,
502502- error: {
503503- type,
504504- value,
505505- desc
506506- } = {}
507507- } = body;
508508- if(value !== undefined && value !== null) {
509509- if(typeof value === 'string') {
510510- valString = value;
511511- } else if(Array.isArray(value)) {
512512- valString = value.map(x => {
513513- if(typeof x === 'string') {
514514- return x;
515515- }
516516- return JSON.stringify(x);
517517- }).join(', ');
518518- } else {
519519- valString = JSON.stringify(value);
520520- }
521521- }
522522- return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`;
523523-}
524524-525525-const buildWarningString = (w: MalojaScrobbleWarning): string => {
526526- const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`];
527527- let vals: string[] = [];
528528- if(w.value !== null && w.value !== undefined) {
529529- if(Array.isArray(w.value)) {
530530- vals = w.value;
531531- } else {
532532- vals.push(w.value);
533533- }
534534- }
535535- if(vals.length > 0) {
536536- parts.push(vals.join(' | '));
537537- }
538538- return parts.join(' => ');
539539-}
128128+}