diff --git a/CHANGELOG.md b/CHANGELOG.md index 5856217..d924a9e 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.1] - 2023-04-29 + +### Changed + +- A speaker accessory can now be added in the plugin settings using either the Spotify `id` or `name` of the device. + ## [1.3.0] - 2023-04-06 ### Added @@ -26,7 +32,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.0...HEAD +[unreleased]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.3.1...HEAD +[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 [1.2.4]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/v1.2.3...v1.2.4 [1.2.3]: https://github.com/joeyhage/homebridge-spotify-speaker/compare/1.2.2...v1.2.3 diff --git a/README.md b/README.md index 748dabe..33f8bcc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![npm version](https://img.shields.io/npm/v/homebridge-spotify-speaker)](https://www.npmjs.com/package/homebridge-spotify-speaker) [![npm downloads](https://img.shields.io/npm/dt/homebridge-spotify-speaker)](https://www.npmjs.com/package/homebridge-spotify-speaker) [![Build and Lint](https://github.com/joeyhage/homebridge-spotify-speaker/actions/workflows/build.yml/badge.svg)](https://github.com/joeyhage/homebridge-spotify-speaker/actions/workflows/build.yml) -Forked from poblouin/homebridge-spotify-speaker since it is no longer being maintained. +This is the new home of the official homebridge-spotify-speaker plugin. ## Please read before using and facing any deceptions @@ -82,15 +82,23 @@ With the previous steps, you will provide the code grant and the plugin will do - It will store them in a file named `.homebridge-spotify-speaker` in the homebridge's persist directory. Thus, when your homebridge server restarts, it can fetch back the tokens. - It will automatically refresh the access token when needed -## Finding a speaker device ID +## Finding a speaker device ID or name Once the spotify authentication flow is done, the plugin will display the list of available devices in your Homebridge logs. In Homebridge UI, keep an eye on the logs when the plugin restarts and you will see a message looking like the following: ![Example Device Log](assets/example-device.png) -You can then take the `id` from the Spotify device that you want to control and this is what you put in the plugin's configuration as the `spotifyDeviceId`. +### Suggested option -You can also use the [Spotify developer console](https://developer.spotify.com/console/get-users-available-devices/) to get the available devices on your account. +You can then take the `name` from the Spotify device that you want to control and this is what you put in the plugin's configuration as the `spotifyDeviceName`. + +This is the suggested option because the device id used by Spotify is prone to change. + +### Alternative option + +Alternatively, you can take the `id` from the Spotify device that you want to control and put in the plugin's configuration as the `spotifyDeviceId`. + +You can also use the [Spotify developer console](https://developer.spotify.com/documentation/web-api/reference/get-a-users-available-devices) to get the available devices on your account. ## Issues and Questions @@ -110,10 +118,10 @@ Common issues related to that though could be: ### Amazon Alexa device not responding -Some devices (notably Amazon Alexa devices) will show an `id` like `00000000-0000-0000-0000-000000000000_amzn_1`. However, in some cases, Spotify doesn't like the `_amzn_1` suffix. +Some devices (notably Amazon Alexa devices) will show an `id` like `00000000-0000-0000-0000-000000000000_amzn_1`. However, in some cases, Spotify doesn't like the `_amzn_1` suffix. Try switching to the `spotifyDeviceName` plugin configuration option instead of `spotifyDeviceId`. -To try it before changing the Homebridge plugin settings, test the [Start/Resume Playback API](https://developer.spotify.com/console/put-play/) in the Spotify developer console. Try setting the `device_id` to the `id` with and without the `_amzn_#` suffix. +If you prefer `spotifyDeviceId`, you can test it using the [Start/Resume Playback API](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback) in the Spotify developer console. Try setting the `device_id` to the `spotifyDeviceId` with and without the `_amzn_#` suffix. ## Contributors -Special thanks to [@poblouin](https://github.com/poblouin) who had the original idea for this plugin and did all the heavy lifting! See [poblouin/homebridge-spotify-speaker](https://github.com/poblouin/homebridge-spotify-speaker) for the original repository. +Special thanks to [@poblouin](https://github.com/poblouin) who had the original idea for this plugin and did all the heavy lifting! diff --git a/config.schema.json b/config.schema.json index d15d4ce..999ca92 100644 --- a/config.schema.json +++ b/config.schema.json @@ -63,9 +63,15 @@ }, "spotifyDeviceId": { "title": "Spotify Device ID", - "description": "The Spotify Device ID. You can find a list of available device in the plugin's logs when it starts or see the README", + "description": "The Spotify Device ID. Either spotifyDeviceId or spotifyDeviceName is required. You can find a list of available devices in the plugin's logs when it starts or see the README", "type": "string", - "required": true + "required": false + }, + "spotifyDeviceName": { + "title": "Spotify Device Name", + "description": "The Spotify Device Name. Either spotifyDeviceId or spotifyDeviceName is required. You can find a list of available devices in the plugin's logs when it starts or see the README", + "type": "string", + "required": false }, "spotifyPlaylistUrl": { "title": "Spotify Playlist URL", diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..363b5cd --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFiles: ['dotenv/config'], +}; diff --git a/package-lock.json b/package-lock.json index 495cfcd..2dc4ffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,9 +25,9 @@ "jest": "^29.5.0", "lint-staged": "^13.2.0", "nodemon": "^2.0.22", - "prettier": "^2.8.7", + "prettier": "^2.8.8", "rimraf": "^4.4.1", - "ts-jest": "^29.0.5", + "ts-jest": "^29.1.0", "ts-node": "^10.9.1", "typescript": "^4.9.5" }, @@ -5715,9 +5715,9 @@ } }, "node_modules/prettier": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", - "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -11321,9 +11321,9 @@ "dev": true }, "prettier": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", - "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, "pretty-format": { diff --git a/package.json b/package.json index b9abb3c..aa5ae74 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "pre-commit": "lint-staged", "prepare": "husky install", "prepublishOnly": "npm run lint && npm run format && npm run build", - "watch": "npm run build && npm link && nodemon" + "watch": "npm run build && npm link && nodemon", + "test": "jest --silent=false" }, "keywords": [ "homebridge-plugin", @@ -54,9 +55,9 @@ "jest": "^29.5.0", "lint-staged": "^13.2.0", "nodemon": "^2.0.22", - "prettier": "^2.8.7", + "prettier": "^2.8.8", "rimraf": "^4.4.1", - "ts-jest": "^29.0.5", + "ts-jest": "^29.1.0", "ts-node": "^10.9.1", "typescript": "^4.9.5" } diff --git a/src/platform.ts b/src/platform.ts index 3ed8e82..1493a10 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -10,7 +10,7 @@ import { import { URL } from 'url'; import { PLATFORM_NAME, PLUGIN_NAME } from './settings'; import { SpotifyApiWrapper } from './spotify-api-wrapper'; -import { SpotifySpeakerAccessory } from './spotify-speaker-accessory'; +import { HomebridgeSpotifySpeakerDevice, SpotifySpeakerAccessory } from './spotify-speaker-accessory'; const DEVICE_CLASS_CONFIG_MAP = { speaker: SpotifySpeakerAccessory, @@ -76,10 +76,20 @@ export class HomebridgeSpotifySpeakerPlatform implements DynamicPlatformPlugin { if (!deviceClass) { continue; } + if (!this.deviceConfigurationIsValid(device)) { + this.log.error( + `${ + device.deviceName ?? 'unknown device' + } is not configured correctly. See the documentation for initial setup`, + ); + continue; + } - const uuid = this.api.hap.uuid.generate(`${device.deviceName}-${device.spotifyDeviceId}`); - const existingAccessory = this.accessories[uuid]; const playlistId = this.extractPlaylistId(device.spotifyPlaylistUrl); + const uuid = this.api.hap.uuid.generate( + `${device.deviceName}-${device.spotifyDeviceId ?? device.spotifyDeviceName}`, + ); + const existingAccessory = this.accessories[uuid]; const accessory = existingAccessory ?? new this.api.platformAccessory(device.deviceName, uuid, deviceClass.CATEGORY); @@ -156,4 +166,8 @@ export class HomebridgeSpotifySpeakerPlatform implements DynamicPlatformPlugin { this.log.info('Available Spotify devices', spotifyDevices); } } + + private deviceConfigurationIsValid(device: HomebridgeSpotifySpeakerDevice) { + return device.spotifyDeviceId || device.spotifyDeviceName; + } } diff --git a/src/spotify-api-wrapper.spec.ts b/src/spotify-api-wrapper.spec.ts new file mode 100644 index 0000000..b113749 --- /dev/null +++ b/src/spotify-api-wrapper.spec.ts @@ -0,0 +1,62 @@ +import { API, Logger, PlatformConfig } from 'homebridge'; +import { SpotifyApiWrapper } from './spotify-api-wrapper'; + +it('should authenticate and persist tokens', async () => { + // given + const wrapper = new SpotifyApiWrapper( + console as Logger, + { + spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!, + spotifyClientId: process.env.SPOTIFY_CLIENT_ID!, + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!, + } as unknown as PlatformConfig, + { user: { persistPath: () => '.' } } as API, + ); + + // when + const result = await wrapper.authenticate(); + wrapper.persistTokens(); + + // then + expect(result).toBe(true); +}); + +it('should retrieve device list', async () => { + // given + const wrapper = new SpotifyApiWrapper( + console as Logger, + { + spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!, + spotifyClientId: process.env.SPOTIFY_CLIENT_ID!, + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!, + } as unknown as PlatformConfig, + { user: { persistPath: () => '.' } } as API, + ); + + // when + await wrapper.authenticate(); + const devices = await wrapper.getMyDevices(); + + // then + expect(devices?.length).toBeGreaterThan(0); +}); + +it('should retrieve playback state', async () => { + // given + const wrapper = new SpotifyApiWrapper( + console as Logger, + { + spotifyAuthCode: process.env.SPOTIFY_AUTH_CODE!, + spotifyClientId: process.env.SPOTIFY_CLIENT_ID!, + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET!, + } as unknown as PlatformConfig, + { user: { persistPath: () => '.' } } as API, + ); + + // when + await wrapper.authenticate(); + const state = await wrapper.getPlaybackState(); + + // then + expect(state?.statusCode).toEqual(200); +}); diff --git a/src/spotify-speaker-accessory.ts b/src/spotify-speaker-accessory.ts index baea254..3cef93e 100644 --- a/src/spotify-speaker-accessory.ts +++ b/src/spotify-speaker-accessory.ts @@ -5,7 +5,8 @@ import type { HomebridgeSpotifySpeakerPlatform } from './platform'; export interface HomebridgeSpotifySpeakerDevice { deviceName: string; deviceType: string; - spotifyDeviceId: string; + spotifyDeviceId?: string; + spotifyDeviceName?: string; spotifyPlaylistUrl: string; playlistRepeat?: boolean; playlistShuffle?: boolean; @@ -74,11 +75,11 @@ 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); - await this.platform.spotifyApiWrapper.setRepeat(!!this.device.playlistRepeat, this.device.spotifyDeviceId); + await this.platform.spotifyApiWrapper.play(this.device.spotifyDeviceId!, this.device.spotifyPlaylistUrl); + await this.platform.spotifyApiWrapper.setShuffle(this.device.playlistShuffle, this.device.spotifyDeviceId!); + await this.platform.spotifyApiWrapper.setRepeat(!!this.device.playlistRepeat, this.device.spotifyDeviceId!); } else { - await this.platform.spotifyApiWrapper.pause(this.device.spotifyDeviceId); + await this.platform.spotifyApiWrapper.pause(this.device.spotifyDeviceId!); } this.activeState = value; @@ -101,7 +102,7 @@ export class SpotifySpeakerAccessory { } try { - await this.platform.spotifyApiWrapper.setVolume(value, this.device.spotifyDeviceId); + await this.platform.spotifyApiWrapper.setVolume(value, this.device.spotifyDeviceId!); this.currentVolume = value; } catch (error) { if ((error as Error).name === 'SpotifyDeviceNotFoundError') { @@ -119,6 +120,17 @@ export class SpotifySpeakerAccessory { } private async setCurrentStates() { + if (this.device.spotifyDeviceName) { + const devices = await this.platform.spotifyApiWrapper.getMyDevices(); + const match = devices?.find((device) => device.name === this.device.spotifyDeviceName); + if (match?.id) { + this.device.spotifyDeviceId = match.id; + } else { + this.log.error( + `spotifyDeviceName '${this.device.spotifyDeviceName}' did not match any Spotify devices. spotifyDeviceName is case sensitive.`, + ); + } + } const state = await this.platform.spotifyApiWrapper.getPlaybackState(); const playingHref = state?.body?.context?.href; const playingDeviceId = state?.body?.device?.id;