[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.

Use device invariant data for iterating devices

* Use invariant hash for seen state logging
* Removes dependence on individual properties being available
* Drop player state if an existing existing player has the same play, rather than dropping by existing group
* Enable logging empty player for more debugging info

try to get device summary with for loop

FoxxMD (Jan 22, 2026, 1:48 PM UTC) 2ac9547f 49529af8

+108 -43
+6 -1
src/backend/common/infrastructure/config/source/sonos.ts
··· 1 1 import { PollingOptions } from "../common.js"; 2 - import { CommonSourceConfig, CommonSourceData } from "./index.js"; 2 + import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; 3 3 4 4 export interface SonosData extends CommonSourceData, PollingOptions { 5 5 /** ··· 27 27 * */ 28 28 groupsBlock?: string | string[] 29 29 } 30 + 31 + export interface SonosSourceOptions extends CommonSourceOptions { 32 + logEmptyPlayer?: boolean 33 + } 30 34 export interface SonosSourceConfig extends CommonSourceConfig { 31 35 data: SonosData 36 + options?: SonosSourceOptions 32 37 } 33 38 34 39 export interface SonosSourceAIOConfig extends SonosSourceConfig {
+102 -42
src/backend/sources/SonosSource.ts
··· 19 19 import { Track } from "@svrooij/sonos/lib/models/track.js"; 20 20 import { parseDurationFromTimestamp } from "../utils/TimeUtils.js"; 21 21 import { FixedSizeList } from "fixed-size-list"; 22 - import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js"; 23 - import { isDebugMode } from "../utils.js"; 22 + import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js"; 23 + import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; 24 + import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; 24 25 25 - export interface UniquePlay { 26 + export interface DeviceState { 26 27 device: SonosDevice 27 28 state: SonosState 28 29 } 29 30 31 + export type SimpleDevice = Pick<SonosDevice, 'Name' | 'GroupName' | 'Uuid' | 'Host'>; 32 + 33 + export interface SimpleDeviceState { 34 + device: SimpleDevice 35 + state: SonosState 36 + } 37 + 30 38 GroupTransportState.GroupPlaying 31 39 32 40 const CLIENT_PLAYER_STATE: Record<string, ReportedPlayerStatus> = { ··· 42 50 declare config: SonosSourceConfig; 43 51 44 52 manager: SonosManager; 45 - mediaIdsSeen: FixedSizeList<string>; 53 + deviceHashSeen: FixedSizeList<string>; 46 54 uniqueDropReasons: FixedSizeList<string>; 47 55 logFilterFailure: false | 'debug' | 'warn'; 56 + logEmptyPlayer: boolean 48 57 49 58 devicesAllow: string[] = []; 50 59 devicesBlock: string[] = []; ··· 64 73 this.requiresAuth = false; 65 74 this.canPoll = true; 66 75 this.manager = new SonosManager(); 67 - this.mediaIdsSeen = new FixedSizeList<string>(100); 76 + this.deviceHashSeen = new FixedSizeList<string>(100); 68 77 this.uniqueDropReasons = new FixedSizeList<string>(100); 69 78 } 70 79 ··· 72 81 const { 73 82 options: { 74 83 logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), 84 + logEmptyPlayer = isDebugMode() 75 85 } = {}, 76 86 data: { 77 87 devicesAllow = [], ··· 85 95 } else { 86 96 this.logFilterFailure = logFilterFailure; 87 97 } 98 + this.logEmptyPlayer = logEmptyPlayer; 88 99 89 100 this.devicesAllow = parseArrayFromMaybeString(devicesAllow, {lower: true}); 90 101 this.devicesBlock = parseArrayFromMaybeString(devicesBlock, {lower: true}); ··· 97 108 protected async doCheckConnection(): Promise<true | string | undefined> { 98 109 try { 99 110 await this.manager.InitializeFromDevice(this.config.data.host); 111 + const devicesSummary = []; 112 + // something about .map isn't iterating all devices? 113 + // see if this works instead 114 + // for good measure advanced to next tick 115 + await sleep(500); 116 + for (const x of this.manager.Devices) { 117 + devicesSummary.push(`Name: ${x.Name} | IP: ${x.Host} | Group: ${x.GroupName}`); 118 + } 119 + this.logger.debug(`Devices in Sonos network\n${devicesSummary.join('\n')}`); 100 120 return `Sonos network is available with ${this.manager.Devices.length} devices`; 101 121 } catch (e) { 102 122 throw e; 103 123 } 104 124 } 105 125 106 - public isValidState = (data: UniquePlay, play: PlayObject): string | undefined => { 126 + public isValidState = (data: DeviceState, play: PlayObject): string | undefined => { 107 127 if (typeof data.state.positionInfo.TrackMetaData === 'string') { 108 128 // ??? 109 129 return; ··· 129 149 130 150 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 131 151 132 - // only need to get one device per group 133 - const uniqueDevices: Record<string, SonosDevice> = {} 152 + const playerStates: PlayerStateData[] = []; 134 153 for (const d of this.manager.Devices) { 135 - if (uniqueDevices[d.GroupName] === undefined) { 136 - uniqueDevices[d.GroupName] = d; 137 - } 138 - } 139 - const uniquePlayers: UniquePlay[] = []; 140 - for (const [k, v] of Object.entries(uniqueDevices)) { 141 - const state = await v.GetState(); 142 - // unsure if devices in the same group can play different things if they are in different zones? 143 - // so double check some unique-ish data is not the same 144 - if (!uniquePlayers.some(x => x.state.mediaInfo.CurrentURI === state.mediaInfo.CurrentURI)) { 145 - uniquePlayers.push({ device: v, state }); 146 - } 147 - } 154 + const state = await d.GetState(); 155 + const x = { 156 + state, 157 + device: d 158 + }; 148 159 149 - const playerStates: PlayerStateData[] = []; 150 - for (const x of uniquePlayers) { 151 160 const { 152 161 Name, 153 162 GroupName, 154 - Uuid 163 + Uuid, 164 + Host 155 165 } = x.device 156 166 167 + const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; 168 + 157 169 try { 158 170 let status = CLIENT_PLAYER_STATE[x.state.transportState]; 159 171 172 + // TODO if status is stopped then drop state if player is also stopped? 173 + 160 174 let seen = true; 161 175 162 - // TrackURI seems to correspond to 1) the device/group playing and 2) the service/source playing 163 - // but NOT the content actually playing -- 2) does not update when content playing changes on the same service 164 - const mediaId = `${x.state.positionInfo.TrackURI}--${typeof x.state.positionInfo.TrackMetaData !== 'string' ? x.state.positionInfo.TrackMetaData.TrackUri : x.state.positionInfo.TrackMetaData}`; 165 - if (!this.mediaIdsSeen.data.includes(mediaId)) { 176 + const { 177 + positionInfo: { 178 + TrackURI: posTrackURI, 179 + TrackMetaData 180 + } = {} 181 + } = state; 182 + 183 + const invariantData = getInvariantDeviceData(x); 184 + 185 + const hash = hashObject(invariantData); 186 + if (!this.deviceHashSeen.data.includes(hash)) { 166 187 seen = false; 167 - this.mediaIdsSeen.add(mediaId); 188 + this.deviceHashSeen.add(hash); 168 189 if (this.config.options?.logPayload || isDebugMode()) { 169 - this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); 190 + this.logger.debug({...invariantData}, 'Sonos Data'); 170 191 } 171 192 } 172 193 173 - const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; 194 + let play: PlayObject | undefined = status === REPORTED_PLAYER_STATUSES.stopped || posTrackURI === undefined ? undefined : formatPlayObj(x.state, { device: x.device }); 195 + let playIsEmpty = false; 174 196 175 - let play = status === REPORTED_PLAYER_STATUSES.stopped ? undefined : formatPlayObj(x.state, { device: x.device }); 176 - 177 - if (play.data.track === undefined && play.data.artists === undefined) { 197 + if (play !== undefined && (play.data?.track === undefined && play.data?.artists === undefined)) { 178 198 // likely sonos is paused and reporting an empty play 179 - play = undefined; 180 - status = REPORTED_PLAYER_STATUSES.stopped; 199 + playIsEmpty = true; 181 200 } 182 201 183 202 const position = play !== undefined ? play.meta.trackProgressPosition : undefined; 184 203 185 204 const playerState: PlayerStateData = { 186 205 platformId: [deviceId, NO_USER], 187 - status, 188 - play, 206 + status: playIsEmpty ? REPORTED_PLAYER_STATUSES.stopped : status, 207 + play: playIsEmpty ? undefined : play, 189 208 position 190 209 } 191 210 192 - if (play !== undefined) { 193 - const reason = this.isValidState(x, play); 211 + if (!playIsEmpty && play !== undefined) { 212 + let reason = this.isValidState(x, play); 213 + if(reason === undefined) { 214 + const dup = playerStates.find(x => x.play !== undefined && playObjDataMatch(x.play, play)); 215 + if(dup !== undefined) { 216 + reason = 'Another player is playing the same track'; 217 + } 218 + } 194 219 if(reason !== undefined) { 195 220 const dropReason = `Player State for -> ${buildStatePlayerPlayIdententifyingInfo(playerState)} <-- is being dropped because ${reason}`; 196 221 if (!this.uniqueDropReasons.data.some(x => x === dropReason)) { ··· 201 226 } 202 227 continue; 203 228 } 229 + } else if(this.logEmptyPlayer) { 230 + this.logger.debug(`Player State for -> ${deviceId} <-- is being dropped because it is empty`); 231 + continue; 204 232 } 205 233 206 234 playerStates.push(playerState); 207 235 } catch (e) { 208 - this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); 236 + this.logger.error({ device: { Name, GroupName, Uuid, Host }, state: x.state }, 'Sonos Data'); 209 237 throw new Error('Failed to parse Sonos data', { cause: e }); 210 238 } 211 239 } ··· 307 335 trackProgressPosition: progress, 308 336 art: { 309 337 album: AlbumArtUri 310 - } 338 + }, 339 + source: 'Sonos' 340 + } 341 + } 342 + } 343 + 344 + export const getInvariantDeviceData = (data: DeviceState): SimpleDeviceState => { 345 + 346 + const { device, state } = data; 347 + 348 + const { 349 + positionInfo: { 350 + RelTime, 351 + ...restPos 352 + } = {}, 353 + ...restState 354 + } = state; 355 + 356 + return { 357 + device: { 358 + Name: device.Name, 359 + GroupName: device.GroupName, 360 + Host: device.Host, 361 + Uuid: device.Uuid 362 + }, 363 + state: { 364 + ...restState, 365 + // @ts-expect-error 366 + positionInfo: { 367 + ...restPos, 368 + RelTime: '0', 369 + }, 370 + volume: 0 311 371 } 312 372 } 313 373 }