diff --git a/README.md b/README.md
index affa2cb0..761b1b50 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
+ * [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
* Supports scrobbling to many **Clients**
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
diff --git a/config/mpd.json.example b/config/mpd.json.example
new file mode 100644
index 00000000..fc4d5815
--- /dev/null
+++ b/config/mpd.json.example
@@ -0,0 +1,13 @@
+[
+ {
+ "enable": true,
+ "name": "MyMPD",
+ "data": {
+ "url": "192.168.0.100:6600",
+ "password": "MY_PASSWORD"
+ },
+ "options": {
+ "disableDiscovery": false
+ }
+ }
+]
diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx
index 6b0c050c..7b9d3026 100644
--- a/docsite/docs/configuration/configuration.mdx
+++ b/docsite/docs/configuration/configuration.mdx
@@ -21,6 +21,7 @@ import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example';
import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example';
import MprisConfig from '!!raw-loader!../../../config/mpris.json.example';
import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example';
+import MPDConfig from '!!raw-loader!../../../config/mpd.json.example';
import PlexConfig from '!!raw-loader!../../../config/plex.json.example';
import SpotifyConfig from '!!raw-loader!../../../config/spotify.json.example';
import SubsonicConfig from '!!raw-loader!../../../config/subsonic.json.example';
@@ -1256,6 +1257,52 @@ If no URL is provided to MS it will try to use `ws://localhost:7905`
+### [MPD (Music Player Daemon)](https://www.musicpd.org/)
+
+MS communicates with MPD using the [TCP client connection.](https://mpd.readthedocs.io/en/stable/user.html#client-connections)
+
+You should uncomment/create the following settings in your mpd config:
+
+```
+bind_to_address "any" # or a specific ipv4/v6 address
+port "6600"
+```
+
+
+
+#### Configuration
+
+
+
+ | Environmental Variable | Required? | Default | Description |
+ |------------------------|-----------|------------------|-------------|
+ | `MPD_URL` | No | `localhost:6600` | |
+ | `MPD_PASSWORD` | No | | |
+
+
+
+
+ Example
+
+ {MPDConfig}
+
+
+
+ or
+
+
+
+
+ Example
+
+
+
+
+
+ or
+
+
+
## Client Configurations
### [Maloja](https://github.com/krateng/maloja)
diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx
index be25f971..c5ff95e8 100644
--- a/docsite/src/pages/index.mdx
+++ b/docsite/src/pages/index.mdx
@@ -28,6 +28,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Kodi](docs/configuration#kodi)
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
* [Musikcube](docs/configuration#musikcube)
+ * [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
diff --git a/package-lock.json b/package-lock.json
index 17d59509..3dc6364d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -51,6 +51,7 @@
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
+ "mpd-api": "^1.1.2",
"nanoid": "^3.3.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.5.4",
@@ -2150,6 +2151,11 @@
"@testing-library/dom": ">=7.21.4"
}
},
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
+ },
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -4852,6 +4858,22 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/file-type": {
+ "version": "16.5.4",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
+ "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
+ "dependencies": {
+ "readable-web-to-node-stream": "^3.0.0",
+ "strtok3": "^6.2.4",
+ "token-types": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -6934,6 +6956,27 @@
}
}
},
+ "node_modules/mpd-api": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/mpd-api/-/mpd-api-1.1.2.tgz",
+ "integrity": "sha512-UWSkIzYQnTvuvhLTfD0bIhBa4PCGCz6OsnDlojp319khsL8xXwS4qLVyJ+R5J7gNUTWiV9Y6d1j6apSYt2WDVA==",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "file-type": "^16.5.3",
+ "mpd2": "^1.0.5"
+ },
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/mpd2": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/mpd2/-/mpd2-1.0.5.tgz",
+ "integrity": "sha512-DcIy3jISfjmFTlOpwDE2KDhBLcGuFIAMTvY23OMkCikJXRnDqqm1jaHG3/LVYkKjrUVzQhlABW31roGqv/esRw==",
+ "dependencies": {
+ "debug": "^4.1.1"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -7927,6 +7970,18 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
+ "node_modules/peek-readable": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
+ "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
@@ -8633,6 +8688,21 @@
"node": ">= 6"
}
},
+ "node_modules/readable-web-to-node-stream": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
+ "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
+ "dependencies": {
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -9426,6 +9496,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strtok3": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
+ "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0",
+ "peek-readable": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -9744,6 +9830,22 @@
"node": ">=0.6"
}
},
+ "node_modules/token-types": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
+ "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
diff --git a/package.json b/package.json
index 4a161d5f..1178db61 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,7 @@
"kodi-api": "^0.2.1",
"lastfm-node-client": "^2.2.0",
"mopidy": "^1.3.0",
+ "mpd-api": "^1.1.2",
"nanoid": "^3.3.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.5.4",
diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts
index 6cdf80cc..897aa747 100644
--- a/src/backend/common/infrastructure/Atomic.ts
+++ b/src/backend/common/infrastructure/Atomic.ts
@@ -22,7 +22,8 @@ export type SourceType =
| 'kodi'
| 'webscrobbler'
| 'chromecast'
- | 'musikcube';
+ | 'musikcube'
+ | 'mpd';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -40,7 +41,8 @@ export const sourceTypes: SourceType[] = [
'kodi',
'webscrobbler',
'chromecast',
- 'musikcube'
+ 'musikcube',
+ 'mpd'
];
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
diff --git a/src/backend/common/infrastructure/config/source/mpd.ts b/src/backend/common/infrastructure/config/source/mpd.ts
new file mode 100644
index 00000000..57297d07
--- /dev/null
+++ b/src/backend/common/infrastructure/config/source/mpd.ts
@@ -0,0 +1,70 @@
+import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
+
+export interface MPDData extends CommonSourceData {
+ /**
+ * URL:PORT of the MPD server to connect to
+ *
+ * To use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections
+ *
+ * @examples ["localhost:6600"]
+ * @default "localhost:6600"
+ * */
+ url?: string
+
+ /**
+ * If using socket specify the path instead of url.
+ *
+ * trailing `~` is replaced by your home directory
+ * */
+ path?: string
+
+ /**
+ * Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords
+ * */
+ password?: string
+
+}
+
+export interface MPDSourceOptions extends CommonSourceOptions {
+ //disableDiscovery?: boolean
+}
+
+export interface MPDSourceConfig extends CommonSourceConfig {
+ data: MPDData
+ options: MPDSourceOptions
+}
+
+export interface MPDSourceAIOConfig extends MPDSourceConfig {
+ type: 'mpd'
+}
+
+export type PlayerState = 'play' | 'stop' | 'pause';
+
+export interface StatusResponse {
+ state: PlayerState
+ /**
+ * Position within the current song in seconds
+ * */
+ elapsed?: number
+ /**
+ * Duration of the current song in seconds
+ * */
+ duration?: number
+ error?: string
+}
+
+export interface CurrentSongResponse {
+ file: string
+ time: number
+ name?: string
+ performer?: string
+ artist?: string
+ album?: string
+ albumartist?: string
+ title?: string
+ musicbrainz_albumartistid?: string
+ musicbrainz_albumid?: string
+ musicbrainz_artistid?: string
+ musicbrainz_releasetrackid?: string
+ musicbrainz_trackid?: string
+}
diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts
index 6a42ed80..48f95e08 100644
--- a/src/backend/common/infrastructure/config/source/sources.ts
+++ b/src/backend/common/infrastructure/config/source/sources.ts
@@ -6,6 +6,7 @@ import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
+import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
@@ -32,7 +33,8 @@ export type SourceConfig =
| KodiSourceConfig
| WebScrobblerSourceConfig
| ChromecastSourceConfig
- | MusikcubeSourceConfig;
+ | MusikcubeSourceConfig
+ | MPDSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -50,4 +52,5 @@ export type SourceAIOConfig =
| KodiSourceAIOConfig
| WebScrobblerSourceAIOConfig
| ChromecastSourceAIOConfig
- | MusikcubeSourceAIOConfig;
+ | MusikcubeSourceAIOConfig
+ | MPDSourceAIOConfig;
diff --git a/src/backend/common/schema/aio-source.json b/src/backend/common/schema/aio-source.json
index b5d3ae91..4ec9ab3c 100644
--- a/src/backend/common/schema/aio-source.json
+++ b/src/backend/common/schema/aio-source.json
@@ -969,6 +969,169 @@
"title": "ListenBrainzSourceData",
"type": "object"
},
+ "MPDData": {
+ "properties": {
+ "password": {
+ "description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
+ "title": "password",
+ "type": "string"
+ },
+ "path": {
+ "description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
+ "title": "path",
+ "type": "string"
+ },
+ "url": {
+ "default": "localhost:6600",
+ "description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
+ "examples": [
+ "localhost:6600"
+ ],
+ "title": "url",
+ "type": "string"
+ }
+ },
+ "title": "MPDData",
+ "type": "object"
+ },
+ "MPDSourceAIOConfig": {
+ "properties": {
+ "clients": {
+ "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
+ "examples": [
+ [
+ "MyMalojaConfigName",
+ "MyLastFMConfigName"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "title": "clients",
+ "type": "array"
+ },
+ "data": {
+ "$ref": "#/definitions/MPDData",
+ "title": "data"
+ },
+ "enable": {
+ "default": true,
+ "description": "Should MS use this client/source? Defaults to true",
+ "examples": [
+ true
+ ],
+ "title": "enable",
+ "type": "boolean"
+ },
+ "name": {
+ "description": "Unique identifier for this source.",
+ "title": "name",
+ "type": "string"
+ },
+ "options": {
+ "$ref": "#/definitions/MPDSourceOptions",
+ "title": "options"
+ },
+ "type": {
+ "enum": [
+ "mpd"
+ ],
+ "title": "type",
+ "type": "string"
+ }
+ },
+ "required": [
+ "data",
+ "options",
+ "type"
+ ],
+ "title": "MPDSourceAIOConfig",
+ "type": "object"
+ },
+ "MPDSourceOptions": {
+ "properties": {
+ "logFilterFailure": {
+ "default": "warn",
+ "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
+ "enum": [
+ "debug",
+ false,
+ "warn"
+ ],
+ "examples": [
+ "warn"
+ ],
+ "title": "logFilterFailure"
+ },
+ "logPayload": {
+ "default": false,
+ "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPayload",
+ "type": "boolean"
+ },
+ "logPlayerState": {
+ "default": false,
+ "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPlayerState",
+ "type": "boolean"
+ },
+ "maxPollRetries": {
+ "default": 5,
+ "description": "default # of automatic polling restarts on error",
+ "examples": [
+ 5
+ ],
+ "title": "maxPollRetries",
+ "type": "number"
+ },
+ "maxRequestRetries": {
+ "default": 1,
+ "description": "default # of http request retries a source/client can make before error is thrown",
+ "examples": [
+ 1
+ ],
+ "title": "maxRequestRetries",
+ "type": "number"
+ },
+ "retryMultiplier": {
+ "default": 1.5,
+ "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
+ "examples": [
+ 1.5
+ ],
+ "title": "retryMultiplier",
+ "type": "number"
+ },
+ "scrobbleBacklog": {
+ "default": true,
+ "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
+ "examples": [
+ true,
+ false
+ ],
+ "title": "scrobbleBacklog",
+ "type": "boolean"
+ },
+ "scrobbleBacklogCount": {
+ "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
+ "title": "scrobbleBacklogCount",
+ "type": "number"
+ },
+ "scrobbleThresholds": {
+ "$ref": "#/definitions/ScrobbleThresholds",
+ "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
+ "title": "scrobbleThresholds"
+ }
+ },
+ "title": "MPDSourceOptions",
+ "type": "object"
+ },
"MPRISData": {
"properties": {
"blacklist": {
@@ -1440,6 +1603,9 @@
{
"$ref": "#/definitions/MopidySourceAIOConfig"
},
+ {
+ "$ref": "#/definitions/MPDSourceAIOConfig"
+ },
{
"$ref": "#/definitions/MPRISSourceAIOConfig"
},
diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json
index 55a4560a..6e62b9b8 100644
--- a/src/backend/common/schema/aio.json
+++ b/src/backend/common/schema/aio.json
@@ -1603,6 +1603,169 @@
"title": "LogOptions",
"type": "object"
},
+ "MPDData": {
+ "properties": {
+ "password": {
+ "description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
+ "title": "password",
+ "type": "string"
+ },
+ "path": {
+ "description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
+ "title": "path",
+ "type": "string"
+ },
+ "url": {
+ "default": "localhost:6600",
+ "description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
+ "examples": [
+ "localhost:6600"
+ ],
+ "title": "url",
+ "type": "string"
+ }
+ },
+ "title": "MPDData",
+ "type": "object"
+ },
+ "MPDSourceAIOConfig": {
+ "properties": {
+ "clients": {
+ "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
+ "examples": [
+ [
+ "MyMalojaConfigName",
+ "MyLastFMConfigName"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "title": "clients",
+ "type": "array"
+ },
+ "data": {
+ "$ref": "#/definitions/MPDData",
+ "title": "data"
+ },
+ "enable": {
+ "default": true,
+ "description": "Should MS use this client/source? Defaults to true",
+ "examples": [
+ true
+ ],
+ "title": "enable",
+ "type": "boolean"
+ },
+ "name": {
+ "description": "Unique identifier for this source.",
+ "title": "name",
+ "type": "string"
+ },
+ "options": {
+ "$ref": "#/definitions/MPDSourceOptions",
+ "title": "options"
+ },
+ "type": {
+ "enum": [
+ "mpd"
+ ],
+ "title": "type",
+ "type": "string"
+ }
+ },
+ "required": [
+ "data",
+ "options",
+ "type"
+ ],
+ "title": "MPDSourceAIOConfig",
+ "type": "object"
+ },
+ "MPDSourceOptions": {
+ "properties": {
+ "logFilterFailure": {
+ "default": "warn",
+ "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
+ "enum": [
+ "debug",
+ false,
+ "warn"
+ ],
+ "examples": [
+ "warn"
+ ],
+ "title": "logFilterFailure"
+ },
+ "logPayload": {
+ "default": false,
+ "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPayload",
+ "type": "boolean"
+ },
+ "logPlayerState": {
+ "default": false,
+ "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPlayerState",
+ "type": "boolean"
+ },
+ "maxPollRetries": {
+ "default": 5,
+ "description": "default # of automatic polling restarts on error",
+ "examples": [
+ 5
+ ],
+ "title": "maxPollRetries",
+ "type": "number"
+ },
+ "maxRequestRetries": {
+ "default": 1,
+ "description": "default # of http request retries a source/client can make before error is thrown",
+ "examples": [
+ 1
+ ],
+ "title": "maxRequestRetries",
+ "type": "number"
+ },
+ "retryMultiplier": {
+ "default": 1.5,
+ "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
+ "examples": [
+ 1.5
+ ],
+ "title": "retryMultiplier",
+ "type": "number"
+ },
+ "scrobbleBacklog": {
+ "default": true,
+ "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
+ "examples": [
+ true,
+ false
+ ],
+ "title": "scrobbleBacklog",
+ "type": "boolean"
+ },
+ "scrobbleBacklogCount": {
+ "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
+ "title": "scrobbleBacklogCount",
+ "type": "number"
+ },
+ "scrobbleThresholds": {
+ "$ref": "#/definitions/ScrobbleThresholds",
+ "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
+ "title": "scrobbleThresholds"
+ }
+ },
+ "title": "MPDSourceOptions",
+ "type": "object"
+ },
"MPRISData": {
"properties": {
"blacklist": {
@@ -2285,6 +2448,9 @@
{
"$ref": "#/definitions/MopidySourceAIOConfig"
},
+ {
+ "$ref": "#/definitions/MPDSourceAIOConfig"
+ },
{
"$ref": "#/definitions/MPRISSourceAIOConfig"
},
diff --git a/src/backend/common/schema/source.json b/src/backend/common/schema/source.json
index 8d15a29c..0e28b0ff 100644
--- a/src/backend/common/schema/source.json
+++ b/src/backend/common/schema/source.json
@@ -25,6 +25,9 @@
{
"$ref": "#/definitions/MopidySourceConfig"
},
+ {
+ "$ref": "#/definitions/MPDSourceConfig"
+ },
{
"$ref": "#/definitions/MPRISSourceConfig"
},
@@ -963,6 +966,161 @@
"title": "ListenBrainzSourceData",
"type": "object"
},
+ "MPDData": {
+ "properties": {
+ "password": {
+ "description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
+ "title": "password",
+ "type": "string"
+ },
+ "path": {
+ "description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
+ "title": "path",
+ "type": "string"
+ },
+ "url": {
+ "default": "localhost:6600",
+ "description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
+ "examples": [
+ "localhost:6600"
+ ],
+ "title": "url",
+ "type": "string"
+ }
+ },
+ "title": "MPDData",
+ "type": "object"
+ },
+ "MPDSourceConfig": {
+ "properties": {
+ "clients": {
+ "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
+ "examples": [
+ [
+ "MyMalojaConfigName",
+ "MyLastFMConfigName"
+ ]
+ ],
+ "items": {
+ "type": "string"
+ },
+ "title": "clients",
+ "type": "array"
+ },
+ "data": {
+ "$ref": "#/definitions/MPDData",
+ "title": "data"
+ },
+ "enable": {
+ "default": true,
+ "description": "Should MS use this client/source? Defaults to true",
+ "examples": [
+ true
+ ],
+ "title": "enable",
+ "type": "boolean"
+ },
+ "name": {
+ "description": "Unique identifier for this source.",
+ "title": "name",
+ "type": "string"
+ },
+ "options": {
+ "$ref": "#/definitions/MPDSourceOptions",
+ "title": "options"
+ }
+ },
+ "required": [
+ "data",
+ "options"
+ ],
+ "title": "MPDSourceConfig",
+ "type": "object"
+ },
+ "MPDSourceOptions": {
+ "properties": {
+ "logFilterFailure": {
+ "default": "warn",
+ "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
+ "enum": [
+ "debug",
+ false,
+ "warn"
+ ],
+ "examples": [
+ "warn"
+ ],
+ "title": "logFilterFailure"
+ },
+ "logPayload": {
+ "default": false,
+ "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPayload",
+ "type": "boolean"
+ },
+ "logPlayerState": {
+ "default": false,
+ "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
+ "examples": [
+ false
+ ],
+ "title": "logPlayerState",
+ "type": "boolean"
+ },
+ "maxPollRetries": {
+ "default": 5,
+ "description": "default # of automatic polling restarts on error",
+ "examples": [
+ 5
+ ],
+ "title": "maxPollRetries",
+ "type": "number"
+ },
+ "maxRequestRetries": {
+ "default": 1,
+ "description": "default # of http request retries a source/client can make before error is thrown",
+ "examples": [
+ 1
+ ],
+ "title": "maxRequestRetries",
+ "type": "number"
+ },
+ "retryMultiplier": {
+ "default": 1.5,
+ "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
+ "examples": [
+ 1.5
+ ],
+ "title": "retryMultiplier",
+ "type": "number"
+ },
+ "scrobbleBacklog": {
+ "default": true,
+ "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
+ "examples": [
+ true,
+ false
+ ],
+ "title": "scrobbleBacklog",
+ "type": "boolean"
+ },
+ "scrobbleBacklogCount": {
+ "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
+ "title": "scrobbleBacklogCount",
+ "type": "number"
+ },
+ "scrobbleThresholds": {
+ "$ref": "#/definitions/ScrobbleThresholds",
+ "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
+ "title": "scrobbleThresholds"
+ }
+ },
+ "title": "MPDSourceOptions",
+ "type": "object"
+ },
"MPRISData": {
"properties": {
"blacklist": {
diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts
new file mode 100644
index 00000000..8049db2f
--- /dev/null
+++ b/src/backend/sources/MPDSource.ts
@@ -0,0 +1,216 @@
+import { EventEmitter } from "events";
+import mpdapiNS, { MPDApi } from 'mpd-api';
+import mpd2 from 'mpd2';
+import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
+import {
+ FormatPlayObjectOptions,
+ InternalConfig,
+ PlayerStateData,
+ REPORTED_PLAYER_STATUSES,
+ ReportedPlayerStatus,
+ SINGLE_USER_PLATFORM_ID,
+} from "../common/infrastructure/Atomic.js";
+import {
+ CurrentSongResponse,
+ MPDSourceConfig,
+ PlayerState,
+ StatusResponse,
+} from "../common/infrastructure/config/source/mpd.js";
+import { isPortReachable } from "../utils/NetworkUtils.js";
+import { RecentlyPlayedOptions } from "./AbstractSource.js";
+import MemorySource from "./MemorySource.js";
+
+const mpdClient = mpdapiNS.default;
+
+const CLIENT_PLAYER_STATE: Record = {
+ 'play': REPORTED_PLAYER_STATUSES.playing,
+ 'pause': REPORTED_PLAYER_STATUSES.paused,
+ 'stop': REPORTED_PLAYER_STATUSES.stopped,
+}
+
+export class MPDSource extends MemorySource {
+ declare config: MPDSourceConfig;
+
+ host?: string
+ port?: number
+ // {host?: string, port?: number, path?: string, password?: string};
+ clientConfig: mpd2.MPD.Config;
+ client!: MPDApi.ClientAPI;
+ deviceId: string
+
+ constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
+ const {
+ data = {}
+ } = config;
+ const {
+ ...rest
+ } = data;
+ super('mpd', name, {...config, data: {...rest}}, internal, emitter);
+
+ this.requiresAuth = true;
+ this.canPoll = true;
+ }
+
+ static parseConnectionUrl(valRaw: string): [string, string] {
+ if(valRaw.trim() === '') {
+ throw new Error(`'url' cannot be an empty string`);
+ }
+
+ const [host, port] = valRaw.trim().split(':');
+ return [host, port ?? '6600'];
+ }
+
+ protected async doBuildInitData(): Promise {
+ const {
+ data: {
+ url,
+ path,
+ password,
+ } = {}
+ } = this.config;
+
+ if(path === undefined) {
+ const [host, port] = MPDSource.parseConnectionUrl(url ?? 'localhost:6600');
+ this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`);
+ this.host = host;
+ this.port = Number.parseInt(port);
+ this.clientConfig = {
+ host,
+ port: this.port,
+ password
+ }
+ } else {
+ this.logger.verbose(`Using socket path: ${path}`);
+ this.clientConfig = {
+ path,
+ password
+ }
+ }
+
+ return true;
+ }
+
+ protected async doCheckConnection(): Promise {
+ if(this.host !== undefined) {
+ try {
+ await isPortReachable(this.port, {host: this.host});
+ return `${this.host}:${this.port} is reachable.`;
+ } catch (e) {
+ throw e;
+ }
+ }
+ return null;
+ }
+
+ doAuthentication = async () => {
+
+ try {
+ this.client = await mpdClient.connect({...this.clientConfig, timeout: 1000});
+ return true;
+ } catch (e) {
+ let friendlyError: string | undefined;
+ if(e.code === 'ENOENT') {
+ friendlyError = 'Socket file does not exist'
+ } else if(e.code === 'EACCES') {
+ friendlyError = 'Incorrect permissions to access socket file'
+ }
+ // if(e.errno !== undefined) {
+ // switch(e.errno) {
+ // case mpd2.default.MPDError.CODES.PERMISSION:
+ // friendlyError = 'No permission to connect';
+ // break;
+ // case mpd2.default.MPDError.CODES.PASSWORD:
+ // friendlyError = 'Password is probably not correct';
+ // break;
+ // }
+ // }
+ throw new Error(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e});
+ }
+ }
+
+ formatPlayObj(obj: CurrentSongResponse, options: FormatPlayObjectOptions = {}): PlayObject {
+ const {
+ file,
+ time,
+ artist,
+ performer,
+ album,
+ albumartist,
+ title,
+ name,
+ musicbrainz_albumartistid,
+ musicbrainz_albumid,
+ musicbrainz_artistid,
+ musicbrainz_releasetrackid,
+ musicbrainz_trackid,
+ } = obj;
+
+ let artists = [];
+ let albumArtists = [];
+ if(artist !== undefined) {
+ artists.push(artist);
+ }
+ if(albumartist !== undefined && albumartist !== artist) {
+ albumArtists.push(albumartist);
+ }
+ if(artists.length === 0 && performer !== undefined) {
+ artists.push(performer);
+ }
+ if(artists.length === 0 && albumArtists.length !== 0) {
+ // switch these, tags are probably improper
+ artists = albumArtists;
+ albumArtists = [];
+ }
+
+ let trackName = title;
+ if(trackName === undefined && name !== undefined) {
+ trackName = name;
+ } else if(trackName === undefined && file !== undefined) {
+ trackName = file;
+ }
+
+ const brainz: BrainzMeta = {
+ albumArtist: musicbrainz_albumartistid,
+ album: musicbrainz_albumid,
+ track: musicbrainz_trackid,
+ };
+ if(musicbrainz_artistid !== undefined) {
+ brainz.artist = [musicbrainz_artistid];
+ }
+
+ return {
+ data: {
+ artists: artists,
+ albumArtists,
+ album,
+ track: trackName,
+ duration: time
+ },
+ meta: {
+ brainz,
+ trackProgressPosition: options.trackProgressPosition,
+ }
+ }
+ }
+
+ getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
+
+ const state = await this.client.api.status.get();
+ const currentSong = await this.client.api.status.currentsong();
+
+ let play: PlayObject | undefined;
+ if(currentSong !== undefined) {
+ play = this.formatPlayObj(currentSong, {trackProgressPosition: state.elapsed});
+ }
+
+ const playerState: PlayerStateData = {
+ platformId: SINGLE_USER_PLATFORM_ID,
+ status: CLIENT_PLAYER_STATE[state.state],
+ play,
+ position: state.elapsed
+ }
+
+ return this.processRecentPlays([playerState]);
+ }
+
+}
diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts
index ccfee5e4..3cd6e46a 100644
--- a/src/backend/sources/ScrobbleSources.ts
+++ b/src/backend/sources/ScrobbleSources.ts
@@ -12,6 +12,7 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
+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 { PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
@@ -34,6 +35,7 @@ import { KodiSource } from "./KodiSource.js";
import LastfmSource from "./LastfmSource.js";
import ListenbrainzSource from "./ListenbrainzSource.js";
import { MopidySource } from "./MopidySource.js";
+import { MPDSource } from "./MPDSource.js";
import { MPRISSource } from "./MPRISSource.js";
import { MusikcubeSource } from "./MusikcubeSource.js";
import PlexSource from "./PlexSource.js";
@@ -543,6 +545,9 @@ export default class ScrobbleSources {
case 'musikcube':
newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, internal, this.emitter);
break;
+ case 'mpd':
+ newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, internal, this.emitter);
+ break;
default:
break;
}
diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts
new file mode 100644
index 00000000..29684780
--- /dev/null
+++ b/src/backend/utils/NetworkUtils.ts
@@ -0,0 +1,41 @@
+import net from 'node:net';
+
+export interface PortReachableOpts {
+ host: string,
+ timeout?: number
+}
+/**
+ * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting
+ * */
+export const isPortReachable = async (port: number, opts: PortReachableOpts) => {
+ const {host, timeout = 1000} = opts;
+
+ const promise = new Promise(((resolve, reject) => {
+ const socket = new net.Socket();
+
+ const onError = (e) => {
+ socket.destroy();
+ reject(e);
+ };
+ const onTimeout = () => {
+ socket.destroy();
+ reject(new Error(`Connection timed out after ${timeout}ms`));
+ }
+
+ socket.setTimeout(timeout);
+ socket.once('error', onError);
+ socket.once('timeout', onTimeout);
+
+ socket.connect(port, host, () => {
+ socket.end();
+ resolve(true);
+ });
+ }));
+
+ try {
+ await promise;
+ return true;
+ } catch (e) {
+ throw e;
+ }
+}
diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts
index cba13876..363de34a 100644
--- a/src/core/Atomic.ts
+++ b/src/core/Atomic.ts
@@ -57,6 +57,14 @@ export interface ListenRangeData {
end: ListenProgress
}
+export interface BrainzMeta {
+ artist?: string[]
+ albumArtist?: string
+ album?: string
+ track?: string
+ releaseGroup?: string
+}
+
export interface TrackData {
artists?: string[]
albumArtists?: string[]
@@ -68,13 +76,7 @@ export interface TrackData {
duration?: number
meta?: {
- brainz?: {
- artist?: string[]
- albumArtist?: string
- album?: string
- track?: string
- releaseGroup?: string
- }
+ brainz?: BrainzMeta
}
}