diff --git a/config/maloja.json.example b/config/maloja.json.example
index 62f64295..00cd2e14 100644
--- a/config/maloja.json.example
+++ b/config/maloja.json.example
@@ -1,7 +1,17 @@
[
{
- "name": "myMaloja",
+ "name": "myMaloja-client",
"enable": true,
+ "configureAs": "client",
+ "data": {
+ "url": "http://localhost:42010",
+ "apiKey": "myMalojaKey"
+ }
+ },
+ {
+ "name": "myMaloja-source",
+ "enable": true,
+ "configureAs": "source",
"data": {
"url": "http://localhost:42010",
"apiKey": "myMalojaKey"
diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx
index 18bf579a..f0bbbebe 100644
--- a/docsite/docs/configuration/configuration.mdx
+++ b/docsite/docs/configuration/configuration.mdx
@@ -1326,6 +1326,55 @@ Most Listenbrainz clients require a token (Authentication Token) to be provided
+### [Maloja (Source)](https://github.com/krateng/maloja)
+
+This Source monitors a Maloja server's scrobble history and then re-scrobbles discovered tracks to configured [Clients.](#client-configurations)
+
+:::tip[Other Uses]
+
+To _scrobble to_ a Maloja server, create a [Maloja (Client)](#maloja)
+
+:::
+
+See the [Maloja (Client)](#maloja) configuration for general setup. The only difference for **Source** configuration:
+
+* Cannot be setup with ENV config
+* [File/AIO config](./?configType=file#configuration-types) must include `"configureAs": "source"`
+
+#### Configuration
+
+
+
+ :::note
+ You cannot use ENV variables shown in the [Maloja Client config](#maloja) -- multi-scrobbler assumes Maloja ENVs are always used for the **client** configuration. You must use the [File or AIO](./?configType=file#configuration-types) config to setup Maloja as a Source.
+ :::
+
+
+
+ Change `configureAs` to `source`
+
+ Example
+
+
+
+
+
+ or
+
+
+
+ Change `configureAs` to `source`
+
+ Example
+
+
+
+
+
+ or
+
+
+
### [Mopidy](https://mopidy.com/)
Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/)
@@ -2686,6 +2735,32 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
### [Maloja](https://github.com/krateng/maloja)
+Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
+
+
+
+ Maloja Setup Intructions
+
+ Using Maloja's example `docker-compose.yml`:
+
+ ```yaml reference title="~/malojaData/docker-compose.yml"
+ https://github.com/krateng/maloja/blob/master/example-compose.yml
+ ```
+
+ Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password
+
+ Start the container:
+
+ ```shell title="~/malojaData"
+ docker compose up -d
+ ```
+
+
+* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://myMalojaServerIP/admin_apikeys)
+ * Create a **New Key** and then copy the generated key value
+
+Finally, add the Maloja server URL and API Key to the configuration type you choose to use, below.
+
#### Configuration
diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx
index 90d01386..2271cb44 100644
--- a/docsite/src/pages/index.mdx
+++ b/docsite/src/pages/index.mdx
@@ -25,6 +25,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
+ * [Maloja](docs/configuration#maloja-source)
* [Mopidy](docs/configuration#mopidy)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts
index cd25effc..90902dcb 100644
--- a/src/backend/common/infrastructure/Atomic.ts
+++ b/src/backend/common/infrastructure/Atomic.ts
@@ -26,6 +26,7 @@ export type SourceType =
| 'kodi'
| 'webscrobbler'
| 'chromecast'
+ | 'maloja'
| 'musikcube'
| 'mpd'
| 'vlc'
@@ -52,6 +53,7 @@ export const sourceTypes: SourceType[] = [
'kodi',
'webscrobbler',
'chromecast',
+ 'maloja',
'musikcube',
'mpd',
'vlc',
diff --git a/src/backend/common/infrastructure/config/source/maloja.ts b/src/backend/common/infrastructure/config/source/maloja.ts
new file mode 100644
index 00000000..dd383f39
--- /dev/null
+++ b/src/backend/common/infrastructure/config/source/maloja.ts
@@ -0,0 +1,21 @@
+import { MalojaData } from "../client/maloja.js";
+import { PollingOptions } from "../common.js";
+import { CommonSourceConfig, CommonSourceData } from "./index.js";
+
+export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions {
+}
+
+export interface MalojaSourceConfig extends CommonSourceConfig {
+ /**
+ * When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client.
+ *
+ * @default source
+ * @examples ["source"]
+ * */
+ configureAs?: 'source'
+ data: MalojaSourceData
+}
+
+export interface MalojaSourceAIOConfig extends MalojaSourceConfig {
+ type: 'maloja'
+}
diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts
index 35817364..7958c99b 100644
--- a/src/backend/common/infrastructure/config/source/sources.ts
+++ b/src/backend/common/infrastructure/config/source/sources.ts
@@ -22,6 +22,7 @@ import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscro
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
+import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
export type SourceConfig =
@@ -45,6 +46,7 @@ export type SourceConfig =
| KodiSourceConfig
| WebScrobblerSourceConfig
| ChromecastSourceConfig
+ | MalojaSourceConfig
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
@@ -74,6 +76,7 @@ export type SourceAIOConfig =
| KodiSourceAIOConfig
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
+ | MalojaSourceAIOConfig
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
diff --git a/src/backend/sources/MalojaSource.ts b/src/backend/sources/MalojaSource.ts
new file mode 100644
index 00000000..f0e32b19
--- /dev/null
+++ b/src/backend/sources/MalojaSource.ts
@@ -0,0 +1,75 @@
+import EventEmitter from "events";
+import request from "superagent";
+import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js";
+import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
+import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
+import { RecentlyPlayedOptions } from "./AbstractSource.js";
+import MemorySource from "./MemorySource.js";
+import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
+import { KoitoSourceConfig } from "../common/infrastructure/config/source/koito.js";
+import { MalojaApiClient } from "../common/vendor/maloja/MalojaApiClient.js";
+import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
+
+export default class MalojaSource extends MemorySource {
+
+ api: MalojaApiClient;
+ requiresAuth = true;
+ requiresAuthInteraction = false;
+
+ declare config: MalojaSourceConfig;
+
+ constructor(name: any, config: MalojaSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
+ const {
+ data: {
+ interval = 15,
+ maxInterval = 60,
+ ...restData
+ } = {}
+ } = config;
+ super('maloja', name, { ...config, data: { interval, maxInterval, ...restData } }, internal, emitter);
+ this.canPoll = true;
+ this.canBacklog = true;
+ this.api = new MalojaApiClient(name, config.data, { logger: this.logger });
+ this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
+ this.supportsUpstreamRecentlyPlayed = true
+ this.SCROBBLE_BACKLOG_COUNT = 20;
+ this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`)
+ }
+
+ protected async doCheckConnection(): Promise {
+ await this.api.testConnection();
+ return true;
+ }
+
+ doAuthentication = async () => {
+ if (this.config.data.apiKey === undefined) {
+ throw new Error(`Must provide 'apiKey' in configuration`);
+ }
+ try {
+ return await this.api.testAuth();
+ } catch (e) {
+ if (isNodeNetworkException(e)) {
+ this.logger.error('Could not communicate with Maloja API');
+ }
+ throw e;
+ }
+ }
+
+
+ getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+ const { limit = 20 } = options;
+ this.processRecentPlays([]);
+ return await this.api.getRecentScrobbles(limit);
+ }
+
+ getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise => {
+ try {
+ return await this.api.getRecentScrobbles(20);
+ } catch (e) {
+ throw e;
+ }
+ }
+
+ protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({ formatted: true, ...options })
+
+}
\ No newline at end of file
diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts
index e8e68c08..88f89565 100644
--- a/src/backend/sources/ScrobbleSources.ts
+++ b/src/backend/sources/ScrobbleSources.ts
@@ -25,6 +25,7 @@ import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
+import { MalojaSourceConfig } from "../common/infrastructure/config/source/maloja.js";
import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js";
import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js";
import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js";
@@ -67,6 +68,7 @@ import { IcecastSource } from './IcecastSource.js';
import DeezerInternalSource from './DeezerInternalSource.js';
import KoitoSource from './KoitoSource.js';
import { KoitoSourceConfig } from '../common/infrastructure/config/source/koito.js';
+import MalojaSource from './MalojaSource.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -165,6 +167,9 @@ export default class ScrobbleSources {
case 'ytmusic':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("YTMusicSourceConfig");
break;
+ case 'maloja':
+ this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MalojaSourceConfig");
+ break;
case 'mpris':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPRISSourceConfig");
break;
@@ -697,8 +702,8 @@ export default class ScrobbleSources {
continue;
}
for (const [i,rawConf] of sourceConfigs.entries()) {
- if(['lastfm','listenbrainz','koito'].includes(sourceType) &&
- ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig).configureAs !== 'source'))
+ if(['lastfm','listenbrainz','koito','maloja'].includes(sourceType) &&
+ ((rawConf as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig).configureAs !== 'source'))
{
this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`);
continue;
@@ -873,6 +878,9 @@ export default class ScrobbleSources {
case 'koito':
newSource = await new KoitoSource(name, compositeConfig as KoitoSourceConfig, this.internalConfig, this.emitter);
break;
+ case 'maloja':
+ newSource = await new MalojaSource(name, compositeConfig as MalojaSourceConfig, this.internalConfig, this.emitter);
+ break;
default:
break;
}