Skip to main content
Glama
lastfm-discovery.ts12 kB
/** * Navidrome MCP Server - Last.fm Music Discovery Tools * Copyright (C) 2025 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { z } from 'zod'; import type { Config } from '../config.js'; import { logger } from '../utils/logger.js'; import { DEFAULT_VALUES } from '../constants/defaults.js'; import { ErrorFormatter } from '../utils/error-formatter.js'; interface LastFmArtist { name: string; match: number; url: string; mbid: string | null; } interface SimilarArtistsResult { artist: string; count: number; similarArtists: LastFmArtist[]; } interface LastFmTrack { name: string; artist: string; match: number; url: string; mbid: string | null; } interface SimilarTracksResult { originalTrack: { artist: string; track: string }; count: number; similarTracks: LastFmTrack[]; } interface LastFmTag { name: string; url: string; } interface ArtistInfoResult { name: string; mbid: string | null; url: string; listeners: number; playcount: number; biography: string | null; tags: LastFmTag[]; similar: string[]; } interface TopTrackResult { rank: number; name: string; playcount: number; listeners: number; url: string; mbid: string | null; } interface TopTracksByArtistResult { artist: string; count: number; tracks: TopTrackResult[]; } interface TrendingArtistItem { rank: number; name: string; playcount: number; listeners: number; url: string; mbid: string | null; } interface TrendingTrackItem { rank: number; name: string; artist: string; playcount: number; listeners: number; url: string; mbid: string | null; } interface TrendingTagItem { rank: number; name: string; count: number; url: string; } interface TrendingMusicResult { type: string; page: number; perPage: number; count: number; items: TrendingArtistItem[] | TrendingTrackItem[] | TrendingTagItem[]; } const LASTFM_API_BASE = 'http://ws.audioscrobbler.com/2.0/'; async function callLastFmApi(method: string, params: Record<string, string>, apiKey: string): Promise<Record<string, unknown>> { const url = new URL(LASTFM_API_BASE); url.searchParams.append('method', method); url.searchParams.append('api_key', apiKey); url.searchParams.append('format', 'json'); Object.entries(params).forEach(([key, value]) => { url.searchParams.append(key, value); }); logger.debug(`Calling Last.fm API: ${method}`, params); const response = await fetch(url.toString()); if (!response.ok) { throw new Error(ErrorFormatter.lastfmApi(response)); } const data = await response.json() as Record<string, unknown>; if (data['error'] !== null && data['error'] !== undefined) { throw new Error(ErrorFormatter.lastfmResponse(data['message'] as string)); } return data; } const SimilarArtistsSchema = z.object({ artist: z.string().min(1), limit: z.number().min(1).max(100).optional().default(DEFAULT_VALUES.SIMILAR_ARTISTS_LIMIT), }); export async function getSimilarArtists(config: Config, args: unknown): Promise<SimilarArtistsResult> { const { artist, limit = 20 } = SimilarArtistsSchema.parse(args); if (config.lastFmApiKey === null || config.lastFmApiKey === undefined || config.lastFmApiKey === '') { throw new Error(ErrorFormatter.configMissing('Last.fm', 'LASTFM_API_KEY')); } logger.info(`Getting similar artists for: ${artist}`); const data = await callLastFmApi('artist.getSimilar', { artist, limit: limit.toString(), autocorrect: '1', }, config.lastFmApiKey); const similarArtists = (data['similarartists'] as { artist?: unknown[] })?.artist ?? []; return { artist, count: similarArtists.length, similarArtists: similarArtists.map((a: unknown) => { const artist = a as Record<string, unknown>; return { name: String(artist['name'] ?? ''), match: parseFloat(String(artist['match'] ?? 0)), url: String(artist['url'] ?? ''), mbid: (artist['mbid'] as string) ?? null, }; }), }; } const SimilarTracksSchema = z.object({ artist: z.string().min(1), track: z.string().min(1), limit: z.number().min(1).max(100).optional().default(DEFAULT_VALUES.SIMILAR_TRACKS_LIMIT), }); export async function getSimilarTracks(config: Config, args: unknown): Promise<SimilarTracksResult> { const { artist, track, limit = 20 } = SimilarTracksSchema.parse(args); if (config.lastFmApiKey === null || config.lastFmApiKey === undefined || config.lastFmApiKey === '') { throw new Error(ErrorFormatter.configMissing('Last.fm', 'LASTFM_API_KEY')); } logger.info(`Getting similar tracks for: ${artist} - ${track}`); const data = await callLastFmApi('track.getSimilar', { artist, track, limit: limit.toString(), autocorrect: '1', }, config.lastFmApiKey); const similarTracks = (data['similartracks'] as { track?: unknown[] })?.track ?? []; return { originalTrack: { artist, track }, count: similarTracks.length, similarTracks: similarTracks.map((t: unknown) => { const track = t as Record<string, unknown>; const trackArtist = track['artist'] as Record<string, unknown> | undefined; return { name: String(track['name'] ?? ''), artist: String(trackArtist?.['name'] ?? trackArtist?.['#text'] ?? 'Unknown'), match: parseFloat(String(track['match'] ?? 0)), url: String(track['url'] ?? ''), mbid: (track['mbid'] as string) ?? null, }; }), }; } const ArtistInfoSchema = z.object({ artist: z.string().min(1), lang: z.string().optional().default('en'), }); export async function getArtistInfo(config: Config, args: unknown): Promise<ArtistInfoResult> { const { artist, lang = 'en' } = ArtistInfoSchema.parse(args); if (config.lastFmApiKey === null || config.lastFmApiKey === undefined || config.lastFmApiKey === '') { throw new Error(ErrorFormatter.configMissing('Last.fm', 'LASTFM_API_KEY')); } logger.info(`Getting artist info for: ${artist}`); const data = await callLastFmApi('artist.getInfo', { artist, lang, autocorrect: '1', }, config.lastFmApiKey); const artistInfo = (data['artist'] as Record<string, unknown>) ?? {}; const stats = artistInfo['stats'] as Record<string, unknown> | undefined; const bio = artistInfo['bio'] as Record<string, unknown> | undefined; const tags = artistInfo['tags'] as Record<string, unknown> | undefined; const similar = artistInfo['similar'] as Record<string, unknown> | undefined; return { name: String(artistInfo['name'] ?? ''), mbid: (artistInfo['mbid'] as string) ?? null, url: String(artistInfo['url'] ?? ''), listeners: parseInt(String(stats?.['listeners'] ?? '0'), 10), playcount: parseInt(String(stats?.['playcount'] ?? '0'), 10), biography: bio?.['summary'] !== null && bio?.['summary'] !== undefined ? String(bio['summary']).replace(/<[^>]*>/g, '') : null, tags: ((tags?.['tag'] as Record<string, unknown>[]) ?? []).map((t: Record<string, unknown>) => ({ name: String(t['name'] ?? ''), url: String(t['url'] ?? ''), })), similar: ((similar?.['artist'] as Record<string, unknown>[]) ?? []).slice(0, 5).map((a: Record<string, unknown>) => String(a['name'] ?? '')), }; } const TopTracksByArtistSchema = z.object({ artist: z.string().min(1), limit: z.number().min(1).max(50).optional().default(DEFAULT_VALUES.TOP_TRACKS_BY_ARTIST_LIMIT), }); export async function getTopTracksByArtist(config: Config, args: unknown): Promise<TopTracksByArtistResult> { const { artist, limit = 10 } = TopTracksByArtistSchema.parse(args); if (config.lastFmApiKey === null || config.lastFmApiKey === undefined || config.lastFmApiKey === '') { throw new Error(ErrorFormatter.configMissing('Last.fm', 'LASTFM_API_KEY')); } logger.info(`Getting top tracks for artist: ${artist}`); const data = await callLastFmApi('artist.getTopTracks', { artist, limit: limit.toString(), autocorrect: '1', }, config.lastFmApiKey); const topTracks = (data['toptracks'] as Record<string, unknown>)?.['track'] as Record<string, unknown>[] ?? []; return { artist, count: topTracks.length, tracks: topTracks.map((t: Record<string, unknown>, index: number) => ({ rank: index + 1, name: String(t['name'] ?? ''), playcount: parseInt(String(t['playcount'] ?? '0'), 10), listeners: parseInt(String(t['listeners'] ?? '0'), 10), url: String(t['url'] ?? ''), mbid: (t['mbid'] as string) ?? null, })), }; } const GlobalChartsSchema = z.object({ type: z.enum(['artists', 'tracks', 'tags']), limit: z.number().min(1).max(100).optional().default(DEFAULT_VALUES.TRENDING_MUSIC_LIMIT), page: z.number().min(1).optional().default(1), }); export async function getTrendingMusic(config: Config, args: unknown): Promise<TrendingMusicResult> { const { type, limit = 20, page = 1 } = GlobalChartsSchema.parse(args); if (config.lastFmApiKey === null || config.lastFmApiKey === undefined || config.lastFmApiKey === '') { throw new Error(ErrorFormatter.configMissing('Last.fm', 'LASTFM_API_KEY')); } logger.info(`Getting global ${type} chart`); const method = type === 'artists' ? 'chart.getTopArtists' : type === 'tracks' ? 'chart.getTopTracks' : 'chart.getTopTags'; const data = await callLastFmApi(method, { limit: limit.toString(), page: page.toString(), }, config.lastFmApiKey); if (type === 'artists') { const artists = ((data['artists'] as Record<string, unknown>)?.['artist'] as Record<string, unknown>[] ?? []).map((a: Record<string, unknown>, index: number): TrendingArtistItem => ({ rank: (page - 1) * limit + index + 1, name: String(a['name'] ?? ''), playcount: parseInt(String(a['playcount'] ?? '0'), 10), listeners: parseInt(String(a['listeners'] ?? '0'), 10), url: String(a['url'] ?? ''), mbid: (a['mbid'] as string) ?? null, })); return { type, page, perPage: limit, count: artists.length, items: artists, }; } else if (type === 'tracks') { const tracks = ((data['tracks'] as Record<string, unknown>)?.['track'] as Record<string, unknown>[] ?? []).map((t: Record<string, unknown>, index: number): TrendingTrackItem => ({ rank: (page - 1) * limit + index + 1, name: String(t['name'] ?? ''), artist: String(((t['artist'] as Record<string, unknown>)?.['name']) ?? 'Unknown'), playcount: parseInt(String(t['playcount'] ?? '0'), 10), listeners: parseInt(String(t['listeners'] ?? '0'), 10), url: String(t['url'] ?? ''), mbid: (t['mbid'] as string) ?? null, })); return { type, page, perPage: limit, count: tracks.length, items: tracks, }; } else { const tags = ((data['tags'] as Record<string, unknown>)?.['tag'] as Record<string, unknown>[] ?? []).map((t: Record<string, unknown>, index: number): TrendingTagItem => ({ rank: (page - 1) * limit + index + 1, name: String(t['name'] ?? ''), count: parseInt(String(t['count'] ?? '0'), 10), url: String(t['url'] ?? ''), })); return { type, page, perPage: limit, count: tags.length, items: tags, }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Blakeem/Navidrome-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server