From 240fcbac90366213668f6cf1ed6cf4b09b42d7a0 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Sat, 15 Jan 2022 19:35:43 +0100 Subject: [PATCH] Connect Athena to Spotify --- .adonisrc.json | 3 +- .env.example | 3 + ace-manifest.json | 65 ++++- app/Controllers/Http/SongsController.ts | 44 ++++ app/Controllers/Http/StatesController.ts | 5 +- app/Models/Song.ts | 27 ++ app/Tasks/CurrentSongTask.ts | 16 ++ app/Tasks/HistorySongsTask.ts | 40 +++ app/Tasks/SongsTask.ts | 13 - app/Tasks/StatesTask.ts | 44 +--- app/Types/ILocalSpotify.ts | 22 ++ app/Types/ISpotify.ts | 110 ++++++++ app/Types/IStats.ts | 16 ++ app/Utils/SongUtils.ts | 238 +++++++++++++++++- app/Utils/StatesTask.ts | 40 +++ app/Utils/StatsUtils.ts | 18 +- app/Validators/song/SongHistoryValidator.ts | 15 ++ .../1642256040742_spotify_songs_history.ts | 22 ++ env.ts | 18 +- package.json | 2 + start/routes/api.ts | 11 + start/routes/home.ts | 1 + yarn.lock | 10 + 23 files changed, 684 insertions(+), 99 deletions(-) create mode 100644 app/Controllers/Http/SongsController.ts create mode 100644 app/Models/Song.ts create mode 100644 app/Tasks/CurrentSongTask.ts create mode 100644 app/Tasks/HistorySongsTask.ts delete mode 100644 app/Tasks/SongsTask.ts create mode 100644 app/Types/ILocalSpotify.ts create mode 100644 app/Types/ISpotify.ts create mode 100644 app/Types/IStats.ts create mode 100644 app/Utils/StatesTask.ts create mode 100644 app/Validators/song/SongHistoryValidator.ts create mode 100644 database/migrations/1642256040742_spotify_songs_history.ts diff --git a/.adonisrc.json b/.adonisrc.json index a34ea65..016dbae 100755 --- a/.adonisrc.json +++ b/.adonisrc.json @@ -6,7 +6,8 @@ "@adonisjs/repl/build/commands", "@adonisjs/lucid/build/commands", "@adonisjs/mail/build/commands", - "@adonisjs/bouncer/build/commands" + "@adonisjs/bouncer/build/commands", + "pretty-list-routes/build/commands/PrettyRoute.js" ], "exceptionHandlerNamespace": "App/Exceptions/Handler", "aliases": { diff --git a/.env.example b/.env.example index b09cfdf..d0152e6 100755 --- a/.env.example +++ b/.env.example @@ -37,3 +37,6 @@ SMTP_PASSWORD= WAKATIME_USER= WAKATIME_KEY= WAKATIME_ID= + +SPOTIFY_ID= +SPOTIFY_SECRET= diff --git a/ace-manifest.json b/ace-manifest.json index 149a594..a88b18a 100755 --- a/ace-manifest.json +++ b/ace-manifest.json @@ -2,7 +2,7 @@ "commands": { "dump:rcfile": { "settings": {}, - "commandPath": "@adonisjs/core/commands/DumpRc", + "commandPath": "@adonisjs/core/build/commands/DumpRc", "commandName": "dump:rcfile", "description": "Dump contents of .adonisrc.json file along with defaults", "args": [], @@ -13,7 +13,7 @@ "settings": { "loadApp": true }, - "commandPath": "@adonisjs/core/commands/ListRoutes", + "commandPath": "@adonisjs/core/build/commands/ListRoutes", "commandName": "list:routes", "description": "List application routes", "args": [], @@ -29,7 +29,7 @@ }, "generate:key": { "settings": {}, - "commandPath": "@adonisjs/core/commands/GenerateKey", + "commandPath": "@adonisjs/core/build/commands/GenerateKey", "commandName": "generate:key", "description": "Generate a new APP_KEY secret", "args": [], @@ -83,7 +83,9 @@ ] }, "make:model": { - "settings": {}, + "settings": { + "loadApp": true + }, "commandPath": "@adonisjs/lucid/build/commands/MakeModel", "commandName": "make:model", "description": "Make a new Lucid model", @@ -314,6 +316,61 @@ "description": "Actions to implement" } ] + }, + "routes:pretty-list": { + "settings": { + "loadApp": true, + "stayAlive": false + }, + "commandPath": "pretty-list-routes/build/commands/PrettyRoute", + "commandName": "routes:pretty-list", + "description": "", + "args": [], + "aliases": [], + "flags": [ + { + "name": "verbose", + "propertyName": "verbose", + "type": "boolean", + "alias": "f", + "description": "Display more information" + }, + { + "name": "reverse", + "propertyName": "reverse", + "type": "boolean", + "alias": "r", + "description": "Reverse routes display" + }, + { + "name": "json", + "propertyName": "json", + "type": "boolean", + "alias": "j", + "description": "Output to JSNO" + }, + { + "name": "method", + "propertyName": "methodFilter", + "type": "string", + "alias": "m", + "description": "Filter routes by method" + }, + { + "name": "path", + "propertyName": "pathFilter", + "type": "string", + "alias": "p", + "description": "Filter routes by path" + }, + { + "name": "name", + "propertyName": "nameFilter", + "type": "string", + "alias": "n", + "description": "Filter routes by name" + } + ] } }, "aliases": {} diff --git a/app/Controllers/Http/SongsController.ts b/app/Controllers/Http/SongsController.ts new file mode 100644 index 0000000..447d557 --- /dev/null +++ b/app/Controllers/Http/SongsController.ts @@ -0,0 +1,44 @@ +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' +import { + fetchTopArtist, + fetchTopTrack, + getAuthorizationURI, + getCurrentPlayingFromCache, + getHistory, + setupSpotify, +} from 'App/Utils/SongUtils' +import SongHistoryValidator from 'App/Validators/song/SongHistoryValidator' + +export default class SongsController { + public async getCurrentSong({ response }: HttpContextContract) { + return response.status(200).send(getCurrentPlayingFromCache()) + } + + public async getHistory({ request, response }: HttpContextContract) { + const { range } = await request.validate(SongHistoryValidator) + const history = await getHistory(range) + return response.status(200).send({ + history, + }) + } + + public async getTopTrack({ response }: HttpContextContract) { + return response.status(200).send({ + tracks: await fetchTopTrack(), + }) + } + + public async getTopArtist({ response }: HttpContextContract) { + return response.status(200).send({ + tracks: await fetchTopArtist(), + }) + } + + public async authorize({ response }: HttpContextContract) { + return response.status(200).redirect(getAuthorizationURI()) + } + + public async callback({ request }: HttpContextContract) { + await setupSpotify(request.param('code')) + } +} diff --git a/app/Controllers/Http/StatesController.ts b/app/Controllers/Http/StatesController.ts index ae60764..a7f292d 100644 --- a/app/Controllers/Http/StatesController.ts +++ b/app/Controllers/Http/StatesController.ts @@ -1,17 +1,16 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import Redis from '@ioc:Adonis/Addons/Redis' import StateSleepingValidator from 'App/Validators/states/StateSleepingValidator' +import { getCurrentPlayingFromCache } from 'App/Utils/SongUtils' export default class StatesController { - // Listening Music - public async index({ response }: HttpContextContract) { const sleeping = this.formatValue(await Redis.get('states:sleeping')) const developing = this.formatValue(await Redis.get('states:developing')) return response.status(200).send({ sleeping, developing, - listening_music: 'Soon', + listening_music: await getCurrentPlayingFromCache(), }) } diff --git a/app/Models/Song.ts b/app/Models/Song.ts new file mode 100644 index 0000000..358d89c --- /dev/null +++ b/app/Models/Song.ts @@ -0,0 +1,27 @@ +import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm' + +export default class Song extends BaseModel { + @column({ isPrimary: true }) + public date: number + + @column() + public device_name: string + + @column() + public device_type: string + + @column() + public item_name: string + + @column() + public item_type: string + + @column() + public author: string + + @column() + public image: string + + @column() + public duration: number +} diff --git a/app/Tasks/CurrentSongTask.ts b/app/Tasks/CurrentSongTask.ts new file mode 100644 index 0000000..a716ed7 --- /dev/null +++ b/app/Tasks/CurrentSongTask.ts @@ -0,0 +1,16 @@ +import Logger from '@ioc:Adonis/Core/Logger' +import { getCurrentPlayingFromSpotify } from 'App/Utils/SongUtils' + +const MS = 1000 +let taskId + +export async function Activate(): Promise { + Logger.info(`Starting task runner for watching spotify current playing [${MS} ms]`) + await getCurrentPlayingFromSpotify() + taskId = setInterval(getCurrentPlayingFromSpotify, MS) +} + +export function ShutDown(): void { + clearInterval(taskId) + Logger.info('Shutdown task runner for getting current developing state') +} diff --git a/app/Tasks/HistorySongsTask.ts b/app/Tasks/HistorySongsTask.ts new file mode 100644 index 0000000..93fff04 --- /dev/null +++ b/app/Tasks/HistorySongsTask.ts @@ -0,0 +1,40 @@ +import Logger from '@ioc:Adonis/Core/Logger' +import { getCurrentPlayingFromCache } from 'App/Utils/SongUtils' +import Song from 'App/Models/Song' + +const MS = 10000 +let taskId + +async function LogSpotifyHistory(): Promise { + const current = await getCurrentPlayingFromCache() + + if (!current.is_playing) return + + if (current.progress && current.progress < 1000) return + + const last_entry = await Song.query().where('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, + duration: current.duration, + item_name: current.name, + item_type: current.type, + author: current.author, + device_name: current.device_name, + device_type: current.device_type, + image: current.image?.url, + }) +} + +export async function Activate(): Promise { + Logger.info(`Starting task runner for tracking spotify listen history [${MS} ms]`) + await LogSpotifyHistory() + taskId = setInterval(LogSpotifyHistory, MS) +} + +export function ShutDown(): void { + clearInterval(taskId) + Logger.info('Shutdown task runner for getting current developing state') +} diff --git a/app/Tasks/SongsTask.ts b/app/Tasks/SongsTask.ts deleted file mode 100644 index 18698f2..0000000 --- a/app/Tasks/SongsTask.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Logger from '@ioc:Adonis/Core/Logger' - -const MS = 1000 - -export async function getCurrentPlayingMusic(): Promise { - // Fetch from deezer -} - -export async function Activate(): Promise { - Logger.info(`Starting task runner for watching deezer current playing [${MS} ms]`) - await getCurrentPlayingMusic() - setInterval(getCurrentPlayingMusic, MS) -} diff --git a/app/Tasks/StatesTask.ts b/app/Tasks/StatesTask.ts index 5ed048a..198af33 100644 --- a/app/Tasks/StatesTask.ts +++ b/app/Tasks/StatesTask.ts @@ -1,51 +1,13 @@ -import { btoa } from 'buffer' -import axios from 'axios' -import Env from '@ioc:Adonis/Core/Env' import Logger from '@ioc:Adonis/Core/Logger' -import Redis from '@ioc:Adonis/Addons/Redis' +import { fetchDevelopingState } from 'App/Utils/StatesTask' const MS = 1000 * 2 * 60 // 2 min let taskId -interface StatesResponse { - time: number -} - -async function getCurrentTime(): Promise { - try { - const response = await axios.get<{ data: StatesResponse[]}>(`https://wakatime.com/api/v1/users/${Env.get('WAKATIME_USER')}/heartbeats`, { - headers: { - Authorization: `Basic ${btoa(Env.get('WAKATIME_KEY'))}`, - }, - params: { - date: new Date(), - }, - }) - - if (response.status === 200) { - const heartbeat = response.data.data[response.data.data.length - 1] - const current_time = new Date(Date.now()).getTime() / 1000 - - if (heartbeat && heartbeat.time!) { - const active = current_time - heartbeat.time <= 60 * 5 // Less than 5 min. - const redis_state = await Redis.get('states:developing') === 'true' - - if (redis_state !== active) { - await Redis.set('states:developing', String(active)) - if (redis_state) await Redis.set('states:sleeping', 'false') - } - } - } - } - catch (error) { - Logger.error('Error while getting the states') - } -} - export async function Activate(): Promise { Logger.info(`Starting task runner for getting current developing state [every ${MS} ms]`) - await getCurrentTime() - taskId = setInterval(getCurrentTime, MS) + await fetchDevelopingState() + taskId = setInterval(fetchDevelopingState, MS) } export function ShutDown(): void { diff --git a/app/Types/ILocalSpotify.ts b/app/Types/ILocalSpotify.ts new file mode 100644 index 0000000..574d6b2 --- /dev/null +++ b/app/Types/ILocalSpotify.ts @@ -0,0 +1,22 @@ +export interface SpotifyArtist { + id: string + name: string + image: string + genres: string[] + popularity: number + followers: number +} + +export interface SpotifyTrack { + item: { + name: string + type: string + } + device: { + name: string + type: string + } + author: string + duration: number + image: string +} diff --git a/app/Types/ISpotify.ts b/app/Types/ISpotify.ts new file mode 100644 index 0000000..b7a1975 --- /dev/null +++ b/app/Types/ISpotify.ts @@ -0,0 +1,110 @@ +export interface SpotifyToken { + access_token: string + refresh_token: string +} + +interface Device { + name: string + type: 'computer' | 'smartphone' | 'speaker' +} + +interface ExternalUrl { + spotify: string +} + +interface ExternalId { + isrc: string + ean: string + upc: string +} + +interface Image { + url: string + width: number + height: number +} + +interface Restriction { + reason: 'market' | 'explicit' | 'product' +} + +interface Follower { + href: string + total: number +} + +export interface Artist { + external_urls: ExternalUrl + followers: Follower + genres: string[] + href: string + id: string + images: Image[] + name: string + popularity: number + type: string + uri: string +} + +interface Album { + album_type: 'single' | 'album' | 'compilation' + total_tracks: number + available_markets: string[] + external_urls: ExternalUrl + href: string + id: string + images: Image[] + name: string + release_date: string + release_date_precision: 'day' | 'month' | 'year' + restrictions: Restriction + type: string + uri: string +} + +interface Item { + album: Album & { album_group: 'album' | 'single' | 'compilation' | 'appears_on' ; artists: Artist[] } + artists: Artist[] + available_markets: string[] + disc_number: number + duration_ms: number + explicit: boolean + external_ids: ExternalId + external_urls: ExternalUrl + href: string + id: string + is_playable: boolean + restrictions: Restriction + name: string + popularity: number + preview_url: string + track_number: number + type: string + uri: string + is_local: boolean +} + +export interface PlayerResponse { + device: Device + timestamp: number + is_playing: boolean + item: Item + progress_ms: number + shuffle_state: string + repeat_state: string + currently_playing_type: 'track' | 'episode' | 'ad' | 'unknown' +} + +export interface InternalPlayerResponse { + is_playing: boolean + device_name?: string + device_type?: string + name?: string + type?: string + author?: string + id?: string + image?: Image + progress?: number + duration?: number + started_at?: number +} diff --git a/app/Types/IStats.ts b/app/Types/IStats.ts new file mode 100644 index 0000000..7c681ad --- /dev/null +++ b/app/Types/IStats.ts @@ -0,0 +1,16 @@ +export interface Time { + hours: number + minutes: number + seconds: number +} + +export interface Stats { + range: { + start: string + end: string + } + + development_time: Time + commands_ran: number + builds_ran: number +} diff --git a/app/Utils/SongUtils.ts b/app/Utils/SongUtils.ts index 1f3bfb9..fb5f9b5 100644 --- a/app/Utils/SongUtils.ts +++ b/app/Utils/SongUtils.ts @@ -1,11 +1,237 @@ -export async function getHistory(range: 'day' | 'week' | 'month') { - return range +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' + +export function getSpotifyAccount(): { access: string; refresh: string } { + return JSON.parse(readFileSync('.config/.spotify').toString()) } -export async function getTopTrack() { - return 0 +export function getAuthorizationURI(): string { + const query = JSON.stringify({ + response_type: 'code', + client_id: Env.get('SPOTIFY_ID'), + scope: encodeURIComponent('user-read-playback-state user-read-currently-playing'), + redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`, + }) + + return `https://accounts.spotify.com/authorize?${query}` } -export async function GetCurrentPlaying() { - return null +export async function setupSpotify(code: string): Promise { + const authorization_tokens: AxiosResponse = await axios.post( + 'https://accounts.spotify.com/api/token', + { + 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')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ) + + if (authorization_tokens.status === 200) { + writeFileSync( + '.config/.spotify', + JSON.stringify({ + access: authorization_tokens.data.access_token, + refresh: authorization_tokens.data.refresh_token, + }), + ) + } +} + +export async function regenerateTokens(): Promise { + const refresh_token = getSpotifyAccount().refresh + + const authorization_tokens: AxiosResponse = await axios.post( + 'https://accounts.spotify.com/api/token', + { + grant_type: 'refresh_token', + refresh_token, + }, + { + headers: { + 'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ) + + if (authorization_tokens.status === 200) { + writeFileSync( + '.config/.spotify', + JSON.stringify({ + access: authorization_tokens.data.access_token, + refresh: authorization_tokens.data.refresh_token, + }), + ) + } +} + +async function RequestWrapper(url: string): Promise> { + let request + const options: AxiosRequestConfig = { + headers: { + Authorization: `Bearer ${getSpotifyAccount().access}`, + }, + } + request = await axios.get(url, options) + + if (request.status === 401) { + await regenerateTokens() + request = await axios.get(url, options) + } + return request +} + +export async function getCurrentPlayingFromCache(): Promise { + return JSON.parse(await Redis.get('spotify:current') || '') || { is_playing: false } +} + +export async function getCurrentPlayingFromSpotify(): Promise { + 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, + device_name: current_track.data.device.name, + device_type: current_track.data.device.type, + name: current_track.data.item.name, + type: current_track.data.item.type, + author: current_track.data.item.artists.map(artist => artist.name).join(', '), + id: current_track.data.item.id, + image: current_track.data.item.album.images[0], + progress: current_track.data.progress_ms, + duration: current_track.data.item.duration_ms, + started_at: current_track.data.timestamp, + } + } + else { + current = { is_playing: false } + } + + if ((await Redis.get('spotify:current') as string) !== JSON.stringify(current)) + await updateCurrentSong(current) + + return current +} + +export async function updateCurrentSong(song: InternalPlayerResponse): Promise { + // const current = JSON.parse(await Redis.get('spotify/current') as string) + await Redis.set('spotify:current', JSON.stringify(song)) + + // const changed = diff(current, song) + // todo send message to Rabbit +} + +export async function getHistory(range: 'day' | 'week' | 'month' | 'total') { + if (await Redis.exists(`spotify:history:range:${range || 'day'}`)) + return JSON.parse(await Redis.get(`spotify:history:range:${range || 'day'}`) || '') + + let startDate = new Date(new Date().getTime() - 24 * 60 * 60 * 1000) + if (range === 'week') startDate = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) + else if (range === 'month') startDate = new Date(new Date().setMonth(new Date().getMonth() - 1)) + + const endDate = new Date() + + const songs = await Song + .query() + .where('date', '<=', endDate) + .where('date', '>=', startDate) + .orderBy('date', 'desc') + + if (songs.length <= 0) + return { history: 'no_tracks_in_that_range' } + + await Redis.set(`spotify:history:range:${range || 'day'}`, JSON.stringify({ + cached: new Date().toUTCString(), + history: songs, + }), 'ex', 300) + + return { history: songs } +} + +export async function fetchTopArtist(): Promise { + if (await Redis.exists('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 artists: SpotifyArtist[] = [] + + if (fetched_artists.data) { + for (const artist of fetched_artists.data.items) { + artists.push({ + id: artist.id, + image: artist.images[0].url, + name: artist.name, + followers: artist.followers.total, + genres: artist.genres, + popularity: artist.popularity, + }) + } + } + else { + return [] + } + + await Redis.set('spotify:top:artists', JSON.stringify({ + cached: new Date().toUTCString(), + top: artists, + }), 'ex', 600) + + return artists +} + +export async function fetchTopTrack(): Promise { + if (await Redis.exists('spotify:top:tracks')) + return JSON.parse(await Redis.get('spotify:top:tracks') || '') + + const fetched_tracks = await Song + .query() + .orderBy('date', 'desc') + .limit(5) + + const tracks: SpotifyTrack[] = [] + + if (fetched_tracks.length >= 0) { + for (const track of fetched_tracks) { + tracks.push({ + item: { + name: track.item_name, + type: track.item_type, + }, + device: { + name: track.device_name, + type: track.device_type, + }, + duration: track.duration, + author: track.author, + image: track.image, + }) + } + } + else { + return [] + } + + await Redis.set('spotify:top:tracks', JSON.stringify({ + cached: new Date().toUTCString(), + top: tracks, + }), 'ex', 300) + + return tracks } diff --git a/app/Utils/StatesTask.ts b/app/Utils/StatesTask.ts new file mode 100644 index 0000000..9efc642 --- /dev/null +++ b/app/Utils/StatesTask.ts @@ -0,0 +1,40 @@ +import { btoa } from 'buffer' +import axios from 'axios' +import Env from '@ioc:Adonis/Core/Env' +import Redis from '@ioc:Adonis/Addons/Redis' +import Logger from '@ioc:Adonis/Core/Logger' + +interface StatesResponse { + time: number +} + +export async function fetchDevelopingState(): Promise { + try { + const response = await axios.get<{ data: StatesResponse[]}>(`https://wakatime.com/api/v1/users/${Env.get('WAKATIME_USER')}/heartbeats`, { + headers: { + Authorization: `Basic ${btoa(Env.get('WAKATIME_KEY'))}`, + }, + params: { + date: new Date(), + }, + }) + + if (response.status === 200) { + const heartbeat = response.data.data[response.data.data.length - 1] + const current_time = new Date(Date.now()).getTime() / 1000 + + if (heartbeat && heartbeat.time!) { + const active = current_time - heartbeat.time <= 60 * 5 // Less than 5 min. + const redis_state = await Redis.get('states:developing') === 'true' + + if (redis_state !== active) { + await Redis.set('states:developing', String(active)) + if (redis_state) await Redis.set('states:sleeping', 'false') + } + } + } + } + catch (error) { + Logger.error('Error while getting the states') + } +} diff --git a/app/Utils/StatsUtils.ts b/app/Utils/StatsUtils.ts index c5ddc43..8739591 100644 --- a/app/Utils/StatsUtils.ts +++ b/app/Utils/StatsUtils.ts @@ -1,23 +1,7 @@ import DevelopmentHour from 'App/Models/DevelopmentHour' import CommandsRun from 'App/Models/CommandsRun' import BuildsRun from 'App/Models/BuildsRun' - -interface Time { - hours: number - minutes: number - seconds: number -} - -export interface Stats { - range: { - start: string - end: string - } - - development_time: Time - commands_ran: number - builds_ran: number -} +import { Stats, Time } from 'App/Types/IStats' function formatDate(date: Date): string { return date.toISOString().split('T')[0] diff --git a/app/Validators/song/SongHistoryValidator.ts b/app/Validators/song/SongHistoryValidator.ts new file mode 100644 index 0000000..923cdd7 --- /dev/null +++ b/app/Validators/song/SongHistoryValidator.ts @@ -0,0 +1,15 @@ +import { schema } from '@ioc:Adonis/Core/Validator' +import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class SongHistoryValidator { + constructor(protected ctx: HttpContextContract) { + } + + public schema = schema.create({ + range: schema.enum(['day', 'week', 'month', 'total'] as const), + }) + + public messages = { + required: 'The field {{field}} is required', + } +} diff --git a/database/migrations/1642256040742_spotify_songs_history.ts b/database/migrations/1642256040742_spotify_songs_history.ts new file mode 100644 index 0000000..f1ca497 --- /dev/null +++ b/database/migrations/1642256040742_spotify_songs_history.ts @@ -0,0 +1,22 @@ +import BaseSchema from '@ioc:Adonis/Lucid/Schema' + +export default class SpotifySongsHistory extends BaseSchema { + protected tableName = 'spotify_songs_history' + + public async up() { + this.schema.alterTable(this.tableName, (table) => { + table.timestamp('date').primary() + table.string('device_name').notNullable() + table.string('device_type').notNullable() + table.string('item_name').notNullable() + table.string('item_type').notNullable() + table.string('author').notNullable() + table.string('image').notNullable() + table.bigInteger('duration').notNullable() + }) + } + + public async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/env.ts b/env.ts index 5fb916f..375583d 100755 --- a/env.ts +++ b/env.ts @@ -1,17 +1,3 @@ -/* -|-------------------------------------------------------------------------- -| Validating Environment Variables -|-------------------------------------------------------------------------- -| -| In this file we define the rules for validating environment variables. -| By performing validation we ensure that your application is running in -| a stable environment with correct configuration values. -| -| This file is read automatically by the framework during the boot lifecycle -| and hence do not rename or move this file to a different location. -| -*/ - import Env from '@ioc:Adonis/Core/Env' export default Env.rules({ @@ -61,4 +47,8 @@ export default Env.rules({ WAKATIME_USER: Env.schema.string(), WAKATIME_KEY: Env.schema.string(), WAKATIME_ID: Env.schema.string(), + + // Spotify + SPOTIFY_ID: Env.schema.string(), + SPOTIFY_SECRET: Env.schema.string(), }) diff --git a/package.json b/package.json index 2ea66cf..73554fd 100755 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "@adonisjs/session": "^6.1.2", "@adonisjs/view": "^6.1.1", "axios": "^0.22.0", + "deep-object-diff": "^1.1.0", "luxon": "^1.27.0", "mjml": "^4.10.1", "mysql": "^2.18.1", "phc-argon2": "^1.1.1", + "pretty-list-routes": "^0.0.5", "proxy-addr": "^2.0.7", "reflect-metadata": "^0.1.13", "tslib": "^2.3.1" diff --git a/start/routes/api.ts b/start/routes/api.ts index aa3eca2..a020e0a 100644 --- a/start/routes/api.ts +++ b/start/routes/api.ts @@ -21,6 +21,17 @@ 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/start/routes/home.ts b/start/routes/home.ts index 40ad320..c0591d5 100644 --- a/start/routes/home.ts +++ b/start/routes/home.ts @@ -15,6 +15,7 @@ Route.get('/', async({ response }: HttpContextContract) => { profile: `${BASE_URL}/me`, stats: `${BASE_URL}/stats`, states: `${BASE_URL}/states`, + songs: `${BASE_URL}/spotify`, locations: `${BASE_URL}/locations`, }, }) diff --git a/yarn.lock b/yarn.lock index 340ae30..bc45858 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2028,6 +2028,11 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deep-object-diff@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.0.tgz#d6fabf476c2ed1751fc94d5ca693d2ed8c18bc5a" + integrity sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw== + defer-to-connect@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" @@ -5345,6 +5350,11 @@ pretty-hrtime@^1.0.3: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= +pretty-list-routes@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/pretty-list-routes/-/pretty-list-routes-0.0.5.tgz#843b0d4445c10f9f0f967a4dbadbd8e60ecfe169" + integrity sha512-+BI6MZ3GeZlH9OdDCbHuqNHHHy1SbhyAlABKzdf45PV4xM28fECdIh2uhG7ucw5XOoqOwJmyY5Jv+jXP/CfUIg== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"