Skip to main content
Glama

Spotify Playlist MCP Server

by kylestratis
strategies.py8.6 kB
"""Similarity calculation strategies for audio features.""" import math from enum import Enum from typing import Any from pydantic import BaseModel, ConfigDict, Field class SimilarityStrategy(str, Enum): """Algorithm for calculating track similarity.""" EUCLIDEAN = "euclidean" WEIGHTED = "weighted" COSINE = "cosine" MANHATTAN = "manhattan" ENERGY_MATCH = "energy_match" MOOD_MATCH = "mood_match" RHYTHM_MATCH = "rhythm_match" GENRE_MATCH = "genre_match" class FeatureWeights(BaseModel): """Weights for audio features in similarity calculation.""" model_config = ConfigDict(validate_assignment=True) acousticness: float = Field(default=1.0, ge=0.0, le=10.0) danceability: float = Field(default=1.0, ge=0.0, le=10.0) energy: float = Field(default=1.0, ge=0.0, le=10.0) instrumentalness: float = Field(default=1.0, ge=0.0, le=10.0) liveness: float = Field(default=1.0, ge=0.0, le=10.0) loudness: float = Field(default=1.0, ge=0.0, le=10.0) speechiness: float = Field(default=1.0, ge=0.0, le=10.0) valence: float = Field(default=1.0, ge=0.0, le=10.0) tempo: float = Field(default=1.0, ge=0.0, le=10.0) def normalize_audio_features(features: dict[str, Any]) -> dict[str, float]: """Normalize audio features to 0-1 range for similarity calculations. Args: features: Raw audio features from Spotify API Returns: Normalized features in 0-1 range """ normalized = {} # Already in 0-1 range for key in [ "acousticness", "danceability", "energy", "instrumentalness", "liveness", "speechiness", "valence", ]: normalized[key] = features.get(key, 0.0) # Normalize loudness (typically -60 to 0 dB) loudness = features.get("loudness", -30.0) normalized["loudness"] = (loudness + 60.0) / 60.0 # Map to 0-1 # Normalize tempo (typically 50-200 BPM) tempo = features.get("tempo", 120.0) normalized["tempo"] = (tempo - 50.0) / 150.0 # Map to 0-1 return normalized def calculate_euclidean_distance( features1: dict[str, float], features2: dict[str, float], weights: dict[str, float] | None = None, ) -> float: """Calculate Euclidean distance between two feature sets. Args: features1: First feature set features2: Second feature set weights: Optional feature weights Returns: Euclidean distance """ weights = weights or {} distance_squared = 0.0 for key in features1.keys(): if key in features2: weight = weights.get(key, 1.0) diff = features1[key] - features2[key] distance_squared += weight * (diff**2) return math.sqrt(distance_squared) def calculate_cosine_similarity( features1: dict[str, float], features2: dict[str, float] ) -> float: """Calculate cosine similarity between two feature sets. Args: features1: First feature set features2: Second feature set Returns: Cosine similarity (0-1) """ dot_product = sum( features1[k] * features2[k] for k in features1.keys() if k in features2 ) magnitude1 = math.sqrt(sum(v**2 for v in features1.values())) magnitude2 = math.sqrt(sum(v**2 for v in features2.values())) if magnitude1 == 0 or magnitude2 == 0: return 0.0 return dot_product / (magnitude1 * magnitude2) def calculate_manhattan_distance( features1: dict[str, float], features2: dict[str, float] ) -> float: """Calculate Manhattan distance between two feature sets. Args: features1: First feature set features2: Second feature set Returns: Manhattan distance """ return sum( abs(features1[k] - features2[k]) for k in features1.keys() if k in features2 ) def calculate_similarity( source_features: dict[str, float], target_features: dict[str, float], strategy: SimilarityStrategy, weights: FeatureWeights | None = None, ) -> float: """Calculate similarity score based on strategy. Args: source_features: Source track audio features target_features: Target track audio features strategy: Similarity algorithm to use weights: Optional feature weights (for weighted strategy) Returns: Similarity score (0-1, higher = more similar) """ # Normalize features source_norm = normalize_audio_features(source_features) target_norm = normalize_audio_features(target_features) if strategy == SimilarityStrategy.EUCLIDEAN: # Lower distance = more similar, invert to 0-1 similarity score distance = calculate_euclidean_distance(source_norm, target_norm) return 1.0 / (1.0 + distance) elif strategy == SimilarityStrategy.WEIGHTED: # Use custom weights weight_dict = {} if weights: weight_dict = { "acousticness": weights.acousticness, "danceability": weights.danceability, "energy": weights.energy, "instrumentalness": weights.instrumentalness, "liveness": weights.liveness, "loudness": weights.loudness, "speechiness": weights.speechiness, "valence": weights.valence, "tempo": weights.tempo, } distance = calculate_euclidean_distance(source_norm, target_norm, weight_dict) return 1.0 / (1.0 + distance) elif strategy == SimilarityStrategy.COSINE: # Cosine similarity is already 0-1 return calculate_cosine_similarity(source_norm, target_norm) elif strategy == SimilarityStrategy.MANHATTAN: # Lower distance = more similar distance = calculate_manhattan_distance(source_norm, target_norm) return 1.0 / (1.0 + distance) elif strategy == SimilarityStrategy.ENERGY_MATCH: # Focus on energy and danceability energy_diff = abs(source_features["energy"] - target_features["energy"]) dance_diff = abs( source_features["danceability"] - target_features["danceability"] ) return 1.0 - ((energy_diff + dance_diff) / 2.0) elif strategy == SimilarityStrategy.MOOD_MATCH: # Focus on valence and acousticness valence_diff = abs(source_features["valence"] - target_features["valence"]) acoustic_diff = abs( source_features["acousticness"] - target_features["acousticness"] ) return 1.0 - ((valence_diff + acoustic_diff) / 2.0) elif strategy == SimilarityStrategy.RHYTHM_MATCH: # Focus on tempo (normalize to percentage difference) tempo1 = source_features["tempo"] tempo2 = target_features["tempo"] tempo_diff = abs(tempo1 - tempo2) / max(tempo1, tempo2, 1.0) return 1.0 - min(tempo_diff, 1.0) elif strategy == SimilarityStrategy.GENRE_MATCH: # Genre matching is handled separately - this shouldn't be called raise ValueError( "GENRE_MATCH strategy requires genre data, not audio features. " "Use calculate_genre_similarity() instead." ) return 0.0 def calculate_genre_similarity( source_genres: list[str], target_genres: list[str] ) -> float: """Calculate similarity based on genre overlap. Uses exact and partial matching with weighted scoring: - Exact genre match: 1.0 points - Partial match (substring): 0.5 points Args: source_genres: List of genre strings for source track/artist target_genres: List of genre strings for target track/artist Returns: Similarity score (0-1, higher = more similar) """ if not source_genres or not target_genres: return 0.0 # Normalize genres to lowercase for comparison source_lower = [g.lower() for g in source_genres] target_lower = [g.lower() for g in target_genres] total_score = 0.0 max_possible_score = len(source_lower) for source_genre in source_lower: # Check for exact match if source_genre in target_lower: total_score += 1.0 else: # Check for partial matches (substring) for target_genre in target_lower: if source_genre in target_genre or target_genre in source_genre: total_score += 0.5 break # Only count first partial match # Normalize to 0-1 range if max_possible_score > 0: return min(total_score / max_possible_score, 1.0) return 0.0

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/kylestratis/spotify-mcp'

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