From 16ad50ca4dfeb439bd523bf54220123c3a4cc0a8 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 12 Jan 2026 22:05:31 +0000 Subject: [PATCH] feat: Implement Sonos Source disable debug test Add mising type to source status data --- config/sonos.json.example | 9 + docsite/docs/configuration/sources/sonos.mdx | 40 +++ .../docs/configuration/sources/sources.mdx | 1 + docsite/docs/index.mdx | 1 + package-lock.json | 79 +++++ package.json | 1 + src/backend/common/infrastructure/Atomic.ts | 6 +- .../infrastructure/config/source/sonos.ts | 18 ++ .../infrastructure/config/source/sources.ts | 7 +- src/backend/sources/ScrobbleSources.ts | 25 +- src/backend/sources/SonosSource.ts | 280 ++++++++++++++++++ src/backend/tests/sonos/sonos.test.ts | 29 ++ src/backend/utils/TimeUtils.ts | 1 + src/core/Atomic.ts | 3 +- src/core/StringUtils.ts | 2 +- 15 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 config/sonos.json.example create mode 100644 docsite/docs/configuration/sources/sonos.mdx create mode 100644 src/backend/common/infrastructure/config/source/sonos.ts create mode 100644 src/backend/sources/SonosSource.ts create mode 100644 src/backend/tests/sonos/sonos.test.ts 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