diff --git a/config/musiccast.json.example b/config/musiccast.json.example new file mode 100644 index 00000000..a741c347 --- /dev/null +++ b/config/musiccast.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "myYamaha", + "enable": true, + "data": { + "url": "192.168.0.101" + } + } +] diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index a18f38c5..910e1e47 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -20,6 +20,7 @@ export type SourceType = | 'ytmusic' | 'mpris' | 'mopidy' + | 'musiccast' | 'listenbrainz' | 'jriver' | 'kodi' @@ -42,6 +43,7 @@ export const sourceTypes: SourceType[] = [ 'ytmusic', 'mpris', 'mopidy', + 'musiccast', 'listenbrainz', 'jriver', 'kodi', diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts new file mode 100644 index 00000000..5b4a0904 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -0,0 +1,87 @@ +import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward' + +export interface MusicCastResponse { + response_code: number +} + +export interface DeviceInfoResponse extends MusicCastResponse { + model_name: string + device_id: string + system_version: number + api_version: number +} + +export interface DeviceStatusResponse extends MusicCastResponse { + power: 'on' | 'standby' +} + +/** use with /netusb/getPlayInfo or /cd/getPlayInfo */ +export interface PlayInfoCDResponse extends MusicCastResponse { + device_status: 'open' | 'close' | 'ready' | 'not_ready' + playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward' + /** in seconds */ + play_time: number + /** in seconds */ + total_time: number + artist: string + album: string + track: string +} + +export interface PlayInfoNetResponse extends PlayInfoCDResponse { + input: string +} + +const MusicCastResponseCodes = new Map([ + [0, 'Success'], + [1, 'Initializing'], + [2, 'Internal Error'], + [3, 'Invalid Request'], + [4, 'Invalid Parameter'], + [5, 'Guarded (Unable to setup in current status)'], + [6, 'Time out'], + [100, 'Access Error'], + [101, 'Other Error'], + [107, 'Service Maintenance'], + [109, 'License Error'], + [110, 'Read Only Mode'], + [112, 'Access Denied'], + [115, 'Simultaneous logins has reached the upper limit'], + [200, 'Linking in progress'], + [201, 'Unlinking in progress'] +]); + +export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => { + switch(pb) { + case 'play': + case 'fast_forward': + case 'fast_reverse': + return REPORTED_PLAYER_STATUSES.playing; + case 'pause': + return REPORTED_PLAYER_STATUSES.paused; + case 'stop': + return REPORTED_PLAYER_STATUSES.stopped; + default: + return REPORTED_PLAYER_STATUSES.unknown; + } +} + +export interface MusicCastData extends CommonSourceData { + /** + * The host or URL of the YamahaExtendedControl endpoint to use + * + * @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]] + * */ + url: string +} + +export interface MusicCastSourceConfig extends CommonSourceConfig { + data: MusicCastData +} + +export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig { + type: 'musiccast' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index d4212b07..40646548 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -11,6 +11,7 @@ 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 { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js"; import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js"; import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js"; import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; @@ -41,6 +42,7 @@ export type SourceConfig = | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig + | MusicCastSourceConfig | MPDSourceConfig | VLCSourceConfig; @@ -65,5 +67,6 @@ export type SourceAIOConfig = | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig + | MusicCastSourceAIOConfig | MPDSourceAIOConfig | VLCSourceAIOConfig; diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts new file mode 100644 index 00000000..c25bf720 --- /dev/null +++ b/src/backend/sources/MusicCastSource.ts @@ -0,0 +1,148 @@ +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import { EventEmitter } from "events"; +import { PlayObject, URLData } from "../../core/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + SINGLE_USER_PLATFORM_ID, +} from "../common/infrastructure/Atomic.js"; +import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { DeviceInfoResponse, DeviceStatusResponse, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; +import request, { Request, Response } from 'superagent'; + + +export class MusicCastSource extends MemoryPositionalSource { + + declare config: MusicCastSourceConfig; + + urlData!: URLData; + + + 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 isPortReachable(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; + 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 cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); + if (cdResp.body !== undefined && typeof cdResp.body === 'object') { + return cdResp.body as PlayInfoCDResponse; + } + } catch (e) { + this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); + } + + try { + const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); + if (netResp.body !== undefined && typeof netResp.body === 'object') { + return netResp.body as PlayInfoNetResponse + } + } catch (e) { + this.logger.warn(new Error('Not OK response from netusb 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 this.processRecentPlays([]); + } + + const playInfo = await this.getAnyPlayInfo(); + if(playInfo === undefined) { + return this.processRecentPlays([]); + } + + const play = formatPlayObj(playInfo); + + + const playerState: PlayerStateData = { + platformId: SINGLE_USER_PLATFORM_ID, + status: playbackToReportedStatus(playInfo.playback), + play, + position: play.meta.trackProgressPosition + } + + return this.processRecentPlays([playerState]); + } +} + +const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => { + + const { + play_time, + total_time, + artist, + album, + track, + device_status, + playback + } = obj; + + return { + data: { + artists: artist !== undefined && artist !== '' ? [artist] : [], + album: album !== '' ? album : undefined, + track, + duration: total_time + }, + meta: { + trackProgressPosition: play_time, + deviceId: 'input' in obj ? obj.input : 'cd' + } + } +} \ No newline at end of file diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 76e3751c..39f63e3c 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -18,6 +18,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 { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.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"; @@ -47,6 +48,7 @@ import { MopidySource } from "./MopidySource.js"; import { MPDSource } from "./MPDSource.js"; import { MPRISSource } from "./MPRISSource.js"; import { MusikcubeSource } from "./MusikcubeSource.js"; +import { MusicCastSource } from "./MusicCastSource.js"; import PlexSource from "./PlexSource.js"; import SpotifySource from "./SpotifySource.js"; import { SubsonicSource } from "./SubsonicSource.js"; @@ -176,6 +178,9 @@ export default class ScrobbleSources { case 'musikcube': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig"); break; + case 'musiccast': + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusicCastSourceConfig"); + break; case 'mpd': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig"); break; @@ -498,6 +503,21 @@ export default class ScrobbleSources { }); } break; + case 'musiccast': + const musecase = { + url: process.env.MCAST_URL, + } + if (!Object.values(musecase).every(x => x === undefined)) { + configs.push({ + type: 'musiccast', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: musecase as MusicCastData + }); + } + break; case 'musikcube': const mc = { url: process.env.MC_URL, @@ -727,6 +747,9 @@ export default class ScrobbleSources { case 'musikcube': newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, this.internalConfig, this.emitter); break; + case 'musiccast': + newSource = await new MusicCastSource(name, compositeConfig as MusicCastSourceConfig, this.internalConfig, this.emitter); + break; case 'mpd': newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, this.internalConfig, this.emitter); break; diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index 0d60b1b1..ff83306f 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -49,13 +49,15 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) => const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); -export const normalizeWebAddress = (val: string): URLData => { +export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } + const {defaultPath} = options; + let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true}); const u = new URL(normal); let port: number; @@ -72,6 +74,12 @@ export const normalizeWebAddress = (val: string): URLData => { normal = normal.replace('http:', 'https:'); } } + + if(u.pathname === '/' && defaultPath !== undefined) { + u.pathname = defaultPath; + normal = normalizeUrl(u.toString()); + } + return { url: u, normal,