From b6d09afc2f9756aa03841829f384a93ff3662139 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Fri, 1 Nov 2024 17:34:53 -0400 Subject: [PATCH 1/6] add track queue api, start building spotify service, split from auth --- docs/Spotify.md | 18 ++ docs/Track-Queue.md | 12 ++ src/jukebox/jukebox.controller.ts | 56 +++-- src/jukebox/tests/jukebox.controller.spec.ts | 4 +- .../track-queue/track-queue.service.ts | 41 +++- src/spotify/spotify-auth.service.ts | 201 ++++++++++++++++++ src/spotify/spotify-base.service.ts | 14 ++ src/spotify/spotify.controller.ts | 8 +- src/spotify/spotify.module.ts | 6 +- src/spotify/spotify.service.ts | 190 +---------------- src/spotify/tests/spotify.controller.spec.ts | 4 +- src/spotify/tests/spotify.service.spec.ts | 8 +- 12 files changed, 340 insertions(+), 222 deletions(-) create mode 100644 docs/Spotify.md create mode 100644 docs/Track-Queue.md create mode 100644 src/spotify/spotify-auth.service.ts create mode 100644 src/spotify/spotify-base.service.ts diff --git a/docs/Spotify.md b/docs/Spotify.md new file mode 100644 index 0000000..f932e1c --- /dev/null +++ b/docs/Spotify.md @@ -0,0 +1,18 @@ +# Helpful Spotify API Docs + +## General Links + +- Dashboard: +- How spotify authorization works: + +## Web Playback SDK + +The web playback sdk allows users to select the frontend web app to play music through as a sort of speaker. + +Docs: + +## Web API + +The web api allows more granular control over spotify functionality via a server application. + +Docs: diff --git a/docs/Track-Queue.md b/docs/Track-Queue.md new file mode 100644 index 0000000..8a60150 --- /dev/null +++ b/docs/Track-Queue.md @@ -0,0 +1,12 @@ +# How the Track Queue Works + +When the current track is over... + +1. If the queue is empty, do nothing. Spotify will play next song in their queue. +2. If the queue is not empty, pop the next track from queue, set to current track. + +## FAQ + +**Q: How do we know when the track is over?** + +A: We can only know if one of the admins is playing spotify through the web player, if that is the case the frontend will update the backend via a websocket diff --git a/src/jukebox/jukebox.controller.ts b/src/jukebox/jukebox.controller.ts index 8ce2361..cdaf5bf 100644 --- a/src/jukebox/jukebox.controller.ts +++ b/src/jukebox/jukebox.controller.ts @@ -9,37 +9,41 @@ import { Post, } from '@nestjs/common' import { ApiTags } from '@nestjs/swagger' +import { SpotifyService } from 'src/spotify/spotify.service' import { CurrentUser } from '../auth/current-user.decorator' -import { SpotifyService } from '../spotify/spotify.service' +import { SpotifyAuthService } from '../spotify/spotify-auth.service' import { AddJukeboxLinkDto } from './dto/add-jukebox-link.dto' import { CreateJukeboxDto } from './dto/create-jukebox.dto' import { JukeboxDto, JukeboxLinkDto } from './dto/jukebox.dto' import { UpdateJukeboxDto } from './dto/update-jukebox.dto' import { JukeboxService } from './jukebox.service' +import { TrackQueueService } from './track-queue/track-queue.service' @ApiTags('jukeboxes') @Controller('jukebox/') export class JukeboxController { constructor( - private readonly jukeboxService: JukeboxService, - private spotifyService: SpotifyService, + private readonly jukeboxSvc: JukeboxService, + private spotifyAuthSvc: SpotifyAuthService, + private spotifySvc: SpotifyService, + private queueSvc: TrackQueueService, ) {} @Post('jukeboxes/') async create(@Body() createJukeboxDto: CreateJukeboxDto): Promise { - const jbx = await this.jukeboxService.create(createJukeboxDto) + const jbx = await this.jukeboxSvc.create(createJukeboxDto) return jbx.serialize() } @Get('jukeboxes/') async findAll(): Promise { - const jbxs = await this.jukeboxService.findAll() + const jbxs = await this.jukeboxSvc.findAll() return jbxs.map((jbx) => jbx.serialize()) } @Get('jukeboxes/:id/') async findOne(@Param('id') id: number): Promise { - const jbx = await this.jukeboxService.findOne(id) + const jbx = await this.jukeboxSvc.findOne(id) return jbx.serialize() } @@ -48,19 +52,19 @@ export class JukeboxController { @Param('id') id: number, @Body() updateJukeboxDto: UpdateJukeboxDto, ): Promise { - const jbx = await this.jukeboxService.update(id, updateJukeboxDto) + const jbx = await this.jukeboxSvc.update(id, updateJukeboxDto) return jbx.serialize() } @Delete('jukeboxes/:id/') async remove(@Param('id') id: number): Promise { - const jbx = await this.jukeboxService.remove(id) + const jbx = await this.jukeboxSvc.remove(id) return jbx.serialize() } @Get('/:jukebox_id/links/') getJukeboxLinks(@Param('jukebox_id') jukeboxId: number): Promise { - return this.jukeboxService.getJukeboxLinks(jukeboxId) + return this.jukeboxSvc.getJukeboxLinks(jukeboxId) } @Post('/:jukebox_id/links/') @@ -69,7 +73,7 @@ export class JukeboxController { @Param('jukebox_id') jukeboxId: number, @Body() jukeboxLink: AddJukeboxLinkDto, ): Promise { - const link = await this.spotifyService.findOneUserLink(user.id, jukeboxLink.email) + const link = await this.spotifyAuthSvc.findOneUserAccount(user.id, jukeboxLink.email) if (!link) { throw new NotFoundException( @@ -77,23 +81,23 @@ export class JukeboxController { ) } - return await this.jukeboxService.addLinkToJukebox(jukeboxId, link) + return await this.jukeboxSvc.addLinkToJukebox(jukeboxId, link) } @Delete('/:jukebox_id/links/:id/') async deleteJukeboxLink(@Param('jukebox_id') jukeboxId: number, @Param('id') linkId: number) { - const link = await this.jukeboxService.removeJukeboxLink(jukeboxId, linkId) + const link = await this.jukeboxSvc.removeJukeboxLink(jukeboxId, linkId) return link } @Get('/:jukebox_id/active-link/') async getActiveJukeboxLink(@Param('jukebox_id') jukeboxId: number) { - const link = await this.jukeboxService.getJukeboxActiveSpotifyLink(jukeboxId) + const link = await this.jukeboxSvc.getJukeboxActiveSpotifyLink(jukeboxId) if (!link) { return } - const refreshed = await this.spotifyService.refreshSpotifyLink(link) + const refreshed = await this.spotifyAuthSvc.refreshSpotifyAccount(link) return refreshed } @@ -102,9 +106,27 @@ export class JukeboxController { @Param('jukebox_id') jukeboxId: number, @Body() jukeboxLink: AddJukeboxLinkDto, ) { - const link = await this.jukeboxService.findJukeboxLink(jukeboxId, jukeboxLink) - const activeLink = await this.jukeboxService.setActiveLink(jukeboxId, link.id) + const link = await this.jukeboxSvc.findJukeboxLink(jukeboxId, jukeboxLink) + const activeLink = await this.jukeboxSvc.setActiveLink(jukeboxId, link.id) - return await this.spotifyService.refreshSpotifyLink(activeLink.spotify_link) + return await this.spotifyAuthSvc.refreshSpotifyAccount(activeLink.spotify_link) + } + + @Get('/:jukebox_id/tracks-queue/') + async getTracksQueue(@Param('jukebox_id') jukeboxId: number) { + return this.queueSvc.listTracks(jukeboxId) + } + + @Post('/:jukebox_id/tracks-queue') + async addTrackToQueue( + @Param('jukebox_id') jukeboxId: number, + @Body() track: { trackId: string; position?: number }, + ) { + const account = await this.jukeboxSvc.getJukeboxActiveSpotifyLink(jukeboxId) + const trackItem = await this.spotifySvc.getTrack(account, track.trackId) + + this.queueSvc.queueTrack(jukeboxId, trackItem, track.position) + + return trackItem } } diff --git a/src/jukebox/tests/jukebox.controller.spec.ts b/src/jukebox/tests/jukebox.controller.spec.ts index c81b589..6d91c1f 100644 --- a/src/jukebox/tests/jukebox.controller.spec.ts +++ b/src/jukebox/tests/jukebox.controller.spec.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing' import { getRepositoryToken } from '@nestjs/typeorm' import { NetworkModule } from 'src/network/network.module' import { SpotifyAccount } from 'src/spotify/entities/spotify-account.entity' -import { SpotifyService } from 'src/spotify/spotify.service' +import { SpotifyAuthService } from 'src/spotify/spotify-auth.service' import type { MockType } from 'src/utils' import { AxiosProvider } from 'src/utils/providers/axios.provider' import type { Repository } from 'typeorm' @@ -26,7 +26,7 @@ describe('JukeboxController', () => { controllers: [JukeboxController], providers: [ AxiosProvider, - SpotifyService, + SpotifyAuthService, JukeboxService, { provide: getRepositoryToken(Jukebox), diff --git a/src/jukebox/track-queue/track-queue.service.ts b/src/jukebox/track-queue/track-queue.service.ts index cec7971..0a52073 100644 --- a/src/jukebox/track-queue/track-queue.service.ts +++ b/src/jukebox/track-queue/track-queue.service.ts @@ -5,14 +5,19 @@ export class TrackQueueItem { constructor(public track: Track) {} } -// @Injectable() export class TrackQueue { + private static queues: { [jukeboxId: number]: TrackQueue } = {} protected tracks: TrackQueueItem[] = [] - // private groupId: string = ''; - constructor(readonly groupId: string) {} - public setGroupId(groupId: string) { - // this.groupId = groupId; + private constructor(readonly jukeboxId: number) {} + + public static getQueue(jukeboxId: number) { + if (!(jukeboxId in this.queues)) { + console.log('Creating new track queue...') + this.queues[jukeboxId] = new TrackQueue(jukeboxId) + } + + return this.queues[jukeboxId] } // Pushes a track to the end of the queue and returns the new length @@ -33,6 +38,10 @@ export class TrackQueue { return item ? item.track : undefined // Return the track or undefined if the queue is empty } + public list(): Track[] { + return this.tracks.map((item) => item.track) + } + // Moves a track to a new position in the queue public setPosition(track: Track, pos: number) { const currentIndex = this.tracks.findIndex((item) => item.track === track) @@ -52,4 +61,24 @@ export class TrackQueue { } @Injectable() -export class TrackQueueService {} +export class TrackQueueService { + constructor() {} + + private getQueue(jukeboxId) { + return TrackQueue.getQueue(jukeboxId) + } + + public listTracks(jukeboxId: number) { + const queue = this.getQueue(jukeboxId) + return queue.list() + } + + public queueTrack(jukeboxId: number, track: Track, position = -1) { + const queue = this.getQueue(jukeboxId) + + queue.push(track) + if (position >= 0) { + queue.setPosition(track, position) + } + } +} diff --git a/src/spotify/spotify-auth.service.ts b/src/spotify/spotify-auth.service.ts new file mode 100644 index 0000000..a26ffbc --- /dev/null +++ b/src/spotify/spotify-auth.service.ts @@ -0,0 +1,201 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Axios } from 'axios' +import { stringify } from 'querystring' +import { + SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET, + SPOTIFY_REDIRECT_URI, + SPOTIFY_SCOPES, +} from 'src/config' +import { Repository } from 'typeorm' +import { CreateSpotifyAccountDto, UpdateSpotifyAccountDto } from './dto/spotify-account.dto' +import { SpotifyTokensDto } from './dto/spotify-tokens.dto' +import { isSpotifyLink, SpotifyAccount } from './entities/spotify-account.entity' +import { SpotifyBaseService } from './spotify-base.service' + +@Injectable() +export class SpotifyAuthService extends SpotifyBaseService { + constructor( + @InjectRepository(SpotifyAccount) private repo: Repository, + protected axios: Axios, + ) { + super() + } + + private async authenticateSpotify(code: string): Promise { + const body = { + grant_type: 'authorization_code', + code: code, + redirect_uri: SPOTIFY_REDIRECT_URI, + } + const authBuffer = Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET) + + const res = await this.axios + .post('https://accounts.spotify.com/api/token', body, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + authBuffer.toString('base64'), + }, + }) + .catch((error) => { + throw new BadRequestException(error?.response?.data?.error_description || error) + }) + + if (res.status > 299 || !res.data) { + throw new BadRequestException('Unable to authenticate with Spotify') + } + + const tokens: SpotifyTokensDto = { + access_token: res.data.access_token, + expires_in: res.data.expires_in, + refresh_token: res.data.refresh_token, + token_type: res.data.token_type, + } + + return tokens + } + + public getSpotifyRedirectUri(userId: number, finalRedirect?: string) { + const state = JSON.stringify({ userId, finalRedirect }) + const url = + 'https://accounts.spotify.com/authorize?' + + stringify({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: SPOTIFY_SCOPES.join(','), + redirect_uri: SPOTIFY_REDIRECT_URI, + state: state, + }) + + return url + } + + public async handleAuthCode(userId: number, code: string) { + const tokens = await this.authenticateSpotify(code) + const sdk = this.getSdk(tokens) + const profile = await sdk.currentUser.profile() + + await this.updateOrCreateAccount(userId, profile.email, tokens) + + return profile + } + + // TODO: Implement not found error, should be implemented in service or controller? + public async findUserAccounts(userId: number) { + return await this.repo.findBy({ user_id: userId }) + } + + public async findOneUserAccount(userId: number, spotifyEmail: string) { + return await this.repo.findOneBy({ user_id: userId, spotify_email: spotifyEmail }) + } + + private async findAccountFromEmail(spotifyEmail: string) { + return await this.repo.findOneBy({ spotify_email: spotifyEmail }) + } + + public async refreshSpotifyAccount( + account: { spotify_email: string } | SpotifyAccount, + ): Promise { + let spotifyAccount: SpotifyAccount + + if (!isSpotifyLink(account)) { + spotifyAccount = (await this.findAccountFromEmail(account.spotify_email)) as SpotifyAccount + } else { + spotifyAccount = account + } + + if (!spotifyAccount.isExpired()) { + return spotifyAccount + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: spotifyAccount.refresh_token, + }) + const authBuffer = Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET) + + const res = await this.axios + .post('https://accounts.spotify.com/api/token', body, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + authBuffer.toString('base64'), + }, + }) + .catch((error) => { + console.log('Error from axios spotify:', error) + throw new BadRequestException(error?.response?.data?.error_description || error) + }) + + spotifyAccount.access_token = res.data.access_token + spotifyAccount.expires_in = res.data.expires_in + spotifyAccount.syncExpiresAt() + Logger.debug('Refreshed spotify token.') + + await spotifyAccount.save() + return spotifyAccount + } + + private async addAccount(createSpotifyLinkDto: CreateSpotifyAccountDto) { + const { user_id: userId, spotify_email: spotifyEmail, tokens } = createSpotifyLinkDto + const account = this.repo.create({ user_id: userId, spotify_email: spotifyEmail, ...tokens }) + + account.syncExpiresAt() + await this.repo.save(account) + + return account + } + + private async updateAccount(id: number, updateSpotifyLInkDto: UpdateSpotifyAccountDto) { + const account = await this.repo.findOneBy({ id }) + + Object.assign(account, updateSpotifyLInkDto) + + await this.repo.save(account) + + if (!account) { + throw new BadRequestException(`Cannot find preexisting spotify account link with id ${id}`) + } + + return account + } + + private async updateOrCreateAccount( + userId: number, + spotifyEmail: string, + tokens: SpotifyTokensDto, + ) { + const existing = await this.repo.findOneBy({ user_id: userId, spotify_email: spotifyEmail }) + + if (!existing) { + await this.addAccount({ user_id: userId, spotify_email: spotifyEmail, tokens }) + } else { + await this.updateAccount(existing.id, { + access_token: tokens.access_token, + expires_in: tokens.expires_in, + }) + } + } + + public async removeAccount(id: number) { + const account = await this.repo.findOneBy({ id }) + await account.remove() + + return account + } + + public async getAccountTokens(id: number): Promise { + let account = await this.repo.findOneBy({ id }) + if (!account) { + throw new NotFoundException(`Spotify account not found with linked id ${id}.`) + } + account = await this.refreshSpotifyAccount(account) + + return { + access_token: account.access_token, + expires_in: account.expires_in, + refresh_token: account.refresh_token, + token_type: account.token_type, + } + } +} diff --git a/src/spotify/spotify-base.service.ts b/src/spotify/spotify-base.service.ts new file mode 100644 index 0000000..63b5a67 --- /dev/null +++ b/src/spotify/spotify-base.service.ts @@ -0,0 +1,14 @@ +import { SpotifyApi } from '@spotify/web-api-ts-sdk' +import { SPOTIFY_CLIENT_ID } from 'src/config' +import { SpotifyTokensDto } from './dto/spotify-tokens.dto' + +export class SpotifyBaseService { + protected getSdk(tokens: SpotifyTokensDto) { + return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in: tokens.expires_in, + token_type: tokens.token_type, + }) + } +} diff --git a/src/spotify/spotify.controller.ts b/src/spotify/spotify.controller.ts index e3e48ff..33fd110 100644 --- a/src/spotify/spotify.controller.ts +++ b/src/spotify/spotify.controller.ts @@ -3,12 +3,12 @@ import { ApiTags } from '@nestjs/swagger' import { Response } from 'express' import { AuthInterceptor } from 'src/auth/auth.interceptor' import { CurrentUser } from 'src/auth/current-user.decorator' -import { SpotifyService } from './spotify.service' +import { SpotifyAuthService } from './spotify-auth.service' @ApiTags('spotify') @Controller('spotify/') export class SpotifyController { - constructor(protected spotifyService: SpotifyService) {} + constructor(protected spotifyService: SpotifyAuthService) {} @Get('login/') @UseInterceptors(AuthInterceptor) @@ -37,12 +37,12 @@ export class SpotifyController { @Get('links/') async getSpotifyLinks(@CurrentUser() user: IUser) { - return this.spotifyService.findUserLinks(user.id) + return this.spotifyService.findUserAccounts(user.id) } @Delete('links/:id/') async deleteSpotifyLink(@CurrentUser() user: IUser, @Param('id') id: number) { - const link = await this.spotifyService.deleteLink(id) + const link = await this.spotifyService.removeAccount(id) return link } } diff --git a/src/spotify/spotify.module.ts b/src/spotify/spotify.module.ts index 45f93af..9a364f4 100644 --- a/src/spotify/spotify.module.ts +++ b/src/spotify/spotify.module.ts @@ -4,14 +4,14 @@ import { NetworkModule } from 'src/network/network.module' import { NetworkService } from '../network/network.service' import { AxiosProvider } from '../utils/providers/axios.provider' import { SpotifyAccount } from './entities/spotify-account.entity' +import { SpotifyAuthService } from './spotify-auth.service' import { SpotifyController } from './spotify.controller' import { SpotifyService } from './spotify.service' @Module({ imports: [NetworkModule, TypeOrmModule.forFeature([SpotifyAccount])], controllers: [SpotifyController], - providers: [SpotifyService, AxiosProvider, NetworkService], - // imports: [MongooseModule.forFeature([{ name: SpotifyLink.name, schema: SpotifyLinkSchema }])], - exports: [SpotifyService], + providers: [SpotifyAuthService, AxiosProvider, NetworkService, SpotifyService], + exports: [SpotifyAuthService, SpotifyService], }) export class SpotifyModule {} diff --git a/src/spotify/spotify.service.ts b/src/spotify/spotify.service.ts index 1ef7590..57ae974 100644 --- a/src/spotify/spotify.service.ts +++ b/src/spotify/spotify.service.ts @@ -1,189 +1,11 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common' -import { InjectRepository } from '@nestjs/typeorm' -import { SpotifyApi } from '@spotify/web-api-ts-sdk' -import { Axios } from 'axios' -import { stringify } from 'querystring' -import { - SPOTIFY_CLIENT_ID, - SPOTIFY_CLIENT_SECRET, - SPOTIFY_REDIRECT_URI, - SPOTIFY_SCOPES, -} from 'src/config' -import { Repository } from 'typeorm' -import { CreateSpotifyAccountDto, UpdateSpotifyAccountDto } from './dto/spotify-account.dto' +import { Injectable } from '@nestjs/common' import { SpotifyTokensDto } from './dto/spotify-tokens.dto' -import { isSpotifyLink, SpotifyAccount } from './entities/spotify-account.entity' +import { SpotifyBaseService } from './spotify-base.service' @Injectable() -export class SpotifyService { - constructor( - @InjectRepository(SpotifyAccount) private repo: Repository, - protected axios: Axios, - ) {} - - private getSdk(tokens: SpotifyTokensDto) { - return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_in: tokens.expires_in, - token_type: tokens.token_type, - }) - } - - private async authenticateSpotify(code: string): Promise { - const body = { - grant_type: 'authorization_code', - code: code, - redirect_uri: SPOTIFY_REDIRECT_URI, - } - const authBuffer = Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET) - - const res = await this.axios - .post('https://accounts.spotify.com/api/token', body, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + authBuffer.toString('base64'), - }, - }) - .catch((error) => { - throw new BadRequestException(error?.response?.data?.error_description || error) - }) - - if (res.status > 299 || !res.data) { - throw new BadRequestException('Unable to authenticate with Spotify') - } - - const tokens: SpotifyTokensDto = { - access_token: res.data.access_token, - expires_in: res.data.expires_in, - refresh_token: res.data.refresh_token, - token_type: res.data.token_type, - } - - return tokens - } - - public getSpotifyRedirectUri(userId: number, finalRedirect?: string) { - const state = JSON.stringify({ userId, finalRedirect }) - const url = - 'https://accounts.spotify.com/authorize?' + - stringify({ - response_type: 'code', - client_id: SPOTIFY_CLIENT_ID, - scope: SPOTIFY_SCOPES.join(','), - redirect_uri: SPOTIFY_REDIRECT_URI, - state: state, - }) - - return url - } - - public async handleAuthCode(userId: number, code: string) { - const tokens = await this.authenticateSpotify(code) - const sdk = this.getSdk(tokens) - const profile = await sdk.currentUser.profile() - - await this.updateOrCreateLink(userId, profile.email, tokens) - - return profile - } - - // TODO: Implement not found error, should be implemented in service or controller? - public async findUserLinks(userId: number) { - return await this.repo.findBy({ user_id: userId }) - } - - public async findOneUserLink(userId: number, spotifyEmail: string) { - return await this.repo.findOneBy({ user_id: userId, spotify_email: spotifyEmail }) - } - - private async findLinkFromEmail(spotifyEmail: string) { - return await this.repo.findOneBy({ spotify_email: spotifyEmail }) - } - - public async refreshSpotifyLink( - link: { spotify_email: string } | SpotifyAccount, - ): Promise { - let spotifyLink: SpotifyAccount - - if (!isSpotifyLink(link)) { - spotifyLink = (await this.findLinkFromEmail(link.spotify_email)) as SpotifyAccount - } else { - spotifyLink = link - } - - if (!spotifyLink.isExpired()) { - return spotifyLink - } - - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: spotifyLink.refresh_token, - }) - const authBuffer = Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET) - - const res = await this.axios - .post('https://accounts.spotify.com/api/token', body, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + authBuffer.toString('base64'), - }, - }) - .catch((error) => { - console.log('Error from axios spotify:', error) - throw new BadRequestException(error?.response?.data?.error_description || error) - }) - - spotifyLink.access_token = res.data.access_token - spotifyLink.expires_in = res.data.expires_in - spotifyLink.syncExpiresAt() - Logger.debug('Refreshed spotify token.') - - await spotifyLink.save() - return spotifyLink - } - - private async createLink(createSpotifyLinkDto: CreateSpotifyAccountDto) { - const { user_id: userId, spotify_email: spotifyEmail, tokens } = createSpotifyLinkDto - const link = this.repo.create({ user_id: userId, spotify_email: spotifyEmail, ...tokens }) - - link.syncExpiresAt() - await this.repo.save(link) - - return link - } - - private async updateLink(id: number, updateSpotifyLInkDto: UpdateSpotifyAccountDto) { - const link = await this.repo.findOneBy({ id }) - - Object.assign(link, updateSpotifyLInkDto) - - await this.repo.save(link) - - if (!link) { - throw new BadRequestException(`Cannot find preexisting spotify account link with id ${id}`) - } - - return link - } - - private async updateOrCreateLink(userId: number, spotifyEmail: string, tokens: SpotifyTokensDto) { - const existing = await this.repo.findOneBy({ user_id: userId, spotify_email: spotifyEmail }) - - if (!existing) { - await this.createLink({ user_id: userId, spotify_email: spotifyEmail, tokens }) - } else { - await this.updateLink(existing.id, { - access_token: tokens.access_token, - expires_in: tokens.expires_in, - }) - } - } - - public async deleteLink(id: number) { - const link = await this.repo.findOneBy({ id }) - await link.remove() - - return link +export class SpotifyService extends SpotifyBaseService { + public async getTrack(account: SpotifyTokensDto, trackId: string) { + const sdk = this.getSdk(account) + return await sdk.tracks.get(trackId) } } diff --git a/src/spotify/tests/spotify.controller.spec.ts b/src/spotify/tests/spotify.controller.spec.ts index f6d3608..4e2f255 100644 --- a/src/spotify/tests/spotify.controller.spec.ts +++ b/src/spotify/tests/spotify.controller.spec.ts @@ -6,8 +6,8 @@ import { NetworkModule } from 'src/network/network.module' import type { MockType } from 'src/utils' import type { Repository } from 'typeorm' import { SpotifyAccount } from '../entities/spotify-account.entity' +import { SpotifyAuthService } from '../spotify-auth.service' import { SpotifyController } from '../spotify.controller' -import { SpotifyService } from '../spotify.service' describe('SpotifyController', () => { let controller: SpotifyController @@ -18,7 +18,7 @@ describe('SpotifyController', () => { imports: [NetworkModule], controllers: [SpotifyController], providers: [ - SpotifyService, + SpotifyAuthService, { provide: Axios.Axios, useValue: Axios.create(), diff --git a/src/spotify/tests/spotify.service.spec.ts b/src/spotify/tests/spotify.service.spec.ts index b16834a..11dcee4 100644 --- a/src/spotify/tests/spotify.service.spec.ts +++ b/src/spotify/tests/spotify.service.spec.ts @@ -7,16 +7,16 @@ import { Model } from 'mongoose' import type { MockType } from 'src/utils' import type { Repository } from 'typeorm' import { SpotifyAccount } from '../entities/spotify-account.entity' -import { SpotifyService } from '../spotify.service' +import { SpotifyAuthService } from '../spotify-auth.service' describe('SpotifyService', () => { - let service: SpotifyService + let service: SpotifyAuthService beforeEach(async () => { const mockSpotifyLinkRepo: () => MockType> = jest.fn(() => ({})) const module: TestingModule = await Test.createTestingModule({ providers: [ - SpotifyService, + SpotifyAuthService, { provide: getModelToken(SpotifyAccount.name), useValue: Model }, { provide: Axios.Axios, @@ -29,7 +29,7 @@ describe('SpotifyService', () => { ], }).compile() - service = module.get(SpotifyService) + service = module.get(SpotifyAuthService) }) it('should be defined', () => { From 69935bc93e053c4fba5c21e74bf5bb3bba937859 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sat, 2 Nov 2024 13:07:50 -0400 Subject: [PATCH 2/6] implement track queue into new service, add jukebox ws gateway, add queue api --- docker-compose.network.yml | 11 + docker-compose.yml | 8 + package-lock.json | 234 +++++++++++++++++- package.json | 4 + src/app.module.ts | 9 +- src/config/cache-options.ts | 20 ++ src/jukebox/jukebox.controller.ts | 25 +- src/jukebox/jukebox.gateway.ts | 47 ++++ src/jukebox/jukebox.module.ts | 3 +- src/jukebox/jukebox.service.ts | 2 +- src/jukebox/tests/jukebox.gateway.spec.ts | 18 ++ .../track-queue/dtos/track-queue.dto.ts | 15 ++ .../track-queue/track-queue.service.ts | 71 ++++-- src/spotify/spotify.service.ts | 15 +- 14 files changed, 437 insertions(+), 45 deletions(-) create mode 100644 src/config/cache-options.ts create mode 100644 src/jukebox/jukebox.gateway.ts create mode 100644 src/jukebox/tests/jukebox.gateway.spec.ts create mode 100644 src/jukebox/track-queue/dtos/track-queue.dto.ts diff --git a/docker-compose.network.yml b/docker-compose.network.yml index 80907a5..b66d8c5 100644 --- a/docker-compose.network.yml +++ b/docker-compose.network.yml @@ -24,12 +24,15 @@ services: - DB_USER=devuser - DB_PASS=devpass - DB_NAME=devdatabase + - REDIS_HOST=jbx-network-redis + - REDIS_PORT=6379 ports: - 9000:9000 depends_on: - postgres - pgadmin - kafka + - redis volumes: - ./src:/app/src - ./package.json:/app/package.json @@ -71,6 +74,14 @@ services: networks: - cluster + redis: + image: redis:alpine + container_name: jbx-network-redis + ports: + - 6379:6379 + networks: + - cluster + pgadmin: image: dpage/pgadmin4 container_name: jbx-netork-pgadmin diff --git a/docker-compose.yml b/docker-compose.yml index 7cefb0f..972d9de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: - DB_USER=devuser - DB_PASS=devpass - DB_NAME=devdatabase + - REDIS_HOST=jbx-redis + - REDIS_PORT=6379 ports: - 8000:8000 depends_on: @@ -43,6 +45,12 @@ services: - POSTGRES_DB=devdatabase - POSTGRES_USER=devuser - POSTGRES_PASSWORD=devpass + + redis: + image: redis:alpine + container_name: jbx-redis + ports: + - 6379:6379 volumes: jukebox-pg-data: diff --git a/package-lock.json b/package-lock.json index 28794c1..310df46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/axios": "^3.0.3", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", @@ -22,6 +23,8 @@ "@redocly/cli": "^1.25.5", "@spotify/web-api-ts-sdk": "^1.2.0", "axios": "^1.7.7", + "cache-manager": "^5.7.6", + "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "mongoose": "^8.7.0", @@ -30,6 +33,7 @@ "pg": "^8.13.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20", "yaml": "^2.5.1" @@ -1765,6 +1769,18 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", + "integrity": "sha512-pxeBp9w/s99HaW2+pezM1P3fLiWmUEnTUoUMLa9UYViCtjj0E0A19W/vaT5JFACCzFIeNrwH4/16jkpAhQ25Vw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -2050,6 +2066,54 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-socket.io/node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/@nestjs/platform-socket.io/node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.1.4", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", @@ -2253,6 +2317,71 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -3994,6 +4123,39 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager-redis-store": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz", + "integrity": "sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ==", + "license": "MIT", + "dependencies": { + "redis": "^4.3.1" + }, + "engines": { + "node": ">= 16.18.0" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -4419,6 +4581,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5165,9 +5336,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", - "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", "license": "MIT", "dependencies": { "@types/cookie": "^0.4.1", @@ -5175,7 +5346,7 @@ "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", - "cookie": "~0.4.1", + "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", @@ -5195,9 +5366,9 @@ } }, "node_modules/engine.io/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6360,6 +6531,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8392,6 +8572,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.escape": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", @@ -10258,6 +10444,15 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10535,6 +10730,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/redoc": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/redoc/-/redoc-2.1.5.tgz", @@ -11342,16 +11554,16 @@ } }, "node_modules/socket.io": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", - "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", "license": "MIT", "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", - "engine.io": "~6.5.2", + "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" }, diff --git a/package.json b/package.json index 67af1b8..63cf663 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@nestjs/axios": "^3.0.3", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", @@ -35,6 +36,8 @@ "@redocly/cli": "^1.25.5", "@spotify/web-api-ts-sdk": "^1.2.0", "axios": "^1.7.7", + "cache-manager": "^5.7.6", + "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "mongoose": "^8.7.0", @@ -43,6 +46,7 @@ "pg": "^8.13.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20", "yaml": "^2.5.1" diff --git a/src/app.module.ts b/src/app.module.ts index e30d8c8..7c01700 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,21 +1,22 @@ +import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { APP_INTERCEPTOR } from '@nestjs/core' import { AppGateway } from './app.gateway' import { AppService } from './app.service' import { AuthInterceptor } from './auth/auth.interceptor' +import { CacheOptions } from './config/cache-options' import { DatabaseModule } from './config/database.module' import { JukeboxModule } from './jukebox/jukebox.module' +import { TrackQueueModule } from './jukebox/track-queue/track-queue.module' import { NetworkModule } from './network/network.module' import { SpotifyModule } from './spotify/spotify.module' -import { TrackQueueModule } from './jukebox/track-queue/track-queue.module' import { AxiosProvider } from './utils/providers/axios.provider' @Module({ imports: [ - ConfigModule.forRoot(), - // MongooseModule.forRoot(MONGO_URI), - // TypeOrmModule.forRoot(), + ConfigModule.forRoot({ isGlobal: true }), + CacheModule.registerAsync(CacheOptions), DatabaseModule, SpotifyModule, TrackQueueModule, diff --git a/src/config/cache-options.ts b/src/config/cache-options.ts new file mode 100644 index 0000000..28526fa --- /dev/null +++ b/src/config/cache-options.ts @@ -0,0 +1,20 @@ +import { CacheModuleAsyncOptions } from '@nestjs/cache-manager' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { redisStore } from 'cache-manager-redis-store' + +export const CacheOptions: CacheModuleAsyncOptions = { + isGlobal: true, + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + const store = await redisStore({ + socket: { + host: configService.get('REDIS_HOST'), + port: parseInt(configService.get('REDIS_PORT')!), + }, + }) + return { + store: () => store, + } + }, + inject: [ConfigService], +} diff --git a/src/jukebox/jukebox.controller.ts b/src/jukebox/jukebox.controller.ts index cdaf5bf..60554d8 100644 --- a/src/jukebox/jukebox.controller.ts +++ b/src/jukebox/jukebox.controller.ts @@ -17,6 +17,7 @@ import { CreateJukeboxDto } from './dto/create-jukebox.dto' import { JukeboxDto, JukeboxLinkDto } from './dto/jukebox.dto' import { UpdateJukeboxDto } from './dto/update-jukebox.dto' import { JukeboxService } from './jukebox.service' +import { AddTrackToQueueDto } from './track-queue/dtos/track-queue.dto' import { TrackQueueService } from './track-queue/track-queue.service' @ApiTags('jukeboxes') @@ -92,7 +93,7 @@ export class JukeboxController { @Get('/:jukebox_id/active-link/') async getActiveJukeboxLink(@Param('jukebox_id') jukeboxId: number) { - const link = await this.jukeboxSvc.getJukeboxActiveSpotifyLink(jukeboxId) + const link = await this.jukeboxSvc.getActiveSpotifyAccount(jukeboxId) if (!link) { return } @@ -118,15 +119,23 @@ export class JukeboxController { } @Post('/:jukebox_id/tracks-queue') - async addTrackToQueue( - @Param('jukebox_id') jukeboxId: number, - @Body() track: { trackId: string; position?: number }, - ) { - const account = await this.jukeboxSvc.getJukeboxActiveSpotifyLink(jukeboxId) - const trackItem = await this.spotifySvc.getTrack(account, track.trackId) + async addTrackToQueue(@Param('jukebox_id') jukeboxId: number, @Body() track: AddTrackToQueueDto) { + const account = await this.jukeboxSvc.getActiveSpotifyAccount(jukeboxId) + const trackItem = await this.spotifySvc.getTrack(account, track.track_id) - this.queueSvc.queueTrack(jukeboxId, trackItem, track.position) + await this.queueSvc.queueTrack(jukeboxId, trackItem, track.position) return trackItem } + + @Post('/:jukebox_id/connect/') + async connectJukeboxPlayer( + @Param('jukebox_id') jukeboxId: number, + @Body() body: { device_id: string }, + ) { + const account = await this.jukeboxSvc.getActiveSpotifyAccount(jukeboxId) + await this.spotifySvc.setPlayerDevice(account, body.device_id) + + return + } } diff --git a/src/jukebox/jukebox.gateway.ts b/src/jukebox/jukebox.gateway.ts new file mode 100644 index 0000000..286d2d9 --- /dev/null +++ b/src/jukebox/jukebox.gateway.ts @@ -0,0 +1,47 @@ +import { SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets' +import { Track } from '@spotify/web-api-ts-sdk' +import { Server, Socket } from 'socket.io' +import { SpotifyService } from 'src/spotify/spotify.service' +import { JukeboxService } from './jukebox.service' +import { TrackQueueService } from './track-queue/track-queue.service' + +@WebSocketGateway() +export class JukeboxGateway { + constructor( + private queueSvc: TrackQueueService, + private jukeboxSvc: JukeboxService, + private spotifySvc: SpotifyService, + ) {} + + @WebSocketServer() server: Server + + @SubscribeMessage('message') + handleMessage(client: any, payload: any): string { + return 'Hello world!' + } + + @SubscribeMessage('track-state') + async handleQueueNextTrack( + client: Socket, + payload: { new_track: boolean; track: Track; jukebox_id: number }, + ) { + const { new_track, track, jukebox_id } = payload + + if (!new_track) { + return + } + + let nextTrack = await this.queueSvc.peekTrack(jukebox_id) + if (String(nextTrack?.name) === String(track.name)) { + await this.queueSvc.popTrack(jukebox_id) + nextTrack = await this.queueSvc.peekTrack(jukebox_id) + } + + if (!nextTrack) { + return + } + + const account = await this.jukeboxSvc.getActiveSpotifyAccount(jukebox_id) + this.spotifySvc.queueTrack(account, nextTrack) + } +} diff --git a/src/jukebox/jukebox.module.ts b/src/jukebox/jukebox.module.ts index 49a7448..db78cea 100644 --- a/src/jukebox/jukebox.module.ts +++ b/src/jukebox/jukebox.module.ts @@ -5,10 +5,11 @@ import { Jukebox, JukeboxLinkAssignment } from './entities/jukebox.entity' import { JukeboxController } from './jukebox.controller' import { JukeboxService } from './jukebox.service' import { TrackQueueService } from './track-queue/track-queue.service' +import { JukeboxGateway } from './jukebox.gateway'; @Module({ controllers: [JukeboxController], - providers: [JukeboxService, TrackQueueService], + providers: [JukeboxService, TrackQueueService, JukeboxGateway], imports: [ TypeOrmModule.forFeature([Jukebox, JukeboxLinkAssignment]), SpotifyModule, diff --git a/src/jukebox/jukebox.service.ts b/src/jukebox/jukebox.service.ts index 721faf3..bbf2f4b 100644 --- a/src/jukebox/jukebox.service.ts +++ b/src/jukebox/jukebox.service.ts @@ -135,7 +135,7 @@ export class JukeboxService { return assignment.serialize() } - async getJukeboxActiveSpotifyLink(jukeboxId: number): Promise { + async getActiveSpotifyAccount(jukeboxId: number): Promise { const jukebox = await this.findOne(jukeboxId) const assignment = jukebox.link_assignments.find((lnk) => lnk.active) diff --git a/src/jukebox/tests/jukebox.gateway.spec.ts b/src/jukebox/tests/jukebox.gateway.spec.ts new file mode 100644 index 0000000..607e84d --- /dev/null +++ b/src/jukebox/tests/jukebox.gateway.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JukeboxGateway } from '../jukebox.gateway'; + +describe('JukeboxGateway', () => { + let gateway: JukeboxGateway; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [JukeboxGateway], + }).compile(); + + gateway = module.get(JukeboxGateway); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/src/jukebox/track-queue/dtos/track-queue.dto.ts b/src/jukebox/track-queue/dtos/track-queue.dto.ts new file mode 100644 index 0000000..58f6c38 --- /dev/null +++ b/src/jukebox/track-queue/dtos/track-queue.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsOptional } from 'class-validator' + +export class QueueNextTrackDto { + jukebox_id: number +} + +export class AddTrackToQueueDto { + @ApiProperty() + track_id: string + + @ApiProperty() + @IsOptional() + position?: number +} diff --git a/src/jukebox/track-queue/track-queue.service.ts b/src/jukebox/track-queue/track-queue.service.ts index 0a52073..dcaf636 100644 --- a/src/jukebox/track-queue/track-queue.service.ts +++ b/src/jukebox/track-queue/track-queue.service.ts @@ -1,24 +1,28 @@ -import { Injectable } from '@nestjs/common' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable } from '@nestjs/common' import type { Track } from '@spotify/web-api-ts-sdk' +import { Cache } from 'cache-manager' export class TrackQueueItem { + recommended_by: string constructor(public track: Track) {} } export class TrackQueue { - private static queues: { [jukeboxId: number]: TrackQueue } = {} - protected tracks: TrackQueueItem[] = [] + // private static queues: { [jukeboxId: number]: TrackQueue } = {} + // protected tracks: TrackQueueItem[] = [] - private constructor(readonly jukeboxId: number) {} + // private constructor(readonly jukeboxId: number) {} + constructor(readonly tracks: TrackQueueItem[]) {} - public static getQueue(jukeboxId: number) { - if (!(jukeboxId in this.queues)) { - console.log('Creating new track queue...') - this.queues[jukeboxId] = new TrackQueue(jukeboxId) - } + // public static getQueue(jukeboxId: number) { + // if (!(jukeboxId in this.queues)) { + // console.log('Creating new track queue...') + // this.queues[jukeboxId] = new TrackQueue(jukeboxId) + // } - return this.queues[jukeboxId] - } + // return this.queues[jukeboxId] + // } // Pushes a track to the end of the queue and returns the new length public push(track: Track): number { @@ -62,23 +66,54 @@ export class TrackQueue { @Injectable() export class TrackQueueService { - constructor() {} + constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} + + private getCacheKey(jukeboxId: number) { + return `queue-${jukeboxId}` + } - private getQueue(jukeboxId) { - return TrackQueue.getQueue(jukeboxId) + private async getQueue(jukeboxId: number) { + const key = this.getCacheKey(jukeboxId) + const tracks = (await this.cache.get(key)) ?? [] + return new TrackQueue(tracks) + // return TrackQueue.getQueue(jukeboxId) } - public listTracks(jukeboxId: number) { - const queue = this.getQueue(jukeboxId) + private async commitQueue(jukeboxId: number, queue: TrackQueue) { + const key = this.getCacheKey(jukeboxId) + await this.cache.set(key, queue.tracks) + } + + public async listTracks(jukeboxId: number) { + const queue = await this.getQueue(jukeboxId) return queue.list() } - public queueTrack(jukeboxId: number, track: Track, position = -1) { - const queue = this.getQueue(jukeboxId) + public async queueTrack(jukeboxId: number, track: Track, position = -1) { + const queue = await this.getQueue(jukeboxId) queue.push(track) if (position >= 0) { queue.setPosition(track, position) } + + await this.commitQueue(jukeboxId, queue) + return track + } + + public async popTrack(jukeboxId: number): Promise { + const queue = await this.getQueue(jukeboxId) + const track: Track | null = queue.pop() ?? null + console.log('New queue:', queue.list()) + + this.commitQueue(jukeboxId, queue) + return track + } + + public async peekTrack(jukeboxId: number): Promise { + const queue = await this.getQueue(jukeboxId) + const track: Track | null = queue.peek() ?? null + + return track } } diff --git a/src/spotify/spotify.service.ts b/src/spotify/spotify.service.ts index 57ae974..58c9050 100644 --- a/src/spotify/spotify.service.ts +++ b/src/spotify/spotify.service.ts @@ -1,11 +1,22 @@ import { Injectable } from '@nestjs/common' +import { Track } from '@spotify/web-api-ts-sdk' import { SpotifyTokensDto } from './dto/spotify-tokens.dto' import { SpotifyBaseService } from './spotify-base.service' @Injectable() export class SpotifyService extends SpotifyBaseService { - public async getTrack(account: SpotifyTokensDto, trackId: string) { - const sdk = this.getSdk(account) + public async getTrack(spotifyAuth: SpotifyTokensDto, trackId: string) { + const sdk = this.getSdk(spotifyAuth) return await sdk.tracks.get(trackId) } + + public async queueTrack(spotifyAuth: SpotifyTokensDto, track: Track) { + const sdk = this.getSdk(spotifyAuth) + await sdk.player.addItemToPlaybackQueue(track.uri) + } + + public async setPlayerDevice(spotifyAuth: SpotifyTokensDto, deviceId: string) { + const sdk = this.getSdk(spotifyAuth) + await sdk.player.transferPlayback([deviceId]) + } } From 2947bf1c612d79ec4b3d24d61f95c8e6acf58815 Mon Sep 17 00:00:00 2001 From: Isaac Hunter Date: Sat, 2 Nov 2024 15:27:03 -0400 Subject: [PATCH 3/6] setup 2-way updates with client via ws, improve queue management --- proxy/api/index.html | 6 +- proxy/api/output.swagger.yml | 66 +++++++++++++++++++ src/app.gateway.ts | 12 ++-- src/jukebox/dto/track-player-state.dto.ts | 11 ++++ src/jukebox/jukebox.controller.ts | 9 +++ src/jukebox/jukebox.gateway.ts | 41 ++++++------ src/jukebox/jukebox.module.ts | 3 +- .../track-queue/track-queue.service.ts | 1 - src/types/spotify.d.ts | 13 ++++ 9 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 src/jukebox/dto/track-player-state.dto.ts create mode 100644 src/types/spotify.d.ts diff --git a/proxy/api/index.html b/proxy/api/index.html index 83e479e..53b9e67 100644 --- a/proxy/api/index.html +++ b/proxy/api/index.html @@ -392,7 +392,7 @@ -

OSC Microservices API (1.0.0)

Download OpenAPI specification:Download

jukeboxes

JukeboxController_create

Request Body schema: application/json
required
name
required
string
club_id
required
number

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "club_id": 0
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_findAll

Responses

Response samples

Content type
application/json
[
  • {
    }
]

JukeboxController_findOne

path Parameters
id
required
number

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_update

path Parameters
id
required
number
Request Body schema: application/json
required
name
string

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_remove

path Parameters
id
required
number

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_addLinkToJukebox

path Parameters
jukebox_id
required
number
Request Body schema: application/json
required
type
required
string
email
required
string

Responses

Request samples

Content type
application/json
{
  • "type": "string",
  • "email": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "type": "string",
  • "email": "string",
  • "active": true
}

spotify

SpotifyController_login

Responses

SpotifyController_loginSuccessCallback

Responses

Response samples

Content type
application/json
{ }

club

club_clubs_list

OSC Microservices API (1.0.0)

Download OpenAPI specification:Download

jukeboxes

JukeboxController_create

Request Body schema: application/json
required
name
required
string
club_id
required
number

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "club_id": 0
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_findAll

Responses

Response samples

Content type
application/json
[
  • {
    }
]

JukeboxController_findOne

path Parameters
id
required
number

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_update

path Parameters
id
required
number
Request Body schema: application/json
required
name
string

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_remove

path Parameters
id
required
number

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "name": "string",
  • "club_id": 0,
  • "links": [
    ]
}

JukeboxController_addLinkToJukebox

path Parameters
jukebox_id
required
number
Request Body schema: application/json
required
type
required
string
email
required
string

Responses

Request samples

Content type
application/json
{
  • "type": "string",
  • "email": "string"
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "type": "string",
  • "email": "string",
  • "active": true
}

JukeboxController_getTracksQueue

path Parameters
jukebox_id
required
number

Responses

Response samples

Content type
application/json
[
  • { }
]

JukeboxController_addTrackToQueue

path Parameters
jukebox_id
required
number
Request Body schema: application/json
required
track_id
required
string
position
number

Responses

Request samples

Content type
application/json
{
  • "track_id": "string",
  • "position": 0
}

Response samples

Content type
application/json
{ }

JukeboxController_connectJukeboxPlayer

path Parameters
jukebox_id
required
number

Responses

spotify

SpotifyController_login

Responses

SpotifyController_loginSuccessCallback

Responses

Response samples

Content type
application/json
{ }

club

club_clubs_list

CRUD Api routes for Club models.

Authorizations:
tokenAuth

Responses

Response samples

Content type
application/json
[
  • {
    }
]

club_clubs_create

CRUD Api routes for Club models.

@@ -476,7 +476,7 @@ " class="sc-epnzzT sc-eMwmJz drsioI dWZUhK">

Create a new user in the system.

Authorizations:
cookieAuthbasicAuthNone
Request Body schema:
required
username
string
email
required
string <email>
first_name
string
last_name
string
password
required
string [ 5 .. 128 ] characters
Array of objects (UserClubNested)

Responses

Request samples

Content type
{
  • "username": "string",
  • "email": "user@example.com",
  • "first_name": "string",
  • "last_name": "string",
  • "password": "string",
  • "clubs": [
    ]
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "username": "string",
  • "email": "user@example.com",
  • "first_name": "string",
  • "last_name": "string",
  • "clubs": [
    ]
}