From 7c66816023dc64e2d09ffdc48f865bc438b3ea84 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 26 Jun 2024 12:35:13 -0400 Subject: [PATCH] Implement musikcube source --- README.md | 1 + config/musikcube.json.example | 10 + docsite/docs/configuration/configuration.md | 32 ++ docsite/docs/configuration/musikcube.jpg | Bin 0 -> 53368 bytes docsite/src/pages/index.mdx | 1 + package-lock.json | 123 ++++++- package.json | 1 + src/backend/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/source/musikcube.ts | 116 +++++++ .../infrastructure/config/source/sources.ts | 5 +- src/backend/common/schema/aio-source.json | 83 +++++ src/backend/common/schema/aio.json | 83 +++++ src/backend/common/schema/source.json | 75 +++++ src/backend/sources/MusikcubeSource.ts | 304 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 21 ++ 15 files changed, 842 insertions(+), 17 deletions(-) create mode 100644 config/musikcube.json.example create mode 100644 docsite/docs/configuration/musikcube.jpg create mode 100644 src/backend/common/infrastructure/config/source/musikcube.ts create mode 100644 src/backend/sources/MusikcubeSource.ts diff --git a/README.md b/README.md index 6a16e4ba..1b98c657 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [JRiver](/docsite/docs/configuration/configuration.md#jriver) * [Kodi](/docsite/docs/configuration/configuration.md#kodi) * [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-) + * [Musikcube](/docsite/docs/configuration/configuration.md#muikcube) * Supports scrobbling to many **Clients** * [Maloja](/docsite/docs/configuration/configuration.md#maloja) * [Last.fm](/docsite/docs/configuration/configuration.md#lastfm) diff --git a/config/musikcube.json.example b/config/musikcube.json.example new file mode 100644 index 00000000..59435e31 --- /dev/null +++ b/config/musikcube.json.example @@ -0,0 +1,10 @@ +[ + { + "type": "musikcube", + "enable": true, + "name": "musikcube", + "data": { + "password": "MY_PASSWORD" + } + } +] diff --git a/docsite/docs/configuration/configuration.md b/docsite/docs/configuration/configuration.md index 539d4ace..f54e685b 100644 --- a/docsite/docs/configuration/configuration.md +++ b/docsite/docs/configuration/configuration.md @@ -846,6 +846,38 @@ Note: [Manually configuring cast device connections](#connecting-devices) is onl See [`chromecast.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FChromecastSourceConfig/%23%2Fdefinitions%2FChromecastData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json) +## [Musikcube](https://musikcube.com) + +In order to use Musikcube configure it to accept [websocket connections](https://github.com/clangen/musikcube/wiki/remote-api-documentation) in **server setup**: + +* Enable the **Metadata Server** +* Set a **Password** + +Both of these settings are found in _Musikcube -> (s)ettings -> server setup_ + +![Server Setup](musikcube.jpg) + +The URL used by MS has the syntax: + +``` +[ws|wss]://HOST:[PORT] +``` + +The **port** is the same as shown in the server setup screenshot from above, under **metadata server enabled**. If no port is provided to MS it will default to `7905`. + +If no URL is provided to MS it will try to use `ws://localhost:7905` + +### ENV-Based + +| Environmental Variable | Required? | Default | Description | +|------------------------|-----------|-----------------------|--------------------------------------| +| `MC_URL` | No | `ws://localhost:7905` | Use port set for **metadata server** | +| `MC_PASSWORD` | Yes | | | + +### File-Based + +See [`musikcube.json.example`](https://github.com/FoxxMD/multi-scrobbler/blob/master/config/chromecast.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMuikcubeSourceConfig/%23%2Fdefinitions%2FMuikcubeData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json) + # Client Configurations ## [Maloja](https://github.com/krateng/maloja) diff --git a/docsite/docs/configuration/musikcube.jpg b/docsite/docs/configuration/musikcube.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615c3f98e175faaf42e9ce88b25a8c9e61f317a3 GIT binary patch literal 53368 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 literal 0 HcmV?d00001 diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index 982d95ef..7dfae6bc 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -27,6 +27,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [JRiver](docs/configuration#jriver) * [Kodi](docs/configuration#kodi) * [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-) + * [Musikcube](docs/configuration#musikcube) * 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 d91f1b78..9071e54b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "fixed-size-list": "^0.3.0", "formidable": "^2.1", "gotify": "^1.1.0", + "iso-websocket": "^0.2.0", "iti": "^0.6.0", "json5": "^2.2.3", "kodi-api": "^0.2.1", @@ -2885,14 +2886,6 @@ "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", "dev": true }, - "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/xml2js": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", @@ -6518,6 +6511,18 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/iso-websocket": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/iso-websocket/-/iso-websocket-0.2.0.tgz", + "integrity": "sha512-imBalzmPSq0C9CfMouimB2kZ5X1qS4Yai8kGTQdluGRb0T0iu+BkPcakFelh4FIlTM8y6+BNuCEGog3lf8HC4A==", + "dependencies": { + "debug": "^4.3.4", + "retry": "^0.13.1", + "typescript-event-target": "^1.1.0", + "unws": "^0.2.4", + "ws": "^8.16.0" + } + }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -6747,6 +6752,34 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" }, + "node_modules/jayson/node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/jayson/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jest-diff": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", @@ -7439,6 +7472,26 @@ "node": ">= 10" } }, + "node_modules/mopidy/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9439,6 +9492,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -9537,6 +9598,26 @@ "uuid": "bin/uuid" } }, + "node_modules/rpc-websocket-client/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/run-async": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", @@ -10751,6 +10832,11 @@ } } }, + "node_modules/typescript-event-target": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz", + "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==" + }, "node_modules/typescript-json-schema": { "version": "0.55.0", "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.55.0.tgz", @@ -10919,6 +11005,17 @@ "node": ">= 0.8" } }, + "node_modules/unws": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/unws/-/unws-0.2.4.tgz", + "integrity": "sha512-/N1ajiqrSp0A/26/LBg7r10fOcPtGXCqJRJ61sijUFoGZMr6ESWGYn7i0cwr7fR7eEECY5HsitqtjGHDZLAu2w==", + "engines": { + "node": ">=16.14.0" + }, + "peerDependencies": { + "ws": "*" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -11622,15 +11719,15 @@ "dev": true }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { diff --git a/package.json b/package.json index e7e4d820..e80128c6 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "fixed-size-list": "^0.3.0", "formidable": "^2.1", "gotify": "^1.1.0", + "iso-websocket": "^0.2.0", "iti": "^0.6.0", "json5": "^2.2.3", "kodi-api": "^0.2.1", diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index dfb3c290..402d72c5 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -6,8 +6,8 @@ import { FixedSizeList } from 'fixed-size-list'; import { PlayMeta, PlayObject } from "../../../core/Atomic.js"; import TupleMap from "../TupleMap.js"; -export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast'; -export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast']; +export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast' | 'musikcube'; +export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast', 'musikcube']; export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; diff --git a/src/backend/common/infrastructure/config/source/musikcube.ts b/src/backend/common/infrastructure/config/source/musikcube.ts new file mode 100644 index 00000000..2bff4ff5 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/musikcube.ts @@ -0,0 +1,116 @@ +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export const PLAYBACK_STATUS_PLAYING_MC = 'playing'; +export const PLAYBACK_STATUS_PAUSED_MC = 'paused'; +export const PLAYBACK_STATUS_STOPPED_MC = 'stopped'; + +export type MCPlaybackStatus = 'playing' | 'stopped' | 'paused'; + +export interface MCResponseCommon { + id: string + name: string + type: 'response' +} + +export interface MCRequestCommon { + type: 'request' + id: string + name: string +} + +export interface MCTrackResponse { + album: string + album_artist: string + album_artist_id: number + album_id: number + artist: string + artist_id: number + external_id: string + genre: string + genre_id: number + id: number + thumbnail_id: number + title: string + track: number +} + +export interface MCPlaybackOverviewResponse extends MCResponseCommon { + options: { + muted: boolean + play_queue_position: number + playing_current_time: number + playing_duration: number + playing_track: MCTrackResponse + repeat_mode: string + shuffled: boolean + state: MCPlaybackStatus + track_count: number + volume: number + } +} + +export interface MCAuthenticateResponse extends MCResponseCommon { + options: { + authenticated: boolean + environment: { + api_version: number + app_version: string + http_server_enabled: boolean + http_server_port: number + sdk_version: number + } + } +} + +export interface MCAuthenticateRequest extends MCRequestCommon { + name: 'authenticate', + device_id: string + options: { + password: string + } +} + +export interface MCPlaybackOverviewRequest extends MCRequestCommon { + name: 'get_playback_overview' + device_id: string +} + +export interface MusikcubeData extends CommonSourceData { + /** + * URL of the Musikcube Websocket (Metadata) server to connect to + * + * You MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation + * * musikcube -> settings -> server setup + * + * The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them. + * + * Parts => [default value] + * + * * Protocol => `ws://` + * * Hostname => `localhost` + * * Port => `7905` + * + * + * @examples ["ws://localhost:7905"] + * @default "ws://localhost:7905" + * */ + url?: string + + /** + * Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation + * + * * musikcube -> settings -> server setup -> password + * */ + password: string + + device_id?: string + +} + +export interface MusikcubeSourceConfig extends CommonSourceConfig { + data: MusikcubeData +} + +export interface MusikcubeSourceAIOConfig extends MusikcubeSourceConfig { + type: 'musikcube' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index dc76a456..3fb92f89 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -7,6 +7,7 @@ import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js"; import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js"; import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js"; import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js"; +import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js"; import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js"; import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js"; import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; @@ -15,6 +16,6 @@ import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscro import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; -export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig; +export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig; -export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig; +export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig; diff --git a/src/backend/common/schema/aio-source.json b/src/backend/common/schema/aio-source.json index e9c2370e..9db002d3 100644 --- a/src/backend/common/schema/aio-source.json +++ b/src/backend/common/schema/aio-source.json @@ -1189,6 +1189,86 @@ "title": "MopidySourceAIOConfig", "type": "object" }, + "MusikcubeData": { + "properties": { + "device_id": { + "title": "device_id", + "type": "string" + }, + "password": { + "description": "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation\n\n* musikcube -> settings -> server setup -> password", + "title": "password", + "type": "string" + }, + "url": { + "default": "ws://localhost:7905", + "description": "URL of the Musikcube Websocket (Metadata) server to connect to\n\nYou MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation\n * musikcube -> settings -> server setup\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `7905`", + "examples": [ + "ws://localhost:7905" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "MusikcubeData", + "type": "object" + }, + "MusikcubeSourceAIOConfig": { + "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/MusikcubeData", + "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/CommonSourceOptions", + "title": "options" + }, + "type": { + "enum": [ + "musikcube" + ], + "title": "type", + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "title": "MusikcubeSourceAIOConfig", + "type": "object" + }, "PlexSourceAIOConfig": { "properties": { "clients": { @@ -1363,6 +1443,9 @@ { "$ref": "#/definitions/MPRISSourceAIOConfig" }, + { + "$ref": "#/definitions/MusikcubeSourceAIOConfig" + }, { "$ref": "#/definitions/PlexSourceAIOConfig" }, diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json index 40b02bb4..33d299bf 100644 --- a/src/backend/common/schema/aio.json +++ b/src/backend/common/schema/aio.json @@ -1945,6 +1945,86 @@ "title": "MopidySourceAIOConfig", "type": "object" }, + "MusikcubeData": { + "properties": { + "device_id": { + "title": "device_id", + "type": "string" + }, + "password": { + "description": "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation\n\n* musikcube -> settings -> server setup -> password", + "title": "password", + "type": "string" + }, + "url": { + "default": "ws://localhost:7905", + "description": "URL of the Musikcube Websocket (Metadata) server to connect to\n\nYou MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation\n * musikcube -> settings -> server setup\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `7905`", + "examples": [ + "ws://localhost:7905" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "MusikcubeData", + "type": "object" + }, + "MusikcubeSourceAIOConfig": { + "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/MusikcubeData", + "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/CommonSourceOptions", + "title": "options" + }, + "type": { + "enum": [ + "musikcube" + ], + "title": "type", + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "title": "MusikcubeSourceAIOConfig", + "type": "object" + }, "NtfyConfig": { "properties": { "name": { @@ -2208,6 +2288,9 @@ { "$ref": "#/definitions/MPRISSourceAIOConfig" }, + { + "$ref": "#/definitions/MusikcubeSourceAIOConfig" + }, { "$ref": "#/definitions/PlexSourceAIOConfig" }, diff --git a/src/backend/common/schema/source.json b/src/backend/common/schema/source.json index 91e44437..f3f8a7e2 100644 --- a/src/backend/common/schema/source.json +++ b/src/backend/common/schema/source.json @@ -28,6 +28,9 @@ { "$ref": "#/definitions/MPRISSourceConfig" }, + { + "$ref": "#/definitions/MusikcubeSourceConfig" + }, { "$ref": "#/definitions/PlexSourceConfig" }, @@ -1164,6 +1167,78 @@ "title": "MopidySourceConfig", "type": "object" }, + "MusikcubeData": { + "properties": { + "device_id": { + "title": "device_id", + "type": "string" + }, + "password": { + "description": "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation\n\n* musikcube -> settings -> server setup -> password", + "title": "password", + "type": "string" + }, + "url": { + "default": "ws://localhost:7905", + "description": "URL of the Musikcube Websocket (Metadata) server to connect to\n\nYou MUST have enabled 'metadata' server and set a password: https://github.com/clangen/musikcube/wiki/remote-api-documentation\n * musikcube -> settings -> server setup\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `7905`", + "examples": [ + "ws://localhost:7905" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "MusikcubeData", + "type": "object" + }, + "MusikcubeSourceConfig": { + "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/MusikcubeData", + "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/CommonSourceOptions", + "title": "options" + } + }, + "required": [ + "data" + ], + "title": "MusikcubeSourceConfig", + "type": "object" + }, "PlexSourceConfig": { "properties": { "clients": { diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts new file mode 100644 index 00000000..f92a4472 --- /dev/null +++ b/src/backend/sources/MusikcubeSource.ts @@ -0,0 +1,304 @@ +import { childLogger } from "@foxxmd/logging"; +import { EventEmitter } from "events"; +import { WS } from 'iso-websocket' +// TODO remove when/if iso-websocket exports these +// @ ts-expect-error not exported properly by package +//import { CloseEvent, ErrorEvent, RetryEvent } from "iso-websocket/dist/src/events.js"; +import { randomUUID } from "node:crypto"; +import normalizeUrl from 'normalize-url'; +import pEvent from 'p-event'; +import { URL } from "url"; +import { PlayObject } from "../../core/Atomic.js"; +import { UpstreamError } from "../common/errors/UpstreamError.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + SINGLE_USER_PLATFORM_ID, +} from "../common/infrastructure/Atomic.js"; +import { + MCAuthenticateRequest, + MCAuthenticateResponse, + MCPlaybackOverviewRequest, + MCPlaybackOverviewResponse, + MusikcubeSourceConfig +} from "../common/infrastructure/config/source/musikcube.js"; +import { sleep } from "../utils.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import MemorySource from "./MemorySource.js"; + +const CLIENT_STATE = { + 0: 'connecting', + 1: 'open', + 2: 'closing', + 3: 'closed' +} + +export class MusikcubeSource extends MemorySource { + declare config: MusikcubeSourceConfig; + + url: URL; + + + client!: WS; + deviceId: string + + constructor(name: any, config: MusikcubeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data = {} + } = config; + const { + ...rest + } = data; + super('musikcube', name, {...config, data: {...rest}}, internal, emitter); + + const { + data: { + url = 'ws://localhost:7905', + device_id + } = {} + } = config; + this.deviceId = device_id ?? name; + this.url = MusikcubeSource.parseConnectionUrl(url); + this.requiresAuth = true; + this.canPoll = true; + } + + static parseConnectionUrl(valRaw: string) { + let val = valRaw.trim(); + if(!val.match(/^(?:wss?|https?):/i)) { + val = `ws://${val}`; + } + const normal = normalizeUrl(val, {removeTrailingSlash: false}) + const url = new URL(normal); + + // default WS + if (url.protocol === 'https:') { + url.protocol = 'wss:'; + } else if (url.protocol === 'http:') { + url.protocol = 'ws:'; + } else { + url.protocol = 'ws:' + } + + if (url.port === null || url.port === '') { + url.port = '7905'; + } + return url; + } + + protected async doBuildInitData(): Promise { + const { + data: { + url + } = {} + } = this.config; + const normal = this.url.toString(); + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) + if (!normal.includes('ws://') && !normal.includes('wss://')) { + throw new Error(`Server URL must be start with with ws:// or wss://`); + } + this.client = new WS(this.url.toString(), { + automaticOpen: false, + retry: { + retries: 0 + }, + //errorInfo: true + }); + const wsLogger = childLogger(this.logger, 'WS'); + this.client.addEventListener('retry', (e) => { + wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, {labels: 'WS'}); + }); + this.client.addEventListener('close', (e) => { + wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, {labels: 'WS'}); + if (e.reason.includes('unauthenticated')) { + this.authed = false; + } + }); + this.client.addEventListener('open', (e) => { + wsLogger.verbose(`Connection was established.`, {labels: 'WS'}); + if (this.authed) { + // was a reconnect, try auto authenticating + wsLogger.verbose('Resending auth message after (probably) reconnection...'); + this.client.send(JSON.stringify(this.getAuthPayload())); + } + }); + this.client.addEventListener('error', (e) => { + if (e.message.includes('Connection failed after')) { + this.connectionOK = false; + this.authed = false; + } + if(e.error.message === ('Websocket error')) { + wsLogger.error('Communication with server failed => Websocket error'); + + } else { + wsLogger.error(new Error('Communication with server failed', {cause: e.error})); + } + }); + + this.client.addEventListener('message', (e) => { + const data = getMessageData(e); + if(isAuthenticateResponse(data)) { + wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`); + } + }); + return true; + } + + protected async doCheckConnection(): Promise { + try { + this.client.open(); + const e = await pEvent(this.client, 'open'); + return true; + } catch (e) { + this.client.close(); + throw new Error(`Could not connect to Musikcube metadata server`); + } + + } + + protected getAuthPayload = (): MCAuthenticateRequest => { + return { + name: 'authenticate', + type: 'request', + id: randomUUID(), + device_id: this.deviceId, + options: { + password: this.config.data.password + } + } + } + + doAuthentication = async () => { + try { + const authRace = Promise.race([ + pEvent(this.client, 'message'), + pEvent(this.client, 'close'), + sleep(2000), + ]); + this.client.send(JSON.stringify(this.getAuthPayload())); + const authE = await authRace; + if(authE === undefined) { + throw new Error('Musikcube did not respond to auth message after 2000 ms'); + } else if(isCloseEvent(authE)) { + throw new Error(`Password is not correct: ${authE.code} => ${authE.reason}`); + } else if(isErrorEvent(authE)) { + throw new Error(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error}); + } + + return true; + } catch (e) { + throw e; + } + } + + formatPlayObj(obj: MCPlaybackOverviewResponse, options: FormatPlayObjectOptions = {}): PlayObject { + const { + options: { + playing_duration, + playing_current_time, + playing_track: { + album, + album_artist, + artist, + title, + id, + external_id + } + }, + } = obj; + const artists = []; + const albumArtists = []; + if(artist !== undefined) { + artists.push(artist); + } + if(album_artist !== undefined && album_artist !== artist) { + albumArtists.push(album_artist); + } + return { + data: { + artists: artists, + albumArtists, + album: album === '' ? undefined : album, + track: title === '' ? undefined: title, + duration: playing_duration + }, + meta: { + trackProgressPosition: playing_current_time, + deviceId: this.deviceId, + trackId: external_id + } + } + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + if (this.client.readyState !== this.client.OPEN) { + throw new Error('WS connection is no longer open.'); + } + + const overviewPayload: MCPlaybackOverviewRequest = { + name: 'get_playback_overview', + type: 'request', + device_id: this.deviceId, + id: randomUUID() + } + + const messageEventPromise = pEvent(this.client, 'message'); + this.client.send(JSON.stringify(overviewPayload)); + const messageEvent = await Promise.race([ + messageEventPromise, + sleep(2000), + ]); + + if(messageEvent === undefined) { + throw new UpstreamError('Did not receive playback message after waiting 2000ms'); + } + + const playbackOverview = getMessageData(messageEvent) + + const play: PlayObject | undefined = playbackOverview.options.playing_track === undefined ? undefined : this.formatPlayObj(playbackOverview); + + const playerState: PlayerStateData = { + platformId: SINGLE_USER_PLATFORM_ID, + status: playbackOverview.options.state, + play, + position: playbackOverview.options.playing_current_time + } + + return this.processRecentPlays([playerState]); + } + +} + +const getMessageData = (e: any): T => { + return JSON.parse(e.data) as T; +} + +const isCloseEvent = (e: Event): e is CloseEvent => { + return e.type === 'close'; +} +const isErrorEvent = (e: Event): e is ErrorEvent => { + return e.type === 'error'; +} +const isRetryEvent = (e: Event): e is RetryEvent => { + return e.type === 'retry'; +} + +// TODO remove when/if iso-websockets exports these +interface ErrorEvent extends Event { + type: 'error' + error: Error + message: string +} +interface CloseEvent extends Event { + type: 'close' + reason: string + code: number +} +interface RetryEvent extends Event { + type: 'retry' +} + +const isAuthenticateResponse = (data: any): data is MCAuthenticateResponse => { + return 'name' in data && data.name === 'authenticate'; +} diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 97825ec3..ccfee5e4 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -13,6 +13,7 @@ import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastf import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js"; import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.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"; import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js"; import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; @@ -34,6 +35,7 @@ import LastfmSource from "./LastfmSource.js"; import ListenbrainzSource from "./ListenbrainzSource.js"; import { MopidySource } from "./MopidySource.js"; import { MPRISSource } from "./MPRISSource.js"; +import { MusikcubeSource } from "./MusikcubeSource.js"; import PlexSource from "./PlexSource.js"; import SpotifySource from "./SpotifySource.js"; import { SubsonicSource } from "./SubsonicSource.js"; @@ -343,6 +345,22 @@ export default class ScrobbleSources { }); } break; + case 'musikcube': + const mc = { + url: process.env.MC_URL, + password: process.env.MC_PASSWORD + } + if (!Object.values(mc).every(x => x === undefined)) { + configs.push({ + type: 'musikcube', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: mc as MusikcubeData + }); + } + break; default: break; } @@ -522,6 +540,9 @@ export default class ScrobbleSources { case 'chromecast': newSource = await new ChromecastSource(name, compositeConfig as ChromecastSourceConfig, internal, this.emitter); break; + case 'musikcube': + newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, internal, this.emitter); + break; default: break; } -- 2.51.2