Update spotify connection

This commit is contained in:
2022-01-17 16:51:49 +01:00
parent 9e822ba674
commit f02635b83c
13 changed files with 130 additions and 88 deletions

View File

@@ -11,12 +11,12 @@ import SongHistoryValidator from 'App/Validators/song/SongHistoryValidator'
export default class SongsController { export default class SongsController {
public async getCurrentSong({ response }: HttpContextContract) { public async getCurrentSong({ response }: HttpContextContract) {
return response.status(200).send(getCurrentPlayingFromCache()) return response.status(200).send(await getCurrentPlayingFromCache())
} }
public async getHistory({ request, response }: HttpContextContract) { public async getHistory({ request, response }: HttpContextContract) {
const { range } = await request.validate(SongHistoryValidator) const { range } = await request.validate(SongHistoryValidator)
const history = await getHistory(range) const history = await getHistory(range || 'day')
return response.status(200).send({ return response.status(200).send({
history, history,
}) })
@@ -35,10 +35,14 @@ export default class SongsController {
} }
public async authorize({ response }: HttpContextContract) { public async authorize({ response }: HttpContextContract) {
return response.status(200).redirect(getAuthorizationURI()) return response.redirect(getAuthorizationURI())
} }
public async callback({ request }: HttpContextContract) { public async callback({ request, response }: HttpContextContract) {
await setupSpotify(request.param('code')) if (await setupSpotify(request.qs().code)) {
return response.status(200).send({
message: 'Athena successfully connected to Spotify',
})
}
} }
} }

View File

@@ -1,8 +1,10 @@
import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm' import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'
export default class Song extends BaseModel { export default class Song extends BaseModel {
public static table = 'spotify_songs_history'
@column({ isPrimary: true }) @column({ isPrimary: true })
public date: number public date: Date
@column() @column()
public device_name: string public device_name: string
@@ -13,6 +15,9 @@ export default class Song extends BaseModel {
@column() @column()
public item_name: string public item_name: string
@column()
public item_id: string
@column() @column()
public item_type: string public item_type: string

View File

@@ -1,11 +1,11 @@
import Logger from '@ioc:Adonis/Core/Logger' import Logger from '@ioc:Adonis/Core/Logger'
import { getCurrentPlayingFromSpotify, getSpotifyAccount } from 'App/Utils/SongUtils' import { getCurrentPlayingFromSpotify, getSpotifyAccount } from 'App/Utils/SongUtils'
const MS = 1000 const MS = 3000
let taskId let taskId
async function SpotifyCurrentListeningWatcher(): Promise<void> { async function SpotifyCurrentListeningWatcher(): Promise<void> {
if (getSpotifyAccount().access === '') return if ((await getSpotifyAccount()).access_token === '') return
await getCurrentPlayingFromSpotify() await getCurrentPlayingFromSpotify()
} }

View File

@@ -12,15 +12,16 @@ async function LogSpotifyHistory(): Promise<void> {
if (current.progress && current.progress < 1000) return 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 if (last_entry && new Date().getTime() - last_entry.duration <= new Date(last_entry.date).getTime()) return
await Song.create({ await Song.create({
date: current.started_at, date: new Date(current.started_at!),
duration: current.duration, duration: current.duration,
item_name: current.name, item_name: current.name,
item_type: current.type, item_type: current.type,
item_id: current.id,
author: current.author, author: current.author,
device_name: current.device_name, device_name: current.device_name,
device_type: current.device_type, device_type: current.device_type,

View File

@@ -11,6 +11,7 @@ export interface SpotifyTrack {
item: { item: {
name: string name: string
type: string type: string
id: string
} }
device: { device: {
name: string name: string

View File

@@ -1,6 +1,7 @@
export interface SpotifyToken { export interface SpotifyToken {
access_token: string access_token: string
refresh_token: string refresh_token: string
expires_in: number
} }
interface Device { interface Device {

View File

@@ -1,35 +1,47 @@
import { readFileSync, writeFileSync } from 'fs'
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import Env from '@ioc:Adonis/Core/Env' import Env from '@ioc:Adonis/Core/Env'
import Redis from '@ioc:Adonis/Addons/Redis' import Redis from '@ioc:Adonis/Addons/Redis'
import { SpotifyArtist, SpotifyTrack } from 'App/Types/ILocalSpotify' import { SpotifyArtist, SpotifyTrack } from 'App/Types/ILocalSpotify'
import { Artist, InternalPlayerResponse, PlayerResponse, SpotifyToken } from 'App/Types/ISpotify' import { Artist, InternalPlayerResponse, PlayerResponse, SpotifyToken } from 'App/Types/ISpotify'
import Song from 'App/Models/Song' import Song from 'App/Models/Song'
import queryString from 'query-string'
export function getSpotifyAccount(): { access: string; refresh: string } { export async function getSpotifyAccount(): Promise<SpotifyToken> {
console.log(JSON.parse(readFileSync('spotify.json').toString())) return await Redis.exists('spotify:account')
return JSON.parse(readFileSync('spotify.json').toString()) ? JSON.parse(await Redis.get('spotify:account') || '{}')
: {
access_token: '',
refresh_token: '',
expires_in: -1,
}
}
export async function setSpotifyAccount(token: SpotifyToken): Promise<void> {
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 { export function getAuthorizationURI(): string {
const query = JSON.stringify({ const query = queryString.stringify({
response_type: 'code', response_type: 'code',
client_id: Env.get('SPOTIFY_ID'), 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`, redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`,
}) })
return `https://accounts.spotify.com/authorize?${query}` return `https://accounts.spotify.com/authorize?${query}`
} }
export async function setupSpotify(code: string): Promise<void> { export async function setupSpotify(code: string): Promise<boolean> {
const authorization_tokens: AxiosResponse<SpotifyToken> = await axios.post( const authorization_tokens: AxiosResponse<SpotifyToken> = await axios.post(
'https://accounts.spotify.com/api/token', 'https://accounts.spotify.com/api/token',
{ queryString.stringify({
code, code,
grant_type: 'authorization_code', grant_type: 'authorization_code',
redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`, redirect_uri: `${Env.get('BASE_URL')}/spotify/callback`,
}, }),
{ {
headers: { headers: {
'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`, '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<void> {
}, },
) )
if (authorization_tokens.status === 200) { if (authorization_tokens.status === 200)
writeFileSync( await setSpotifyAccount(authorization_tokens.data)
'spotify.json',
JSON.stringify({ return true
access: authorization_tokens.data.access_token,
refresh: authorization_tokens.data.refresh_token,
}),
)
}
} }
export async function regenerateTokens(): Promise<void> { export async function regenerateTokens(): Promise<void> {
const refresh_token = getSpotifyAccount().refresh const refresh_token = (await getSpotifyAccount()).refresh_token
const authorization_tokens: AxiosResponse<SpotifyToken> = await axios.post( const authorization_tokens: AxiosResponse<SpotifyToken> = await axios.post(
'https://accounts.spotify.com/api/token', 'https://accounts.spotify.com/api/token',
{ queryString.stringify({
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token, refresh_token,
}, }),
{ {
headers: { headers: {
'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`, 'Authorization': `Basic ${Buffer.from(`${Env.get('SPOTIFY_ID')}:${Env.get('SPOTIFY_SECRET')}`).toString('base64')}`,
@@ -66,22 +73,15 @@ export async function regenerateTokens(): Promise<void> {
}, },
) )
if (authorization_tokens.status === 200) { if (authorization_tokens.status === 200)
writeFileSync( await setSpotifyAccount(authorization_tokens.data)
'spotify.json',
JSON.stringify({
access: authorization_tokens.data.access_token,
refresh: authorization_tokens.data.refresh_token,
}),
)
}
} }
async function RequestWrapper<T = never>(url: string): Promise<AxiosResponse<T>> { async function RequestWrapper<T = never>(url: string): Promise<AxiosResponse<T>> {
let request let request
const options: AxiosRequestConfig = { const options: AxiosRequestConfig = {
headers: { headers: {
Authorization: `Bearer ${getSpotifyAccount().access}`, Authorization: `Bearer ${(await getSpotifyAccount()).access_token}`,
}, },
} }
request = await axios.get<T>(url, options) request = await axios.get<T>(url, options)
@@ -94,17 +94,15 @@ async function RequestWrapper<T = never>(url: string): Promise<AxiosResponse<T>>
} }
export async function getCurrentPlayingFromCache(): Promise<InternalPlayerResponse> { export async function getCurrentPlayingFromCache(): Promise<InternalPlayerResponse> {
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<InternalPlayerResponse> { export async function getCurrentPlayingFromSpotify(): Promise<InternalPlayerResponse> {
if ((await getSpotifyAccount()).access_token === '') return { is_playing: false }
const current_track = await RequestWrapper<PlayerResponse>('https://api.spotify.com/v1/me/player?additional_types=track,episode') const current_track = await RequestWrapper<PlayerResponse>('https://api.spotify.com/v1/me/player?additional_types=track,episode')
let current: InternalPlayerResponse 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) { if (current_track.data && current_track.data.is_playing) {
current = { current = {
is_playing: true, is_playing: true,
@@ -140,7 +138,7 @@ export async function updateCurrentSong(song: InternalPlayerResponse): Promise<v
export async function getHistory(range: 'day' | 'week' | 'month' | 'total') { export async function getHistory(range: 'day' | 'week' | 'month' | 'total') {
if (await Redis.exists(`spotify:history:range:${range || 'day'}`)) if (await Redis.exists(`spotify:history:range:${range || 'day'}`))
return JSON.parse(await Redis.get(`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) 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) if (range === 'week') startDate = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000)
@@ -159,6 +157,7 @@ export async function getHistory(range: 'day' | 'week' | 'month' | 'total') {
await Redis.set(`spotify:history:range:${range || 'day'}`, JSON.stringify({ await Redis.set(`spotify:history:range:${range || 'day'}`, JSON.stringify({
cached: new Date().toUTCString(), cached: new Date().toUTCString(),
expiration: new Date(new Date().setMinutes(new Date().getMinutes() + 5)).toUTCString(),
history: songs, history: songs,
}), 'ex', 300) }), 'ex', 300)
@@ -167,9 +166,9 @@ export async function getHistory(range: 'day' | 'week' | 'month' | 'total') {
export async function fetchTopArtist(): Promise<SpotifyArtist[]> { export async function fetchTopArtist(): Promise<SpotifyArtist[]> {
if (await Redis.exists('spotify:top:artists')) 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[] = [] const artists: SpotifyArtist[] = []
@@ -191,29 +190,30 @@ export async function fetchTopArtist(): Promise<SpotifyArtist[]> {
await Redis.set('spotify:top:artists', JSON.stringify({ await Redis.set('spotify:top:artists', JSON.stringify({
cached: new Date().toUTCString(), cached: new Date().toUTCString(),
expiration: new Date(new Date().setMinutes(new Date().getMinutes() + 5)).toUTCString(),
top: artists, top: artists,
}), 'ex', 600) }), 'ex', 600)
return artists return artists
} }
export async function fetchTopTrack(): Promise<SpotifyTrack[]> { export async function fetchTopTrack(): Promise<Array<SpotifyTrack & { times: number }>> {
if (await Redis.exists('spotify:top:tracks')) 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 // Fetch all songs
.query() const fetched_tracks = await Song.query()
.orderBy('date', 'desc')
.limit(5)
const tracks: SpotifyTrack[] = [] if (fetched_tracks.length <= 0)
return []
if (fetched_tracks.length >= 0) { const filtered = fetched_tracks.map((track) => {
for (const track of fetched_tracks) { return {
tracks.push({ ...{
item: { item: {
name: track.item_name, name: track.item_name,
type: track.item_type, type: track.item_type,
id: track.item_id,
}, },
device: { device: {
name: track.device_name, name: track.device_name,
@@ -222,17 +222,20 @@ export async function fetchTopTrack(): Promise<SpotifyTrack[]> {
duration: track.duration, duration: track.duration,
author: track.author, author: track.author,
image: track.image, 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({ await Redis.set('spotify:top:tracks', JSON.stringify({
cached: new Date().toUTCString(), cached: new Date().toUTCString(),
top: tracks, expiration: new Date(new Date().setMinutes(new Date().getMinutes() + 5)).toUTCString(),
top: remove_dupes,
}), 'ex', 300) }), 'ex', 300)
return tracks return remove_dupes
} }

View File

@@ -6,7 +6,7 @@ export default class SongHistoryValidator {
} }
public schema = schema.create({ 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 = { public messages = {

View File

@@ -4,10 +4,11 @@ export default class SpotifySongsHistory extends BaseSchema {
protected tableName = 'spotify_songs_history' protected tableName = 'spotify_songs_history'
public async up() { public async up() {
this.schema.alterTable(this.tableName, (table) => { this.schema.createTable(this.tableName, (table) => {
table.timestamp('date').primary() table.timestamp('date').primary().unique().defaultTo(this.now())
table.string('device_name').notNullable() table.string('device_name').notNullable()
table.string('device_type').notNullable() table.string('device_type').notNullable()
table.string('item_id').notNullable()
table.string('item_name').notNullable() table.string('item_name').notNullable()
table.string('item_type').notNullable() table.string('item_type').notNullable()
table.string('author').notNullable() table.string('author').notNullable()

View File

@@ -8,7 +8,7 @@
"dev": "node ace serve --watch", "dev": "node ace serve --watch",
"seed": "node ace db:seed", "seed": "node ace db:seed",
"mig": "node ace migration:run", "mig": "node ace migration:run",
"lr": "node ace list:routes", "lr": "node ace routes:pretty-list",
"lint": "npx eslint --ext .json,.ts --fix ." "lint": "npx eslint --ext .json,.ts --fix ."
}, },
"dependencies": { "dependencies": {
@@ -29,6 +29,7 @@
"phc-argon2": "^1.1.1", "phc-argon2": "^1.1.1",
"pretty-list-routes": "^0.0.5", "pretty-list-routes": "^0.0.5",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"query-string": "^7.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"tslib": "^2.3.1" "tslib": "^2.3.1"
}, },

View File

@@ -20,13 +20,13 @@ export default class AppProvider {
// App is ready // App is ready
const StatsTask = await import('App/Tasks/StatsTask') const StatsTask = await import('App/Tasks/StatsTask')
const StatesTask = await import('App/Tasks/StatesTask') const StatesTask = await import('App/Tasks/StatesTask')
// const CurrentSongTask = await import('App/Tasks/CurrentSongTask') const CurrentSongTask = await import('App/Tasks/CurrentSongTask')
// const HistorySongsTask = await import('App/Tasks/HistorySongsTask') const HistorySongsTask = await import('App/Tasks/HistorySongsTask')
await StatsTask.Activate() await StatsTask.Activate()
await StatesTask.Activate() await StatesTask.Activate()
// await CurrentSongTask.Activate() await CurrentSongTask.Activate()
// await HistorySongsTask.Activate() await HistorySongsTask.Activate()
Logger.info('Application is ready!') Logger.info('Application is ready!')
} }
@@ -35,13 +35,13 @@ export default class AppProvider {
// Cleanup, since app is going down // Cleanup, since app is going down
const StatsTask = await import('App/Tasks/StatsTask') const StatsTask = await import('App/Tasks/StatsTask')
const StatesTask = await import('App/Tasks/StatesTask') const StatesTask = await import('App/Tasks/StatesTask')
// const CurrentSongTask = await import('App/Tasks/CurrentSongTask') const CurrentSongTask = await import('App/Tasks/CurrentSongTask')
// const HistorySongsTask = await import('App/Tasks/HistorySongsTask') const HistorySongsTask = await import('App/Tasks/HistorySongsTask')
await StatsTask.ShutDown() await StatsTask.ShutDown()
await StatesTask.ShutDown() await StatesTask.ShutDown()
// await CurrentSongTask.ShutDown() await CurrentSongTask.ShutDown()
// await HistorySongsTask.ShutDown() await HistorySongsTask.ShutDown()
Logger.info('Application is closing. Bye...') Logger.info('Application is closing. Bye...')
} }

View File

@@ -6,6 +6,17 @@ Route.get('/stats', 'StatsController.index')
Route.get('/states', 'StatesController.index') Route.get('/states', 'StatesController.index')
Route.resource('/locations', 'LocationsController').only(['index', 'store']) 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.group(() => {
Route.resource('/users', 'UsersController').except(['edit', 'create']) Route.resource('/users', 'UsersController').except(['edit', 'create'])
@@ -21,17 +32,6 @@ 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 }) => {

View File

@@ -2851,6 +2851,11 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" 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: find-cache-dir@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" 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: dependencies:
side-channel "^1.0.4" 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: queue-microtask@^1.2.2:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" 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" resolved "https://registry.yarnpkg.com/split-lines/-/split-lines-2.1.0.tgz#3bc9dbf75637c8bae6ed5dcbc7dbd83956b72311"
integrity sha512-8dv+1zKgTpfTkOy8XZLFyWrfxO0NV/bj/3EaQ+hBrBxGv2DwiroljPjU8NlCr+59nLnsVm9WYT7lXKwe4TC6bw== 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: split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" 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" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 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: string-width@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"