[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.

feat(maloja): Add Maloja Source #295

FoxxMD (Aug 6, 2025, 4:48 PM UTC) abf532c6 976c7c0e

+198 -3
+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',
+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
+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;