diff --git a/src/commands/stats.ts b/src/commands/stats.ts new file mode 100644 index 0000000..4962ecf --- /dev/null +++ b/src/commands/stats.ts @@ -0,0 +1,45 @@ +import { AppDataSource } from '../db'; +import { SongRequest } from '../db/entities/SongRequest'; +import { Command } from '../interfaces/Command'; +import { ENV } from '../utils/ENV'; + +export default { + name: 'stats', + description: 'Bot requests stats', + async execute(client, message) { + if (!ENV.USE_DB) { + return message.reply('Song requests recording is not enabled'); + } + + const repository = AppDataSource.getRepository(SongRequest); + const averageRequestsPerGuild = await repository + .createQueryBuilder('song_request') + .select('song_request.guildId', 'guildId') + .addSelect('COUNT(*) / 30.0', 'average_requests_per_day') + .where('song_request.requestedAt >= :date', { + date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago + }) + .groupBy('song_request.guildId') + .getRawMany(); + + const avg = await Promise.all( + averageRequestsPerGuild.map(async (guild) => { + const cached = client.guilds.cache.get(guild.guildId); + if (cached) { + return `${cached.name}: ${guild.average_requests_per_day}`; + } + + const guildName = await client.guilds + .fetch(guild.guildId) + .then((guild) => guild.name); + return `${guildName}: ${guild.average_requests_per_day}`; + }) + ); + + if (!avg.length) { + return message.reply('No stats available'); + } + + message.reply(`Average requests per day:\n${avg.join('\n')}`); + }, +} as Command; diff --git a/src/external/lastfm/findTrackByQuery.ts b/src/external/lastfm/findTrackByQuery.ts new file mode 100644 index 0000000..79638f9 --- /dev/null +++ b/src/external/lastfm/findTrackByQuery.ts @@ -0,0 +1,24 @@ +import { lastFmFetch } from './lastFmAxiosClient'; + +export interface LastFmTrack { + name: string; + artist: string; +} + +export async function findTrackByQuery( + query: string +): Promise { + try { + const res = await lastFmFetch('track.search', { + params: { + track: query, + limit: 1, + }, + }); + + return res?.results?.trackmatches?.track?.[0] || null; + } catch (error) { + console.error('Error occurred while fetching track by query', error); + return null; + } +} diff --git a/src/external/lastfm/getSimilarLastFmTracks.ts b/src/external/lastfm/getSimilarLastFmTracks.ts new file mode 100644 index 0000000..0fb8aa1 --- /dev/null +++ b/src/external/lastfm/getSimilarLastFmTracks.ts @@ -0,0 +1,31 @@ +import { LastFmTrack, findTrackByQuery } from './findTrackByQuery'; +import { lastFmFetch } from './lastFmAxiosClient'; + +export async function getSimilarLastFmTracks( + query: string +): Promise { + try { + const track = await findTrackByQuery(query); + if (!track) { + throw new Error('Track not found'); + } + + const res = await lastFmFetch('track.getSimilar', { + params: { + track: track.name, + artist: track.artist, + limit: 20, + }, + }); + + const tracks = res?.similartracks?.track || []; + + return tracks.map((track: any) => ({ + name: track.name, + artist: track.artist.name, + })); + } catch (error) { + console.error('Error occurred while fetching track by query', error); + throw error; + } +} diff --git a/src/external/lastfm/lastFmAxiosClient.ts b/src/external/lastfm/lastFmAxiosClient.ts new file mode 100644 index 0000000..6b91174 --- /dev/null +++ b/src/external/lastfm/lastFmAxiosClient.ts @@ -0,0 +1,68 @@ +import retry from 'async-retry'; +import axios, { AxiosRequestConfig } from 'axios'; +import { ENV } from '../../utils/ENV'; + +const lastFmClient = axios.create({ + baseURL: 'https://ws.audioscrobbler.com/2.0/?', + params: { + api_key: ENV.LASTFM_API_KEY, + format: 'json', + }, +}); + +lastFmClient.interceptors.response.use( + (response) => { + const error = response.data.error; + if (error) { + throw new Error(`Last.fm error: ${error.message}`); + } + + return response; + }, + (error) => { + const url = error.config.url; + const params = error.config.params; + const apiErrorMessage = error?.response?.data?.error?.message; + const errorMessage = apiErrorMessage || error.message || 'Unknown error'; + + return Promise.reject( + `Error occurred at endpoint: ${url} with params: ${JSON.stringify( + params + )}. Error message: ${errorMessage}` + ); + } +); + +export async function lastFmFetch(method: string, config?: AxiosRequestConfig) { + if (!process.env.LASTFM_API_KEY) { + throw new Error('LASTFM_API_KEY is not set'); + } + + return await retry( + async (bail) => { + try { + const response = await lastFmClient({ + params: { + method, + ...config?.params, + }, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + error.message = `Last.fm error: ${error.message}`; + if (!error.response || error.response.status >= 500) { + throw error; + } else { + bail(error); + } + } + throw error; + } + }, + { + retries: 0, + } + ); +} diff --git a/src/external/spotify/getSimilarTracks.ts b/src/external/spotify/getSimilarTracks.ts index 1159823..ada7284 100644 --- a/src/external/spotify/getSimilarTracks.ts +++ b/src/external/spotify/getSimilarTracks.ts @@ -1,65 +1,16 @@ -import { TrackDetails, getTrackDetails } from './getTrackDetails'; -import { getSpotifyTrackTitle } from './utils/getSpotifyTrackTitle'; -import { getTrackFeatures } from './getTrackFeatures'; -import { spotifyFetch } from './spotifyAxiosClient'; -import { removeTrackDuplicates } from '../../utils/removeArrayDuplicates'; -import { getBannedArtists } from '../../db/methods/getBannedArtists'; +import { LastFmTrack } from '../lastfm/findTrackByQuery'; +import { getSimilarLastFmTracks } from '../lastfm/getSimilarLastFmTracks'; +import { TrackDetails } from './getTrackDetails'; export interface SpotifyTrack extends TrackDetails { title: string; } -export async function getSimilarTracks(id: string): Promise { +export async function getSimilarTracks(id: string): Promise { try { - const trackFeatures = await getTrackFeatures(id); - if (!trackFeatures) { - throw new Error(`Unable to get track features for ${id}`); - } + const tracks = await getSimilarLastFmTracks(id); - const trackDetails = await getTrackDetails(id); - if (!trackDetails) { - throw new Error(`Unable to get track details for ${id}`); - } - - const requestParams: Record = { - seed_tracks: id, - target_danceability: trackFeatures.danceability, - target_energy: trackFeatures.energy, - target_valence: trackFeatures.valence, - target_tempo: trackFeatures.tempo, - target_loudness: trackFeatures.loudness, - min_popularity: trackDetails.popularity - 10, - limit: 60, - }; - - const data = await spotifyFetch('/recommendations', { - params: requestParams, - }); - - const bannedArtists = await getBannedArtists(); - const tracks = (data?.tracks || []) - .filter((t: any) => { - const artists = (t?.artists || []).map((a: any) => a.id); - - return !bannedArtists.find((b) => artists.includes(b.spotifyId)); - }) - .map((t: any) => ({ - id: t.id, - title: getSpotifyTrackTitle(t), - popularity: t.popularity, - })); - // .sort((a: SpotifyTrack, b: SpotifyTrack) => b.popularity - a.popularity); - - const uniqueTracks: SpotifyTrack[] = removeTrackDuplicates([ - { - id: id, - title: getSpotifyTrackTitle(trackDetails), - popularity: trackDetails.popularity, - }, - ...tracks, - ]); - - return uniqueTracks.slice(0, 30); + return tracks; } catch (error) { throw error; } diff --git a/src/interfaces/RadioSession.ts b/src/interfaces/RadioSession.ts index 99429d1..f8dad26 100644 --- a/src/interfaces/RadioSession.ts +++ b/src/interfaces/RadioSession.ts @@ -1,5 +1,5 @@ -export interface RadioSessionTrack { - spotifyId: string; +interface RadioSessionTrack { + artist: string; title: string; } diff --git a/src/test/spotify_test.ts b/src/test/spotify_test.ts index 5754dd9..47a94ab 100644 --- a/src/test/spotify_test.ts +++ b/src/test/spotify_test.ts @@ -1,25 +1,14 @@ -import { AppDataSource } from '../db'; import { getSimilarTracks } from '../external/spotify/getSimilarTracks'; -import { SPOTIFY_TRACK_REGEX } from '../utils/helpers'; async function main() { try { - await AppDataSource.initialize(); + const query = process.argv[2]; + if (!query) throw new Error('Please provide query'); - const url = process.argv[2]; - if (!url) throw new Error('Please provide a Spotify track URL'); - - const id = url.match(SPOTIFY_TRACK_REGEX)?.[1]; - if (!id) throw new Error('Invalid Spotify track URL'); - - const tracks = await getSimilarTracks(id); + const tracks = await getSimilarTracks(query); for (const track of tracks) { - console.log( - `- [${track.popularity || 'xx'}] https://open.spotify.com/track/${ - track.id - } : ${track.title}` - ); + console.log(`- ${track.artist} - ${track.name}`); } } catch (error) { if (error instanceof Error) { diff --git a/src/utils/ENV.ts b/src/utils/ENV.ts index e12f033..f43657b 100644 --- a/src/utils/ENV.ts +++ b/src/utils/ENV.ts @@ -18,4 +18,5 @@ export const ENV = { SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID as string, SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET as string, ADMINS: process.env.ADMINS?.split(',') || [], + LASTFM_API_KEY: process.env.LASTFM_API_KEY as string, };