Skip to main content
Glama
user-preferences.ts10.4 kB
/** * Navidrome MCP Server - User Preferences 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 type { NavidromeClient } from '../client/navidrome-client.js'; import { logger } from '../utils/logger.js'; import type { Config } from '../config.js'; import { transformSongsToDTO, transformAlbumsToDTO, transformArtistsToDTO } from '../transformers/index.js'; import { StarItemSchema, SetRatingSchema, StarredItemsPaginationSchema, TopRatedItemsPaginationSchema, } from '../schemas/index.js'; // Helper function to parse duration from MM:SS format to seconds function parseDuration(durationFormatted: string): number { const parts = durationFormatted.split(':'); if (parts.length === 2) { const minutes = parseInt(parts[0] ?? '0', 10); const seconds = parseInt(parts[1] ?? '0', 10); return minutes * 60 + seconds; } return 0; } // Helper function to extract starredAt timestamp from raw data function extractStarredAt(item: unknown): string | undefined { // The transformers don't currently include starredAt, so we need to check the raw data // This is a limitation that should be addressed in the transformers later if (typeof item === 'object' && item !== null && 'starredAt' in item) { const starredAt = (item as { starredAt?: unknown }).starredAt; return typeof starredAt === 'string' ? starredAt : undefined; } return undefined; } interface StarItemResult { success: boolean; message: string; id: string; type: string; } interface StarredItem { id: string; title?: string; name?: string; artist?: string; album?: string; year?: number; duration?: number; albumCount?: number; songCount?: number; starredAt?: string; } interface ListStarredResult { type: string; count: number; items: StarredItem[]; } interface RatedItem { id: string; title?: string; name?: string; artist?: string; album?: string; year?: number; rating: number; playCount?: number; albumCount?: number; songCount?: number; } interface ListTopRatedResult { type: string; minRating: number; count: number; items: RatedItem[]; } interface SetRatingResult { success: boolean; message: string; id: string; type: string; rating: number; } export async function starItem(client: NavidromeClient, _config: Config, args: unknown): Promise<StarItemResult> { const { id, type } = StarItemSchema.parse(args); logger.info(`Starring ${type}: ${id}`); // Use Subsonic REST API for starring const response = await client.subsonicRequest('/star', { id }); logger.debug('Star response:', response); return { success: true, message: `Successfully starred ${type}`, id, type, }; } export async function unstarItem(client: NavidromeClient, _config: Config, args: unknown): Promise<StarItemResult> { const { id, type } = StarItemSchema.parse(args); logger.info(`Unstarring ${type}: ${id}`); // Use Subsonic REST API for unstarring const response = await client.subsonicRequest('/unstar', { id }); logger.debug('Unstar response:', response); return { success: true, message: `Successfully unstarred ${type}`, id, type, }; } export async function setRating(client: NavidromeClient, _config: Config, args: unknown): Promise<SetRatingResult> { const { id, type, rating } = SetRatingSchema.parse(args); logger.info(`Setting rating ${rating} for ${type}: ${id}`); // Use Subsonic REST API for setting rating const response = await client.subsonicRequest('/setRating', { id, rating: rating.toString() }); logger.debug('Set rating response:', response); return { success: true, message: rating > 0 ? `Successfully set rating to ${rating} stars` : 'Successfully removed rating', id, type, rating, }; } export async function listStarredItems(client: NavidromeClient, args: unknown): Promise<ListStarredResult> { const { type, limit, offset } = StarredItemsPaginationSchema.parse(args); logger.info(`Listing starred ${type}`); const endpoint = type === 'songs' ? '/song' : type === 'albums' ? '/album' : '/artist'; // Fetch more items to account for client-side filtering const fetchLimit = Math.min(limit * 5, 500); // Fetch 5x requested or max 500 const response = await client.request<unknown>( `${endpoint}?_start=${offset}&_end=${offset + fetchLimit}&_sort=starredAt&_order=DESC` ); // Transform using the appropriate transformer let transformedItems: StarredItem[]; if (type === 'songs') { const songs = transformSongsToDTO(response); transformedItems = songs .filter(song => song.starred === true) // Filter on client side to ensure we only get starred items .map(song => { const item: StarredItem = { id: song.id }; if (song.title !== null && song.title !== undefined && song.title !== '') item.title = song.title; if (song.artist !== null && song.artist !== undefined && song.artist !== '') item.artist = song.artist; if (song.album !== null && song.album !== undefined && song.album !== '') item.album = song.album; if (song.durationFormatted !== null && song.durationFormatted !== undefined && song.durationFormatted !== '') { item.duration = parseDuration(song.durationFormatted); } const starredAt = extractStarredAt(song); if (starredAt !== null && starredAt !== undefined && starredAt !== '') item.starredAt = starredAt; return item; }); } else if (type === 'albums') { const albums = transformAlbumsToDTO(response); transformedItems = albums .filter(album => album.starred === true) .map(album => { const item: StarredItem = { id: album.id }; if (album.name !== null && album.name !== undefined && album.name !== '') item.name = album.name; if (album.artist !== null && album.artist !== undefined && album.artist !== '') item.artist = album.artist; if (album.releaseYear !== null && album.releaseYear !== undefined) item.year = album.releaseYear; item.songCount = album.songCount; // Always present in albums const starredAt = extractStarredAt(album); if (starredAt !== null && starredAt !== undefined && starredAt !== '') item.starredAt = starredAt; return item; }); } else { const artists = transformArtistsToDTO(response); transformedItems = artists .filter(artist => artist.starred === true) .map(artist => { const item: StarredItem = { id: artist.id }; if (artist.name !== null && artist.name !== undefined && artist.name !== '') item.name = artist.name; item.albumCount = artist.albumCount; // Always present item.songCount = artist.songCount; // Always present const starredAt = extractStarredAt(artist); if (starredAt !== null && starredAt !== undefined && starredAt !== '') item.starredAt = starredAt; return item; }); } return { type, count: transformedItems.length, items: transformedItems, }; } export async function listTopRated(client: NavidromeClient, args: unknown): Promise<ListTopRatedResult> { const { type, minRating, limit, offset } = TopRatedItemsPaginationSchema.parse(args); logger.info(`Listing top rated ${type} (min rating: ${minRating})`); const endpoint = type === 'songs' ? '/song' : type === 'albums' ? '/album' : '/artist'; // Fetch more items to account for filtering by minRating // We'll fetch 3x the requested amount to ensure we have enough after filtering const fetchLimit = limit * 3; const response = await client.request<unknown>( `${endpoint}?_sort=rating&_order=DESC&_start=${offset}&_end=${offset + fetchLimit}` ); // Transform using the appropriate transformer let transformedItems: RatedItem[]; if (type === 'songs') { const songs = transformSongsToDTO(response); transformedItems = songs .filter(song => (song.rating ?? 0) >= minRating) // Filter on client side .map(song => { const item: RatedItem = { id: song.id, rating: song.rating ?? 0 }; if (song.title) item.title = song.title; if (song.artist) item.artist = song.artist; if (song.album) item.album = song.album; if (song.playCount !== null && song.playCount !== undefined && song.playCount > 0) item.playCount = song.playCount; return item; }) .slice(0, limit); } else if (type === 'albums') { const albums = transformAlbumsToDTO(response); transformedItems = albums .filter(album => (album.rating ?? 0) >= minRating) .map(album => { const item: RatedItem = { id: album.id, rating: album.rating ?? 0 }; if (album.name) item.name = album.name; if (album.artist) item.artist = album.artist; if (album.releaseYear !== null && album.releaseYear !== undefined) item.year = album.releaseYear; if (album.playCount !== null && album.playCount !== undefined && album.playCount > 0) item.playCount = album.playCount; return item; }) .slice(0, limit); } else { const artists = transformArtistsToDTO(response); transformedItems = artists .filter(artist => (artist.rating ?? 0) >= minRating) .map(artist => { const item: RatedItem = { id: artist.id, rating: artist.rating ?? 0 }; if (artist.name) item.name = artist.name; item.albumCount = artist.albumCount; item.songCount = artist.songCount; return item; }) .slice(0, limit); } return { type, minRating, count: transformedItems.length, items: transformedItems, }; }

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