Skip to main content
Glama
lyrics.ts8.79 kB
/** * Navidrome MCP Server - Lyrics 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 { LyricsDTO, LyricsLine } from '../types/index.js'; import type { Config } from '../config.js'; import { ErrorFormatter } from '../utils/error-formatter.js'; /** * Schema for getting lyrics */ const GetLyricsArgsSchema = z.object({ title: z.string(), artist: z.string(), album: z.string().optional(), durationMs: z.number().min(0).optional(), id: z.string().optional() }); /** * LRCLIB API response interface */ interface LRCLIBResponse { id?: number; trackName?: string; artistName?: string; albumName?: string; duration?: number; instrumental?: boolean; plainLyrics?: string; syncedLyrics?: string; } /** * Parse LRC format synced lyrics into structured format */ function parseSyncedLyrics(lrcText: string): LyricsLine[] { const lines: LyricsLine[] = []; const lrcRegex = /\[(\d{2}):(\d{2})\.(\d{2})\](.+)/g; let match; while ((match = lrcRegex.exec(lrcText)) !== null) { const [, minutesStr, secondsStr, centisecondsStr, textStr] = match; if (minutesStr === null || minutesStr === undefined || minutesStr === '' || secondsStr === null || secondsStr === undefined || secondsStr === '' || centisecondsStr === null || centisecondsStr === undefined || centisecondsStr === '' || textStr === null || textStr === undefined || textStr === '') continue; const minutes = parseInt(minutesStr, 10); const seconds = parseInt(secondsStr, 10); const centiseconds = parseInt(centisecondsStr, 10); const timeMs = (minutes * 60 + seconds) * 1000 + centiseconds * 10; const text = textStr.trim(); if (text) { lines.push({ timeMs, text }); } } return lines; } /** * Try to get lyrics using exact match */ async function tryExactMatch(params: z.infer<typeof GetLyricsArgsSchema>, config: Config): Promise<LRCLIBResponse | null> { try { const url = new URL('/api/get', config.lrclibBase); if (params.id !== null && params.id !== undefined && params.id !== '') { url.searchParams.set('id', params.id); } else { url.searchParams.set('track_name', params.title); url.searchParams.set('artist_name', params.artist); if (params.album !== null && params.album !== undefined && params.album !== '') url.searchParams.set('album_name', params.album); if (params.durationMs !== null && params.durationMs !== undefined) url.searchParams.set('duration', String(Math.round(params.durationMs / 1000))); } const response = await fetch(url.toString(), { headers: { 'User-Agent': config.lrclibUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error(ErrorFormatter.httpRequest('LRCLIB API', response)); } return await response.json() as LRCLIBResponse; } catch { // If exact match fails, return null to try search return null; } } /** * Search for lyrics and find best match */ async function searchLyrics(params: z.infer<typeof GetLyricsArgsSchema>, config: Config): Promise<LRCLIBResponse | null> { try { const url = new URL('/api/search', config.lrclibBase); url.searchParams.set('query', `${params.title} ${params.artist}`); if (params.durationMs !== null && params.durationMs !== undefined) { url.searchParams.set('duration', String(Math.round(params.durationMs / 1000))); } const response = await fetch(url.toString(), { headers: { 'User-Agent': config.lrclibUserAgent ?? 'Navidrome-MCP/1.0', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(ErrorFormatter.httpRequest('LRCLIB search API', response)); } const results = await response.json() as LRCLIBResponse[]; if (results === null || results === undefined || results.length === 0) { return null; } // Find best match // 1. Prefer exact artist and title match // 2. If duration provided, prefer within 3% tolerance const titleLower = params.title.toLowerCase(); const artistLower = params.artist.toLowerCase(); const durationSec = (params.durationMs !== null && params.durationMs !== undefined) ? params.durationMs / 1000 : null; let bestMatch: LRCLIBResponse | null = null; let bestScore = -1; for (const result of results) { let score = 0; // Check title match if (result.trackName?.toLowerCase() === titleLower) { score += 10; } else if (result.trackName?.toLowerCase()?.includes(titleLower) === true) { score += 5; } // Check artist match if (result.artistName?.toLowerCase() === artistLower) { score += 10; } else if (result.artistName?.toLowerCase()?.includes(artistLower) === true) { score += 5; } // Check duration match (within 3% tolerance) if (durationSec !== null && durationSec !== undefined && result.duration !== null && result.duration !== undefined) { const tolerance = durationSec * 0.03; const diff = Math.abs(result.duration - durationSec); if (diff <= tolerance) { score += 5; } } if (score > bestScore) { bestScore = score; bestMatch = result; } } return bestMatch; } catch { return null; } } /** * Get lyrics for a song */ export async function getLyrics(config: Config, args: unknown): Promise<LyricsDTO> { const params = GetLyricsArgsSchema.parse(args); try { // Try exact match first let lyricsData = await tryExactMatch(params, config); // If no exact match, try searching lyricsData ??= await searchLyrics(params, config); // If still no match, return empty lyrics if (lyricsData === null || lyricsData === undefined) { const result: LyricsDTO = { track: { title: params.title, artist: params.artist, ...(params.album !== null && params.album !== undefined && params.album !== '' ? { album: params.album } : {}), ...(params.durationMs !== null && params.durationMs !== undefined ? { durationMs: params.durationMs } : {}) }, isInstrumental: false, provider: 'lrclib', attribution: { url: 'https://lrclib.net', license: 'community-sourced' } }; return result; } // Parse synced lyrics if available let syncedLines: LyricsLine[] | undefined; if (lyricsData.syncedLyrics !== null && lyricsData.syncedLyrics !== undefined && lyricsData.syncedLyrics !== '') { syncedLines = parseSyncedLyrics(lyricsData.syncedLyrics); } const result: LyricsDTO = { track: { title: lyricsData.trackName ?? params.title, artist: lyricsData.artistName ?? params.artist }, isInstrumental: Boolean(lyricsData.instrumental), provider: 'lrclib', attribution: { url: 'https://lrclib.net', license: 'community-sourced' } }; // Add optional fields only if they have values const album = lyricsData.albumName ?? params.album; if (album !== null && album !== undefined && album !== '') { result.track.album = album; } const durationMs = (lyricsData.duration !== null && lyricsData.duration !== undefined) ? lyricsData.duration * 1000 : params.durationMs; if (durationMs !== null && durationMs !== undefined) { result.track.durationMs = durationMs; } if (syncedLines !== null && syncedLines !== undefined && syncedLines.length > 0) { result.synced = syncedLines; } if (lyricsData.plainLyrics !== null && lyricsData.plainLyrics !== undefined && lyricsData.plainLyrics !== '') { result.unsynced = lyricsData.plainLyrics; } return result; } catch (error) { throw new Error(ErrorFormatter.toolExecution('getLyrics', error)); } }

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