From cec5350d2d9c4a5525e6ff686409adb8c89098e1 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sun, 8 Sep 2024 15:11:37 -0400 Subject: [PATCH] create spotify state controller, spotify responds 502 error --- server/controllers/groupController.ts | 65 +++++- server/docs/swagger.ts | 21 +- server/docs/swagger_output.json | 298 ++++++++++++++++++++++---- server/lib/spotify.ts | 6 +- server/middleware/authMiddleware.ts | 10 +- server/models/groupModel.ts | 4 + server/routes/groupRoutes.ts | 18 +- server/services/spotifyService.ts | 36 ++++ server/views/groupViews.ts | 60 ++++-- 9 files changed, 428 insertions(+), 90 deletions(-) diff --git a/server/controllers/groupController.ts b/server/controllers/groupController.ts index 78dc044..5f4d9a6 100644 --- a/server/controllers/groupController.ts +++ b/server/controllers/groupController.ts @@ -1,26 +1,81 @@ +import type { Model } from 'mongoose' import { Group, SpotifyAuth, type User } from 'server/models' import { SpotifyService } from 'server/services' import { NotFoundError } from 'server/utils' -export const assignSpotifyToGroup = async (user: User, groupId: string, spotifyEmail: string) => { +const getOrError = async >(id: string, model: T): Promise> => { + const query = await model.findById(id) + if (!query) { + throw new NotFoundError(`${model.name} with id ${id} not found.`) + } + + return query +} + +export const assignSpotifyToGroup = async ( + user: User, + groupId: string, + spotifyEmail: string +): Promise => { const auth = await SpotifyAuth.findOne({ userId: user._id.toString(), spotifyEmail }) if (!auth) throw new Error(`User ${user.email} is not connected to spotify account ${spotifyEmail}.`) - const group = await Group.findById(groupId) - if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) + const group = await getOrError(groupId, Group) await group.updateOne({ spotifyAuthId: auth._id }, { new: true }) return group } export const getGroupSpotify = async (groupId: string) => { - const group = await Group.findById(groupId) - if (!group) throw new NotFoundError(`Group with id ${groupId} not found.`) + const group = await getOrError(groupId, Group) const auth = await SpotifyAuth.findById(group.spotifyAuthId) if (!auth) throw new Error(`No linked Spotify accounts for group ${group.name}.`) return SpotifyService.connect(auth.spotifyEmail) } + +export const getGroupTrack = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getCurrentlyPlayingTrack() +} + +export const getGroupDevices = async (groupId: string) => { + const spotify = await getGroupSpotify(groupId) + return await spotify.sdk.player.getAvailableDevices() +} + +export const setGroupDefaultDevice = async (groupId: string, deviceId: string) => { + const group = await getOrError(groupId, Group) + group.defaultDeviceId = deviceId + await group.save() + + return group +} + +export const setGroupPlayerState = async ( + groupId: string, + state: 'play' | 'pause' | 'next' | 'previous' +) => { + const spotify = await getGroupSpotify(groupId) + const group = await getOrError(groupId, Group) + + switch (state) { + case 'play': + await spotify.sdk.player.startResumePlayback(group.defaultDeviceId ?? '') + break + case 'pause': + await spotify.sdk.player.pausePlayback(group.defaultDeviceId ?? '') + break + case 'next': + await spotify.sdk.player.skipToNext(group.defaultDeviceId ?? '') + break + case 'previous': + await spotify.sdk.player.skipToPrevious(group.defaultDeviceId ?? '') + break + default: + throw new Error(`Cannot set player state to ${state}.`) + } +} diff --git a/server/docs/swagger.ts b/server/docs/swagger.ts index edfcb87..98959b6 100644 --- a/server/docs/swagger.ts +++ b/server/docs/swagger.ts @@ -33,22 +33,15 @@ const doc = { description: 'Communicate with Spotify' } ], - components: { - securitySchemes: { - Bearer: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - in: 'header', - description: 'Token used to authenticate with network.' - } + components: {}, + securityDefinitions: { + Bearer: { + type: 'apiKey', + name: 'Authorization', + in: 'header', + description: 'The token for authentication into system.' } }, - security: [ - { - Bearer: [] - } - ], definitions: { Group: { name: '', ownerId: '' } as IGroup } diff --git a/server/docs/swagger_output.json b/server/docs/swagger_output.json index 2bafb00..6e28d6c 100644 --- a/server/docs/swagger_output.json +++ b/server/docs/swagger_output.json @@ -25,6 +25,14 @@ "http", "https" ], + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "The token for authentication into system." + } + }, "consumes": [ "application/json" ], @@ -113,7 +121,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/spotify/login-callback": { @@ -207,7 +220,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/register": { @@ -357,7 +375,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/reset-password": { @@ -408,7 +431,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/me": { @@ -442,7 +470,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/user/me/spotify-accounts": { @@ -476,7 +509,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/group/{id}/spotify": { @@ -530,7 +568,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, "/api/group/{id}/spotify/current-track": { @@ -572,15 +615,19 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/{id}/jam": { + "/api/group/{id}/spotify/state": { "post": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { @@ -588,6 +635,18 @@ "in": "path", "required": true, "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "state": { + "example": "any" + } + } + } } ], "responses": { @@ -615,13 +674,19 @@ }, "description": "Not implemented" } - } - }, - "delete": { + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/{id}/spotify/devices": { + "get": { "tags": [ "Group" ], - "summary": "Not implemented", "description": "", "parameters": [ { @@ -656,26 +721,88 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } }, - "/api/group/groups": { + "/api/group/{id}/spotify/default-device": { "post": { "tags": [ "Group" ], "description": "", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, { "name": "body", "in": "body", - "description": "New Group", - "required": true, "schema": { - "$ref": "#/definitions/Group" + "type": "object", + "properties": { + "deviceId": { + "example": "any" + } + } } } ], + "responses": { + "400": { + "schema": { + "$ref": "#/definitions/Error400" + }, + "description": "Bad request" + }, + "404": { + "schema": { + "$ref": "#/definitions/Error404" + }, + "description": "Not found" + }, + "500": { + "schema": { + "$ref": "#/definitions/Error500" + }, + "description": "Internal Server Error" + }, + "501": { + "schema": { + "$ref": "#/definitions/Error501" + }, + "description": "Not implemented" + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/group/{id}/jam": { + "post": { + "tags": [ + "Group" + ], + "summary": "Not implemented", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], "responses": { "400": { "schema": { @@ -703,11 +830,20 @@ } } }, - "get": { + "delete": { "tags": [ "Group" ], + "summary": "Not implemented", "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], "responses": { "400": { "schema": { @@ -736,6 +872,82 @@ } } }, + "/api/group/groups": { + "post": { + "tags": [ + "Group" + ], + "description": "", + "responses": { + "400": { + "schema": { + "$ref": "#/definitions/Error400" + }, + "description": "Bad request" + }, + "404": { + "schema": { + "$ref": "#/definitions/Error404" + }, + "description": "Not found" + }, + "500": { + "schema": { + "$ref": "#/definitions/Error500" + }, + "description": "Internal Server Error" + }, + "501": { + "schema": { + "$ref": "#/definitions/Error501" + }, + "description": "Not implemented" + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "Group" + ], + "description": "", + "responses": { + "400": { + "schema": { + "$ref": "#/definitions/Error400" + }, + "description": "Bad request" + }, + "404": { + "schema": { + "$ref": "#/definitions/Error404" + }, + "description": "Not found" + }, + "500": { + "schema": { + "$ref": "#/definitions/Error500" + }, + "description": "Internal Server Error" + }, + "501": { + "schema": { + "$ref": "#/definitions/Error501" + }, + "description": "Not implemented" + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/group/groups/{id}": { "get": { "tags": [ @@ -775,7 +987,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "put": { "tags": [ @@ -815,7 +1032,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "patch": { "tags": [ @@ -855,7 +1077,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "delete": { "tags": [ @@ -895,7 +1122,12 @@ }, "description": "Not implemented" } - } + }, + "security": [ + { + "Bearer": [] + } + ] } } }, @@ -1236,21 +1468,5 @@ } } } - }, - "components": { - "securitySchemes": { - "Bearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "in": "header", - "description": "Token used to authenticate with network." - } - } - }, - "security": [ - { - "Bearer": [] - } - ] + } } \ No newline at end of file diff --git a/server/lib/spotify.ts b/server/lib/spotify.ts index 1241416..9e13a50 100644 --- a/server/lib/spotify.ts +++ b/server/lib/spotify.ts @@ -2,6 +2,7 @@ * Resources * - Repo: https://github.com/spotify/spotify-web-api-ts-sdk/tree/main * - Authentication: https://developer.spotify.com/documentation/web-api/tutorials/code-flow + * - Scopes: https://developer.spotify.com/documentation/web-api/concepts/scopes */ import { SpotifyApi } from '@spotify/web-api-ts-sdk' import axios from 'axios' @@ -14,7 +15,10 @@ const SPOTIFY_SCOPES = [ 'playlist-modify-public', 'playlist-modify-private', 'user-read-playback-state', - 'user-modify-playback-state' + 'user-modify-playback-state', + 'user-read-currently-playing', + 'app-remote-control', + 'streaming' ] export type SpotifySdk = SpotifyApi diff --git a/server/middleware/authMiddleware.ts b/server/middleware/authMiddleware.ts index 4acb038..d700355 100644 --- a/server/middleware/authMiddleware.ts +++ b/server/middleware/authMiddleware.ts @@ -4,9 +4,8 @@ import type { Jwt } from 'jsonwebtoken' import jwt from 'jsonwebtoken' -import { NODE_ENV } from '@jukebox/config' import type { NextFunction, Request, Response } from 'express' -import { AUTH_TOKEN_COOKIE_NAME, JWT_ALGORITHM, JWT_ISSUER, JWT_SECRET_KEY } from 'server/config' +import { JWT_ALGORITHM, JWT_ISSUER, JWT_SECRET_KEY } from 'server/config' import { User } from 'server/models' import { httpUnauthorized } from '../utils' @@ -19,7 +18,12 @@ export type AuthResponse = Response & { } export const isAuthenticated = async (req: Request, res: Response, next: NextFunction) => { - // if (NODE_ENV === 'development') return next() + /** + @swagger + #swagger.security = [{ + "Bearer": [] + }] + */ let token: string = req.headers['authorization'] || '' let jwtPayload: Jwt diff --git a/server/models/groupModel.ts b/server/models/groupModel.ts index 855b8af..b86eeef 100644 --- a/server/models/groupModel.ts +++ b/server/models/groupModel.ts @@ -5,6 +5,7 @@ export interface IGroup { name: string ownerId: string spotifyAuthId?: string + defaultDeviceId?: string } export interface IGroupFields extends Omit { @@ -30,6 +31,9 @@ const GroupSchema = new mongoose.Schema + // public async getActiveDevice(failSilently?: false): Promise + // public async getActiveDevice(failSilently = false) { + // const devices = await this.sdk.player.getAvailableDevices() + // const activeDevice = devices.devices.reduce((currentDevice, device) => { + // if (device.is_active || !currentDevice) return device + + // return currentDevice + // }) + + // if (!failSilently && !activeDevice) { + // throw new Error('No active devices to play tracks.') + // } + + // return activeDevice + // } + + // public async playTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.startResumePlayback(activeDevice.id!) + // } + + // public async pauseTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.pausePlayback(activeDevice.id!) + // } + // public async nextTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.skipToNext(activeDevice.id!) + // } + // public async previousTrack() { + // const activeDevice = await this.getActiveDevice() + // await this.sdk.player.skipToPrevious(activeDevice.id!) + // } } diff --git a/server/views/groupViews.ts b/server/views/groupViews.ts index ed3fffb..30f9244 100644 --- a/server/views/groupViews.ts +++ b/server/views/groupViews.ts @@ -1,5 +1,11 @@ import type { NextFunction, Request, Response } from 'express' -import { assignSpotifyToGroup, getGroupSpotify } from 'server/controllers/groupController' +import { + assignSpotifyToGroup, + getGroupDevices, + getGroupTrack, + setGroupDefaultDevice, + setGroupPlayerState +} from 'server/controllers/groupController' import { Group } from 'server/models' import { apiAuthRequest } from 'server/utils' import { Viewset } from '../utils/apis/viewsets' @@ -14,11 +20,8 @@ export const assignSpotifyAccountView = apiAuthRequest(async (req, res, next) => #swagger.tags = ['Group'] */ const { user } = res.locals - let { spotifyEmail } = req.body - let { id } = req.params - - spotifyEmail = String(spotifyEmail) - id = String(id) + const spotifyEmail = String(req.body.spotifyEmail) + const id = String(req.params.id) return await assignSpotifyToGroup(user, id, spotifyEmail) }) @@ -28,29 +31,48 @@ export const getGroupCurrentTrackView = apiAuthRequest(async (req, res, next) => @swagger #swagger.tags = ['Group'] */ - let {id} = req.params - id = String(id) - - const spotify = await getGroupSpotify(id) - return await spotify.sdk.player.getCurrentlyPlayingTrack() + const id = String(req.params.id) + return await getGroupTrack(id) +}) + +export const getGroupDevicesView = apiAuthRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + return await getGroupDevices(id) +}) +export const setGroupDefaultDeviceView = apiAuthRequest(async (req, res) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + const deviceId = String(req.body.deviceId) + + return await setGroupDefaultDevice(id, deviceId) }) -/** ========= Resource CRUD Views ========== */ +export const setGroupPlayerStateView = apiAuthRequest(async (req, res, next) => { + /** + @swagger + #swagger.tags = ['Group'] + */ + const id = String(req.params.id) + const state = String(req.body.state) as 'play' | 'pause' + + return await setGroupPlayerState(id, state) +}) export const groupCreateView = (...args: ApiArgs) => { /** @swagger #swagger.tags = ['Group'] - #swagger.parameters['body'] = { - in: "body", - name: "body", - description: "New Group", - required: true, - schema: {$ref: "#/definitions/Group"} - } */ return groupViewset.create(...args) } + export const groupListView = (...args: ApiArgs) => { /** @swagger