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; }