import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import type { EventEmitter } from "events"; import type {PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData, } from "../common/infrastructure/Atomic.ts"; import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.ts"; import { type DeviceInfoResponse, type DeviceStatusResponse, MusicCastResponseCodes, type MusicCastSourceConfig, playbackToReportedStatus, type PlayInfoCDResponse, type PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.ts"; import request from 'superagent'; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; export class MusicCastSource extends MemoryPositionalSource { declare config: MusicCastSourceConfig; urlData!: URLData; version?: string; constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data = {} } = config; const { ...rest } = data; super('musiccast', name, { ...config, data: { ...rest } }, internal, emitter); this.requiresAuth = false; this.canPoll = true; } protected async doBuildInitData(): Promise { const { data: { url } = {} } = this.config; if (url === null || url === undefined || url === '') { throw new Error('url must be defined'); } this.urlData = normalizeWebAddress(url, { defaultPath: '/YamahaExtendedControl/v1' }); const normal = this.urlData.normal; this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) return true; } protected async doCheckConnection(): Promise { try { await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname }); this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) if (resp.body !== undefined && typeof resp.body === 'object') { const deviceInfo = resp.body as DeviceInfoResponse; if(deviceInfo.api_version !== undefined) { this.version = deviceInfo.api_version.toString(); } this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`); } else { this.logger.warn('Could not get device info! Ignoring but probably not good...'); } return true; } catch (e) { const hint = e.error?.cause?.message ?? undefined; throw new Error(`Could not connect to MusicCast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e }); } } getAnyPlayInfo = async (): Promise => { try { const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); if (netResp.body !== undefined && typeof netResp.body === 'object') { const resp = netResp.body as PlayInfoNetResponse if(resp.response_code !== 0) { throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); } return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); } try { const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); if (cdResp.body !== undefined && typeof cdResp.body === 'object') { const resp = cdResp.body as PlayInfoCDResponse; if(resp.response_code !== 0) { throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); } return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); } return undefined; } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const statusResp = await request.get(joinedUrl(this.urlData.url, 'main/getStatus').toString()); if (statusResp.body == undefined || typeof statusResp.body !== 'object') { this.logger.error({ getStatusResponse: statusResp }); throw new Error('Could not determine status of MusicCast device'); } if ((statusResp.body as DeviceStatusResponse).power !== 'on') { this.logger.debug('MusicCast device is offline'); return await this.processRecentPlays([]); } const playInfo = await this.getAnyPlayInfo(); if(playInfo === undefined) { return await this.processRecentPlays([]); } const play = formatPlayObj(playInfo); const playerState: PlayerStateData = { platformId: SINGLE_USER_PLATFORM_ID, status: playbackToReportedStatus(playInfo.playback), play, position: play.meta.trackProgressPosition } return await this.processRecentPlays([playerState]); } } const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions & {version?: string} = {}): PlayObject => { const { play_time, total_time, artist, album, track, device_status, playback } = obj; const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), album: album !== '' ? album : undefined, track, // we should treat 0 time as the same as not being provided duration: total_time === 0 ? undefined : total_time }, meta: { trackProgressPosition: play_time, deviceId: 'input' in obj ? obj.input : 'cd', mediaPlayerName: 'MusicCast', mediaPlayerVersion: options.version } } return baseFormatPlayObj(obj, play); }