[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

test(musiccast): Add tests for basic connectivity

FoxxMD (Mar 17, 2025, 8:36 PM UTC) 4fee231a b5ead397

+291 -6
+9
package-lock.json
··· 22 22 "@foxxmd/logging": "^0.2.2", 23 23 "@foxxmd/regex-buddy-core": "^0.1.2", 24 24 "@foxxmd/string-sameness": "^0.4.0", 25 + "@gr2m/net-interceptor": "^1.0.0", 25 26 "@jellyfin/sdk": "^0.11.0", 26 27 "@kenyip/backoff-strategies": "^1.0.4", 27 28 "@lukehagar/plexjs": "^0.32.1", ··· 1134 1135 "engines": { 1135 1136 "node": ">=18.0.0", 1136 1137 "npm": ">=9.3.0" 1138 + } 1139 + }, 1140 + "node_modules/@gr2m/net-interceptor": { 1141 + "version": "1.0.0", 1142 + "resolved": "https://registry.npmjs.org/@gr2m/net-interceptor/-/net-interceptor-1.0.0.tgz", 1143 + "integrity": "sha512-qLTp3npmB5SDiEIh1Jg1v8a09dlnfYV1kcSyLmsyucvZBT89nM+6L1QdlrCC/W1cTvdTpvI+Vleit94UVX7/Cg==", 1144 + "engines": { 1145 + "node": ">= 14" 1137 1146 } 1138 1147 }, 1139 1148 "node_modules/@homebridge/long": {
+1
package.json
··· 54 54 "@foxxmd/logging": "^0.2.2", 55 55 "@foxxmd/regex-buddy-core": "^0.1.2", 56 56 "@foxxmd/string-sameness": "^0.4.0", 57 + "@gr2m/net-interceptor": "^1.0.0", 57 58 "@jellyfin/sdk": "^0.11.0", 58 59 "@kenyip/backoff-strategies": "^1.0.4", 59 60 "@lukehagar/plexjs": "^0.32.1",
+1 -1
src/backend/common/infrastructure/config/source/musiccast.ts
··· 35 35 input: string 36 36 } 37 37 38 - const MusicCastResponseCodes = new Map<number, string>([ 38 + export const MusicCastResponseCodes = new Map<number, string>([ 39 39 [0, 'Success'], 40 40 [1, 'Initializing'], 41 41 [2, 'Internal Error'],
+13 -5
src/backend/sources/MusicCastSource.ts
··· 8 8 PlayerStateData, 9 9 SINGLE_USER_PLATFORM_ID, 10 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"; 11 + import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; 12 + import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; 13 13 import request, { Request, Response } from 'superagent'; 14 14 15 15 ··· 51 51 52 52 protected async doCheckConnection(): Promise<true | string | undefined> { 53 53 try { 54 - await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); 54 + await isPortReachableConnect(1130, { host: this.urlData.url.hostname }); 55 55 this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); 56 56 57 57 const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) ··· 73 73 try { 74 74 const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); 75 75 if (netResp.body !== undefined && typeof netResp.body === 'object') { 76 - return netResp.body as PlayInfoNetResponse 76 + const resp = netResp.body as PlayInfoNetResponse 77 + if(resp.response_code !== 0) { 78 + throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); 79 + } 80 + return resp; 77 81 } 78 82 } catch (e) { 79 83 this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); ··· 82 86 try { 83 87 const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); 84 88 if (cdResp.body !== undefined && typeof cdResp.body === 'object') { 85 - return cdResp.body as PlayInfoCDResponse; 89 + const resp = cdResp.body as PlayInfoCDResponse; 90 + if(resp.response_code !== 0) { 91 + throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); 92 + } 93 + return resp; 86 94 } 87 95 } catch (e) { 88 96 this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e}));
+229
src/backend/tests/musiccast/musiccast.test.ts
··· 1 + import { loggerTest, loggerDebug } from "@foxxmd/logging"; 2 + import { assert, expect } from 'chai'; 3 + import EventEmitter from "events"; 4 + import dayjs from "dayjs"; 5 + import { describe, it, before, after } from 'mocha'; 6 + import { http, HttpResponse } from "msw"; 7 + import { withRequestInterception } from "../utils/networking.js"; 8 + import { MusicCastData } from "../../common/infrastructure/config/source/musiccast.js"; 9 + import { MusicCastSource } from "../../sources/MusicCastSource.js"; 10 + import netInterceptor from "@gr2m/net-interceptor"; 11 + import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; 12 + 13 + const TEST_IP = '192.168.10.101'; 14 + 15 + const createSource = (data: MusicCastData = { url: TEST_IP }): MusicCastSource => { 16 + const source = new MusicCastSource('Test', { 17 + data, 18 + options: {} 19 + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter()); 20 + return source; 21 + } 22 + 23 + describe('MusicCast Startup', function () { 24 + 25 + after(() => { 26 + netInterceptor.stop(); 27 + }); 28 + it('tests for device info correctly', withRequestInterception( 29 + [ 30 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/system/getDeviceInfo`, () => { 31 + return HttpResponse.json({ 32 + model_name: 'test', 33 + device_id: 'testid', 34 + system_version: '1234', 35 + version: '1test', 36 + response_code: 0 37 + }, { status: 200 }); 38 + }) 39 + ], 40 + async function () { 41 + const source = createSource(); 42 + 43 + await source.buildInitData(); 44 + 45 + netInterceptor.start(); 46 + 47 + netInterceptor.on("connection", (socket) => { 48 + socket.write("Hello there."); 49 + netInterceptor.stop(); 50 + }); 51 + await source.checkConnection(); 52 + expect(source.connectionOK).to.be.true; 53 + } 54 + )); 55 + }); 56 + 57 + describe('MusicCast State Handling', function() { 58 + 59 + it('Handles standby mode', withRequestInterception( 60 + [ 61 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { 62 + return HttpResponse.json({ 63 + power: 'standby', 64 + response_code: 0 65 + }, { status: 200 }); 66 + }) 67 + ], 68 + async function () { 69 + const source = createSource(); 70 + 71 + await source.buildInitData(); 72 + source.connectionOK = true; 73 + await source.getRecentlyPlayed(); 74 + expect(source.players.size).to.eq(0); 75 + } 76 + )); 77 + 78 + it('Handles valid netusb device playing state', withRequestInterception( 79 + [ 80 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { 81 + return HttpResponse.json({ 82 + power: 'on', 83 + response_code: 0 84 + }, { status: 200 }); 85 + }), 86 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { 87 + return HttpResponse.json({ 88 + device_status: 'ready', 89 + playback: 'play', 90 + play_time: 10, 91 + total_time: 60, 92 + artist: 'Test Artist', 93 + album: 'Test Album', 94 + track: 'Cool Track', 95 + input: 'av1', 96 + response_code: 0 97 + }, { status: 200 }); 98 + }), 99 + ], 100 + async function () { 101 + const source = createSource(); 102 + 103 + await source.buildInitData(); 104 + source.connectionOK = true; 105 + await source.getRecentlyPlayed(); 106 + expect(source.players.size).to.eq(1); 107 + const playerState = source.players.get(source.players.keys().next().value).getApiState(); 108 + expect(playerState.play.data.album).to.eq('Test Album'); 109 + expect(playerState.play.data.track).to.eq('Cool Track'); 110 + expect(playerState.play.data.duration).to.eq(60); 111 + expect(playerState.play.meta.trackProgressPosition).to.eq(10); 112 + expect(playerState.play.meta.deviceId).to.eq('av1'); 113 + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); 114 + } 115 + )); 116 + 117 + it('Handles valid cd device playing state', withRequestInterception( 118 + [ 119 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { 120 + return HttpResponse.json({ 121 + power: 'on', 122 + response_code: 0 123 + }, { status: 200 }); 124 + }), 125 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { 126 + return HttpResponse.json({ 127 + response_code: 100 128 + }, { status: 200 }); 129 + }), 130 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { 131 + return HttpResponse.json({ 132 + device_status: 'ready', 133 + playback: 'play', 134 + play_time: 10, 135 + total_time: 60, 136 + artist: 'Test Artist', 137 + album: 'Test Album', 138 + track: 'Cool Track', 139 + response_code: 0 140 + }, { status: 200 }); 141 + }), 142 + ], 143 + async function () { 144 + const source = createSource(); 145 + 146 + await source.buildInitData(); 147 + source.connectionOK = true; 148 + await source.getRecentlyPlayed(); 149 + expect(source.players.size).to.eq(1); 150 + const playerState = source.players.get(source.players.keys().next().value).getApiState(); 151 + expect(playerState.play.data.album).to.eq('Test Album'); 152 + expect(playerState.play.data.track).to.eq('Cool Track'); 153 + expect(playerState.play.data.duration).to.eq(60); 154 + expect(playerState.play.meta.trackProgressPosition).to.eq(10); 155 + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); 156 + } 157 + )); 158 + 159 + it('Handles non 200 status from getPlayInfo', withRequestInterception( 160 + [ 161 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { 162 + return HttpResponse.json({ 163 + power: 'on', 164 + response_code: 0 165 + }, { status: 200 }); 166 + }), 167 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { 168 + return HttpResponse.json({ 169 + response_code: 100 170 + }, { status: 500 }); 171 + }), 172 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { 173 + return HttpResponse.json({ 174 + device_status: 'ready', 175 + playback: 'play', 176 + play_time: 10, 177 + total_time: 60, 178 + artist: 'Test Artist', 179 + album: 'Test Album', 180 + track: 'Cool Track', 181 + response_code: 0 182 + }, { status: 200 }); 183 + }), 184 + ], 185 + async function () { 186 + const source = createSource(); 187 + 188 + await source.buildInitData(); 189 + source.connectionOK = true; 190 + await source.getRecentlyPlayed(); 191 + expect(source.players.size).to.eq(1); 192 + } 193 + )); 194 + 195 + it('Handles stopped player', withRequestInterception( 196 + [ 197 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { 198 + return HttpResponse.json({ 199 + power: 'on', 200 + response_code: 0 201 + }, { status: 200 }); 202 + }), 203 + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { 204 + return HttpResponse.json({ 205 + device_status: 'ready', 206 + playback: 'stop', 207 + play_time: 10, 208 + total_time: 60, 209 + artist: 'Test Artist', 210 + album: 'Test Album', 211 + track: 'Cool Track', 212 + input: 'av1', 213 + response_code: 0 214 + }, { status: 200 }); 215 + }), 216 + ], 217 + async function () { 218 + const source = createSource(); 219 + 220 + await source.buildInitData(); 221 + source.connectionOK = true; 222 + await source.getRecentlyPlayed(); 223 + expect(source.players.size).to.eq(1); 224 + const playerState = source.players.get(source.players.keys().next().value).getApiState(); 225 + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.stopped); 226 + } 227 + )); 228 + 229 + });
+38
src/backend/utils/NetworkUtils.ts
··· 47 47 } 48 48 } 49 49 50 + /** Test if a host:port is reachable via TCP 51 + * 52 + * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket 53 + * 54 + * https://github.com/gr2m/node-net-interceptor/issues/2 55 + * https://github.com/moll/node-mitm/issues/42 56 + * 57 + */ 58 + export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => { 59 + const {host, timeout = 1000} = opts; 60 + 61 + const promise = new Promise(((resolve, reject) => { 62 + const client = net.connect({ 63 + timeout, 64 + port, 65 + host 66 + }, () => { 67 + client.end(); 68 + resolve(true); 69 + }); 70 + 71 + client.on('error', (err) => { 72 + client.destroy(); 73 + reject(err); 74 + }); 75 + client.on('timeout', () => { 76 + reject(new Error(`Connection timed out after ${timeout}ms`)); 77 + }); 78 + })); 79 + 80 + try { 81 + await promise; 82 + return true; 83 + } catch (e) { 84 + throw e; 85 + } 86 + } 87 + 50 88 const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); 51 89 52 90 export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => {