mirror of
https://github.com/ArthurDanjou/artdanj-api.git
synced 2026-01-14 12:14:33 +01:00
Connect Athena to Spotify
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"@adonisjs/repl/build/commands",
|
"@adonisjs/repl/build/commands",
|
||||||
"@adonisjs/lucid/build/commands",
|
"@adonisjs/lucid/build/commands",
|
||||||
"@adonisjs/mail/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",
|
"exceptionHandlerNamespace": "App/Exceptions/Handler",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
|
|||||||
@@ -37,3 +37,6 @@ SMTP_PASSWORD=
|
|||||||
WAKATIME_USER=
|
WAKATIME_USER=
|
||||||
WAKATIME_KEY=
|
WAKATIME_KEY=
|
||||||
WAKATIME_ID=
|
WAKATIME_ID=
|
||||||
|
|
||||||
|
SPOTIFY_ID=
|
||||||
|
SPOTIFY_SECRET=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"commands": {
|
"commands": {
|
||||||
"dump:rcfile": {
|
"dump:rcfile": {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
"commandPath": "@adonisjs/core/commands/DumpRc",
|
"commandPath": "@adonisjs/core/build/commands/DumpRc",
|
||||||
"commandName": "dump:rcfile",
|
"commandName": "dump:rcfile",
|
||||||
"description": "Dump contents of .adonisrc.json file along with defaults",
|
"description": "Dump contents of .adonisrc.json file along with defaults",
|
||||||
"args": [],
|
"args": [],
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"loadApp": true
|
"loadApp": true
|
||||||
},
|
},
|
||||||
"commandPath": "@adonisjs/core/commands/ListRoutes",
|
"commandPath": "@adonisjs/core/build/commands/ListRoutes",
|
||||||
"commandName": "list:routes",
|
"commandName": "list:routes",
|
||||||
"description": "List application routes",
|
"description": "List application routes",
|
||||||
"args": [],
|
"args": [],
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"generate:key": {
|
"generate:key": {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
"commandPath": "@adonisjs/core/commands/GenerateKey",
|
"commandPath": "@adonisjs/core/build/commands/GenerateKey",
|
||||||
"commandName": "generate:key",
|
"commandName": "generate:key",
|
||||||
"description": "Generate a new APP_KEY secret",
|
"description": "Generate a new APP_KEY secret",
|
||||||
"args": [],
|
"args": [],
|
||||||
@@ -83,7 +83,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"make:model": {
|
"make:model": {
|
||||||
"settings": {},
|
"settings": {
|
||||||
|
"loadApp": true
|
||||||
|
},
|
||||||
"commandPath": "@adonisjs/lucid/build/commands/MakeModel",
|
"commandPath": "@adonisjs/lucid/build/commands/MakeModel",
|
||||||
"commandName": "make:model",
|
"commandName": "make:model",
|
||||||
"description": "Make a new Lucid model",
|
"description": "Make a new Lucid model",
|
||||||
@@ -314,6 +316,61 @@
|
|||||||
"description": "Actions to implement"
|
"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": {}
|
"aliases": {}
|
||||||
|
|||||||
44
app/Controllers/Http/SongsController.ts
Normal file
44
app/Controllers/Http/SongsController.ts
Normal file
@@ -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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
|
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
|
||||||
import Redis from '@ioc:Adonis/Addons/Redis'
|
import Redis from '@ioc:Adonis/Addons/Redis'
|
||||||
import StateSleepingValidator from 'App/Validators/states/StateSleepingValidator'
|
import StateSleepingValidator from 'App/Validators/states/StateSleepingValidator'
|
||||||
|
import { getCurrentPlayingFromCache } from 'App/Utils/SongUtils'
|
||||||
|
|
||||||
export default class StatesController {
|
export default class StatesController {
|
||||||
// Listening Music
|
|
||||||
|
|
||||||
public async index({ response }: HttpContextContract) {
|
public async index({ response }: HttpContextContract) {
|
||||||
const sleeping = this.formatValue(await Redis.get('states:sleeping'))
|
const sleeping = this.formatValue(await Redis.get('states:sleeping'))
|
||||||
const developing = this.formatValue(await Redis.get('states:developing'))
|
const developing = this.formatValue(await Redis.get('states:developing'))
|
||||||
return response.status(200).send({
|
return response.status(200).send({
|
||||||
sleeping,
|
sleeping,
|
||||||
developing,
|
developing,
|
||||||
listening_music: 'Soon',
|
listening_music: await getCurrentPlayingFromCache(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
app/Models/Song.ts
Normal file
27
app/Models/Song.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
16
app/Tasks/CurrentSongTask.ts
Normal file
16
app/Tasks/CurrentSongTask.ts
Normal file
@@ -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<void> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
40
app/Tasks/HistorySongsTask.ts
Normal file
40
app/Tasks/HistorySongsTask.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import Logger from '@ioc:Adonis/Core/Logger'
|
|
||||||
|
|
||||||
const MS = 1000
|
|
||||||
|
|
||||||
export async function getCurrentPlayingMusic(): Promise<void> {
|
|
||||||
// Fetch from deezer
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function Activate(): Promise<void> {
|
|
||||||
Logger.info(`Starting task runner for watching deezer current playing [${MS} ms]`)
|
|
||||||
await getCurrentPlayingMusic()
|
|
||||||
setInterval(getCurrentPlayingMusic, MS)
|
|
||||||
}
|
|
||||||
@@ -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 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
|
const MS = 1000 * 2 * 60 // 2 min
|
||||||
let taskId
|
let taskId
|
||||||
|
|
||||||
interface StatesResponse {
|
|
||||||
time: number
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrentTime(): Promise<void> {
|
|
||||||
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<void> {
|
export async function Activate(): Promise<void> {
|
||||||
Logger.info(`Starting task runner for getting current developing state [every ${MS} ms]`)
|
Logger.info(`Starting task runner for getting current developing state [every ${MS} ms]`)
|
||||||
await getCurrentTime()
|
await fetchDevelopingState()
|
||||||
taskId = setInterval(getCurrentTime, MS)
|
taskId = setInterval(fetchDevelopingState, MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShutDown(): void {
|
export function ShutDown(): void {
|
||||||
|
|||||||
22
app/Types/ILocalSpotify.ts
Normal file
22
app/Types/ILocalSpotify.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
110
app/Types/ISpotify.ts
Normal file
110
app/Types/ISpotify.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
16
app/Types/IStats.ts
Normal file
16
app/Types/IStats.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,237 @@
|
|||||||
export async function getHistory(range: 'day' | 'week' | 'month') {
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
return range
|
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() {
|
export function getAuthorizationURI(): string {
|
||||||
return 0
|
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() {
|
export async function setupSpotify(code: string): Promise<void> {
|
||||||
return null
|
const authorization_tokens: AxiosResponse<SpotifyToken> = 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<void> {
|
||||||
|
const refresh_token = getSpotifyAccount().refresh
|
||||||
|
|
||||||
|
const authorization_tokens: AxiosResponse<SpotifyToken> = 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<T = never>(url: string): Promise<AxiosResponse<T>> {
|
||||||
|
let request
|
||||||
|
const options: AxiosRequestConfig = {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getSpotifyAccount().access}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
request = await axios.get<T>(url, options)
|
||||||
|
|
||||||
|
if (request.status === 401) {
|
||||||
|
await regenerateTokens()
|
||||||
|
request = await axios.get<T>(url, options)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentPlayingFromCache(): Promise<InternalPlayerResponse> {
|
||||||
|
return JSON.parse(await Redis.get('spotify:current') || '') || { is_playing: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentPlayingFromSpotify(): Promise<InternalPlayerResponse> {
|
||||||
|
const current_track = await RequestWrapper<PlayerResponse>('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<void> {
|
||||||
|
// 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<SpotifyArtist[]> {
|
||||||
|
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<SpotifyTrack[]> {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/Utils/StatesTask.ts
Normal file
40
app/Utils/StatesTask.ts
Normal file
@@ -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<void> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
import DevelopmentHour from 'App/Models/DevelopmentHour'
|
import DevelopmentHour from 'App/Models/DevelopmentHour'
|
||||||
import CommandsRun from 'App/Models/CommandsRun'
|
import CommandsRun from 'App/Models/CommandsRun'
|
||||||
import BuildsRun from 'App/Models/BuildsRun'
|
import BuildsRun from 'App/Models/BuildsRun'
|
||||||
|
import { Stats, Time } from 'App/Types/IStats'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
return date.toISOString().split('T')[0]
|
return date.toISOString().split('T')[0]
|
||||||
|
|||||||
15
app/Validators/song/SongHistoryValidator.ts
Normal file
15
app/Validators/song/SongHistoryValidator.ts
Normal file
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
22
database/migrations/1642256040742_spotify_songs_history.ts
Normal file
22
database/migrations/1642256040742_spotify_songs_history.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
env.ts
18
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'
|
import Env from '@ioc:Adonis/Core/Env'
|
||||||
|
|
||||||
export default Env.rules({
|
export default Env.rules({
|
||||||
@@ -61,4 +47,8 @@ export default Env.rules({
|
|||||||
WAKATIME_USER: Env.schema.string(),
|
WAKATIME_USER: Env.schema.string(),
|
||||||
WAKATIME_KEY: Env.schema.string(),
|
WAKATIME_KEY: Env.schema.string(),
|
||||||
WAKATIME_ID: Env.schema.string(),
|
WAKATIME_ID: Env.schema.string(),
|
||||||
|
|
||||||
|
// Spotify
|
||||||
|
SPOTIFY_ID: Env.schema.string(),
|
||||||
|
SPOTIFY_SECRET: Env.schema.string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
"@adonisjs/session": "^6.1.2",
|
"@adonisjs/session": "^6.1.2",
|
||||||
"@adonisjs/view": "^6.1.1",
|
"@adonisjs/view": "^6.1.1",
|
||||||
"axios": "^0.22.0",
|
"axios": "^0.22.0",
|
||||||
|
"deep-object-diff": "^1.1.0",
|
||||||
"luxon": "^1.27.0",
|
"luxon": "^1.27.0",
|
||||||
"mjml": "^4.10.1",
|
"mjml": "^4.10.1",
|
||||||
"mysql": "^2.18.1",
|
"mysql": "^2.18.1",
|
||||||
"phc-argon2": "^1.1.1",
|
"phc-argon2": "^1.1.1",
|
||||||
|
"pretty-list-routes": "^0.0.5",
|
||||||
"proxy-addr": "^2.0.7",
|
"proxy-addr": "^2.0.7",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"tslib": "^2.3.1"
|
"tslib": "^2.3.1"
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ Route.group(() => {
|
|||||||
Route.post('/commands', 'StatsController.incrementCommandCount')
|
Route.post('/commands', 'StatsController.incrementCommandCount')
|
||||||
Route.post('/builds', 'StatsController.incrementBuildCount')
|
Route.post('/builds', 'StatsController.incrementBuildCount')
|
||||||
}).prefix('stats')
|
}).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')
|
}).middleware('auth:web,api')
|
||||||
|
|
||||||
Route.get('/files/:filename', async({ response, params }) => {
|
Route.get('/files/:filename', async({ response, params }) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Route.get('/', async({ response }: HttpContextContract) => {
|
|||||||
profile: `${BASE_URL}/me`,
|
profile: `${BASE_URL}/me`,
|
||||||
stats: `${BASE_URL}/stats`,
|
stats: `${BASE_URL}/stats`,
|
||||||
states: `${BASE_URL}/states`,
|
states: `${BASE_URL}/states`,
|
||||||
|
songs: `${BASE_URL}/spotify`,
|
||||||
locations: `${BASE_URL}/locations`,
|
locations: `${BASE_URL}/locations`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
10
yarn.lock
10
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"
|
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
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:
|
defer-to-connect@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
|
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"
|
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||||
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
|
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:
|
process-nextick-args@~2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||||
|
|||||||
Reference in New Issue
Block a user