diff --git a/CHANGELOG.md b/CHANGELOG.md index 655f893..535d818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.3] - 2023-08-09 + +### Fixed + +- If shuffle is enabled for the accessory, ensure the first track played is randomized when the accessory is turned on. + ## [1.3.2] - 2023-08-08 ### Added @@ -42,7 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Change poblouin references to joeyhage since the old homebridge plugin is no longer maintained -[unreleased]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.2...HEAD +[unreleased]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.3...HEAD +[1.3.3]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.2...v1.3.3 [1.3.2]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.2.4...v1.3.0 diff --git a/src/platform.ts b/src/platform.ts index 6bf5441..cf7027e 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -161,7 +161,7 @@ export class HomebridgeSpotifySpeakerPlatform implements DynamicPlatformPlugin { } } - private extractPlaylistId(playlistUrl: string): string | null { + extractPlaylistId(playlistUrl: string): string | null { try { // Empty playlist ID is allowed for cases where one wants to only // play or pause one speaker started from an external source. diff --git a/src/spotify-api-wrapper.ts b/src/spotify-api-wrapper.ts index c9e8ba9..39dd53d 100644 --- a/src/spotify-api-wrapper.ts +++ b/src/spotify-api-wrapper.ts @@ -78,13 +78,16 @@ export class SpotifyApiWrapper { } } - async play(deviceId: string, contextUri: string) { + async play(deviceId: string, contextUri: string, startAtOffset = 0) { const options = { device_id: deviceId, context_uri: contextUri, + offset: { position: startAtOffset }, }; - this.logger.debug(`play called with deviceId=${deviceId}, contextUri=${contextUri}`); + this.logger.debug( + `play called with deviceId=${deviceId}, contextUri=${contextUri}. startAtOffset=${startAtOffset}`, + ); await this.wrappedRequest(() => this.spotifyApi.play(options)); } @@ -93,14 +96,23 @@ export class SpotifyApiWrapper { await this.wrappedRequest(() => this.spotifyApi.pause({ device_id: deviceId })); } + async getPlaylist(contextUri: string) { + this.logger.debug(`getPlaylist called with contextUri=${contextUri}`); + const playlistId = SpotifyApiWrapper.extractPlaylistId(contextUri); + if (playlistId) { + const res = await this.wrappedRequest(() => this.spotifyApi.getPlaylist(playlistId, { fields: 'tracks.total' })); + return res?.body?.tracks?.total ?? 0; + } + } + async getPlaybackState(): Promise { this.logger.debug('getPlaybackState called'); return this.wrappedRequest(() => this.spotifyApi.getMyCurrentPlaybackState()); } - async setShuffle(state: HomebridgeSpotifySpeakerDevice['playlistShuffle'], deviceId: string) { + async setShuffle(state: boolean, deviceId: string) { this.logger.debug(`setShuffle called with state=${state}, deviceId=${deviceId}`); - await this.wrappedRequest(() => this.spotifyApi.setShuffle(state ?? true, { device_id: deviceId })); + await this.wrappedRequest(() => this.spotifyApi.setShuffle(state, { device_id: deviceId })); } async setRepeat(state: HomebridgeSpotifySpeakerDevice['playlistRepeat'], deviceId: string) { @@ -232,6 +244,20 @@ export class SpotifyApiWrapper { this.logger.error('Unexpected error when making request to Spotify:', JSON.stringify(errorMessage)); } } + + static extractPlaylistId(playlistUrl: string): string | null { + try { + // Empty playlist ID is allowed for cases where one wants to only + // play or pause one speaker started from an external source. + if (!playlistUrl) { + return null; + } + + return new URL(playlistUrl).pathname.split('/')[2]; + } catch (error) { + return null; + } + } } const WebApiErrorTypes = ['WebapiError', 'WebapiRegularError', 'WebapiAuthenticationError', 'WebapiPlayerError']; diff --git a/src/spotify-speaker-accessory.it.ts b/src/spotify-speaker-accessory.it.ts new file mode 100644 index 0000000..74c0dde --- /dev/null +++ b/src/spotify-speaker-accessory.it.ts @@ -0,0 +1,82 @@ +import * as hapNodeJs from 'hap-nodejs'; +import { API, Characteristic, Logger, PlatformAccessory, PlatformConfig, Service } from 'homebridge'; +import { HomebridgeSpotifySpeakerPlatform } from './platform'; +import type { PluginLogger } from './plugin-logger'; +import { SpotifyApiWrapper } from './spotify-api-wrapper'; +import { SpotifySpeakerAccessory } from './spotify-speaker-accessory'; + +it('set repeat, shuffle, and play', async () => { + // given + const apiWrapper = getSpotifyApiWrapper(); + + await apiWrapper.authenticate(); + SpotifySpeakerAccessory.DEVICES = await apiWrapper.getMyDevices(); + + // when + const speaker = getSpotifySpeakerAccessory(apiWrapper); + const result = speaker.handleOnSet(true); + + // then + clearInterval(speaker.pollInterval); + await expect(result).resolves.toBeUndefined(); +}); + +function getService(): Service { + return { + updateCharacteristic: () => ({} as unknown as Service), + getCharacteristic: () => + ({ + onGet: () => + ({ + onSet: () => ({} as unknown as Characteristic), + } as unknown as Characteristic), + updateValue: () => ({} as unknown as Characteristic), + } as unknown as Characteristic), + } as unknown as Service; +} + +function getPlatformAccessory(): PlatformAccessory { + return { getService } as unknown as PlatformAccessory; +} + +function getPlatformConfig(): PlatformConfig { + return { + spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!, + spotifyClientId: process.env.SPOTIFY_CLIENT_ID!, + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!, + deviceNotFoundRetry: { enable: false }, + } as unknown as PlatformConfig; +} + +function getSpotifyApiWrapper(): SpotifyApiWrapper { + return new SpotifyApiWrapper(console as unknown as PluginLogger, getPlatformConfig(), { + user: { persistPath: () => '.' }, + } as API); +} + +function getSpotifySpeakerAccessory(apiWrapper: SpotifyApiWrapper): SpotifySpeakerAccessory { + const logger = console as unknown as PluginLogger; + return new SpotifySpeakerAccessory( + { + Characteristic: hapNodeJs.Characteristic, + Service: hapNodeJs.Service, + accessories: {}, + api: {}, + config: getPlatformConfig(), + log: console as Logger, + logger, + pollIntervalSec: 20, + spotifyApiWrapper: apiWrapper, + } as unknown as HomebridgeSpotifySpeakerPlatform, + getPlatformAccessory(), + { + deviceName: 'test', + deviceType: 'speaker', + spotifyDeviceName: process.env.SPOTIFY_DEVICE_NAME!, + spotifyPlaylistUrl: process.env.SPOTIFY_PLAYLIST_URL!, + playlistRepeat: true, + playlistShuffle: true, + }, + logger, + ); +} diff --git a/src/spotify-speaker-accessory.ts b/src/spotify-speaker-accessory.ts index 3802570..f42643a 100644 --- a/src/spotify-speaker-accessory.ts +++ b/src/spotify-speaker-accessory.ts @@ -8,6 +8,8 @@ export class SpotifySpeakerAccessory { private activeState: boolean; private currentVolume: number; + public readonly pollInterval: NodeJS.Timer; + public static CATEGORY = Categories.LIGHTBULB; public static DEVICES: SpotifyApi.UserDevice[] = []; public static CURRENT_STATE: SpotifyApi.CurrentPlaybackResponse | undefined = undefined; @@ -39,7 +41,7 @@ export class SpotifySpeakerAccessory { this.setInitialState(); - setInterval(async () => { + this.pollInterval = setInterval(async () => { const oldActiveState = this.activeState; const oldVolume = this.currentVolume; @@ -67,8 +69,14 @@ export class SpotifySpeakerAccessory { try { if (value) { - await this.platform.spotifyApiWrapper.play(this.device.spotifyDeviceId!, this.device.spotifyPlaylistUrl); - await this.platform.spotifyApiWrapper.setShuffle(this.device.playlistShuffle, this.device.spotifyDeviceId!); + const doShuffle = this.device.playlistShuffle ?? true; + const offset = await this.chooseRandomOffset(doShuffle); + await this.platform.spotifyApiWrapper.play( + this.device.spotifyDeviceId!, + this.device.spotifyPlaylistUrl, + offset, + ); + await this.platform.spotifyApiWrapper.setShuffle(doShuffle, this.device.spotifyDeviceId!); await this.platform.spotifyApiWrapper.setRepeat(!!this.device.playlistRepeat, this.device.spotifyDeviceId!); } else { await this.platform.spotifyApiWrapper.pause(this.device.spotifyDeviceId!); @@ -167,4 +175,13 @@ export class SpotifySpeakerAccessory { return false; } + + private async chooseRandomOffset(doShuffle: boolean) { + if (doShuffle) { + const trackCount = (await this.platform.spotifyApiWrapper.getPlaylist(this.device.spotifyPlaylistUrl)) ?? 0; + return Math.floor(Math.random() * trackCount); + } else { + return 0; + } + } }