diff --git a/config/sonos.json.example b/config/sonos.json.example new file mode 100644 index 00000000..2dd62eae --- /dev/null +++ b/config/sonos.json.example @@ -0,0 +1,9 @@ +[ + { + "enable": true, + "name": "MySonos", + "data": { + "host": "192.168.0.150" + } + } +] diff --git a/docsite/docs/configuration/sources/sonos.mdx b/docsite/docs/configuration/sources/sonos.mdx new file mode 100644 index 00000000..20e4b799 --- /dev/null +++ b/docsite/docs/configuration/sources/sonos.mdx @@ -0,0 +1,40 @@ +--- +title: Sonos +toc_min_heading_level: 2 +toc_max_heading_level: 5 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import JsonConfig from '!!raw-loader!@site/../config/sonos.json.example'; + +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. + +MS supports scrobbling simultaneous, different Plays based on what each Group/Zone is playing. + +:::note + +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. + +::: + +:::tip + +Activity reporting for **other [Sources](/configuration/sources)** does not work well **when played through Sonos.** + +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. + +An example of this for [Plex](/configuration/sources/plex) would be to add Sonos to the "block" list ENV EX: `PLEX_DEVICES_BLOCK=sonos` + +::: + +## Configuration + +You **must** define the IP address of at least one Sonos/accessory in order for Multi-scrobbler to connect to your Sonos system. + + + | Environmental Variable | Required? | Default | Description | + | ---------------------- | --------- | ------- | ---------------------------------------------------------------------- | + | `SONOS_HOST` | Yes | | The IP address of any active Sonos device/accessory EX `192.168.0.150` | + \ No newline at end of file diff --git a/docsite/docs/configuration/sources/sources.mdx b/docsite/docs/configuration/sources/sources.mdx index db7bb015..8e85655e 100644 --- a/docsite/docs/configuration/sources/sources.mdx +++ b/docsite/docs/configuration/sources/sources.mdx @@ -33,6 +33,7 @@ A **Source** is a data source that contains information about tracks you are pla | [Musikcube](/configuration/sources/musikcube) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | | [Plex](/configuration/sources/plex) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | | [Rocksky](/configuration/sources/rocksky-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | +| [Sonos](/configuration/sources/sonos) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | | [Spotify](/configuration/sources/spotify) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | | [Subsonic-compatible APIs](/configuration/sources/subsonic) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ | | [teal.fm](/configuration/sources/tealfm-source) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | diff --git a/docsite/docs/index.mdx b/docsite/docs/index.mdx index 35c24b94..5115a003 100644 --- a/docsite/docs/index.mdx +++ b/docsite/docs/index.mdx @@ -33,6 +33,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Musikcube](/configuration/sources/musikcube) * [Plex](/configuration/sources/plex) * [Rocksky](/configuration/sources/rocksky-source) + * [Sonos](/configuration/sources/sonos) * [Spotify](/configuration/sources/spotify) * [Subsonic-compatible APIs](/configuration/sources/subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/)) * [teal.fm](/configuration/sources/tealfm-source) diff --git a/package-lock.json b/package-lock.json index 398b82c1..df7d9849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", "@supercharge/promise-pool": "^3.0.0", + "@svrooij/sonos": "^2.5.0", "@tailwindcss/vite": "^4.1.16", "@types/express-serve-static-core": "^4.19.6", "address": "^1.2.2", @@ -3015,6 +3016,43 @@ "node": ">=8" } }, + "node_modules/@svrooij/sonos": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz", + "integrity": "sha512-QcRJRo9aILj5/6ikebQsGZL9kqGP9Er6XyWJG5akHI0XIALMk/kLyXVrAnYZ2jef3Hj2GCCHCqCNkbAIqCZ53Q==", + "license": "MIT", + "dependencies": { + "debug": "4.3.1", + "fast-xml-parser": "3.19.0", + "guid-typescript": "^1.0.9", + "html-entities": "^2.3.2", + "node-fetch": "^2.6.1", + "typed-emitter": "^1.3.1" + } + }, + "node_modules/@svrooij/sonos/node_modules/debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@svrooij/sonos/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -6288,6 +6326,19 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" }, + "node_modules/fast-xml-parser": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz", + "integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg==", + "license": "MIT", + "bin": { + "xml2js": "cli.js" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -6969,6 +7020,12 @@ "node": ">=14.0.0" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -7108,6 +7165,22 @@ "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", "license": "MIT" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12328,6 +12401,12 @@ "node": ">= 0.6" } }, + "node_modules/typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/package.json b/package.json index 4b373f41..c48ade46 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", "@supercharge/promise-pool": "^3.0.0", + "@svrooij/sonos": "^2.5.0", "@tailwindcss/vite": "^4.1.16", "@types/express-serve-static-core": "^4.19.6", "address": "^1.2.2", diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 411f6ab1..75c81fbc 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -34,7 +34,8 @@ export type SourceType = | 'azuracast' | 'koito' | 'tealfm' - | 'rocksky'; + | 'rocksky' + | 'sonos'; export const sourceTypes: SourceType[] = [ 'spotify', @@ -63,7 +64,8 @@ export const sourceTypes: SourceType[] = [ 'azuracast', 'koito', 'tealfm', - 'rocksky' + 'rocksky', + 'sonos' ]; export const sourceInterfaces = [ diff --git a/src/backend/common/infrastructure/config/source/sonos.ts b/src/backend/common/infrastructure/config/source/sonos.ts new file mode 100644 index 00000000..d76d6b52 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/sonos.ts @@ -0,0 +1,18 @@ +import { PollingOptions } from "../common.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface SonosData extends CommonSourceData, PollingOptions { + /** + * IP address of any connected Sonos speaker or device + * + * @examples ["192.168.0.170"] + * */ + host: string +} +export interface SonosSourceConfig extends CommonSourceConfig { + data: SonosData +} + +export interface SonosSourceAIOConfig extends SonosSourceConfig { + type: 'sonos' +} diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index ccc05d80..6a650fcf 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -25,6 +25,7 @@ import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js"; import { RockskySourceAIOConfig, RockskySourceConfig } from "./rocksky.js"; import { LibrefmSouceAIOConfig, LibrefmSourceConfig } from "./librefm.js"; +import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js"; export type SourceConfig = @@ -55,7 +56,8 @@ export type SourceConfig = | AzuracastSourceConfig | KoitoSourceConfig | TealSourceConfig - | RockskySourceConfig; + | RockskySourceConfig + | SonosSourceConfig; export type SourceAIOConfig = SpotifySourceAIOConfig @@ -85,4 +87,5 @@ export type SourceAIOConfig = | AzuracastSourceAIOConfig | KoitoSourceAIOConfig | TealSourceAIOConfig - | RockskySourceAIOConfig; + | RockskySourceAIOConfig + | SonosSourceAIOConfig; diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 0b0bbf39..6e4ccd59 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -30,6 +30,7 @@ import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/con import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; import { YTMusicData, YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; +import { SonosData, SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js"; import { WildcardEmitter } from "../common/WildcardEmitter.js"; import { parseBool } from "../utils.js"; import { readJson } from '../utils/DataUtils.js'; @@ -70,6 +71,7 @@ import { CommonSourceOptions } from '../common/infrastructure/config/source/inde import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js'; //import LibrefmSource from './LibrefmSource.js'; import { LibrefmSourceConfig } from '../common/infrastructure/config/source/librefm.js'; +import { SonosSource } from './SonosSource.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -184,6 +186,8 @@ export default class ScrobbleSources { return "TealSourceConfig"; case 'rocksky': return "RockskySourceConfig"; + case 'sonos': + return 'SonosSourceConfig'; } } @@ -790,7 +794,23 @@ export default class ScrobbleSources { options: transformPresetEnv('AZURA') }); } - break; + break; + case 'sonos': { + const sonos = { + host: process.env.SONOS_HOST, + } + if (!Object.values(sonos).every(x => x === undefined)) { + configs.push({ + type: 'sonos', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: sonos as unknown as SonosData, + options: transformPresetEnv('SONOS') + }); + } + } break; default: break; } @@ -1020,6 +1040,9 @@ export default class ScrobbleSources { const RockskySource = (await import('./RockskySource.js')).default; newSource = await new RockskySource(name, compositeConfig as RockskySourceConfig, this.internalConfig, this.emitter); break; + case 'sonos': + newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter); + break; default: break; } diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts new file mode 100644 index 00000000..1fcf89c5 --- /dev/null +++ b/src/backend/sources/SonosSource.ts @@ -0,0 +1,280 @@ +import { EventEmitter } from "events"; +import { PlayObject } from "../../core/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + NO_DEVICE, + NO_USER, + PlayerStateData, + REPORTED_PLAYER_STATUSES, + ReportedPlayerStatus, + SINGLE_USER_PLATFORM_ID, +} from "../common/infrastructure/Atomic.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js"; +import { SonosDevice, SonosManager } from '@svrooij/sonos'; +import { SonosState } from "@svrooij/sonos/lib/models/sonos-state.js"; +import { GroupTransportState } from "@svrooij/sonos/lib/models/transport-state.js"; +import { Track } from "@svrooij/sonos/lib/models/track.js"; +import { parseDurationFromTimestamp } from "../utils/TimeUtils.js"; +import { FixedSizeList } from "fixed-size-list"; +import { buildStatePlayerPlayIdententifyingInfo } from "../utils/StringUtils.js"; +import { isDebugMode } from "../utils.js"; + +export interface UniquePlay { + device: SonosDevice + state: SonosState +} + +GroupTransportState.GroupPlaying + +const CLIENT_PLAYER_STATE: Record = { + 'GROUP_PLAYING': REPORTED_PLAYER_STATUSES.playing, + 'PLAYING': REPORTED_PLAYER_STATUSES.playing, + 'TRANSITIONING': REPORTED_PLAYER_STATUSES.playing, + 'GROUP_STOPPED': REPORTED_PLAYER_STATUSES.stopped, + 'STOPPED': REPORTED_PLAYER_STATUSES.stopped, + 'PAUSED_PLAYBACK': REPORTED_PLAYER_STATUSES.paused, +} + +export class SonosSource extends MemoryPositionalSource { + declare config: SonosSourceConfig; + + manager: SonosManager; + mediaIdsSeen: FixedSizeList; + uniqueDropReasons: FixedSizeList; + logFilterFailure: false | 'debug' | 'warn'; + + constructor(name: any, config: SonosSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data, + } = config; + const { + interval = 5, // reduced polling interval because its likely we are on the same network + ...rest + } = data || {}; + super('sonos', name, { ...config, data: { ...rest, interval } }, internal, emitter); + + this.requiresAuth = false; + this.canPoll = true; + this.manager = new SonosManager(); + this.mediaIdsSeen = new FixedSizeList(100); + this.uniqueDropReasons = new FixedSizeList(100); + } + + protected async doBuildInitData(): Promise { + const { + options: { + logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), + } = {} + } = this.config; + if (logFilterFailure !== false && !['debug', 'warn'].includes(logFilterFailure)) { + this.logger.warn(`logFilterFailure value of '${logFilterFailure.toString()}' is NOT VALID. Logging will not occur if filters fail. You should fix this.`); + } else { + this.logFilterFailure = logFilterFailure; + } + + return true; + } + + protected async doCheckConnection(): Promise { + try { + await this.manager.InitializeFromDevice(this.config.data.host); + return `Sonos network is available with ${this.manager.Devices.length} devices`; + } catch (e) { + throw e; + } + } + + public isValidState = (data: UniquePlay, play: PlayObject): string | undefined => { + if (typeof data.state.positionInfo.TrackMetaData === 'string') { + // ??? + return; + } + if (!data.state.positionInfo.TrackMetaData.UpnpClass.toLocaleLowerCase().includes('musictrack')) { + return `UpnpClass does include 'musictrack', found '${data.state.positionInfo.TrackMetaData.UpnpClass}'`; + } + return; + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + + // only need to get one device per group + const uniqueDevices: Record = {} + for (const d of this.manager.Devices) { + if (uniqueDevices[d.GroupName] === undefined) { + uniqueDevices[d.GroupName] = d; + } + } + const uniquePlayers: UniquePlay[] = []; + for (const [k, v] of Object.entries(uniqueDevices)) { + const state = await v.GetState(); + // unsure if devices in the same group can play different things if they are in different zones? + // so double check some unique-ish data is not the same + if (!uniquePlayers.some(x => x.state.mediaInfo.CurrentURI === state.mediaInfo.CurrentURI)) { + uniquePlayers.push({ device: v, state }); + } + } + + const playerStates: PlayerStateData[] = []; + for (const x of uniquePlayers) { + const { + Name, + GroupName, + Uuid + } = x.device + + try { + let status = CLIENT_PLAYER_STATE[x.state.transportState]; + + let seen = true; + if (!this.mediaIdsSeen.data.includes(x.state.positionInfo.TrackURI)) { + seen = false; + this.mediaIdsSeen.add(x.state.positionInfo.TrackURI); + if (this.config.options?.logPayload || isDebugMode()) { + this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); + } + } + + const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; + + let play = status === REPORTED_PLAYER_STATUSES.stopped ? undefined : formatPlayObj(x.state, { device: x.device }); + + if (play.data.track === undefined && play.data.artists === undefined) { + // likely sonos is paused and reporting an empty play + play = undefined; + status = REPORTED_PLAYER_STATUSES.stopped; + } + + const position = play !== undefined ? play.meta.trackProgressPosition : undefined; + + const playerState: PlayerStateData = { + platformId: [deviceId, NO_USER], + status, + play, + position + } + + if (play !== undefined) { + const reason = this.isValidState(x, play); + if(reason !== undefined) { + const dropReason = `Player State for -> ${buildStatePlayerPlayIdententifyingInfo(playerState)} <-- is being dropped because ${reason}`; + if (!this.uniqueDropReasons.data.some(x => x === dropReason)) { + if(this.logFilterFailure !== false) { + this.logger[this.logFilterFailure](dropReason); + } + this.uniqueDropReasons.add(dropReason); + } + continue; + } + } + + playerStates.push(playerState); + } catch (e) { + this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); + throw new Error('Failed to parse Sonos data', { cause: e }); + } + } + return await this.processRecentPlays(playerStates); + } + +} + + +export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions & { device?: SonosDevice } = {}): PlayObject => { + + const { + Name, + GroupName, + Uuid + } = options.device ?? {}; + + const { + mediaInfo: { + CurrentURIMetaData + } = {}, + positionInfo: { + // 0:03:16 + TrackDuration, + // 0:01:31 + RelTime, + // 2147483647 + // always the same + RelCount, + TrackMetaData, + // "x-sonos-vli:RINCON_48A6B8EF0F2E01400:2,spotify:9969e2e07a6c024b39964aa7e0378262" + TrackURI + } = {} + } = obj; + + const metadatas: Track[] = []; + + if (typeof CurrentURIMetaData !== 'string') { + metadatas.push(CurrentURIMetaData); + } + if (typeof TrackMetaData !== 'string') { + metadatas.push(TrackMetaData); + } + + let titleStr: string; + + if (metadatas.length === 0) { + titleStr = TrackMetaData as string ?? CurrentURIMetaData as string; + } + + const mergedMetadata: Track = Object.assign({}, ...metadatas); + + const { + Album, + AlbumArtUri, + // only renders first artist + Artist, + // should be same as TrackDuration + Duration, + // CurrentURIMetadata => Spotify + // TrackMetaData => (the actual title) + Title = titleStr, + // "x-sonos-vli:*:audio:*" + ProtocolInfo, + // "x-sonos-spotify:spotify:track:0yh5FKzDJfz3xkEOvnOfTm?sid=12&flags=0&sn=2" + TrackUri, // may be able to parse track resource + // TrackMetaData => object.item.audioItem.musicTrack (for spotify) + // CurrentURIMetadata => object.item.audioItem.linein + UpnpClass + } = mergedMetadata; + + let dur: number; + if (Duration !== undefined && Duration !== "NOT_IMPLEMENTED") { + dur = parseDurationFromTimestamp(Duration).asSeconds(); + } else if (TrackDuration !== undefined && TrackDuration !== "NOT_IMPLEMENTED") { + dur = parseDurationFromTimestamp(TrackDuration).asSeconds(); + } + + let progress: number; + if (RelTime !== undefined && RelTime !== "NOT_IMPLEMENTED") { + progress = parseDurationFromTimestamp(RelTime).asSeconds(); + } + + if (titleStr === undefined && Title !== undefined && Title !== 'Spotify') { + titleStr = Title; + } + + return { + data: { + track: titleStr, + album: Album, + artists: Artist === undefined ? undefined : [Artist], + duration: dur, + }, + meta: { + user: NO_USER, + deviceId: Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`, + sessionId: Uuid, + trackProgressPosition: progress, + art: { + album: AlbumArtUri + } + } + } +} \ No newline at end of file diff --git a/src/backend/tests/sonos/sonos.test.ts b/src/backend/tests/sonos/sonos.test.ts new file mode 100644 index 00000000..c054daae --- /dev/null +++ b/src/backend/tests/sonos/sonos.test.ts @@ -0,0 +1,29 @@ +import { loggerTest, loggerDebug } from "@foxxmd/logging"; +import { assert, expect } from 'chai'; +import EventEmitter from "events"; +import { describe, it } from 'mocha'; +import { SonosData } from "../../common/infrastructure/config/source/sonos.js"; +import { SonosSource } from "../../sources/SonosSource.js"; +import * as dotenv from 'dotenv'; +import path from 'path'; +import { projectDir } from "../../common/index.js"; + +const envPath = path.join(projectDir, '.env'); +dotenv.config({ path: envPath }); + +const createSource = async (data: SonosData): Promise => { + const source = new SonosSource('Test', { + data, + options: {} + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerDebug, version: 'test' }, new EventEmitter()); + await source.buildInitData(); + return source; +} + +// it('does stuff', async function() { + +// const s = await createSource({host: process.env.SONOS_HOST}); +// await s.checkConnection(); +// await s.getRecentlyPlayed(); +// const f = 1; +// }); \ No newline at end of file diff --git a/src/backend/utils/TimeUtils.ts b/src/backend/utils/TimeUtils.ts index a270e775..66a3cd9c 100644 --- a/src/backend/utils/TimeUtils.ts +++ b/src/backend/utils/TimeUtils.ts @@ -1,3 +1,4 @@ + import dayjs, { Dayjs } from "dayjs"; import isToday from 'dayjs/plugin/isToday.js'; import { diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 1415c660..687b9740 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -32,7 +32,8 @@ export interface SourceStatusData { | 'azuracast' | 'koito' | 'tealfm' - | 'rocksky'; + | 'rocksky' + | 'sonos'; display: string; tracksDiscovered: number; name: string; diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index 93486b89..f23bb95b 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -36,7 +36,7 @@ export const truncateStringToLength = (length: any, truncStr = '...') => (val: a } export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input; export const defaultReducer = (acc, curr) => `${acc} ${curr}`; -export const defaultArtistFunc = (a: string[]) => a.join(' / '); +export const defaultArtistFunc = (a: string[]) => a === undefined ? '' : a.join(' / '); export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => { if(input === undefined) { return undefined; -- 2.51.2 From 54eabf49f036ecd00fd7c5fde618685888706118 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 13 Jan 2026 14:53:51 +0000 Subject: [PATCH 2/6] feat(sonos): Fix first media seen logging and add debug guidance to docs add env gate to sonos test playground --- docsite/docs/configuration/sources/sonos.mdx | 54 +++++++++++++++++++- src/backend/sources/SonosSource.ts | 8 ++- src/backend/tests/sonos/sonos.test.ts | 22 +++++--- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/docsite/docs/configuration/sources/sonos.mdx b/docsite/docs/configuration/sources/sonos.mdx index 20e4b799..2f48a63b 100644 --- a/docsite/docs/configuration/sources/sonos.mdx +++ b/docsite/docs/configuration/sources/sonos.mdx @@ -25,10 +25,62 @@ Activity reporting for **other [Sources](/configuration/sources)** does not work 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. -An example of this for [Plex](/configuration/sources/plex) would be to add Sonos to the "block" list ENV EX: `PLEX_DEVICES_BLOCK=sonos` +For example, to prevent the [Plex](/configuration/sources/plex) Source from monitoring audio played through Sonos, add Sonos to the "block" list ENV EX: `PLEX_DEVICES_BLOCK=sonos` ::: +
+ +How to report issues + +If your Sonos Source is logging errors or the scrobble data is not as expected please [create a bug report](https://github.com/FoxxMD/multi-scrobbler/issues/new?template=01-bug-report.yml) **after** enabling the following debug logging options based on the config type you have: + + + + +Enable [Debug Mode](/configuration#debug-mode) using `DEBUG_MODE=true` + + + + +Enable `logPayload` and `logFitlerFailure` in your `sonos.json`, or AIO file, config: + +```json +[ + { + "enable": true, + "name": "MySonos", + "data": { + "host": "192.168.0.150" + }, + "options": { + "logFilterFailure": "warn", + "logPayload": true + } + } +] +``` + + + + + +Additional logging for the Sonos library may be enabled by including this environmental variable (regardless of config type): + +``` +DEBUG=sonos:* +``` + +The above changes: + +* Triggers multi-scrobbler to log the raw data recieved from Sonos when a new track/device/service is seen for the first time +* Trigger multi-scrobbler to log the reason it considers a play from Sonos as invalid (and will not monitor it) +* Logs low-level activity from the Sonos library + +Please include these additional logs when creating a bug report. They are essential to troubleshooting your issue. + +
+ ## Configuration You **must** define the IP address of at least one Sonos/accessory in order for Multi-scrobbler to connect to your Sonos system. diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 1fcf89c5..87354b74 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -129,9 +129,13 @@ export class SonosSource extends MemoryPositionalSource { let status = CLIENT_PLAYER_STATE[x.state.transportState]; let seen = true; - if (!this.mediaIdsSeen.data.includes(x.state.positionInfo.TrackURI)) { + + // TrackURI seems to correspond to 1) the device/group playing and 2) the service/source playing + // but NOT the content actually playing -- 2) does not update when content playing changes on the same service + const mediaId = `${x.state.positionInfo.TrackURI}--${typeof x.state.positionInfo.TrackMetaData !== 'string' ? x.state.positionInfo.TrackMetaData.TrackUri : x.state.positionInfo.TrackMetaData}`; + if (!this.mediaIdsSeen.data.includes(mediaId)) { seen = false; - this.mediaIdsSeen.add(x.state.positionInfo.TrackURI); + this.mediaIdsSeen.add(mediaId); if (this.config.options?.logPayload || isDebugMode()) { this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); } diff --git a/src/backend/tests/sonos/sonos.test.ts b/src/backend/tests/sonos/sonos.test.ts index c054daae..4fa5e5e3 100644 --- a/src/backend/tests/sonos/sonos.test.ts +++ b/src/backend/tests/sonos/sonos.test.ts @@ -20,10 +20,20 @@ const createSource = async (data: SonosData): Promise => { return source; } -// it('does stuff', async function() { +describe('#Sonos', function() { -// const s = await createSource({host: process.env.SONOS_HOST}); -// await s.checkConnection(); -// await s.getRecentlyPlayed(); -// const f = 1; -// }); \ No newline at end of file + before(function () { + if (process.env.SONOS_TEST !== 'true') { + this.skip(); + } + }); + + it('does stuff', async function() { + + const s = await createSource({host: process.env.SONOS_HOST_TEST}); + await s.checkConnection(); + await s.getRecentlyPlayed(); + const f = 1; + }); + +}); \ No newline at end of file -- 2.51.2 From 49529af813cab4ff71cec196403924c2b85bb304 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 13 Jan 2026 15:52:43 +0000 Subject: [PATCH 3/6] feat(sonos): Add group/device filtering --- config/sonos.json.example | 6 +++- docsite/docs/configuration/sources/sonos.mdx | 10 +++++- .../infrastructure/config/source/sonos.ts | 18 +++++++++++ src/backend/sources/ScrobbleSources.ts | 4 +++ src/backend/sources/SonosSource.ts | 31 ++++++++++++++++++- 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/config/sonos.json.example b/config/sonos.json.example index 2dd62eae..1eab0cc5 100644 --- a/config/sonos.json.example +++ b/config/sonos.json.example @@ -3,7 +3,11 @@ "enable": true, "name": "MySonos", "data": { - "host": "192.168.0.150" + "host": "192.168.0.150", + "devicesAllow": ["move","beam"], + "devicesBlock": ["roam"], + "groupsAllow": ["family"], + "groupsBlock": ["bedroom"] } } ] diff --git a/docsite/docs/configuration/sources/sonos.mdx b/docsite/docs/configuration/sources/sonos.mdx index 2f48a63b..81d96d5e 100644 --- a/docsite/docs/configuration/sources/sonos.mdx +++ b/docsite/docs/configuration/sources/sonos.mdx @@ -81,12 +81,20 @@ Please include these additional logs when creating a bug report. They are essent +### Filtering Activity + +The **optional** `devices` and `groups` related properties found in the config below can be used to include or exclude Sonos devices/groups by name, case-insensitive. + ## Configuration You **must** define the IP address of at least one Sonos/accessory in order for Multi-scrobbler to connect to your Sonos system. | Environmental Variable | Required? | Default | Description | - | ---------------------- | --------- | ------- | ---------------------------------------------------------------------- | + | ---------------------- | --------- | ------- | :--------------------------------------------------------------------- | | `SONOS_HOST` | Yes | | The IP address of any active Sonos device/accessory EX `192.168.0.150` | + | `SONOS_DEVICES_ALLOW` | No | | Comma-separated list of Sonos device names to scrobble from | + | `SONOS_DEVICES_BLOCK` | No | | Comma-separated list of Sonos device names to disallow scrobbles from | + | `SONOS_GROUPS_ALLOW` | No | | Comma-separated list of device group names to scrobble from | + | `SONOS_GROUPS_BLOCK` | No | | Comma-separated list of devices group names to disallow scrobbles from | \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/sonos.ts b/src/backend/common/infrastructure/config/source/sonos.ts index d76d6b52..c2a5575c 100644 --- a/src/backend/common/infrastructure/config/source/sonos.ts +++ b/src/backend/common/infrastructure/config/source/sonos.ts @@ -8,6 +8,24 @@ export interface SonosData extends CommonSourceData, PollingOptions { * @examples ["192.168.0.170"] * */ host: string + + /** + * Only scrobble if device name contains strings from this list (case-insensitive) + * */ + devicesAllow?: string | string[] + /** + * Do not scrobble if device name contains strings from this list (case-insensitive) + * */ + devicesBlock?: string | string[] + + /** + * Only scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive) + * */ + groupsAllow?: string | string[] + /** + * Do not scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive) + * */ + groupsBlock?: string | string[] } export interface SonosSourceConfig extends CommonSourceConfig { data: SonosData diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 6e4ccd59..abfca60a 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -798,6 +798,10 @@ export default class ScrobbleSources { case 'sonos': { const sonos = { host: process.env.SONOS_HOST, + devicesAllow: process.env.SONOS_DEVICES_ALLOW, + devicesBlock: process.env.SONOS_DEVICES_BLOCK, + groupsAllow: process.env.SONOS_GROUPS_ALLOW, + groupsBlocks: process.env.SONOS_GROUPS_BLOCK } if (!Object.values(sonos).every(x => x === undefined)) { configs.push({ diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 87354b74..9fec8ab2 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -19,7 +19,7 @@ import { GroupTransportState } from "@svrooij/sonos/lib/models/transport-state.j import { Track } from "@svrooij/sonos/lib/models/track.js"; import { parseDurationFromTimestamp } from "../utils/TimeUtils.js"; import { FixedSizeList } from "fixed-size-list"; -import { buildStatePlayerPlayIdententifyingInfo } from "../utils/StringUtils.js"; +import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { isDebugMode } from "../utils.js"; export interface UniquePlay { @@ -46,6 +46,11 @@ export class SonosSource extends MemoryPositionalSource { uniqueDropReasons: FixedSizeList; logFilterFailure: false | 'debug' | 'warn'; + devicesAllow: string[] = []; + devicesBlock: string[] = []; + groupsAllow: string[] = []; + groupsBlock: string[] = []; + constructor(name: any, config: SonosSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data, @@ -67,6 +72,12 @@ export class SonosSource extends MemoryPositionalSource { const { options: { logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), + } = {}, + data: { + devicesAllow = [], + devicesBlock = [], + groupsAllow = [], + groupsBlock = [] } = {} } = this.config; if (logFilterFailure !== false && !['debug', 'warn'].includes(logFilterFailure)) { @@ -75,6 +86,11 @@ export class SonosSource extends MemoryPositionalSource { this.logFilterFailure = logFilterFailure; } + this.devicesAllow = parseArrayFromMaybeString(devicesAllow, {lower: true}); + this.devicesBlock = parseArrayFromMaybeString(devicesBlock, {lower: true}); + this.groupsAllow = parseArrayFromMaybeString(groupsAllow, {lower: true}); + this.groupsBlock = parseArrayFromMaybeString(groupsBlock, {lower: true}); + return true; } @@ -95,6 +111,19 @@ export class SonosSource extends MemoryPositionalSource { if (!data.state.positionInfo.TrackMetaData.UpnpClass.toLocaleLowerCase().includes('musictrack')) { return `UpnpClass does include 'musictrack', found '${data.state.positionInfo.TrackMetaData.UpnpClass}'`; } + + if(this.devicesAllow.length > 0 && !this.devicesAllow.some(x => data.device.Name.toLocaleLowerCase().includes(x))) { + return `'devicesAllow does not include a phrase found in ${data.device.Name}`; + } + if(this.devicesBlock.length > 0 && this.devicesBlock.some(x => data.device.Name.toLocaleLowerCase().includes(x))) { + return `'devicesBlock includes a phrase found in ${data.device.Name}`; + } + if(this.groupsAllow.length > 0 && !this.groupsAllow.some(x => data.device.GroupName.toLocaleLowerCase().includes(x))) { + return `'groupsAllow does not include a phrase found in ${data.device.GroupName}`; + } + if(this.groupsBlock.length > 0 && this.groupsBlock.some(x => data.device.GroupName.toLocaleLowerCase().includes(x))) { + return `'groupsBlock includes a phrase found in ${data.device.GroupName}`; + } return; } -- 2.51.2 From 2ac9547f6a017b299c47e239235c540e5ade5ce4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 15 Jan 2026 15:04:10 +0000 Subject: [PATCH 4/6] 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 --- .../infrastructure/config/source/sonos.ts | 7 +- src/backend/sources/SonosSource.ts | 144 +++++++++++++----- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/src/backend/common/infrastructure/config/source/sonos.ts b/src/backend/common/infrastructure/config/source/sonos.ts index c2a5575c..40222680 100644 --- a/src/backend/common/infrastructure/config/source/sonos.ts +++ b/src/backend/common/infrastructure/config/source/sonos.ts @@ -1,5 +1,5 @@ import { PollingOptions } from "../common.js"; -import { CommonSourceConfig, CommonSourceData } from "./index.js"; +import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; export interface SonosData extends CommonSourceData, PollingOptions { /** @@ -27,8 +27,13 @@ export interface SonosData extends CommonSourceData, PollingOptions { * */ groupsBlock?: string | string[] } + +export interface SonosSourceOptions extends CommonSourceOptions { + logEmptyPlayer?: boolean +} export interface SonosSourceConfig extends CommonSourceConfig { data: SonosData + options?: SonosSourceOptions } export interface SonosSourceAIOConfig extends SonosSourceConfig { diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 9fec8ab2..71f648a0 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -19,14 +19,22 @@ import { GroupTransportState } from "@svrooij/sonos/lib/models/transport-state.j import { Track } from "@svrooij/sonos/lib/models/track.js"; import { parseDurationFromTimestamp } from "../utils/TimeUtils.js"; import { FixedSizeList } from "fixed-size-list"; -import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js"; -import { isDebugMode } from "../utils.js"; +import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js"; +import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; +import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; -export interface UniquePlay { +export interface DeviceState { device: SonosDevice state: SonosState } +export type SimpleDevice = Pick; + +export interface SimpleDeviceState { + device: SimpleDevice + state: SonosState +} + GroupTransportState.GroupPlaying const CLIENT_PLAYER_STATE: Record = { @@ -42,9 +50,10 @@ export class SonosSource extends MemoryPositionalSource { declare config: SonosSourceConfig; manager: SonosManager; - mediaIdsSeen: FixedSizeList; + deviceHashSeen: FixedSizeList; uniqueDropReasons: FixedSizeList; logFilterFailure: false | 'debug' | 'warn'; + logEmptyPlayer: boolean devicesAllow: string[] = []; devicesBlock: string[] = []; @@ -64,7 +73,7 @@ export class SonosSource extends MemoryPositionalSource { this.requiresAuth = false; this.canPoll = true; this.manager = new SonosManager(); - this.mediaIdsSeen = new FixedSizeList(100); + this.deviceHashSeen = new FixedSizeList(100); this.uniqueDropReasons = new FixedSizeList(100); } @@ -72,6 +81,7 @@ export class SonosSource extends MemoryPositionalSource { const { options: { logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), + logEmptyPlayer = isDebugMode() } = {}, data: { devicesAllow = [], @@ -85,6 +95,7 @@ export class SonosSource extends MemoryPositionalSource { } else { this.logFilterFailure = logFilterFailure; } + this.logEmptyPlayer = logEmptyPlayer; this.devicesAllow = parseArrayFromMaybeString(devicesAllow, {lower: true}); this.devicesBlock = parseArrayFromMaybeString(devicesBlock, {lower: true}); @@ -97,13 +108,22 @@ export class SonosSource extends MemoryPositionalSource { protected async doCheckConnection(): Promise { try { await this.manager.InitializeFromDevice(this.config.data.host); + const devicesSummary = []; + // something about .map isn't iterating all devices? + // see if this works instead + // for good measure advanced to next tick + await sleep(500); + for (const x of this.manager.Devices) { + devicesSummary.push(`Name: ${x.Name} | IP: ${x.Host} | Group: ${x.GroupName}`); + } + this.logger.debug(`Devices in Sonos network\n${devicesSummary.join('\n')}`); return `Sonos network is available with ${this.manager.Devices.length} devices`; } catch (e) { throw e; } } - public isValidState = (data: UniquePlay, play: PlayObject): string | undefined => { + public isValidState = (data: DeviceState, play: PlayObject): string | undefined => { if (typeof data.state.positionInfo.TrackMetaData === 'string') { // ??? return; @@ -129,68 +149,73 @@ export class SonosSource extends MemoryPositionalSource { getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { - // only need to get one device per group - const uniqueDevices: Record = {} + const playerStates: PlayerStateData[] = []; for (const d of this.manager.Devices) { - if (uniqueDevices[d.GroupName] === undefined) { - uniqueDevices[d.GroupName] = d; - } - } - const uniquePlayers: UniquePlay[] = []; - for (const [k, v] of Object.entries(uniqueDevices)) { - const state = await v.GetState(); - // unsure if devices in the same group can play different things if they are in different zones? - // so double check some unique-ish data is not the same - if (!uniquePlayers.some(x => x.state.mediaInfo.CurrentURI === state.mediaInfo.CurrentURI)) { - uniquePlayers.push({ device: v, state }); - } - } + const state = await d.GetState(); + const x = { + state, + device: d + }; - const playerStates: PlayerStateData[] = []; - for (const x of uniquePlayers) { const { Name, GroupName, - Uuid + Uuid, + Host } = x.device + const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; + try { let status = CLIENT_PLAYER_STATE[x.state.transportState]; + // TODO if status is stopped then drop state if player is also stopped? + let seen = true; - // TrackURI seems to correspond to 1) the device/group playing and 2) the service/source playing - // but NOT the content actually playing -- 2) does not update when content playing changes on the same service - const mediaId = `${x.state.positionInfo.TrackURI}--${typeof x.state.positionInfo.TrackMetaData !== 'string' ? x.state.positionInfo.TrackMetaData.TrackUri : x.state.positionInfo.TrackMetaData}`; - if (!this.mediaIdsSeen.data.includes(mediaId)) { + const { + positionInfo: { + TrackURI: posTrackURI, + TrackMetaData + } = {} + } = state; + + const invariantData = getInvariantDeviceData(x); + + const hash = hashObject(invariantData); + if (!this.deviceHashSeen.data.includes(hash)) { seen = false; - this.mediaIdsSeen.add(mediaId); + this.deviceHashSeen.add(hash); if (this.config.options?.logPayload || isDebugMode()) { - this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); + this.logger.debug({...invariantData}, 'Sonos Data'); } } - const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; - - let play = status === REPORTED_PLAYER_STATUSES.stopped ? undefined : formatPlayObj(x.state, { device: x.device }); + let play: PlayObject | undefined = status === REPORTED_PLAYER_STATUSES.stopped || posTrackURI === undefined ? undefined : formatPlayObj(x.state, { device: x.device }); + let playIsEmpty = false; - if (play.data.track === undefined && play.data.artists === undefined) { + if (play !== undefined && (play.data?.track === undefined && play.data?.artists === undefined)) { // likely sonos is paused and reporting an empty play - play = undefined; - status = REPORTED_PLAYER_STATUSES.stopped; + playIsEmpty = true; } const position = play !== undefined ? play.meta.trackProgressPosition : undefined; const playerState: PlayerStateData = { platformId: [deviceId, NO_USER], - status, - play, + status: playIsEmpty ? REPORTED_PLAYER_STATUSES.stopped : status, + play: playIsEmpty ? undefined : play, position } - if (play !== undefined) { - const reason = this.isValidState(x, play); + if (!playIsEmpty && play !== undefined) { + let reason = this.isValidState(x, play); + if(reason === undefined) { + const dup = playerStates.find(x => x.play !== undefined && playObjDataMatch(x.play, play)); + if(dup !== undefined) { + reason = 'Another player is playing the same track'; + } + } if(reason !== undefined) { const dropReason = `Player State for -> ${buildStatePlayerPlayIdententifyingInfo(playerState)} <-- is being dropped because ${reason}`; if (!this.uniqueDropReasons.data.some(x => x === dropReason)) { @@ -201,11 +226,14 @@ export class SonosSource extends MemoryPositionalSource { } continue; } + } else if(this.logEmptyPlayer) { + this.logger.debug(`Player State for -> ${deviceId} <-- is being dropped because it is empty`); + continue; } playerStates.push(playerState); } catch (e) { - this.logger.debug({ device: { Name, GroupName, Uuid }, state: x.state }, 'Sonos Data'); + this.logger.error({ device: { Name, GroupName, Uuid, Host }, state: x.state }, 'Sonos Data'); throw new Error('Failed to parse Sonos data', { cause: e }); } } @@ -307,7 +335,39 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions trackProgressPosition: progress, art: { album: AlbumArtUri - } + }, + source: 'Sonos' + } + } +} + +export const getInvariantDeviceData = (data: DeviceState): SimpleDeviceState => { + + const { device, state } = data; + + const { + positionInfo: { + RelTime, + ...restPos + } = {}, + ...restState + } = state; + + return { + device: { + Name: device.Name, + GroupName: device.GroupName, + Host: device.Host, + Uuid: device.Uuid + }, + state: { + ...restState, + // @ts-expect-error + positionInfo: { + ...restPos, + RelTime: '0', + }, + volume: 0 } } } \ No newline at end of file -- 2.51.2 From dea6d68228aa736d7263458fa76c275b704ebcc1 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 16 Jan 2026 15:14:36 +0000 Subject: [PATCH 5/6] feat(sonos): Better cleanup for for devices --- src/backend/sources/MemorySource.ts | 7 ++++++- src/backend/sources/SonosSource.ts | 22 ++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index f7b1d1ef..9db83603 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -29,6 +29,7 @@ import AbstractSource from "./AbstractSource.js"; import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js"; import { hashObject } from "../utils/StringUtils.js"; +import { useDebugValue } from "react"; const EXPECTED_NON_DISCOVERED_REASON = 'not added because an identical play with the same timestamp was already discovered.'; @@ -177,6 +178,10 @@ export default class MemorySource extends AbstractSource { return this.players.has(id); } + genPlayerId = (data: PlayObject | PlayerStateDataMaybePlay): string => { + return genGroupIdStr(getPlatformIdFromData(data)); + } + deletePlayer = (id: string, reason?: string) => { if(!this.players.has(id)) { return; @@ -209,7 +214,7 @@ export default class MemorySource extends AbstractSource { // create any new players from incoming data for (const data of datas) { const id = getPlatformIdFromData(data); - const idStr = genGroupIdStr(id); + const idStr = this.genPlayerId(data); if (!this.players.has(idStr)) { this.setNewPlayer(idStr, this.logger, id); diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 71f648a0..ddab9bdf 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -167,7 +167,7 @@ export class SonosSource extends MemoryPositionalSource { const deviceId = Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`; try { - let status = CLIENT_PLAYER_STATE[x.state.transportState]; + let status = CLIENT_PLAYER_STATE[x.state.transportState] ?? REPORTED_PLAYER_STATUSES.unknown; // TODO if status is stopped then drop state if player is also stopped? @@ -226,9 +226,23 @@ export class SonosSource extends MemoryPositionalSource { } continue; } - } else if(this.logEmptyPlayer) { - this.logger.debug(`Player State for -> ${deviceId} <-- is being dropped because it is empty`); - continue; + } else { + let allowOneNonProgress = false; + + const playerId = this.genPlayerId(playerState); + if(this.hasPlayer(playerId) && this.players.get(playerId).isProgressing()) { + // update player state with a stopped/paused/unknown reported state so that player scrobbles any existing play + allowOneNonProgress = true; + } + + if(!allowOneNonProgress) { + // if no player or status is not progressing then drop + // so sonos devices that aren't doing anything get stale/orphaned/pruned + if(this.logEmptyPlayer) { + this.logger.debug(`Player State for -> ${deviceId} <-- is being dropped because it is empty`); + } + continue; + } } playerStates.push(playerState); -- 2.51.2 From 5fba6436ab42dfc699b75949a5d9db6a65f7ec36 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 22 Jan 2026 13:51:54 +0000 Subject: [PATCH 6/6] refactor(sonos): Add missing sonos source dynamic usage --- src/backend/common/infrastructure/Atomic.ts | 3 ++- src/backend/sources/ScrobbleSources.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 75c81fbc..db8404de 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -96,7 +96,8 @@ export const sourceInterfaces = [ 'AzuracastSourceConfig', 'KoitoSourceConfig', 'TealSourceConfig', - 'RockskySourceConfig' + 'RockskySourceConfig', + 'SonosSourceConfig' ] export const isSourceType = (data: string): data is SourceType => { diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index abfca60a..37df9a82 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -71,7 +71,7 @@ import { CommonSourceOptions } from '../common/infrastructure/config/source/inde import { ExternalMetadataTerm, PlayTransformHooks, PlayTransformOptions } from '../common/infrastructure/Transform.js'; //import LibrefmSource from './LibrefmSource.js'; import { LibrefmSourceConfig } from '../common/infrastructure/config/source/librefm.js'; -import { SonosSource } from './SonosSource.js'; +//import { SonosSource } from './SonosSource.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -1045,6 +1045,7 @@ export default class ScrobbleSources { newSource = await new RockskySource(name, compositeConfig as RockskySourceConfig, this.internalConfig, this.emitter); break; case 'sonos': + const {SonosSource} = (await import('./SonosSource.js')); newSource = await new SonosSource(name, compositeConfig as SonosSourceConfig, this.internalConfig, this.emitter); break; default: