From f02635b83c670f81af81eb4f3dfcb5e699fca9c4 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 17 Jan 2022 16:51:49 +0100 Subject: [PATCH] Update spotify connection --- app/Controllers/Http/SongsController.ts | 14 ++- app/Models/Song.ts | 7 +- app/Tasks/CurrentSongTask.ts | 4 +- app/Tasks/HistorySongsTask.ts | 5 +- app/Types/ILocalSpotify.ts | 1 + app/Types/ISpotify.ts | 1 + app/Utils/SongUtils.ts | 113 +++++++++--------- app/Validators/song/SongHistoryValidator.ts | 2 +- .../1642256040742_spotify_songs_history.ts | 5 +- package.json | 3 +- providers/AppProvider.ts | 16 +-- start/routes/api.ts | 22 ++-- yarn.lock | 25 ++++ 13 files changed, 130 insertions(+), 88 deletions(-) diff --git a/app/Controllers/Http/SongsController.ts b/app/Controllers/Http/SongsController.ts index 447d557..f1da731 100644 --- a/app/Controllers/Http/SongsController.ts +++ b/app/Controllers/Http/SongsController.ts @@ -11,12 +11,12 @@ import SongHistoryValidator from 'App/Validators/song/SongHistoryValidator' export default class SongsController { public async getCurrentSong({ response }: HttpContextContract) { - return response.status(200).send(getCurrentPlayingFromCache()) + return response.status(200).send(await getCurrentPlayingFromCache()) } public async getHistory({ request, response }: HttpContextContract) { const { range } = await request.validate(SongHistoryValidator) - const history = await getHistory(range) + const history = await getHistory(range || 'day') return response.status(200).send({ history, }) @@ -35,10 +35,14 @@ export default class SongsController { } public async authorize({ response }: HttpContextContract) { - return response.status(200).redirect(getAuthorizationURI()) + return response.redirect(getAuthorizationURI()) } - public async callback({ request }: HttpContextContract) { - await setupSpotify(request.param('code')) + public async callback({ request, response }: HttpContextContract) { + if (await setupSpotify(request.qs().code)) { + return response.status(200).send({ + message: 'Athena successfully connected to Spotify', + }) + } } } diff --git a/app/Models/Song.ts b/app/Models/Song.ts index 358d89c..66b9508 100644 --- a/app/Models/Song.ts +++ b/app/Models/Song.ts @@ -1,8 +1,10 @@ import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm' export default class Song extends BaseModel { + public static table = 'spotify_songs_history' + @column({ isPrimary: true }) - public date: number + public date: Date @column() public device_name: string @@ -13,6 +15,9 @@ export default class Song extends BaseModel { @column() public item_name: string + @column() + public item_id: string + @column() public item_type: string diff --git a/app/Tasks/CurrentSongTask.ts b/app/Tasks/CurrentSongTask.ts index 34d350c..862a2a0 100644 --- a/app/Tasks/CurrentSongTask.ts +++ b/app/Tasks/CurrentSongTask.ts @@ -1,11 +1,11 @@ import Logger from '@ioc:Adonis/Core/Logger' import { getCurrentPlayingFromSpotify, getSpotifyAccount } from 'App/Utils/SongUtils' -const MS = 1000 +const MS = 3000 let taskId async function SpotifyCurrentListeningWatcher(): Promise { - if (getSpotifyAccount().access === '') return + if ((await getSpotifyAccount()).access_token === '') return await getCurrentPlayingFromSpotify() } diff --git a/app/Tasks/HistorySongsTask.ts b/app/Tasks/HistorySongsTask.ts index 93fff04..c612e4f 100644 --- a/app/Tasks/HistorySongsTask.ts +++ b/app/Tasks/HistorySongsTask.ts @@ -12,15 +12,16 @@ async function LogSpotifyHistory(): Promise { if (current.progress && current.progress < 1000) return - const last_entry = await Song.query().where('id', current.id!).orderBy('date', 'desc').first() + const last_entry = await Song.query().where('item_id', current.id!).orderBy('date', 'desc').first() if (last_entry && new Date().getTime() - last_entry.duration <= new Date(last_entry.date).getTime()) return await Song.create({ - date: current.started_at, + date: new Date(current.started_at!), duration: current.duration, item_name: current.name, item_type: current.type, + item_id: current.id, author: current.author, device_name: current.device_name, device_type: current.device_type, diff --git a/app/Types/ILocalSpotify.ts b/app/Types/ILocalSpotify.ts index 574d6b2..8d54963 100644 --- a/app/Types/ILocalSpotify.ts +++ b/app/Types/ILocalSpotify.ts @@ -11,6 +11,7 @@ export interface SpotifyTrack { item: { name: string type: string + id: string } device: { name: string diff --git a/app/Types/ISpotify.ts b/app/Types/ISpotify.ts index b7a1975..c4f6810 100644 --- a/app/Types/ISpotify.ts +++ b/app/Types/ISpotify.ts @@ -1,6 +1,7 @@ export interface SpotifyToken { access_token: string refresh_token: string + expires_in: number } interface Device { diff --git a/app/Utils/SongUtils.ts b/app/Utils/SongUtils.ts index b17bf6d..02c2bc3 100644 --- a/app/Utils/SongUtils.ts +++ b/app/Utils/SongUtils.ts @@ -1,35 +1,47 @@ -import { readFileSync, writeFileSync } from 'fs' import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import Env from '@ioc:Adonis/Core/Env' import Redis from '@ioc:Adonis/Addons/Redis' import { SpotifyArtist, SpotifyTrack } from 'App/Types/ILocalSpotify' import { Artist, InternalPlayerResponse, PlayerResponse, SpotifyToken } from 'App/Types/ISpotify' import Song from 'App/Models/Song' +import queryString from 'query-string' -export function getSpotifyAccount(): { access: string; refresh: string } { - console.log(JSON.parse(readFileSync('spotify.json').toString())) - return JSON.parse(readFileSync('spotify.json').toString()) +export async function getSpotifyAccount(): Promise { + return await Redis.exists('spotify:account') + ? JSON.parse(await Redis.get('spotify:account') || '{}') + : { + access_token: '', + refresh_token: '', + expires_in: -1, + } +} + +export async function setSpotifyAccount(token: SpotifyToken): Promise { + await Redis.set('spotify:account', JSON.stringify({ + access_token: token.access_token, + refresh_token: token.refresh_token, + }), 'ex', token.expires_in) } export function getAuthorizationURI(): string { - const query = JSON.stringify({ + const query = queryString.stringify({ response_type: 'code', client_id: Env.get('SPOTIFY_ID'), - scope: encodeURIComponent('user-read-playback-state user-read-currently-playing'), + scope: encodeURIComponent('user-read-playback-state user-read-currently-playing user-top-read'), redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`, }) return `https://accounts.spotify.com/authorize?${query}` } -export async function setupSpotify(code: string): Promise { +export async function setupSpotify(code: string): Promise { const authorization_tokens: AxiosResponse = await axios.post( 'https://accounts.spotify.com/api/token', - { + queryString.stringify({ code, grant_type: 'authorization_code', redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`, - }, + }), { headers: { 'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`, @@ -38,26 +50,21 @@ export async function setupSpotify(code: string): Promise { }, ) - if (authorization_tokens.status === 200) { - writeFileSync( - 'spotify.json', - JSON.stringify({ - access: authorization_tokens.data.access_token, - refresh: authorization_tokens.data.refresh_token, - }), - ) - } + if (authorization_tokens.status === 200) + await setSpotifyAccount(authorization_tokens.data) + + return true } export async function regenerateTokens(): Promise { - const refresh_token = getSpotifyAccount().refresh + const refresh_token = (await getSpotifyAccount()).refresh_token const authorization_tokens: AxiosResponse = await axios.post( 'https://accounts.spotify.com/api/token', - { + queryString.stringify({ grant_type: 'refresh_token', refresh_token, - }, + }), { headers: { 'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`, @@ -66,22 +73,15 @@ export async function regenerateTokens(): Promise { }, ) - if (authorization_tokens.status === 200) { - writeFileSync( - 'spotify.json', - JSON.stringify({ - access: authorization_tokens.data.access_token, - refresh: authorization_tokens.data.refresh_token, - }), - ) - } + if (authorization_tokens.status === 200) + await setSpotifyAccount(authorization_tokens.data) } async function RequestWrapper(url: string): Promise> { let request const options: AxiosRequestConfig = { headers: { - Authorization: `Bearer ${getSpotifyAccount().access}`, + Authorization: `Bearer ${(await getSpotifyAccount()).access_token}`, }, } request = await axios.get(url, options) @@ -94,17 +94,15 @@ async function RequestWrapper(url: string): Promise> } export async function getCurrentPlayingFromCache(): Promise { - return JSON.parse(await Redis.get('spotify:current') || '') || { is_playing: false } + return JSON.parse(await Redis.get('spotify:current') || '{}') || { is_playing: false } } export async function getCurrentPlayingFromSpotify(): Promise { + if ((await getSpotifyAccount()).access_token === '') return { is_playing: false } const current_track = await RequestWrapper('https://api.spotify.com/v1/me/player?additional_types=track,episode') let current: InternalPlayerResponse - if (current_track.data && !['track', 'episode'].includes(current_track.data.currently_playing_type)) - current = { is_playing: false } - if (current_track.data && current_track.data.is_playing) { current = { is_playing: true, @@ -140,7 +138,7 @@ export async function updateCurrentSong(song: InternalPlayerResponse): Promise { if (await Redis.exists('spotify:top:artists')) - return JSON.parse(await Redis.get('spotify:top:artists') || '') + return JSON.parse(await Redis.get('spotify:top:artists') || '{}') - const fetched_artists = await RequestWrapper<{ items: Artist[] }>('https://api.spotify.com/v1/me/top/type/artists?limit=5') + const fetched_artists = await RequestWrapper<{ items: Artist[] }>('https://api.spotify.com/v1/me/top/artists?limit=10') const artists: SpotifyArtist[] = [] @@ -191,29 +190,30 @@ export async function fetchTopArtist(): Promise { await Redis.set('spotify:top:artists', JSON.stringify({ cached: new Date().toUTCString(), + expiration: new Date(new Date().setMinutes(new Date().getMinutes() + 5)).toUTCString(), top: artists, }), 'ex', 600) return artists } -export async function fetchTopTrack(): Promise { +export async function fetchTopTrack(): Promise> { if (await Redis.exists('spotify:top:tracks')) - return JSON.parse(await Redis.get('spotify:top:tracks') || '') + return JSON.parse(await Redis.get('spotify:top:tracks') || '{}') - const fetched_tracks = await Song - .query() - .orderBy('date', 'desc') - .limit(5) + // Fetch all songs + const fetched_tracks = await Song.query() - const tracks: SpotifyTrack[] = [] + if (fetched_tracks.length <= 0) + return [] - if (fetched_tracks.length >= 0) { - for (const track of fetched_tracks) { - tracks.push({ + const filtered = fetched_tracks.map((track) => { + return { + ...{ item: { name: track.item_name, type: track.item_type, + id: track.item_id, }, device: { name: track.device_name, @@ -222,17 +222,20 @@ export async function fetchTopTrack(): Promise { duration: track.duration, author: track.author, image: track.image, - }) + }, + times: fetched_tracks.filter(i => i.item_id === track.item_id).length, } - } - else { - return [] - } + }) + + const sorted = filtered.sort((itemA, itemB) => itemB.times - itemA.times) + + const remove_dupes = sorted.filter((thing, index, self) => index === self.findIndex(t => t.item.id === thing.item.id)).slice(0, 10) await Redis.set('spotify:top:tracks', JSON.stringify({ cached: new Date().toUTCString(), - top: tracks, + expiration: new Date(new Date().setMinutes(new Date().getMinutes() + 5)).toUTCString(), + top: remove_dupes, }), 'ex', 300) - return tracks + return remove_dupes } diff --git a/app/Validators/song/SongHistoryValidator.ts b/app/Validators/song/SongHistoryValidator.ts index 923cdd7..e524f5e 100644 --- a/app/Validators/song/SongHistoryValidator.ts +++ b/app/Validators/song/SongHistoryValidator.ts @@ -6,7 +6,7 @@ export default class SongHistoryValidator { } public schema = schema.create({ - range: schema.enum(['day', 'week', 'month', 'total'] as const), + range: schema.enum.optional(['day', 'week', 'month', 'total'] as const), }) public messages = { diff --git a/database/migrations/1642256040742_spotify_songs_history.ts b/database/migrations/1642256040742_spotify_songs_history.ts index f1ca497..93ace90 100644 --- a/database/migrations/1642256040742_spotify_songs_history.ts +++ b/database/migrations/1642256040742_spotify_songs_history.ts @@ -4,10 +4,11 @@ export default class SpotifySongsHistory extends BaseSchema { protected tableName = 'spotify_songs_history' public async up() { - this.schema.alterTable(this.tableName, (table) => { - table.timestamp('date').primary() + this.schema.createTable(this.tableName, (table) => { + table.timestamp('date').primary().unique().defaultTo(this.now()) table.string('device_name').notNullable() table.string('device_type').notNullable() + table.string('item_id').notNullable() table.string('item_name').notNullable() table.string('item_type').notNullable() table.string('author').notNullable() diff --git a/package.json b/package.json index 73554fd..cf1c398 100755 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "node ace serve --watch", "seed": "node ace db:seed", "mig": "node ace migration:run", - "lr": "node ace list:routes", + "lr": "node ace routes:pretty-list", "lint": "npx eslint --ext .json,.ts --fix ." }, "dependencies": { @@ -29,6 +29,7 @@ "phc-argon2": "^1.1.1", "pretty-list-routes": "^0.0.5", "proxy-addr": "^2.0.7", + "query-string": "^7.1.0", "reflect-metadata": "^0.1.13", "tslib": "^2.3.1" }, diff --git a/providers/AppProvider.ts b/providers/AppProvider.ts index 6026cc9..fe473c3 100755 --- a/providers/AppProvider.ts +++ b/providers/AppProvider.ts @@ -20,13 +20,13 @@ export default class AppProvider { // App is ready const StatsTask = await import('App/Tasks/StatsTask') const StatesTask = await import('App/Tasks/StatesTask') - // const CurrentSongTask = await import('App/Tasks/CurrentSongTask') - // const HistorySongsTask = await import('App/Tasks/HistorySongsTask') + const CurrentSongTask = await import('App/Tasks/CurrentSongTask') + const HistorySongsTask = await import('App/Tasks/HistorySongsTask') await StatsTask.Activate() await StatesTask.Activate() - // await CurrentSongTask.Activate() - // await HistorySongsTask.Activate() + await CurrentSongTask.Activate() + await HistorySongsTask.Activate() Logger.info('Application is ready!') } @@ -35,13 +35,13 @@ export default class AppProvider { // Cleanup, since app is going down const StatsTask = await import('App/Tasks/StatsTask') const StatesTask = await import('App/Tasks/StatesTask') - // const CurrentSongTask = await import('App/Tasks/CurrentSongTask') - // const HistorySongsTask = await import('App/Tasks/HistorySongsTask') + const CurrentSongTask = await import('App/Tasks/CurrentSongTask') + const HistorySongsTask = await import('App/Tasks/HistorySongsTask') await StatsTask.ShutDown() await StatesTask.ShutDown() - // await CurrentSongTask.ShutDown() - // await HistorySongsTask.ShutDown() + await CurrentSongTask.ShutDown() + await HistorySongsTask.ShutDown() Logger.info('Application is closing. Bye...') } diff --git a/start/routes/api.ts b/start/routes/api.ts index a020e0a..0e81220 100644 --- a/start/routes/api.ts +++ b/start/routes/api.ts @@ -6,6 +6,17 @@ Route.get('/stats', 'StatsController.index') Route.get('/states', 'StatesController.index') Route.resource('/locations', 'LocationsController').only(['index', 'store']) +Route.group(() => { + Route.get('/', 'SongsController.getCurrentSong') + Route.get('/history', 'SongsController.getHistory') + + Route.get('/top/track', 'SongsController.getTopTrack') + Route.get('/top/artist', 'SongsController.getTopArtist') + + Route.get('/authorize', 'SongsController.authorize') + Route.get('/callback', 'SongsController.callback') +}).prefix('spotify') + Route.group(() => { Route.resource('/users', 'UsersController').except(['edit', 'create']) @@ -21,17 +32,6 @@ Route.group(() => { Route.post('/commands', 'StatsController.incrementCommandCount') Route.post('/builds', 'StatsController.incrementBuildCount') }).prefix('stats') - - Route.group(() => { - Route.get('/', 'SongsController.getCurrentSong') - Route.get('/history', 'SongsController.getHistory') - - Route.get('/top/track', 'SongsController.getTopTrack') - Route.get('/top/artist', 'SongsController.getTopArtist') - - Route.get('/authorize', 'SongsController.authorize') - Route.get('/callback', 'SongsController.callback') - }).prefix('spotify') }).middleware('auth:web,api') Route.get('/files/:filename', async({ response, params }) => { diff --git a/yarn.lock b/yarn.lock index bc45858..ea29df7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2851,6 +2851,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +filter-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" + integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= + find-cache-dir@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" @@ -5419,6 +5424,16 @@ qs@^6.10.1: dependencies: side-channel "^1.0.4" +query-string@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.0.tgz#96b88f27b39794f97b8c8ccd060bc900495078ef" + integrity sha512-wnJ8covk+S9isYR5JIXPt93kFUmI2fQ4R/8130fuq+qwLiGVTurg7Klodgfw4NSz/oe7xnyi09y3lSrogUeM3g== + dependencies: + decode-uri-component "^0.2.0" + filter-obj "^1.1.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -6017,6 +6032,11 @@ split-lines@^2.0.0: resolved "https://registry.yarnpkg.com/split-lines/-/split-lines-2.1.0.tgz#3bc9dbf75637c8bae6ed5dcbc7dbd83956b72311" integrity sha512-8dv+1zKgTpfTkOy8XZLFyWrfxO0NV/bj/3EaQ+hBrBxGv2DwiroljPjU8NlCr+59nLnsVm9WYT7lXKwe4TC6bw== +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" + integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -6059,6 +6079,11 @@ static-extend@^0.1.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"