import { childLogger } from "@foxxmd/logging"; import type { EventEmitter } from "events"; import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'; import { WS } from 'iso-websocket' import { randomUUID } from "node:crypto"; import pEvent from 'p-event'; import type {ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateDataMaybePlay, } from "../common/infrastructure/Atomic.ts"; import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import type {MCAuthenticateRequest, MCAuthenticateResponse, MCPlaybackOverviewRequest, MCPlaybackOverviewResponse, MusikcubeSourceConfig} from "../common/infrastructure/config/source/musikcube.ts"; import { sleep } from "../utils.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { normalizeWSAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_STATE = { 0: 'connecting', 1: 'open', 2: 'closing', 3: 'closed' } export class MusikcubeSource extends MemoryPositionalSource { declare config: MusikcubeSourceConfig; override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; url: URLData; client!: WS; deviceId: string version?: 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 = normalizeWSAddress(url, {defaultPort: 7905}); this.requiresAuth = true; this.canPoll = true; } protected async doBuildInitData(): Promise { const { data: { url } = {} } = this.config; const normal = this.url.normal; this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) if (!normal.includes('ws://') && !normal.includes('wss://')) { throw new Error(`Server URL must start with ws:// or wss://`); } this.client = new WS(this.url.url.toString(), { automaticOpen: false, retry: { retries: 0 } }); const wsLogger = childLogger(this.logger, 'WS'); this.client.addEventListener('retry', (e) => { wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`); }); this.client.addEventListener('close', (e) => { wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`); if (e.reason.includes('unauthenticated')) { this.authed = false; } }); this.client.addEventListener('open', (e) => { wsLogger.verbose(`Connection was established.`); 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; } const hint = e.error?.cause?.message ?? undefined; wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error})); }); this.client.addEventListener('message', (e) => { const data = getMessageData(e); if(isAuthenticateResponse(data)) { this.version = data.options.environment.app_version; 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 opened = await pEvent(this.client, 'open'); return true; } catch (e: any) { this.client.close(); const hint = e.error?.cause?.message ?? undefined; throw new Error(`Could not connect to Musikcube metadata server${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error ?? e}); } } 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 AuthError(`Password is not correct: ${authE.code} => ${authE.reason}`, {unrecoverable: true}); } else if(isErrorEvent(authE)) { throw new AuthError(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error, unrecoverable: false}); } 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: string[] = []; const albumArtists: string[] = []; if(artist !== undefined) { artists.push(artist); } if(album_artist !== undefined && album_artist !== artist) { albumArtists.push(album_artist); } const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artists), albumArtists: artistNamesToCredits(albumArtists), album: album === '' ? undefined : album, track: title === '' ? undefined: title, duration: playing_duration }, meta: { trackProgressPosition: playing_current_time, deviceId: this.deviceId, trackId: external_id, mediaPlayerName: 'Musikcube', mediaPlayerVersion: this.version } } return baseFormatPlayObj(obj, play); } 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: PlayerStateDataMaybePlay = { platformId: SINGLE_USER_PLATFORM_ID, status: playbackOverview.options.state, play, position: playbackOverview.options.playing_current_time } return await 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'; } const isAuthenticateResponse = (data: any): data is MCAuthenticateResponse => { return 'name' in data && data.name === 'authenticate'; }