Skip to content

Commit 389d39e

Browse files
committed
feat(musiccast): Implement MusicCast Source
1 parent 982e665 commit 389d39e

7 files changed

Lines changed: 281 additions & 1 deletion

File tree

config/musiccast.json.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[
2+
{
3+
"name": "myYamaha",
4+
"enable": true,
5+
"data": {
6+
"url": "192.168.0.101"
7+
}
8+
}
9+
]

src/backend/common/infrastructure/Atomic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type SourceType =
1818
| 'ytmusic'
1919
| 'mpris'
2020
| 'mopidy'
21+
| 'musiccast'
2122
| 'listenbrainz'
2223
| 'jriver'
2324
| 'kodi'
@@ -38,6 +39,7 @@ export const sourceTypes: SourceType[] = [
3839
'ytmusic',
3940
'mpris',
4041
'mopidy',
42+
'musiccast',
4143
'listenbrainz',
4244
'jriver',
4345
'kodi',
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
2+
import { CommonSourceConfig, CommonSourceData } from "./index.js";
3+
4+
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
5+
6+
export interface MusicCastResponse {
7+
response_code: number
8+
}
9+
10+
export interface DeviceInfoResponse extends MusicCastResponse {
11+
model_name: string
12+
device_id: string
13+
system_version: number
14+
api_version: number
15+
}
16+
17+
export interface DeviceStatusResponse extends MusicCastResponse {
18+
power: 'on' | 'standby'
19+
}
20+
21+
/** use with /netusb/getPlayInfo or /cd/getPlayInfo */
22+
export interface PlayInfoCDResponse extends MusicCastResponse {
23+
device_status: 'open' | 'close' | 'ready' | 'not_ready'
24+
playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
25+
/** in seconds */
26+
play_time: number
27+
/** in seconds */
28+
total_time: number
29+
artist: string
30+
album: string
31+
track: string
32+
}
33+
34+
export interface PlayInfoNetResponse extends PlayInfoCDResponse {
35+
input: string
36+
}
37+
38+
const MusicCastResponseCodes = new Map<number, string>([
39+
[0, 'Success'],
40+
[1, 'Initializing'],
41+
[2, 'Internal Error'],
42+
[3, 'Invalid Request'],
43+
[4, 'Invalid Parameter'],
44+
[5, 'Guarded (Unable to setup in current status)'],
45+
[6, 'Time out'],
46+
[100, 'Access Error'],
47+
[101, 'Other Error'],
48+
[107, 'Service Maintenance'],
49+
[109, 'License Error'],
50+
[110, 'Read Only Mode'],
51+
[112, 'Access Denied'],
52+
[115, 'Simultaneous logins has reached the upper limit'],
53+
[200, 'Linking in progress'],
54+
[201, 'Unlinking in progress']
55+
]);
56+
57+
export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => {
58+
switch(pb) {
59+
case 'play':
60+
case 'fast_forward':
61+
case 'fast_reverse':
62+
return REPORTED_PLAYER_STATUSES.playing;
63+
case 'pause':
64+
return REPORTED_PLAYER_STATUSES.paused;
65+
case 'stop':
66+
return REPORTED_PLAYER_STATUSES.stopped;
67+
default:
68+
return REPORTED_PLAYER_STATUSES.unknown;
69+
}
70+
}
71+
72+
export interface MusicCastData extends CommonSourceData {
73+
/**
74+
* The host or URL of the YamahaExtendedControl endpoint to use
75+
*
76+
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
77+
* */
78+
url: string
79+
}
80+
81+
export interface MusicCastSourceConfig extends CommonSourceConfig {
82+
data: MusicCastData
83+
}
84+
85+
export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig {
86+
type: 'musiccast'
87+
}

src/backend/common/infrastructure/config/source/sources.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
99
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
1010
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
1111
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
12+
import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js";
1213
import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js";
1314
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
1415
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
@@ -37,6 +38,7 @@ export type SourceConfig =
3738
| WebScrobblerSourceConfig
3839
| ChromecastSourceConfig
3940
| MusikcubeSourceConfig
41+
| MusicCastSourceConfig
4042
| MPDSourceConfig
4143
| VLCSourceConfig;
4244

@@ -59,5 +61,6 @@ export type SourceAIOConfig =
5961
| WebScrobblerSourceAIOConfig
6062
| ChromecastSourceAIOConfig
6163
| MusikcubeSourceAIOConfig
64+
| MusicCastSourceAIOConfig
6265
| MPDSourceAIOConfig
6366
| VLCSourceAIOConfig;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
2+
import { RecentlyPlayedOptions } from "./AbstractSource.js";
3+
import { EventEmitter } from "events";
4+
import { PlayObject, URLData } from "../../core/Atomic.js";
5+
import {
6+
FormatPlayObjectOptions,
7+
InternalConfig,
8+
PlayerStateData,
9+
SINGLE_USER_PLATFORM_ID,
10+
} from "../common/infrastructure/Atomic.js";
11+
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
12+
import { DeviceInfoResponse, DeviceStatusResponse, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js";
13+
import request, { Request, Response } from 'superagent';
14+
15+
16+
export class MusicCastSource extends MemoryPositionalSource {
17+
18+
declare config: MusicCastSourceConfig;
19+
20+
urlData!: URLData;
21+
22+
23+
constructor(name: any, config: MusicCastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
24+
const {
25+
data = {}
26+
} = config;
27+
const {
28+
...rest
29+
} = data;
30+
super('musiccast', name, { ...config, data: { ...rest } }, internal, emitter);
31+
32+
this.requiresAuth = false;
33+
this.canPoll = true;
34+
}
35+
36+
protected async doBuildInitData(): Promise<true | string | undefined> {
37+
const {
38+
data: {
39+
url
40+
} = {}
41+
} = this.config;
42+
if (url === null || url === undefined || url === '') {
43+
throw new Error('url must be defined');
44+
}
45+
this.urlData = normalizeWebAddress(url, { defaultPath: '/YamahaExtendedControl/v1' });
46+
const normal = this.urlData.normal;
47+
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`)
48+
return true;
49+
}
50+
51+
52+
protected async doCheckConnection(): Promise<true | string | undefined> {
53+
try {
54+
await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname });
55+
this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`);
56+
57+
const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString())
58+
if (resp.body !== undefined && typeof resp.body === 'object') {
59+
const deviceInfo = resp.body as DeviceInfoResponse;
60+
this.logger.info(`Found ${deviceInfo.model_name} (${deviceInfo.device_id}) using API v${deviceInfo.api_version}`);
61+
} else {
62+
this.logger.warn('Could not get device info! Ignoring but probably not good...');
63+
}
64+
return true;
65+
} catch (e) {
66+
const hint = e.error?.cause?.message ?? undefined;
67+
throw new Error(`Could not connect to MusicCast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e });
68+
}
69+
}
70+
71+
getAnyPlayInfo = async (): Promise<PlayInfoCDResponse | PlayInfoNetResponse | undefined> => {
72+
try {
73+
const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString());
74+
if (cdResp.body !== undefined && typeof cdResp.body === 'object') {
75+
return cdResp.body as PlayInfoCDResponse;
76+
}
77+
} catch (e) {
78+
this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e}));
79+
}
80+
81+
try {
82+
const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString());
83+
if (netResp.body !== undefined && typeof netResp.body === 'object') {
84+
return netResp.body as PlayInfoNetResponse
85+
}
86+
} catch (e) {
87+
this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e}));
88+
}
89+
90+
return undefined;
91+
}
92+
93+
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
94+
95+
const statusResp = await request.get(joinedUrl(this.urlData.url, 'main/getStatus').toString());
96+
if (statusResp.body == undefined || typeof statusResp.body !== 'object') {
97+
this.logger.error({ getStatusResponse: statusResp });
98+
throw new Error('Could not determine status of MusicCast device');
99+
}
100+
if ((statusResp.body as DeviceStatusResponse).power !== 'on') {
101+
this.logger.debug('MusicCast device is offline');
102+
return this.processRecentPlays([]);
103+
}
104+
105+
const playInfo = await this.getAnyPlayInfo();
106+
if(playInfo === undefined) {
107+
return this.processRecentPlays([]);
108+
}
109+
110+
const play = formatPlayObj(playInfo);
111+
112+
113+
const playerState: PlayerStateData = {
114+
platformId: SINGLE_USER_PLATFORM_ID,
115+
status: playbackToReportedStatus(playInfo.playback),
116+
play,
117+
position: play.meta.trackProgressPosition
118+
}
119+
120+
return this.processRecentPlays([playerState]);
121+
}
122+
}
123+
124+
const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
125+
126+
const {
127+
play_time,
128+
total_time,
129+
artist,
130+
album,
131+
track,
132+
device_status,
133+
playback
134+
} = obj;
135+
136+
return {
137+
data: {
138+
artists: artist !== undefined && artist !== '' ? [artist] : [],
139+
album: album !== '' ? album : undefined,
140+
track,
141+
duration: total_time
142+
},
143+
meta: {
144+
trackProgressPosition: play_time,
145+
deviceId: 'input' in obj ? obj.input : 'cd'
146+
}
147+
}
148+
}

src/backend/sources/ScrobbleSources.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour
1616
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
1717
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
1818
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
19+
import { MusicCastData, MusicCastSourceConfig } from "../common/infrastructure/config/source/musiccast.js";
1920
import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
2021
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
2122
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
@@ -43,6 +44,7 @@ import { MopidySource } from "./MopidySource.js";
4344
import { MPDSource } from "./MPDSource.js";
4445
import { MPRISSource } from "./MPRISSource.js";
4546
import { MusikcubeSource } from "./MusikcubeSource.js";
47+
import { MusicCastSource } from "./MusicCastSource.js";
4648
import PlexSource from "./PlexSource.js";
4749
import SpotifySource from "./SpotifySource.js";
4850
import { SubsonicSource } from "./SubsonicSource.js";
@@ -163,6 +165,9 @@ export default class ScrobbleSources {
163165
case 'musikcube':
164166
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusikcubeSourceConfig");
165167
break;
168+
case 'musiccast':
169+
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MusicCastSourceConfig");
170+
break;
166171
case 'mpd':
167172
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("MPDSourceConfig");
168173
break;
@@ -452,6 +457,21 @@ export default class ScrobbleSources {
452457
});
453458
}
454459
break;
460+
case 'musiccast':
461+
const musecase = {
462+
url: process.env.MCAST_URL,
463+
}
464+
if (!Object.values(musecase).every(x => x === undefined)) {
465+
configs.push({
466+
type: 'musiccast',
467+
name: 'unnamed',
468+
source: 'ENV',
469+
mode: 'single',
470+
configureAs: defaultConfigureAs,
471+
data: musecase as MusicCastData
472+
});
473+
}
474+
break;
455475
case 'musikcube':
456476
const mc = {
457477
url: process.env.MC_URL,
@@ -675,6 +695,9 @@ export default class ScrobbleSources {
675695
case 'musikcube':
676696
newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, this.internalConfig, this.emitter);
677697
break;
698+
case 'musiccast':
699+
newSource = await new MusicCastSource(name, compositeConfig as MusicCastSourceConfig, this.internalConfig, this.emitter);
700+
break;
678701
case 'mpd':
679702
newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, this.internalConfig, this.emitter);
680703
break;

src/backend/utils/NetworkUtils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) =>
4949

5050
const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);
5151

52-
export const normalizeWebAddress = (val: string): URLData => {
52+
export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => {
5353
let cleanUserUrl = val.trim();
5454
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
5555
if (results !== undefined && results.groups && results.groups.length > 0) {
5656
cleanUserUrl = results.groups[0];
5757
}
5858

59+
const {defaultPath} = options;
60+
5961
let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true});
6062
const u = new URL(normal);
6163
let port: number;
@@ -72,6 +74,12 @@ export const normalizeWebAddress = (val: string): URLData => {
7274
normal = normal.replace('http:', 'https:');
7375
}
7476
}
77+
78+
if(u.pathname === '/' && defaultPath !== undefined) {
79+
u.pathname = defaultPath;
80+
normal = normalizeUrl(u.toString());
81+
}
82+
7583
return {
7684
url: u,
7785
normal,

0 commit comments

Comments
 (0)