[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #336 from FoxxMD/GH-295/malojaSource

feat: Add Maloja Source

authored by

Matt Foxx and committed by
GitHub
(Aug 19, 2025, 11:19 PM EDT) eb6e469d 06b23d62

+701 -489
+11 -1
config/maloja.json.example
··· 1 1 [ 2 2 { 3 - "name": "myMaloja", 3 + "name": "myMaloja-client", 4 4 "enable": true, 5 + "configureAs": "client", 6 + "data": { 7 + "url": "http://localhost:42010", 8 + "apiKey": "myMalojaKey" 9 + } 10 + }, 11 + { 12 + "name": "myMaloja-source", 13 + "enable": true, 14 + "configureAs": "source", 5 15 "data": { 6 16 "url": "http://localhost:42010", 7 17 "apiKey": "myMalojaKey"
+75
docsite/docs/configuration/configuration.mdx
··· 1326 1326 </TabItem> 1327 1327 </Tabs> 1328 1328 1329 + ### [Maloja (Source)](https://github.com/krateng/maloja) 1330 + 1331 + This Source monitors a Maloja server's scrobble history and then re-scrobbles discovered tracks to configured [Clients.](#client-configurations) 1332 + 1333 + :::tip[Other Uses] 1334 + 1335 + To _scrobble to_ a Maloja server, create a [Maloja (Client)](#maloja) 1336 + 1337 + ::: 1338 + 1339 + See the [Maloja (Client)](#maloja) configuration for general setup. The only difference for **Source** configuration: 1340 + 1341 + * Cannot be setup with ENV config 1342 + * [File/AIO config](./?configType=file#configuration-types) must include `"configureAs": "source"` 1343 + 1344 + #### Configuration 1345 + 1346 + <Tabs groupId="configType" queryString> 1347 + <TabItem value="env" label="ENV"> 1348 + :::note 1349 + 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. 1350 + ::: 1351 + </TabItem> 1352 + <TabItem value="file" label="File"> 1353 + <details> 1354 + Change `configureAs` to `source` 1355 + 1356 + <summary>Example</summary> 1357 + 1358 + <FileExample title="CONFIG_DIR/maloja.json" data={MalojaConfig}/> 1359 + 1360 + </details> 1361 + 1362 + or <SchemaLink lower objectName="MalojaSourceConfig"/> 1363 + </TabItem> 1364 + <TabItem value="aio" label="AIO"> 1365 + <details> 1366 + Change `configureAs` to `source` 1367 + 1368 + <summary>Example</summary> 1369 + 1370 + <AIOExample data={MalojaConfig} name="maloja"/> 1371 + 1372 + </details> 1373 + 1374 + or <SchemaLink lower objectName="MalojaSourceConfig"/> 1375 + </TabItem> 1376 + </Tabs> 1377 + 1329 1378 ### [Mopidy](https://mopidy.com/) 1330 1379 1331 1380 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/) ··· 2685 2734 </Tabs> 2686 2735 2687 2736 ### [Maloja](https://github.com/krateng/maloja) 2737 + 2738 + Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this. 2739 + 2740 + <details> 2741 + 2742 + <summary>Maloja Setup Intructions</summary> 2743 + 2744 + Using Maloja's example `docker-compose.yml`: 2745 + 2746 + ```yaml reference title="~/malojaData/docker-compose.yml" 2747 + https://github.com/krateng/maloja/blob/master/example-compose.yml 2748 + ``` 2749 + 2750 + Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password 2751 + 2752 + Start the container: 2753 + 2754 + ```shell title="~/malojaData" 2755 + docker compose up -d 2756 + ``` 2757 + </details> 2758 + 2759 + * Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://myMalojaServerIP/admin_apikeys) 2760 + * Create a **New Key** and then copy the generated key value 2761 + 2762 + Finally, add the Maloja server URL and API Key to the configuration type you choose to use, below. 2688 2763 2689 2764 #### Configuration 2690 2765
+1
docsite/src/pages/index.mdx
··· 25 25 * [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint) 26 26 * [ListenBrainz](docs/configuration#listenbrainz-source) 27 27 * [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint) 28 + * [Maloja](docs/configuration#maloja-source) 28 29 * [Mopidy](docs/configuration#mopidy) 29 30 * [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon) 30 31 * [MPRIS (Linux Desktop)](docs/configuration#mpris)
+2
src/backend/common/infrastructure/Atomic.ts
··· 26 26 | 'kodi' 27 27 | 'webscrobbler' 28 28 | 'chromecast' 29 + | 'maloja' 29 30 | 'musikcube' 30 31 | 'mpd' 31 32 | 'vlc' ··· 52 53 'kodi', 53 54 'webscrobbler', 54 55 'chromecast', 56 + 'maloja', 55 57 'musikcube', 56 58 'mpd', 57 59 'vlc',
+12 -1
src/backend/common/infrastructure/config/client/maloja.ts
··· 1 1 import { RequestRetryOptions } from "../common.js"; 2 2 import { CommonClientConfig, CommonClientData } from "./index.js"; 3 3 4 - export interface MalojaClientData extends RequestRetryOptions, CommonClientData { 4 + export interface MalojaData extends RequestRetryOptions { 5 5 /** 6 6 * URL for maloja server 7 7 * ··· 16 16 apiKey: string 17 17 } 18 18 19 + export interface MalojaClientData extends MalojaData, CommonClientData { 20 + 21 + } 22 + 19 23 export interface MalojaClientConfig extends CommonClientConfig { 24 + /** 25 + * Should always be `client` when using Maloja as a client 26 + * 27 + * @default client 28 + * @examples ["client"] 29 + * */ 30 + configureAs?: 'client' | 'source' 20 31 data: MalojaClientData 21 32 } 22 33
+21
src/backend/common/infrastructure/config/source/maloja.ts
··· 1 + import { MalojaData } from "../client/maloja.js"; 2 + import { PollingOptions } from "../common.js"; 3 + import { CommonSourceConfig, CommonSourceData } from "./index.js"; 4 + 5 + export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions { 6 + } 7 + 8 + export interface MalojaSourceConfig extends CommonSourceConfig { 9 + /** 10 + * When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client. 11 + * 12 + * @default source 13 + * @examples ["source"] 14 + * */ 15 + configureAs?: 'source' 16 + data: MalojaSourceData 17 + } 18 + 19 + export interface MalojaSourceAIOConfig extends MalojaSourceConfig { 20 + type: 'maloja' 21 + }
+3
src/backend/common/infrastructure/config/source/sources.ts
··· 22 22 import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; 23 23 import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; 24 24 import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; 25 + import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; 25 26 26 27 27 28 export type SourceConfig = ··· 45 46 | KodiSourceConfig 46 47 | WebScrobblerSourceConfig 47 48 | ChromecastSourceConfig 49 + | MalojaSourceConfig 48 50 | MusikcubeSourceConfig 49 51 | MusicCastSourceConfig 50 52 | MPDSourceConfig ··· 74 76 | KodiSourceAIOConfig 75 77 | WebScrobblerSourceAIOConfig 76 78 | ChromecastSourceAIOConfig 79 + | MalojaSourceAIOConfig 77 80 | MusikcubeSourceAIOConfig 78 81 | MusicCastSourceAIOConfig 79 82 | MPDSourceAIOConfig
+422
src/backend/common/vendor/maloja/MalojaApiClient.ts
··· 1 + import dayjs from 'dayjs'; 2 + import request, { SuperAgentRequest, Response } from 'superagent'; 3 + import compareVersions from "compare-versions"; 4 + import AbstractApiClient from "../AbstractApiClient.js"; 5 + import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; 6 + import { MalojaData } from "../../infrastructure/config/client/maloja.js"; 7 + import { PlayObject, URLData } from "../../../../core/Atomic.js"; 8 + import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js"; 9 + import { isNodeNetworkException } from "../../errors/NodeErrors.js"; 10 + import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; 11 + import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep } from "../../../utils.js"; 12 + import { UpstreamError } from "../../errors/UpstreamError.js"; 13 + import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; 14 + import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; 15 + import { buildTrackString } from '../../../../core/StringUtils.js'; 16 + 17 + 18 + 19 + export class MalojaApiClient extends AbstractApiClient { 20 + 21 + declare config: MalojaData; 22 + url: URLData; 23 + serverVersion: any; 24 + 25 + constructor(name: any, config: MalojaData, options: AbstractApiOptions) { 26 + super('Maloja', name, config, options); 27 + 28 + const { 29 + url 30 + } = this.config; 31 + 32 + const u = normalizeWebAddress(url); 33 + this.url = u; 34 + 35 + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`) 36 + } 37 + 38 + callApi = async <T = Response>(req: SuperAgentRequest, retries = 0): Promise<T> => { 39 + const { 40 + maxRequestRetries = 1, 41 + retryMultiplier = DEFAULT_RETRY_MULTIPLIER 42 + } = this.config; 43 + 44 + try { 45 + return await req as T; 46 + } catch (e) { 47 + if ((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) { 48 + if (retries < maxRequestRetries) { 49 + const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); 50 + this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); 51 + await sleep(retryAfter * 1000); 52 + return await this.callApi(req, retries + 1) 53 + } else { 54 + throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, { cause: e, showStopper: true }); 55 + } 56 + } else if (isSuperAgentResponseError(e)) { 57 + const { 58 + message, 59 + response: { 60 + status, 61 + body, 62 + } = {}, 63 + response, 64 + } = e; 65 + if (isMalojaAPIErrorBody(body)) { 66 + throw new UpstreamError(buildMalojaErrorString(body), { cause: e }) 67 + } else { 68 + throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, { cause: e }) 69 + } 70 + } else { 71 + throw new Error('Unexpected error occurred during API call', { cause: e }); 72 + } 73 + } 74 + } 75 + 76 + testConnection = async () => { 77 + try { 78 + await isPortReachableConnect(this.url.port, { host: this.url.url.hostname }); 79 + } catch (e) { 80 + throw new Error(`Maloja server is not reachable at ${this.url.url.hostname}:${this.url.port}`, { cause: e }); 81 + } 82 + 83 + try { 84 + const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`)); 85 + const { 86 + statusCode, 87 + body: { 88 + version = [], 89 + versionstring = '', 90 + } = {}, 91 + } = serverInfoResp; 92 + 93 + if (statusCode >= 300) { 94 + throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`); 95 + } 96 + 97 + this.logger.info('Communication test succeeded.'); 98 + 99 + if (version.length === 0) { 100 + 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.'); 101 + } else { 102 + this.logger.info(`Maloja Server Version: ${versionstring}`); 103 + this.serverVersion = versionstring; 104 + if (compareVersions(versionstring, '3.0.0') < 0) { 105 + throw new Error(`Maloja versions below 3.0.0 are not supported.`); 106 + } else if (compareVersions(versionstring, '3.2.0') < 0) { 107 + this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`); 108 + } 109 + } 110 + return true; 111 + } catch (e) { 112 + throw new Error('Communication test failed', { cause: e }) 113 + } 114 + 115 + } 116 + 117 + testHealth = async () => { 118 + 119 + try { 120 + const serverInfoResp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/serverinfo`), 0); 121 + const { 122 + statusCode, 123 + body: { 124 + db_status: { 125 + healthy = false, 126 + rebuildinprogress = false, 127 + complete = false, 128 + } 129 + } = {}, 130 + } = serverInfoResp; 131 + 132 + if (statusCode >= 300) { 133 + throw new Error(`Server responded with NOT OK status: ${statusCode}`); 134 + } 135 + 136 + if (rebuildinprogress) { 137 + throw new Error(`Server is rebuilding database`); 138 + } 139 + 140 + if (!healthy) { 141 + throw new Error('Server responded that it is not healthy'); 142 + } 143 + 144 + return true 145 + } catch (e) { 146 + throw new Error('Error encountered while testing server health', { cause: e }); 147 + } 148 + } 149 + 150 + testAuth = async () => { 151 + try { 152 + const resp = await this.callApi(request 153 + .get(`${this.url.url}/apis/mlj_1/test`) 154 + .query({ key: this.config.apiKey })); 155 + 156 + const { 157 + status, 158 + body: { 159 + status: bodyStatus, 160 + } = {}, 161 + body = {}, 162 + text = '', 163 + } = resp; 164 + if (bodyStatus.toLocaleLowerCase() === 'ok') { 165 + this.logger.info('Auth test passed!'); 166 + return true; 167 + } else { 168 + this.logger.error('Maloja API Response', { 169 + status, 170 + body, 171 + text: text.slice(0, 50) 172 + }); 173 + 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)}`) }) 174 + } 175 + } catch (e) { 176 + throw e; 177 + } 178 + } 179 + 180 + getRecentScrobbles = async (limit: number) => { 181 + const resp = await this.callApi(request.get(`${this.url.url}/apis/mlj_1/scrobbles?perpage=${limit}`)); 182 + const { 183 + body: { 184 + list = [], 185 + } = {}, 186 + } = resp; 187 + return list.map(formatPlayObj); 188 + } 189 + 190 + scrobble = async (playObj: PlayObject): Promise<[(MalojaScrobbleData | undefined), MalojaScrobbleV3ResponseData, string?]> => { 191 + 192 + const { 193 + data: { 194 + album, 195 + albumArtists = [], 196 + duration, 197 + } = {}, 198 + meta: { 199 + newFromSource = false, 200 + } = {} 201 + } = playObj; 202 + 203 + const sType = newFromSource ? 'New' : 'Backlog'; 204 + 205 + const pd = getScrobbleTsSOCDate(playObj); 206 + 207 + const scrobbleData = playToScrobblePayload(playObj, this.config.apiKey); 208 + 209 + try { 210 + 211 + 212 + const response = await this.callApi(request.post(`${this.url.url}/apis/mlj_1/newscrobble`) 213 + .type('json') 214 + .send(scrobbleData)); 215 + 216 + let scrobbleResponse: MalojaScrobbleData, 217 + scrobbledPlay: PlayObject; 218 + 219 + let responseBody: MalojaScrobbleV3ResponseData; 220 + let warnStr: string; 221 + 222 + responseBody = response.body; 223 + const { 224 + track, 225 + status, 226 + warnings = [], 227 + } = responseBody; 228 + if (status === 'success') { 229 + if (track !== undefined) { 230 + scrobbleResponse = { 231 + time: pd.unix(), 232 + track: { 233 + ...track, 234 + length: duration 235 + }, 236 + } 237 + if (album !== undefined) { 238 + const { 239 + album: malojaAlbum = {}, 240 + } = track; 241 + scrobbleResponse.track.album = { 242 + name: album, 243 + artists: albumArtists, 244 + ...malojaAlbum, 245 + } 246 + } 247 + } 248 + if (warnings.length > 0) { 249 + for (const w of warnings) { 250 + warnStr = builMalojadWarningString(w); 251 + if (warnStr.includes('The submitted scrobble was not added')) { 252 + throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, { showStopper: false }); 253 + } 254 + this.logger.warn(`Maloja Warning: ${warnStr}`); 255 + } 256 + } 257 + } else { 258 + throw new UpstreamError(buildMalojaErrorString(response.body), { showStopper: false }); 259 + } 260 + 261 + return [scrobbleResponse, responseBody, warnStr] 262 + } catch (e) { 263 + this.logger.error(`Scrobble Error (${sType})`, { playInfo: buildTrackString(playObj), payload: scrobbleData }); 264 + const responseError = getMalojaResponseError(e); 265 + if (responseError !== undefined) { 266 + if (responseError.status < 500 && e instanceof UpstreamError) { 267 + e.showStopper = false; 268 + } 269 + if (responseError.response?.text !== undefined) { 270 + this.logger.error('Raw Response:', { text: responseError.response?.text }); 271 + } 272 + } 273 + throw e; 274 + } 275 + } 276 + } 277 + 278 + export const buildMalojaErrorString = (body: MalojaResponseV3CommonData) => { 279 + let valString: string | undefined = undefined; 280 + const { 281 + status, 282 + error: { 283 + type, 284 + value, 285 + desc 286 + } = {} 287 + } = body; 288 + if (value !== undefined && value !== null) { 289 + if (typeof value === 'string') { 290 + valString = value; 291 + } else if (Array.isArray(value)) { 292 + valString = value.map(x => { 293 + if (typeof x === 'string') { 294 + return x; 295 + } 296 + return JSON.stringify(x); 297 + }).join(', '); 298 + } else { 299 + valString = JSON.stringify(value); 300 + } 301 + } 302 + return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`; 303 + } 304 + 305 + export const builMalojadWarningString = (w: MalojaScrobbleWarning): string => { 306 + const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`]; 307 + let vals: string[] = []; 308 + if (w.value !== null && w.value !== undefined) { 309 + if (Array.isArray(w.value)) { 310 + vals = w.value; 311 + } else { 312 + vals.push(w.value); 313 + } 314 + } 315 + if (vals.length > 0) { 316 + parts.push(vals.join(' | ')); 317 + } 318 + return parts.join(' => '); 319 + } 320 + 321 + export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject => { 322 + let artists, 323 + title, 324 + album, 325 + duration, 326 + time, 327 + listenedFor; 328 + 329 + const { url } = options; 330 + 331 + // scrobble data structure changed for v3 332 + const { 333 + // when the track was scrobbled 334 + time: mTime, 335 + track: { 336 + artists: mArtists = [], 337 + title: mTitle, 338 + album: mAlbum, 339 + // length of the track 340 + length: mLength, 341 + } = {}, 342 + // how long the track was listened to before it was scrobbled 343 + duration: mDuration, 344 + } = obj; 345 + 346 + artists = mArtists; 347 + time = mTime; 348 + title = mTitle; 349 + duration = getNonEmptyVal(mLength); 350 + listenedFor = getNonEmptyVal(mDuration); 351 + if (mAlbum !== null) { 352 + const { 353 + albumtitle, 354 + name: mAlbumName, 355 + artists: albumArtists = [] 356 + } = mAlbum || {}; 357 + album = albumtitle ?? mAlbumName; 358 + } 359 + 360 + const artistStrings = artists.reduce((acc: any, curr: any) => { 361 + let aString; 362 + if (typeof curr === 'string') { 363 + aString = curr; 364 + } else if (typeof curr === 'object') { 365 + aString = curr.name; 366 + } 367 + const aStrings = aString.split(','); 368 + return [...acc, ...aStrings]; 369 + }, []); 370 + const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); 371 + return { 372 + data: removeUndefinedKeys({ 373 + artists: [...new Set(artistStrings)] as string[], 374 + track: title, 375 + album, 376 + duration, 377 + listenedFor, 378 + playDate: dayjs.unix(time), 379 + }), 380 + meta: { 381 + source: 'Maloja', 382 + url: { 383 + web: `${url}/track?${urlParams.toString()}` 384 + } 385 + } 386 + } 387 + } 388 + 389 + export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => { 390 + 391 + const { 392 + data: { 393 + artists = [], 394 + albumArtists = [], 395 + album, 396 + track, 397 + duration, 398 + listenedFor 399 + } = {} 400 + } = playObj; 401 + 402 + const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj); 403 + 404 + const scrobbleData: MalojaScrobbleV3RequestData = { 405 + title: track, 406 + artists, 407 + album, 408 + key: apiKey, 409 + time: pd.unix(), 410 + // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135 411 + length: duration, 412 + }; 413 + if (listenedFor !== undefined && listenedFor > 0) { 414 + scrobbleData.duration = listenedFor; 415 + } 416 + 417 + if (albumArtists.length > 0) { 418 + scrobbleData.albumartists = albumArtists; 419 + } 420 + 421 + return scrobbleData; 422 + }
+4 -23
src/backend/common/vendor/maloja/interfaces.ts
··· 3 3 import { findCauseByFunc } from "../../../utils/ErrorUtils.js"; 4 4 import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; 5 5 6 - export interface MalojaV2ScrobbleData { 7 - artists: string[] 8 - title: string 9 - album: string 10 - /** 11 - * Length of the track 12 - * */ 13 - duration: number 14 - /** 15 - * unix timestamp (seconds) scrobble was made at 16 - * */ 17 - time: number 18 - } 19 - 20 6 export interface MalojaAlbumData { 21 7 name?: string 22 8 albumtitle?: string ··· 40 26 /** 41 27 * how long the track was listened to before it was scrobbled 42 28 * */ 43 - duration: number 29 + duration?: number 44 30 } 45 31 46 - export type MalojaScrobbleData = MalojaV2ScrobbleData | MalojaV3ScrobbleData; 32 + export type MalojaScrobbleData = MalojaV3ScrobbleData; 47 33 48 34 export interface MalojaScrobbleRequestData { 49 35 /** The auth key used to scrobble */ 50 - key: string 36 + key?: string 51 37 /** name of the track */ 52 38 title: string 53 39 /** name of the album */ ··· 60 46 duration?: number 61 47 } 62 48 63 - export interface MalojaScrobbleV2RequestData extends MalojaScrobbleRequestData { 64 - /** comma-separated list of artists for this track */ 65 - artist: string 66 - } 67 - 68 49 export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData { 69 50 /** a list of artists for this track */ 70 51 artists: string[] 71 52 /** a list of artists for the album the track is on */ 72 - albumartists: string[] 53 + albumartists?: string[] 73 54 /** skip server-side metadata parsing */ 74 55 nofix?: boolean 75 56 }
+38 -449
src/backend/scrobblers/MalojaScrobbler.ts
··· 1 - import { Logger } from "@foxxmd/logging"; 2 - import compareVersions from 'compare-versions'; 3 - import dayjs from 'dayjs'; 1 + import { childLogger, Logger } from "@foxxmd/logging"; 4 2 import EventEmitter from "events"; 5 3 import normalizeUrl from "normalize-url"; 6 - import request, { SuperAgentRequest } from 'superagent'; 7 4 import { PlayObject } from "../../core/Atomic.js"; 8 5 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 9 - import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js"; 10 6 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 11 - import { UpstreamError } from "../common/errors/UpstreamError.js"; 12 - import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 7 + import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 13 8 import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js"; 14 9 import { 15 - getMalojaResponseError, 16 - isMalojaAPIErrorBody, 17 - MalojaResponseV3CommonData, 18 - MalojaScrobbleData, 19 10 MalojaScrobbleRequestData, 20 - MalojaScrobbleV2RequestData, 21 - MalojaScrobbleV3RequestData, 22 - MalojaScrobbleV3ResponseData, MalojaScrobbleWarning, 23 - MalojaV2ScrobbleData, 24 - MalojaV3ScrobbleData, 25 11 } from "../common/vendor/maloja/interfaces.js"; 26 12 import { Notifiers } from "../notifier/Notifiers.js"; 27 - import { parseRetryAfterSecsFromObj, sleep } from "../utils.js"; 28 - import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from "../utils/TimeUtils.js"; 29 13 import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 14 + import { MalojaApiClient, formatPlayObj as formatMalojaScrobbleToPlay, playToScrobblePayload } from "../common/vendor/maloja/MalojaApiClient.js"; 30 15 31 16 const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"]; 32 17 ··· 36 21 serverVersion: any; 37 22 webUrl: string; 38 23 24 + api: MalojaApiClient; 25 + 39 26 declare config: MalojaClientConfig 40 27 41 28 constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { 42 - super('maloja', name, config, notifier, emitter,logger); 29 + super('maloja', name, config, notifier, emitter, logger); 30 + this.api = new MalojaApiClient(name, this.config.data, { logger: childLogger(this.logger, 'API') }); 43 31 this.MAX_INITIAL_SCROBBLES_FETCH = 100; 44 32 } 45 33 46 - static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject { 47 - let artists, 48 - title, 49 - album, 50 - duration, 51 - time, 52 - listenedFor; 53 - 54 - const {serverVersion, url} = options; 55 - 56 - if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) { 57 - // scrobble data structure changed for v3 58 - const { 59 - // when the track was scrobbled 60 - time: mTime, 61 - track: { 62 - artists: mArtists = [], 63 - title: mTitle, 64 - album: mAlbum, 65 - // length of the track 66 - length: mLength, 67 - } = {}, 68 - // how long the track was listened to before it was scrobbled 69 - duration: mDuration, 70 - } = obj as MalojaV3ScrobbleData; 71 - artists = mArtists; 72 - time = mTime; 73 - title = mTitle; 74 - duration = mLength; 75 - listenedFor = mDuration; 76 - if(mAlbum !== null) { 77 - const { 78 - albumtitle, 79 - name: mAlbumName, 80 - artists: albumArtists = [] 81 - } = mAlbum || {}; 82 - album = albumtitle ?? mAlbumName; 83 - } 84 - } else { 85 - // scrobble data structure for v2 and below 86 - const { 87 - artists: mArtists = [], 88 - title: mTitle, 89 - album: mAlbum, 90 - duration: mDuration, 91 - time: mTime, 92 - } = obj as MalojaV2ScrobbleData; 93 - artists = mArtists; 94 - title = mTitle; 95 - album = mAlbum; 96 - duration = mDuration; 97 - time = mTime; 98 - } 99 - const artistStrings = artists.reduce((acc: any, curr: any) => { 100 - let aString; 101 - if (typeof curr === 'string') { 102 - aString = curr; 103 - } else if (typeof curr === 'object') { 104 - aString = curr.name; 105 - } 106 - const aStrings = aString.split(','); 107 - return [...acc, ...aStrings]; 108 - }, []); 109 - const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); 110 - return { 111 - data: { 112 - artists: [...new Set(artistStrings)] as string[], 113 - track: title, 114 - album, 115 - duration, 116 - listenedFor, 117 - playDate: dayjs.unix(time), 118 - }, 119 - meta: { 120 - source: 'Maloja', 121 - url: { 122 - web: `${url}/track?${urlParams.toString()}` 123 - } 124 - } 125 - } 126 - } 127 - 128 - formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion, url: this.webUrl}); 129 - 130 - callApi = async (req: SuperAgentRequest, retries = 0) => { 131 - const { 132 - maxRequestRetries = 1, 133 - retryMultiplier = DEFAULT_RETRY_MULTIPLIER 134 - } = this.config.data; 135 - 136 - try { 137 - return await req; 138 - } catch (e) { 139 - if((isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout)) { 140 - if(retries < maxRequestRetries) { 141 - const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1)); 142 - this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`); 143 - await sleep(retryAfter * 1000); 144 - return await this.callApi(req, retries + 1) 145 - } else { 146 - throw new UpstreamError(`Request continued to fail after reach max retries (${maxRequestRetries})`, {cause : e, showStopper: true}); 147 - } 148 - } else if(isSuperAgentResponseError(e)) { 149 - const { 150 - message, 151 - response: { 152 - status, 153 - body, 154 - } = {}, 155 - response, 156 - } = e; 157 - if(isMalojaAPIErrorBody(body)) { 158 - throw new UpstreamError(buildErrorString(body), {cause: e}) 159 - } else { 160 - throw new UpstreamError(`API Call failed (HTTP ${status}) => ${message}`, {cause: e}) 161 - } 162 - } else { 163 - throw new Error('Unexpected error occurred during API call', {cause : e}); 164 - } 165 - } 166 - } 167 - 168 - testConnection = async () => { 169 - 170 - const {url} = this.config.data; 171 - try { 172 - const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`)); 173 - const { 174 - statusCode, 175 - body: { 176 - version = [], 177 - versionstring = '', 178 - } = {}, 179 - } = serverInfoResp; 180 - 181 - if (statusCode >= 300) { 182 - throw new Error(`Communication test not OK! HTTP Status => Expected: 200 | Received: ${statusCode}`); 183 - } 184 - 185 - this.logger.info('Communication test succeeded.'); 186 - 187 - if (version.length === 0) { 188 - 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.'); 189 - } else { 190 - this.logger.info(`Maloja Server Version: ${versionstring}`); 191 - this.serverVersion = versionstring; 192 - if(compareVersions(versionstring, '3.0.0') < 0) { 193 - this.logger.warn(`Support for Maloja versions below 3.0.0 is DEPRECATED and will be removed in a future minor release.`); 194 - } else if(compareVersions(versionstring, '3.2.0') < 0) { 195 - this.logger.warn(`Maloja versions below 3.2.0 do not support scrobbling albums.`); 196 - } 197 - } 198 - return true; 199 - } catch (e) { 200 - throw new Error('Communication test failed', {cause: e}) 201 - } 202 - } 203 - 204 - testHealth = async () => { 205 - 206 - const {url} = this.config.data; 207 - try { 208 - const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`), 0); 209 - const { 210 - statusCode, 211 - body: { 212 - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message 213 - db_status: { 214 - healthy = false, 215 - rebuildinprogress = false, 216 - complete = false, 217 - } 218 - } = {}, 219 - } = serverInfoResp; 220 - 221 - if (statusCode >= 300) { 222 - throw new Error(`Server responded with NOT OK status: ${statusCode}`); 223 - } 224 - 225 - if(rebuildinprogress) { 226 - throw new Error(`Server is rebuilding database`); 227 - } 228 - 229 - if(!healthy) { 230 - throw new Error('Server responded that it is not healthy'); 231 - } 232 - 233 - return true 234 - } catch (e) { 235 - throw new Error('Error encountered while testing server health', {cause: e}); 236 - } 237 - } 34 + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => formatMalojaScrobbleToPlay(obj, { url: this.webUrl }); 238 35 239 36 protected async doBuildInitData(): Promise<true | string | undefined> { 240 - const {data: {url, apiKey} = {}} = this.config; 37 + const { data: { url, apiKey } = {} } = this.config; 241 38 if (apiKey === undefined) { 242 39 throw new Error("'apiKey' not found in config!"); 243 40 } ··· 249 46 } 250 47 251 48 protected async doCheckConnection(): Promise<true | string | undefined> { 252 - await this.testConnection(); 253 - await this.testHealth(); 254 - return true; 49 + 50 + try { 51 + await this.api.testConnection(); 52 + await this.api.testHealth(); 53 + return true; 54 + } catch (e) { 55 + throw e; 56 + } 57 + 255 58 } 256 59 257 60 258 61 doAuthentication = async () => { 259 62 260 - const {url, apiKey} = this.config.data; 63 + const { data: { url, apiKey } = {} } = this.config; 64 + if (apiKey === undefined) { 65 + throw new Error("'apiKey' not found in config!"); 66 + } 261 67 try { 262 - const resp = await this.callApi(request 263 - .get(`${url}/apis/mlj_1/test`) 264 - .query({key: apiKey})); 265 - 266 - const { 267 - status, 268 - body: { 269 - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message 270 - status: bodyStatus, 271 - } = {}, 272 - body = {}, 273 - text = '', 274 - } = resp; 275 - if (bodyStatus.toLocaleLowerCase() === 'ok') { 276 - this.logger.info('Auth test passed!'); 277 - return true; 278 - } else { 279 - this.logger.error('Maloja API Response', { 280 - status, 281 - body, 282 - text: text.slice(0, 50) 283 - }); 284 - 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)}`)}) 285 - } 68 + await this.api.testAuth(); 69 + return true; 286 70 } catch (e) { 287 - if(e instanceof UpstreamError) { 288 - if((e?.cause as any)?.status === 403) { 289 - // may be an older version that doesn't support auth readiness before db upgrade 290 - // and if it was before api was accessible during db build then test would fail during testConnection() 291 - if(compareVersions(this.serverVersion, '2.12.19') < 0) { 292 - if(!(await this.isReady())) { 293 - throw new UpstreamError(`Could not test auth because server is not ready`, {showStopper: false}); 294 - } 295 - } 296 - } 71 + if (isNodeNetworkException(e)) { 72 + this.logger.error('Could not communicate with Maloja API'); 297 73 } 298 74 throw e; 299 75 } 300 76 } 301 77 302 78 getScrobblesForRefresh = async (limit: number) => { 303 - const {url} = this.config.data; 304 - const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`)); 305 - const { 306 - body: { 307 - list = [], 308 - } = {}, 309 - } = resp; 310 - return list.map((x: any) => this.formatPlayObj(x)); 311 - } 312 - 313 - cleanSourceSearchTitle = (playObj: PlayObject) => { 314 - const { 315 - data: { 316 - track, 317 - artists: sourceArtists = [], 318 - } = {}, 319 - } = playObj; 320 - let lowerTitle = track.toLocaleLowerCase(); 321 - lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle); 322 - // also remove [artist] from the track if found since that gets removed as well 323 - const lowerArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase()); 324 - lowerTitle = lowerArtists.reduce((acc: any, curr: any) => acc.replace(curr, ''), lowerTitle); 325 - 326 - // remove any whitespace in parenthesis 327 - lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '') 328 - // replace parenthesis 329 - .replace('()', '') 330 - .replace('( )', '') 331 - .trim(); 332 - 333 - return lowerTitle; 79 + return await this.api.getRecentScrobbles(limit); 334 80 } 335 81 336 82 alreadyScrobbled = async (playObj: any, log = false) => (await this.existingScrobble(playObj)) !== undefined 337 83 338 84 public playToClientPayload(playObj: PlayObject): MalojaScrobbleRequestData { 339 85 340 - const {apiKey} = this.config.data; 86 + const { apiKey } = this.config.data; 341 87 342 - const { 343 - data: { 344 - artists = [], 345 - albumArtists = [], 346 - album, 347 - track, 348 - duration, 349 - listenedFor 350 - } = {} 351 - } = playObj; 352 - 353 - const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj); 354 - 355 - const scrobbleData: MalojaScrobbleRequestData = { 356 - title: track, 357 - album, 358 - key: apiKey, 359 - time: pd.unix(), 360 - // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135 361 - length: duration, 362 - }; 363 - if(listenedFor !== undefined && listenedFor > 0) { 364 - scrobbleData.duration = listenedFor; 365 - } 366 - 367 - // 3.0.3 has a BC for something (maybe seconds => length ?) -- see #42 in repo 368 - if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.2') > 0) { 369 - (scrobbleData as MalojaScrobbleV3RequestData).artists = artists; 370 - if(albumArtists.length > 0) { 371 - (scrobbleData as MalojaScrobbleV3RequestData).albumartists = albumArtists; 372 - } 373 - } else { 374 - // maloja seems to detect this deliminator much better than commas 375 - // also less likely artist has a forward slash in their name than a comma 376 - (scrobbleData as MalojaScrobbleV2RequestData).artist = artists.join(' / '); 377 - } 378 - 379 - return scrobbleData; 88 + return playToScrobblePayload(playObj); 380 89 } 381 90 382 91 doScrobble = async (playObj: PlayObject) => { 383 - const {url, apiKey} = this.config.data; 384 - 385 92 const { 386 - data: { 387 - album, 388 - duration, 389 - playDate, 390 - } = {}, 391 93 meta: { 392 94 source, 393 95 newFromSource = false, 394 96 } = {} 395 97 } = playObj; 396 98 397 - const pd = getScrobbleTsSOCDate(playObj); 99 + const scrobbleData = playToScrobblePayload(playObj); 398 100 399 - const sType = newFromSource ? 'New' : 'Backlog'; 400 - 401 - const scrobbleData = this.playToClientPayload(playObj); 402 - 403 - let responseBody: MalojaScrobbleV3ResponseData; 101 + let scrobbledPlay: PlayObject; 404 102 405 103 try { 406 - const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`) 407 - .type('json') 408 - .send(scrobbleData)); 104 + const [scrobbleResp, respBody, warnStr] = await this.api.scrobble(playObj); 409 105 410 - let scrobbleResponse: any | undefined = undefined, 411 - scrobbledPlay: PlayObject; 412 - 413 - if(this.serverVersion === undefined || compareVersions(this.serverVersion, '3.0.0') >= 0) { 414 - responseBody = response.body; 415 - const { 416 - track, 417 - status, 418 - warnings = [], 419 - } = responseBody; 420 - if(status === 'success') { 421 - if(track !== undefined) { 422 - scrobbleResponse = { 423 - time: pd.unix(), 424 - track: { 425 - ...track, 426 - length: duration 427 - }, 428 - } 429 - if (album !== undefined) { 430 - const { 431 - album: malojaAlbum = {}, 432 - } = track; 433 - scrobbleResponse.track.album = { 434 - ...malojaAlbum, 435 - name: album 436 - } 437 - } 438 - } 439 - if(warnings.length > 0) { 440 - for(const w of warnings) { 441 - const warnStr = buildWarningString(w); 442 - if(warnStr.includes('The submitted scrobble was not added')) { 443 - throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false}); 444 - } 445 - this.logger.warn(`Maloja Warning: ${warnStr}`); 446 - } 447 - } 448 - } else { 449 - throw new UpstreamError(buildErrorString(response), {showStopper: false}); 450 - } 451 - } else { 452 - const { 453 - body: { 454 - track: { 455 - time: mTime = pd.unix(), 456 - duration: mDuration = duration, 457 - album: mAlbum = album, 458 - ...rest 459 - } = {} 460 - } = {} 461 - } = response; 462 - scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration}; 463 - } 464 106 let warning = ''; 465 - if(scrobbleResponse === undefined) { 107 + if (scrobbleResp === undefined) { 466 108 warning = `WARNING: Maloja did not return track data in scrobble response! Maybe it didn't scrobble correctly??`; 467 109 scrobbledPlay = playObj; 468 110 } else { 469 - scrobbledPlay = this.formatPlayObj(scrobbleResponse) 111 + scrobbledPlay = this.formatPlayObj(scrobbleResp) 470 112 } 471 113 const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`; 472 - if(warning !== '') { 114 + if (warning !== '') { 473 115 this.logger.warn(`${scrobbleInfo} | ${warning}`); 474 - this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(response.body))}`); 116 + this.logger.debug(`Response: ${this.logger.debug(JSON.stringify(respBody))}`); 475 117 } else { 476 118 this.logger.info(scrobbleInfo); 477 119 } 478 120 return scrobbledPlay; 479 121 } catch (e) { 480 - await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 481 - this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData}); 482 - const responseError = getMalojaResponseError(e); 483 - if(responseError !== undefined) { 484 - if(responseError.status < 500 && e instanceof UpstreamError) { 485 - e.showStopper = false; 486 - } 487 - if(responseError.response?.text !== undefined) { 488 - this.logger.error('Raw Response:', { text: responseError.response?.text }); 489 - } 490 - } 122 + await this.notifier.notify({ title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error' }); 491 123 throw e; 492 124 } finally { 493 125 this.logger.debug('Raw Payload:', scrobbleData); 494 126 } 495 127 } 496 - } 497 - 498 - const buildErrorString = (body: MalojaResponseV3CommonData) => { 499 - let valString: string | undefined = undefined; 500 - const { 501 - status, 502 - error: { 503 - type, 504 - value, 505 - desc 506 - } = {} 507 - } = body; 508 - if(value !== undefined && value !== null) { 509 - if(typeof value === 'string') { 510 - valString = value; 511 - } else if(Array.isArray(value)) { 512 - valString = value.map(x => { 513 - if(typeof x === 'string') { 514 - return x; 515 - } 516 - return JSON.stringify(x); 517 - }).join(', '); 518 - } else { 519 - valString = JSON.stringify(value); 520 - } 521 - } 522 - return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`; 523 - } 524 - 525 - const buildWarningString = (w: MalojaScrobbleWarning): string => { 526 - const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`]; 527 - let vals: string[] = []; 528 - if(w.value !== null && w.value !== undefined) { 529 - if(Array.isArray(w.value)) { 530 - vals = w.value; 531 - } else { 532 - vals.push(w.value); 533 - } 534 - } 535 - if(vals.length > 0) { 536 - parts.push(vals.join(' | ')); 537 - } 538 - return parts.join(' => '); 539 - } 128 + }
-1
src/backend/scrobblers/ScrobbleClients.ts
··· 270 270 } 271 271 try { 272 272 const validConfig = validateJson<ClientConfig>(rawConf, this.getSchemaByType(clientType), this.logger); 273 - // @ts-expect-error configureAs should exist 274 273 const {configureAs = defaultConfigureAs} = validConfig; 275 274 if (configureAs === 'client') { 276 275 const parsedConfig: ParsedConfig = {
+75
src/backend/sources/MalojaSource.ts
··· 1 + import EventEmitter from "events"; 2 + import request from "superagent"; 3 + import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 4 + import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 5 + import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 6 + import { RecentlyPlayedOptions } from "./AbstractSource.js"; 7 + import MemorySource from "./MemorySource.js"; 8 + import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; 9 + import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js"; 10 + import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js"; 11 + import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js"; 12 + 13 + export default class MalojaSource extends MemorySource { 14 + 15 + api: MalojaApiClient; 16 + requiresAuth = true; 17 + requiresAuthInteraction = false; 18 + 19 + declare config: MalojaSourceConfig; 20 + 21 + constructor(name: any, config: MalojaSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 22 + const { 23 + data: { 24 + interval = 15, 25 + maxInterval = 60, 26 + ...restData 27 + } = {} 28 + } = config; 29 + super('maloja', name, { ...config, data: { interval, maxInterval, ...restData } }, internal, emitter); 30 + this.canPoll = true; 31 + this.canBacklog = true; 32 + this.api = new MalojaApiClient(name, config.data, { logger: this.logger }); 33 + this.playerSourceOfTruth = SOURCE_SOT.HISTORY; 34 + this.supportsUpstreamRecentlyPlayed = true 35 + this.SCROBBLE_BACKLOG_COUNT = 20; 36 + 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.`) 37 + } 38 + 39 + protected async doCheckConnection(): Promise<true | string | undefined> { 40 + await this.api.testConnection(); 41 + return true; 42 + } 43 + 44 + doAuthentication = async () => { 45 + if (this.config.data.apiKey === undefined) { 46 + throw new Error(`Must provide 'apiKey' in configuration`); 47 + } 48 + try { 49 + return await this.api.testAuth(); 50 + } catch (e) { 51 + if (isNodeNetworkException(e)) { 52 + this.logger.error('Could not communicate with Maloja API'); 53 + } 54 + throw e; 55 + } 56 + } 57 + 58 + 59 + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 60 + const { limit = 20 } = options; 61 + this.processRecentPlays([]); 62 + return await this.api.getRecentScrobbles(limit); 63 + } 64 + 65 + getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => { 66 + try { 67 + return await this.api.getRecentScrobbles(20); 68 + } catch (e) { 69 + throw e; 70 + } 71 + } 72 + 73 + protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({ formatted: true, ...options }) 74 + 75 + }
+10 -2
src/backend/sources/ScrobbleSources.ts
··· 25 25 import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js"; 26 26 import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js"; 27 27 import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js"; 28 + import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js"; 28 29 import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js"; 29 30 import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; 30 31 import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; ··· 67 68 import DeezerInternalSource from './DeezerInternalSource.js'; 68 69 import KoitoSource from './KoitoSource.js'; 69 70 import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js'; 71 + import MalojaSource from './MalojaSource.js'; 70 72 71 73 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 72 74 ··· 164 166 break; 165 167 case 'ytmusic': 166 168 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig"); 169 + break; 170 + case 'maloja': 171 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaSourceConfig"); 167 172 break; 168 173 case 'mpris': 169 174 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig"); ··· 697 702 continue; 698 703 } 699 704 for (const [i,rawConf] of sourceConfigs.entries()) { 700 - if(['lastfm','listenbrainz','koito'].includes(sourceType) && 701 - ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source')) 705 + if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) && 706 + ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source')) 702 707 { 703 708 this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); 704 709 continue; ··· 872 877 break; 873 878 case 'koito': 874 879 newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter); 880 + break; 881 + case 'maloja': 882 + newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter); 875 883 break; 876 884 default: 877 885 break;
+27 -12
src/backend/utils.ts
··· 285 285 if(Array.isArray(obj[key])) { 286 286 newObj[key] = obj[key]; 287 287 } else if (obj[key] === Object(obj[key])) { 288 - newObj[key] = removeUndefinedKeys(obj[key]); 288 + // dumb assign nested objects 289 + // bc they may be third party library-objects that use prototyping and we don't want to mess with 290 + newObj[key] = obj[key]; 289 291 } else if (obj[key] !== undefined) { 290 292 newObj[key] = obj[key]; 291 293 } ··· 609 611 return eArtists.length > 1 || cArtists.length > 1; 610 612 } 611 613 612 - export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable<T> | undefined => { 614 + export interface NonEmptyOptions<T> { 615 + ofType?: string, 616 + test?: (val: T) => boolean 617 + } 618 + export const getFirstNonEmptyVal = <T = unknown>(values: unknown[], options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => { 613 619 for(const v of values) { 614 - if(v === undefined || v === null) { 615 - continue; 616 - } 617 - if(options.ofType !== undefined && typeof v !== options.ofType) { 618 - continue; 620 + const nonEmptyVal = getNonEmptyVal(v, options); 621 + if(nonEmptyVal !== undefined) { 622 + return nonEmptyVal as T; 619 623 } 620 - if(options.test !== undefined && options.test(v as T) === false) { 621 - continue; 622 - } 623 - return v as T; 624 624 } 625 625 return undefined; 626 626 } 627 627 628 - export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, {ofType: 'string', test: (v) => v.trim() !== ''}); 628 + export const getNonEmptyVal = <T = unknown>(value: unknown, options: NonEmptyOptions<T> = {}): NonNullable<T> | undefined => { 629 + if (value === undefined || value === null) { 630 + return undefined; 631 + } 632 + if (options.ofType !== undefined && typeof value !== options.ofType) { 633 + return undefined; 634 + } 635 + if (options.test !== undefined && options.test(value as T) === false) { 636 + return undefined; 637 + } 638 + return value as T; 639 + } 640 + 641 + const nonEmptyStringOpts: NonEmptyOptions<string> = { ofType: 'string', test: (v) => v.trim() !== '' }; 642 + export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, nonEmptyStringOpts); 643 + export const getNonEmptyString = (value: unknown) => getNonEmptyVal<string>(value, nonEmptyStringOpts); 629 644 630 645 /** 631 646 * Runs the function `fn`