From 8b9a7893d3fd03fb2c8c1396f33372ea945f2993 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 19 Jul 2024 12:10:51 -0400 Subject: [PATCH] feat(mpd): Implement MPD Source MVP --- README.md | 1 + config/mpd.json.example | 13 ++ docsite/docs/configuration/configuration.mdx | 47 ++++ docsite/src/pages/index.mdx | 1 + package-lock.json | 102 +++++++++ package.json | 1 + src/backend/common/infrastructure/Atomic.ts | 6 +- .../infrastructure/config/source/mpd.ts | 70 ++++++ .../infrastructure/config/source/sources.ts | 7 +- src/backend/common/schema/aio-source.json | 166 ++++++++++++++ src/backend/common/schema/aio.json | 166 ++++++++++++++ src/backend/common/schema/source.json | 158 +++++++++++++ src/backend/sources/MPDSource.ts | 216 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 5 + src/backend/utils/NetworkUtils.ts | 41 ++++ src/core/Atomic.ts | 16 +- 16 files changed, 1005 insertions(+), 11 deletions(-) create mode 100644 config/mpd.json.example create mode 100644 src/backend/common/infrastructure/config/source/mpd.ts create mode 100644 src/backend/sources/MPDSource.ts create mode 100644 src/backend/utils/NetworkUtils.ts 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 } } -- 2.51.2