From 5d35b041b1e999afdd597d13988e3e22bad0b25d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 6 Jun 2024 13:38:16 -0400 Subject: [PATCH] feat: 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/index.ts | 27 ++ src/backend/sources/MusikcubeSource.ts | 304 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 21 ++ 16 files changed, 869 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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 hg>i0k&gSW8C=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/index.ts b/src/backend/index.ts index 11b7425a..1cf4b62f 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -81,6 +81,33 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const root = getRoot({...config, logger}); initLogger.info(`Version: ${root.get('version')}`); +/* const ws = new WebSocket('ws://localhost:7905'); + ws.on('open', async function open() { + ws.send(JSON.stringify({ + 'name': 'authenticate', + 'type': 'request', + 'id': 'mytest', + "device_id": 'local', + "options": { + "password": 'cowboy314' + } + })); + await sleep(1000); + ws.send(JSON.stringify({ + "name": "get_playback_overview", + "type": "request", + 'id': 'mytest', + "device_id": 'local' + })); + }); + ws.on('error', (err) => { + logger.error(err); + }); + ws.on('message', function message(data) { + logger.info(data.toString()); + });*/ + + initServer(logger, appLoggerStream, output); if(process.env.IS_LOCAL === 'true') { 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; }