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

feat: Implement Sonos Source

disable debug test

Add mising type to source status data

FoxxMD (Jan 22, 2026, 1:48 PM UTC) 16ad50ca 49d09d04

+495 -7
+9
config/sonos.json.example
··· 1 + [ 2 + { 3 + "enable": true, 4 + "name": "MySonos", 5 + "data": { 6 + "host": "192.168.0.150" 7 + } 8 + } 9 + ]
+40
docsite/docs/configuration/sources/sonos.mdx
··· 1 + --- 2 + title: Sonos 3 + toc_min_heading_level: 2 4 + toc_max_heading_level: 5 5 + --- 6 + 7 + import Tabs from '@theme/Tabs'; 8 + import TabItem from '@theme/TabItem'; 9 + import CodeBlock from '@theme/CodeBlock'; 10 + import JsonConfig from '!!raw-loader!@site/../config/sonos.json.example'; 11 + 12 + Multi-scrobbler can use the [Sonos](https://www.sonos.com) [local API](https://docs.sonos.com/docs/control) to monitor what your Sonos system is playing. 13 + 14 + MS supports scrobbling simultaneous, different Plays based on what each Group/Zone is playing. 15 + 16 + :::note 17 + 18 + For some services, including Spotify, Sonos only reports the **first** artist for a Track. You will most likely want to enable [Musicbrainz scrobble enhancements](/configuration/transforms/musicbrainz) so that your plays have correct information, including all artists, when scrobbled. 19 + 20 + ::: 21 + 22 + :::tip 23 + 24 + Activity reporting for **other [Sources](/configuration/sources)** does not work well **when played through Sonos.** 25 + 26 + If you have other Sources configured for Multi-Scrobbler that you may play *through Sonos* you should try to add Sonos to the "deny" list for that Source so that it does not try to monitor Sonos activity. Check the documentation for each individual Source to see how to configure this exclusion. 27 + 28 + An example of this for [Plex](/configuration/sources/plex) would be to add Sonos to the "block" list ENV EX: `PLEX_DEVICES_BLOCK=sonos` 29 + 30 + ::: 31 + 32 + ## Configuration 33 + 34 + You **must** define the IP address of at least one Sonos/accessory in order for Multi-scrobbler to connect to your Sonos system. 35 + 36 + <Config config="SonosSourceConfig" fileContent={JsonConfig} name="sonos"> 37 + | Environmental Variable | Required? | Default | Description | 38 + | ---------------------- | --------- | ------- | ---------------------------------------------------------------------- | 39 + | `SONOS_HOST` | Yes | | The IP address of any active Sonos device/accessory EX `192.168.0.150` | 40 + </Config>
+1
docsite/docs/configuration/sources/sources.mdx
··· 33 33 | [Musikcube](/configuration/sources/musikcube) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | 34 34 | [Plex](/configuration/sources/plex) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | 35 35 | [Rocksky](/configuration/sources/rocksky-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | 36 + | [Sonos](/configuration/sources/sonos) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | 36 37 | [Spotify](/configuration/sources/spotify) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | 37 38 | [Subsonic-compatible APIs](/configuration/sources/subsonic) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | 38 39 | [teal.fm](/configuration/sources/tealfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
+1
docsite/docs/index.mdx
··· 33 33 * [Musikcube](/configuration/sources/musikcube) 34 34 * [Plex](/configuration/sources/plex) 35 35 * [Rocksky](/configuration/sources/rocksky-source) 36 + * [Sonos](/configuration/sources/sonos) 36 37 * [Spotify](/configuration/sources/spotify) 37 38 * [Subsonic-compatible APIs](/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/)) 38 39 * [teal.fm](/configuration/sources/tealfm-source)
+79
package-lock.json
··· 33 33 "@react-nano/use-event-source": "^0.13.0", 34 34 "@reduxjs/toolkit": "^1.9.5", 35 35 "@supercharge/promise-pool": "^3.0.0", 36 + "@svrooij/sonos": "^2.5.0", 36 37 "@tailwindcss/vite": "^4.1.16", 37 38 "@types/express-serve-static-core": "^4.19.6", 38 39 "address": "^1.2.2", ··· 3014 3015 "engines": { 3015 3016 "node": ">=8" 3016 3017 } 3018 + }, 3019 + "node_modules/@svrooij/sonos": { 3020 + "version": "2.5.0", 3021 + "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz", 3022 + "integrity": "sha512-QcRJRo9aILj5/6ikebQsGZL9kqGP9Er6XyWJG5akHI0XIALMk/kLyXVrAnYZ2jef3Hj2GCCHCqCNkbAIqCZ53Q==", 3023 + "license": "MIT", 3024 + "dependencies": { 3025 + "debug": "4.3.1", 3026 + "fast-xml-parser": "3.19.0", 3027 + "guid-typescript": "^1.0.9", 3028 + "html-entities": "^2.3.2", 3029 + "node-fetch": "^2.6.1", 3030 + "typed-emitter": "^1.3.1" 3031 + } 3032 + }, 3033 + "node_modules/@svrooij/sonos/node_modules/debug": { 3034 + "version": "4.3.1", 3035 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", 3036 + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", 3037 + "license": "MIT", 3038 + "dependencies": { 3039 + "ms": "2.1.2" 3040 + }, 3041 + "engines": { 3042 + "node": ">=6.0" 3043 + }, 3044 + "peerDependenciesMeta": { 3045 + "supports-color": { 3046 + "optional": true 3047 + } 3048 + } 3049 + }, 3050 + "node_modules/@svrooij/sonos/node_modules/ms": { 3051 + "version": "2.1.2", 3052 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 3053 + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", 3054 + "license": "MIT" 3017 3055 }, 3018 3056 "node_modules/@szmarczak/http-timer": { 3019 3057 "version": "4.0.6", ··· 6288 6326 "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", 6289 6327 "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" 6290 6328 }, 6329 + "node_modules/fast-xml-parser": { 6330 + "version": "3.19.0", 6331 + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz", 6332 + "integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg==", 6333 + "license": "MIT", 6334 + "bin": { 6335 + "xml2js": "cli.js" 6336 + }, 6337 + "funding": { 6338 + "type": "paypal", 6339 + "url": "https://paypal.me/naturalintelligence" 6340 + } 6341 + }, 6291 6342 "node_modules/fastq": { 6292 6343 "version": "1.17.1", 6293 6344 "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", ··· 6969 7020 "node": ">=14.0.0" 6970 7021 } 6971 7022 }, 7023 + "node_modules/guid-typescript": { 7024 + "version": "1.0.9", 7025 + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", 7026 + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", 7027 + "license": "ISC" 7028 + }, 6972 7029 "node_modules/has-bigints": { 6973 7030 "version": "1.0.2", 6974 7031 "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", ··· 7106 7163 "version": "1.12.0", 7107 7164 "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.0.tgz", 7108 7165 "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", 7166 + "license": "MIT" 7167 + }, 7168 + "node_modules/html-entities": { 7169 + "version": "2.6.0", 7170 + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", 7171 + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", 7172 + "funding": [ 7173 + { 7174 + "type": "github", 7175 + "url": "https://github.com/sponsors/mdevils" 7176 + }, 7177 + { 7178 + "type": "patreon", 7179 + "url": "https://patreon.com/mdevils" 7180 + } 7181 + ], 7109 7182 "license": "MIT" 7110 7183 }, 7111 7184 "node_modules/html-escaper": { ··· 12327 12400 "engines": { 12328 12401 "node": ">= 0.6" 12329 12402 } 12403 + }, 12404 + "node_modules/typed-emitter": { 12405 + "version": "1.4.0", 12406 + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", 12407 + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", 12408 + "license": "MIT" 12330 12409 }, 12331 12410 "node_modules/typedarray": { 12332 12411 "version": "0.0.6",
+1
package.json
··· 67 67 "@react-nano/use-event-source": "^0.13.0", 68 68 "@reduxjs/toolkit": "^1.9.5", 69 69 "@supercharge/promise-pool": "^3.0.0", 70 + "@svrooij/sonos": "^2.5.0", 70 71 "@tailwindcss/vite": "^4.1.16", 71 72 "@types/express-serve-static-core": "^4.19.6", 72 73 "address": "^1.2.2",
+4 -2
src/backend/common/infrastructure/Atomic.ts
··· 34 34 | 'azuracast' 35 35 | 'koito' 36 36 | 'tealfm' 37 - | 'rocksky'; 37 + | 'rocksky' 38 + | 'sonos'; 38 39 39 40 export const sourceTypes: SourceType[] = [ 40 41 'spotify', ··· 63 64 'azuracast', 64 65 'koito', 65 66 'tealfm', 66 - 'rocksky' 67 + 'rocksky', 68 + 'sonos' 67 69 ]; 68 70 69 71 export const sourceInterfaces = [
+18
src/backend/common/infrastructure/config/source/sonos.ts
··· 1 + import { PollingOptions } from "../common.js"; 2 + import { CommonSourceConfig, CommonSourceData } from "./index.js"; 3 + 4 + export interface SonosData extends CommonSourceData, PollingOptions { 5 + /** 6 + * IP address of any connected Sonos speaker or device 7 + * 8 + * @examples ["192.168.0.170"] 9 + * */ 10 + host: string 11 + } 12 + export interface SonosSourceConfig extends CommonSourceConfig { 13 + data: SonosData 14 + } 15 + 16 + export interface SonosSourceAIOConfig extends SonosSourceConfig { 17 + type: 'sonos' 18 + }
+5 -2
src/backend/common/infrastructure/config/source/sources.ts
··· 25 25 import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js"; 26 26 import { RockskySourceAIOConfig, RockskySourceConfig } from "./rocksky.js"; 27 27 import { LibrefmSouceAIOConfig, LibrefmSourceConfig } from "./librefm.js"; 28 + import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js"; 28 29 29 30 30 31 export type SourceConfig = ··· 55 56 | AzuracastSourceConfig 56 57 | KoitoSourceConfig 57 58 | TealSourceConfig 58 - | RockskySourceConfig; 59 + | RockskySourceConfig 60 + | SonosSourceConfig; 59 61 60 62 export type SourceAIOConfig = 61 63 SpotifySourceAIOConfig ··· 85 87 | AzuracastSourceAIOConfig 86 88 | KoitoSourceAIOConfig 87 89 | TealSourceAIOConfig 88 - | RockskySourceAIOConfig; 90 + | RockskySourceAIOConfig 91 + | SonosSourceAIOConfig;
+24 -1
src/backend/sources/ScrobbleSources.ts
··· 30 30 import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; 31 31 import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; 32 32 import { YTMusicData, YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; 33 + import { SonosData, SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js"; 33 34 import { WildcardEmitter } from "../common/WildcardEmitter.js"; 34 35 import { parseBool } from "../utils.js"; 35 36 import { readJson } from '../utils/DataUtils.js'; ··· 70 71 import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js'; 71 72 //import LibrefmSource from './LibrefmSource.js'; 72 73 import { LibrefmSourceConfig } from '../common/infrastructure/config/source/librefm.js'; 74 + import { SonosSource } from './SonosSource.js'; 73 75 74 76 type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; 75 77 ··· 184 186 return "TealSourceConfig"; 185 187 case 'rocksky': 186 188 return "RockskySourceConfig"; 189 + case 'sonos': 190 + return 'SonosSourceConfig'; 187 191 } 188 192 } 189 193 ··· 790 794 options: transformPresetEnv('AZURA') 791 795 }); 792 796 } 793 - break; 797 + break; 798 + case 'sonos': { 799 + const sonos = { 800 + host: process.env.SONOS_HOST, 801 + } 802 + if (!Object.values(sonos).every(x => x === undefined)) { 803 + configs.push({ 804 + type: 'sonos', 805 + name: 'unnamed', 806 + source: 'ENV', 807 + mode: 'single', 808 + configureAs: defaultConfigureAs, 809 + data: sonos as unknown as SonosData, 810 + options: transformPresetEnv('SONOS') 811 + }); 812 + } 813 + } break; 794 814 default: 795 815 break; 796 816 } ··· 1019 1039 case 'rocksky': 1020 1040 const RockskySource = (await import('./RockskySource.js')).default; 1021 1041 newSource = await new RockskySource(name, compositeConfig as RockskySourceConfig, this.internalConfig, this.emitter); 1042 + break; 1043 + case 'sonos': 1044 + newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter); 1022 1045 break; 1023 1046 default: 1024 1047 break;
+280
src/backend/sources/SonosSource.ts
··· 1 + import { EventEmitter } from "events"; 2 + import { PlayObject } from "../../core/Atomic.js"; 3 + import { 4 + FormatPlayObjectOptions, 5 + InternalConfig, 6 + NO_DEVICE, 7 + NO_USER, 8 + PlayerStateData, 9 + REPORTED_PLAYER_STATUSES, 10 + ReportedPlayerStatus, 11 + SINGLE_USER_PLATFORM_ID, 12 + } from "../common/infrastructure/Atomic.js"; 13 + import { RecentlyPlayedOptions } from "./AbstractSource.js"; 14 + import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 15 + import { SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js"; 16 + import { SonosDevice, SonosManager } from '@svrooij/sonos'; 17 + import { SonosState } from "@svrooij/sonos/lib/models/sonos-state.js"; 18 + import { GroupTransportState } from "@svrooij/sonos/lib/models/transport-state.js"; 19 + import { Track } from "@svrooij/sonos/lib/models/track.js"; 20 + import { parseDurationFromTimestamp } from "../utils/TimeUtils.js"; 21 + import { FixedSizeList } from "fixed-size-list"; 22 + import { buildStatePlayerPlayIdententifyingInfo } from "../utils/StringUtils.js"; 23 + import { isDebugMode } from "../utils.js"; 24 + 25 + export interface UniquePlay { 26 + device: SonosDevice 27 + state: SonosState 28 + } 29 + 30 + GroupTransportState.GroupPlaying 31 + 32 + const CLIENT_PLAYER_STATE: Record<string, ReportedPlayerStatus> = { 33 + 'GROUP_PLAYING': REPORTED_PLAYER_STATUSES.playing, 34 + 'PLAYING': REPORTED_PLAYER_STATUSES.playing, 35 + 'TRANSITIONING': REPORTED_PLAYER_STATUSES.playing, 36 + 'GROUP_STOPPED': REPORTED_PLAYER_STATUSES.stopped, 37 + 'STOPPED': REPORTED_PLAYER_STATUSES.stopped, 38 + 'PAUSED_PLAYBACK': REPORTED_PLAYER_STATUSES.paused, 39 + } 40 + 41 + export class SonosSource extends MemoryPositionalSource { 42 + declare config: SonosSourceConfig; 43 + 44 + manager: SonosManager; 45 + mediaIdsSeen: FixedSizeList<string>; 46 + uniqueDropReasons: FixedSizeList<string>; 47 + logFilterFailure: false | 'debug' | 'warn'; 48 + 49 + constructor(name: any, config: SonosSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 50 + const { 51 + data, 52 + } = config; 53 + const { 54 + interval = 5, // reduced polling interval because its likely we are on the same network 55 + ...rest 56 + } = data || {}; 57 + super('sonos', name, { ...config, data: { ...rest, interval } }, internal, emitter); 58 + 59 + this.requiresAuth = false; 60 + this.canPoll = true; 61 + this.manager = new SonosManager(); 62 + this.mediaIdsSeen = new FixedSizeList<string>(100); 63 + this.uniqueDropReasons = new FixedSizeList<string>(100); 64 + } 65 + 66 + protected async doBuildInitData(): Promise<true | string | undefined> { 67 + const { 68 + options: { 69 + logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), 70 + } = {} 71 + } = this.config; 72 + if (logFilterFailure !== false && !['debug', 'warn'].includes(logFilterFailure)) { 73 + this.logger.warn(`logFilterFailure value of '${logFilterFailure.toString()}' is NOT VALID. Logging will not occur if filters fail. You should fix this.`); 74 + } else { 75 + this.logFilterFailure = logFilterFailure; 76 + } 77 + 78 + return true; 79 + } 80 + 81 + protected async doCheckConnection(): Promise<true | string | undefined> { 82 + try { 83 + await this.manager.InitializeFromDevice(this.config.data.host); 84 + return `Sonos network is available with ${this.manager.Devices.length} devices`; 85 + } catch (e) { 86 + throw e; 87 + } 88 + } 89 + 90 + public isValidState = (data: UniquePlay, play: PlayObject): string | undefined => { 91 + if (typeof data.state.positionInfo.TrackMetaData === 'string') { 92 + // ??? 93 + return; 94 + } 95 + if (!data.state.positionInfo.TrackMetaData.UpnpClass.toLocaleLowerCase().includes('musictrack')) { 96 + return `UpnpClass does include 'musictrack', found '${data.state.positionInfo.TrackMetaData.UpnpClass}'`; 97 + } 98 + return; 99 + } 100 + 101 + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 102 + 103 + // only need to get one device per group 104 + const uniqueDevices: Record<string, SonosDevice> = {} 105 + for (const d of this.manager.Devices) { 106 + if (uniqueDevices[d.GroupName] === undefined) { 107 + uniqueDevices[d.GroupName] = d; 108 + } 109 + } 110 + const uniquePlayers: UniquePlay[] = []; 111 + for (const [k, v] of Object.entries(uniqueDevices)) { 112 + const state = await v.GetState(); 113 + // unsure if devices in the same group can play different things if they are in different zones? 114 + // so double check some unique-ish data is not the same 115 + if (!uniquePlayers.some(x => x.state.mediaInfo.CurrentURI === state.mediaInfo.CurrentURI)) { 116 + uniquePlayers.push({ device: v, state }); 117 + } 118 + } 119 + 120 + const playerStates: PlayerStateData[] = []; 121 + for (const x of uniquePlayers) { 122 + const { 123 + Name, 124 + GroupName, 125 + Uuid 126 + } = x.device 127 + 128 + try { 129 + let status = CLIENT_PLAYER_STATE[x.state.transportState]; 130 + 131 + let seen = true; 132 + if (!this.mediaIdsSeen.data.includes(x.state.positionInfo.TrackURI)) { 133 + seen = false; 134 + this.mediaIdsSeen.add(x.state.positionInfo.TrackURI); 135 + if (this.config.options?.logPayload || isDebugMode()) { 136 + this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); 137 + } 138 + } 139 + 140 + const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; 141 + 142 + let play = status === REPORTED_PLAYER_STATUSES.stopped ? undefined : formatPlayObj(x.state, { device: x.device }); 143 + 144 + if (play.data.track === undefined && play.data.artists === undefined) { 145 + // likely sonos is paused and reporting an empty play 146 + play = undefined; 147 + status = REPORTED_PLAYER_STATUSES.stopped; 148 + } 149 + 150 + const position = play !== undefined ? play.meta.trackProgressPosition : undefined; 151 + 152 + const playerState: PlayerStateData = { 153 + platformId: [deviceId, NO_USER], 154 + status, 155 + play, 156 + position 157 + } 158 + 159 + if (play !== undefined) { 160 + const reason = this.isValidState(x, play); 161 + if(reason !== undefined) { 162 + const dropReason = `Player State for -> ${buildStatePlayerPlayIdententifyingInfo(playerState)} <-- is being dropped because ${reason}`; 163 + if (!this.uniqueDropReasons.data.some(x => x === dropReason)) { 164 + if(this.logFilterFailure !== false) { 165 + this.logger[this.logFilterFailure](dropReason); 166 + } 167 + this.uniqueDropReasons.add(dropReason); 168 + } 169 + continue; 170 + } 171 + } 172 + 173 + playerStates.push(playerState); 174 + } catch (e) { 175 + this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); 176 + throw new Error('Failed to parse Sonos data', { cause: e }); 177 + } 178 + } 179 + return await this.processRecentPlays(playerStates); 180 + } 181 + 182 + } 183 + 184 + 185 + export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions & { device?: SonosDevice } = {}): PlayObject => { 186 + 187 + const { 188 + Name, 189 + GroupName, 190 + Uuid 191 + } = options.device ?? {}; 192 + 193 + const { 194 + mediaInfo: { 195 + CurrentURIMetaData 196 + } = {}, 197 + positionInfo: { 198 + // 0:03:16 199 + TrackDuration, 200 + // 0:01:31 201 + RelTime, 202 + // 2147483647 203 + // always the same 204 + RelCount, 205 + TrackMetaData, 206 + // "x-sonos-vli:RINCON_48A6B8EF0F2E01400:2,spotify:9969e2e07a6c024b39964aa7e0378262" 207 + TrackURI 208 + } = {} 209 + } = obj; 210 + 211 + const metadatas: Track[] = []; 212 + 213 + if (typeof CurrentURIMetaData !== 'string') { 214 + metadatas.push(CurrentURIMetaData); 215 + } 216 + if (typeof TrackMetaData !== 'string') { 217 + metadatas.push(TrackMetaData); 218 + } 219 + 220 + let titleStr: string; 221 + 222 + if (metadatas.length === 0) { 223 + titleStr = TrackMetaData as string ?? CurrentURIMetaData as string; 224 + } 225 + 226 + const mergedMetadata: Track = Object.assign({}, ...metadatas); 227 + 228 + const { 229 + Album, 230 + AlbumArtUri, 231 + // only renders first artist 232 + Artist, 233 + // should be same as TrackDuration 234 + Duration, 235 + // CurrentURIMetadata => Spotify 236 + // TrackMetaData => (the actual title) 237 + Title = titleStr, 238 + // "x-sonos-vli:*:audio:*" 239 + ProtocolInfo, 240 + // "x-sonos-spotify:spotify:track:0yh5FKzDJfz3xkEOvnOfTm?sid=12&flags=0&sn=2" 241 + TrackUri, // may be able to parse track resource 242 + // TrackMetaData => object.item.audioItem.musicTrack (for spotify) 243 + // CurrentURIMetadata => object.item.audioItem.linein 244 + UpnpClass 245 + } = mergedMetadata; 246 + 247 + let dur: number; 248 + if (Duration !== undefined && Duration !== "NOT_IMPLEMENTED") { 249 + dur = parseDurationFromTimestamp(Duration).asSeconds(); 250 + } else if (TrackDuration !== undefined && TrackDuration !== "NOT_IMPLEMENTED") { 251 + dur = parseDurationFromTimestamp(TrackDuration).asSeconds(); 252 + } 253 + 254 + let progress: number; 255 + if (RelTime !== undefined && RelTime !== "NOT_IMPLEMENTED") { 256 + progress = parseDurationFromTimestamp(RelTime).asSeconds(); 257 + } 258 + 259 + if (titleStr === undefined && Title !== undefined && Title !== 'Spotify') { 260 + titleStr = Title; 261 + } 262 + 263 + return { 264 + data: { 265 + track: titleStr, 266 + album: Album, 267 + artists: Artist === undefined ? undefined : [Artist], 268 + duration: dur, 269 + }, 270 + meta: { 271 + user: NO_USER, 272 + deviceId: Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`, 273 + sessionId: Uuid, 274 + trackProgressPosition: progress, 275 + art: { 276 + album: AlbumArtUri 277 + } 278 + } 279 + } 280 + }
+29
src/backend/tests/sonos/sonos.test.ts
··· 1 + import { loggerTest, loggerDebug } from "@foxxmd/logging"; 2 + import { assert, expect } from 'chai'; 3 + import EventEmitter from "events"; 4 + import { describe, it } from 'mocha'; 5 + import { SonosData } from "../../common/infrastructure/config/source/sonos.js"; 6 + import { SonosSource } from "../../sources/SonosSource.js"; 7 + import * as dotenv from 'dotenv'; 8 + import path from 'path'; 9 + import { projectDir } from "../../common/index.js"; 10 + 11 + const envPath = path.join(projectDir, '.env'); 12 + dotenv.config({ path: envPath }); 13 + 14 + const createSource = async (data: SonosData): Promise<SonosSource> => { 15 + const source = new SonosSource('Test', { 16 + data, 17 + options: {} 18 + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerDebug, version: 'test' }, new EventEmitter()); 19 + await source.buildInitData(); 20 + return source; 21 + } 22 + 23 + // it('does stuff', async function() { 24 + 25 + // const s = await createSource({host: process.env.SONOS_HOST}); 26 + // await s.checkConnection(); 27 + // await s.getRecentlyPlayed(); 28 + // const f = 1; 29 + // });
+1
src/backend/utils/TimeUtils.ts
··· 1 + 1 2 import dayjs, { Dayjs } from "dayjs"; 2 3 import isToday from 'dayjs/plugin/isToday.js'; 3 4 import {
+2 -1
src/core/Atomic.ts
··· 32 32 | 'azuracast' 33 33 | 'koito' 34 34 | 'tealfm' 35 - | 'rocksky'; 35 + | 'rocksky' 36 + | 'sonos'; 36 37 display: string; 37 38 tracksDiscovered: number; 38 39 name: string;
+1 -1
src/core/StringUtils.ts
··· 36 36 } 37 37 export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input; 38 38 export const defaultReducer = (acc, curr) => `${acc} ${curr}`; 39 - export const defaultArtistFunc = (a: string[]) => a.join(' / '); 39 + export const defaultArtistFunc = (a: string[]) => a === undefined ? '' : a.join(' / '); 40 40 export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => { 41 41 if(input === undefined) { 42 42 return undefined;