@@ -45,6 +45,8 @@ class Track(BaseModel):
4545 artist : str
4646 artists : list [str ] | None = None
4747 album : str | None = None
48+ album_id : str | None = None
49+ release_date : str | None = None
4850 duration_ms : int | None = None
4951 popularity : int | None = None
5052 external_urls : dict [str , str ] | None = None
@@ -84,14 +86,52 @@ class Artist(BaseModel):
8486 followers : int | None = None
8587
8688
89+ class Album (BaseModel ):
90+ """A Spotify album."""
91+
92+ name : str
93+ id : str
94+ artist : str
95+ artists : list [str ] | None = None
96+ release_date : str | None = None
97+ release_date_precision : str | None = None
98+ total_tracks : int | None = None
99+ album_type : str | None = None
100+ label : str | None = None
101+ genres : list [str ] | None = None
102+ popularity : int | None = None
103+ external_urls : dict [str , str ] | None = None
104+
105+
106+ class AudioFeatures (BaseModel ):
107+ """Audio features for a track."""
108+
109+ id : str
110+ tempo : float | None = None
111+ key : int | None = None
112+ mode : int | None = None
113+ time_signature : int | None = None
114+ danceability : float | None = None
115+ energy : float | None = None
116+ valence : float | None = None
117+ loudness : float | None = None
118+ speechiness : float | None = None
119+ acousticness : float | None = None
120+ instrumentalness : float | None = None
121+ liveness : float | None = None
122+
123+
87124def parse_track (item : dict [str , Any ]) -> Track :
88125 """Parse Spotify track data into Track model."""
126+ album_data = item .get ("album" , {})
89127 return Track (
90128 name = item ["name" ],
91129 id = item ["id" ],
92130 artist = item ["artists" ][0 ]["name" ] if item .get ("artists" ) else "Unknown" ,
93131 artists = [a ["name" ] for a in item .get ("artists" , [])],
94- album = item .get ("album" , {}).get ("name" ),
132+ album = album_data .get ("name" ),
133+ album_id = album_data .get ("id" ),
134+ release_date = album_data .get ("release_date" ),
95135 duration_ms = item .get ("duration_ms" ),
96136 popularity = item .get ("popularity" ),
97137 external_urls = item .get ("external_urls" ),
@@ -231,7 +271,15 @@ def playback_control(
231271@mcp .tool ()
232272@log_tool_execution
233273def search_tracks (
234- query : str , qtype : str = "track" , limit : int = 10 , offset : int = 0
274+ query : str ,
275+ qtype : str = "track" ,
276+ limit : int = 10 ,
277+ offset : int = 0 ,
278+ year : str | None = None ,
279+ year_range : str | None = None ,
280+ genre : str | None = None ,
281+ artist : str | None = None ,
282+ album : str | None = None ,
235283) -> dict [str , Any ]:
236284 """Search Spotify for tracks, albums, artists, or playlists.
237285
@@ -240,20 +288,40 @@ def search_tracks(
240288 qtype: Type ('track', 'album', 'artist', 'playlist')
241289 limit: Max results per page (1-50, default 10)
242290 offset: Number of results to skip for pagination (default 0)
291+ year: Filter by year (e.g., '2024')
292+ year_range: Filter by year range (e.g., '2020-2024')
293+ genre: Filter by genre (e.g., 'electronic', 'hip-hop')
294+ artist: Filter by artist name
295+ album: Filter by album name
243296
244297 Returns:
245298 Dict with 'items' (list of tracks) and pagination info ('total', 'limit', 'offset')
246- Note: For large result sets, use offset to paginate through results.
247- Example: offset=0 gets results 1-10, offset=10 gets results 11-20, etc.
299+
300+ Note: Filters use Spotify's search syntax. For large result sets, use offset to paginate.
301+ Example: query='love', year='2024', genre='pop' searches for 'love year:2024 genre:pop'
248302 """
249303 try :
250- # Validate limit (Spotify API accepts 1-50)
251304 limit = max (1 , min (50 , limit ))
252305
306+ # Build filtered query
307+ filters = []
308+ if artist :
309+ filters .append (f"artist:{ artist } " )
310+ if album :
311+ filters .append (f"album:{ album } " )
312+ if year :
313+ filters .append (f"year:{ year } " )
314+ if year_range :
315+ filters .append (f"year:{ year_range } " )
316+ if genre :
317+ filters .append (f"genre:{ genre } " )
318+
319+ full_query = " " .join ([query ] + filters ) if filters else query
320+
253321 logger .info (
254- f"🔍 Searching { qtype } s: '{ query } ' (limit={ limit } , offset={ offset } )"
322+ f"🔍 Searching { qtype } s: '{ full_query } ' (limit={ limit } , offset={ offset } )"
255323 )
256- result = spotify_client .search (q = query , type = qtype , limit = limit , offset = offset )
324+ result = spotify_client .search (q = full_query , type = qtype , limit = limit , offset = offset )
257325
258326 tracks = []
259327 items_key = f"{ qtype } s"
@@ -337,18 +405,39 @@ def get_queue() -> dict[str, Any]:
337405
338406@mcp .tool ()
339407@log_tool_execution
340- def get_track_info (track_id : str ) -> dict [str , Any ]:
341- """Get detailed information about a Spotify track .
408+ def get_track_info (track_ids : str | list [ str ] ) -> dict [str , Any ]:
409+ """Get detailed information about one or more Spotify tracks .
342410
343411 Args:
344- track_id: Spotify track ID
412+ track_ids: Single track ID or list of track IDs (up to 50)
413+
345414 Returns:
346- Dict with complete track metadata
415+ Dict with 'tracks' list containing track metadata including release_date.
416+ For single ID, returns {'tracks': [track]}.
417+
418+ Note: Batch lookup is much more efficient - 50 tracks = 1 API call instead of 50.
347419 """
348420 try :
349- logger .info (f"🎵 Getting track info: { track_id } " )
350- result = spotify_client .track (track_id )
351- return parse_track (result ).model_dump ()
421+ # Normalize to list
422+ ids = [track_ids ] if isinstance (track_ids , str ) else track_ids
423+
424+ if len (ids ) > 50 :
425+ raise ValueError ("Maximum 50 track IDs per request (Spotify API limit)" )
426+
427+ logger .info (f"🎵 Getting track info for { len (ids )} track(s)" )
428+
429+ if len (ids ) == 1 :
430+ result = spotify_client .track (ids [0 ])
431+ tracks = [parse_track (result ).model_dump ()]
432+ else :
433+ result = spotify_client .tracks (ids )
434+ tracks = [
435+ parse_track (item ).model_dump ()
436+ for item in result .get ("tracks" , [])
437+ if item
438+ ]
439+
440+ return {"tracks" : tracks }
352441 except SpotifyException as e :
353442 raise convert_spotify_error (e ) from e
354443
@@ -648,6 +737,210 @@ def modify_playlist_details(
648737 raise convert_spotify_error (e ) from e
649738
650739
740+ @mcp .tool ()
741+ @log_tool_execution
742+ def get_album_info (album_id : str ) -> dict [str , Any ]:
743+ """Get detailed information about a Spotify album.
744+
745+ Args:
746+ album_id: Spotify album ID
747+
748+ Returns:
749+ Dict with album metadata including release_date, label, tracks
750+ """
751+ try :
752+ logger .info (f"💿 Getting album info: { album_id } " )
753+ result = spotify_client .album (album_id )
754+
755+ album = Album (
756+ name = result ["name" ],
757+ id = result ["id" ],
758+ artist = result ["artists" ][0 ]["name" ] if result .get ("artists" ) else "Unknown" ,
759+ artists = [a ["name" ] for a in result .get ("artists" , [])],
760+ release_date = result .get ("release_date" ),
761+ release_date_precision = result .get ("release_date_precision" ),
762+ total_tracks = result .get ("total_tracks" ),
763+ album_type = result .get ("album_type" ),
764+ label = result .get ("label" ),
765+ genres = result .get ("genres" , []),
766+ popularity = result .get ("popularity" ),
767+ external_urls = result .get ("external_urls" ),
768+ )
769+
770+ # Parse album tracks
771+ tracks = []
772+ for item in result .get ("tracks" , {}).get ("items" , []):
773+ if item :
774+ # Album track items don't have album info, add it
775+ item ["album" ] = {
776+ "name" : result ["name" ],
777+ "id" : result ["id" ],
778+ "release_date" : result .get ("release_date" ),
779+ }
780+ tracks .append (parse_track (item ).model_dump ())
781+
782+ return {
783+ "album" : album .model_dump (),
784+ "tracks" : tracks ,
785+ }
786+ except SpotifyException as e :
787+ raise convert_spotify_error (e ) from e
788+
789+
790+ @mcp .tool ()
791+ @log_tool_execution
792+ def get_audio_features (track_ids : str | list [str ]) -> dict [str , Any ]:
793+ """Get audio features for one or more tracks (tempo, key, energy, danceability, etc).
794+
795+ Args:
796+ track_ids: Single track ID or list of track IDs (up to 100)
797+
798+ Returns:
799+ Dict with 'features' list containing audio features for each track.
800+ Features include: tempo, key, mode, time_signature, danceability, energy,
801+ valence, loudness, speechiness, acousticness, instrumentalness, liveness.
802+
803+ Note: Batch lookup is efficient - 100 tracks = 1 API call.
804+ """
805+ try :
806+ # Normalize to list
807+ ids = [track_ids ] if isinstance (track_ids , str ) else track_ids
808+
809+ if len (ids ) > 100 :
810+ raise ValueError ("Maximum 100 track IDs per request (Spotify API limit)" )
811+
812+ logger .info (f"🎼 Getting audio features for { len (ids )} track(s)" )
813+ result = spotify_client .audio_features (ids )
814+
815+ features_list = []
816+ for features in result :
817+ if features :
818+ audio = AudioFeatures (
819+ id = features ["id" ],
820+ tempo = features .get ("tempo" ),
821+ key = features .get ("key" ),
822+ mode = features .get ("mode" ),
823+ time_signature = features .get ("time_signature" ),
824+ danceability = features .get ("danceability" ),
825+ energy = features .get ("energy" ),
826+ valence = features .get ("valence" ),
827+ loudness = features .get ("loudness" ),
828+ speechiness = features .get ("speechiness" ),
829+ acousticness = features .get ("acousticness" ),
830+ instrumentalness = features .get ("instrumentalness" ),
831+ liveness = features .get ("liveness" ),
832+ )
833+ features_list .append (audio .model_dump ())
834+
835+ return {"features" : features_list }
836+ except SpotifyException as e :
837+ raise convert_spotify_error (e ) from e
838+
839+
840+ @mcp .tool ()
841+ @log_tool_execution
842+ def get_saved_tracks (limit : int = 20 , offset : int = 0 ) -> dict [str , Any ]:
843+ """Get user's saved/liked tracks (Liked Songs library).
844+
845+ Args:
846+ limit: Max tracks to return per page (1-50, default 20)
847+ offset: Number of tracks to skip for pagination (default 0)
848+
849+ Returns:
850+ Dict with 'items' (list of tracks with added_at timestamp) and pagination info
851+ """
852+ try :
853+ limit = max (1 , min (50 , limit ))
854+
855+ logger .info (f"❤️ Getting saved tracks (limit={ limit } , offset={ offset } )" )
856+ result = spotify_client .current_user_saved_tracks (limit = limit , offset = offset )
857+
858+ tracks = []
859+ for item in result .get ("items" , []):
860+ if item and item .get ("track" ):
861+ track_data = parse_track (item ["track" ]).model_dump ()
862+ track_data ["added_at" ] = item .get ("added_at" )
863+ tracks .append (track_data )
864+
865+ log_pagination_info ("get_saved_tracks" , result .get ("total" , 0 ), limit , offset )
866+
867+ return {
868+ "items" : tracks ,
869+ "total" : result .get ("total" , 0 ),
870+ "limit" : result .get ("limit" , limit ),
871+ "offset" : result .get ("offset" , offset ),
872+ "next" : result .get ("next" ),
873+ "previous" : result .get ("previous" ),
874+ }
875+ except SpotifyException as e :
876+ raise convert_spotify_error (e ) from e
877+
878+
879+ @mcp .tool ()
880+ @log_tool_execution
881+ def get_recommendations (
882+ seed_artists : list [str ] | None = None ,
883+ seed_tracks : list [str ] | None = None ,
884+ seed_genres : list [str ] | None = None ,
885+ limit : int = 20 ,
886+ ) -> dict [str , Any ]:
887+ """Get track recommendations based on seed artists, tracks, or genres.
888+
889+ Args:
890+ seed_artists: List of artist IDs (up to 5 total seeds combined)
891+ seed_tracks: List of track IDs (up to 5 total seeds combined)
892+ seed_genres: List of genres (up to 5 total seeds combined)
893+ limit: Number of recommendations to return (1-100, default 20)
894+
895+ Returns:
896+ Dict with 'tracks' list of recommended tracks
897+
898+ Note: Total seeds (artists + tracks + genres) must be between 1 and 5.
899+ Use search_tracks to find seed track/artist IDs, or common genres like:
900+ 'pop', 'rock', 'hip-hop', 'electronic', 'jazz', 'classical', 'r-n-b', 'country'
901+ """
902+ try :
903+ # Validate seeds
904+ total_seeds = (
905+ len (seed_artists or [])
906+ + len (seed_tracks or [])
907+ + len (seed_genres or [])
908+ )
909+ if total_seeds == 0 :
910+ raise ValueError ("At least one seed (artist, track, or genre) is required" )
911+ if total_seeds > 5 :
912+ raise ValueError ("Maximum 5 total seeds allowed (artists + tracks + genres)" )
913+
914+ limit = max (1 , min (100 , limit ))
915+
916+ logger .info (
917+ f"🎲 Getting recommendations (artists={ seed_artists } , "
918+ f"tracks={ seed_tracks } , genres={ seed_genres } , limit={ limit } )"
919+ )
920+ result = spotify_client .recommendations (
921+ seed_artists = seed_artists ,
922+ seed_tracks = seed_tracks ,
923+ seed_genres = seed_genres ,
924+ limit = limit ,
925+ )
926+
927+ tracks = []
928+ for item in result .get ("tracks" , []):
929+ if item :
930+ tracks .append (parse_track (item ).model_dump ())
931+
932+ return {
933+ "tracks" : tracks ,
934+ "seeds" : {
935+ "artists" : seed_artists or [],
936+ "tracks" : seed_tracks or [],
937+ "genres" : seed_genres or [],
938+ },
939+ }
940+ except SpotifyException as e :
941+ raise convert_spotify_error (e ) from e
942+
943+
651944# === RESOURCES ===
652945
653946
0 commit comments