main.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"encoding/json"
"fmt"
"net/url"
"slices"
"strconv"
"audiomuse-navidrome-plugin/sonicsimilarity"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// Configuration keys (must match manifest.json)
const (
configAPIUrl = "apiUrl"
configAPIToken = "apiToken"
configServer = "server"
configEliminateDuplicates = "eliminateDuplicates"
configRadiusSimilarity = "radiusSimilarity"
configInstantMixSource = "instantMixSource"
configAlchemyTemperature = "alchemyTemperature"
)
const (
instantMixSimilarSong = "similarSong"
instantMixLyricsBySong = "lyricsBySong"
instantMixHyperbolic = "hyperbolic"
instantMixDCLAP = "dclap"
instantMixSongAlchemy = "songAlchemy"
)
// Default values
const (
defaultAPIUrl = "http://192.168.3.203:8000"
defaultArtistSimilarCount = 10
defaultEliminateDuplicates = true
defaultRadiusSimilarity = true
defaultInstantMixSource = instantMixSimilarSong
defaultAlchemyTemperature = 1.0
)
// Compile-time check that we implement necessary interfaces
var _ metadata.SimilarSongsByArtistProvider = (*audioMusePlugin)(nil)
var _ metadata.SimilarSongsByTrackProvider = (*audioMusePlugin)(nil)
var _ metadata.SimilarArtistsProvider = (*audioMusePlugin)(nil)
var _ sonicsimilarity.SonicSimilarity = (*audioMusePlugin)(nil)
// audioMuseTrackResponse represents a single track from AudioMuse-AI API
// and is used for both similar-track and path responses.
type audioMuseTrackResponse struct {
ItemID string `json:"item_id"`
Title string `json:"title"`
Author string `json:"author"`
Album string `json:"album"`
Distance float64 `json:"distance"`
Similarity float64 `json:"similarity"`
IsSeed bool `json:"is_seed"`
}
func (t *audioMuseTrackResponse) UnmarshalJSON(data []byte) error {
type track audioMuseTrackResponse
decoded := track{Distance: -1, Similarity: -1}
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*t = audioMuseTrackResponse(decoded)
return nil
}
type audioMusePathResponse struct {
Path []audioMuseTrackResponse `json:"path"`
}
type audioMuseResultsResponse struct {
Results []audioMuseTrackResponse `json:"results"`
}
type audioMusePlugin struct{}
func init() {
// NOTE: Navidrome re-instantiates the WASM module on every call, so init()
// runs very frequently. Do not log here: it floods the Navidrome log.
metadata.Register(&audioMusePlugin{})
sonicsimilarity.Register(&audioMusePlugin{})
}
// getConfigString retrieves a string config value with a default fallback
func getConfigString(key, defaultValue string) string {
if value, ok := pdk.GetConfig(key); ok && value != "" {
return value
}
return defaultValue
}
// getConfigInt retrieves an integer config value with a default fallback
func getConfigInt(key string, defaultValue int) int {
if value, ok := pdk.GetConfig(key); ok && value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
// getConfigFloat retrieves a float config value with a default fallback
func getConfigFloat(key string, defaultValue float64) float64 {
if value, ok := pdk.GetConfig(key); ok && value != "" {
if floatVal, err := strconv.ParseFloat(value, 64); err == nil {
return floatVal
}
}
return defaultValue
}
// getConfigBool retrieves a boolean config value with a default fallback
func getConfigBool(key string, defaultValue bool) bool {
if value, ok := pdk.GetConfig(key); ok && value != "" {
return value == "true"
}
return defaultValue
}
// authHeaders returns a headers map with a Bearer token if configured, or nil otherwise.
func authHeaders() map[string]string {
if token := getConfigString(configAPIToken, ""); token != "" {
return map[string]string{
"Authorization": "Bearer " + token,
}
}
return nil
}
func jsonHeaders() map[string]string {
headers := authHeaders()
if headers == nil {
headers = map[string]string{}
}
headers["Content-Type"] = "application/json"
return headers
}
func (p *audioMusePlugin) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] GetSimilarSongsByTrack called for track ID: %s, Name: %s, Artist: %s", input.ID, input.Name, input.Artist))
tracks, err := p.getAudioMuseSimilarTracks(input.ID, int(input.Count))
if err != nil {
return nil, err
}
// Convert to Navidrome SongRef format preserving order
songs := make([]metadata.SongRef, 0, len(tracks))
for _, track := range tracks {
songs = append(songs, metadata.SongRef{
ID: track.ItemID,
Name: track.Title,
Artist: track.Author,
Album: track.Album,
})
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Returning %d songs to Navidrome", len(songs)))
return &metadata.SimilarSongsResponse{Songs: songs}, nil
}
func (p *audioMusePlugin) getAudioMuseSimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
switch getConfigString(configInstantMixSource, defaultInstantMixSource) {
case instantMixLyricsBySong:
return getLyricsSimilarTracks(itemID, count)
case instantMixHyperbolic:
return getHyperbolicSimilarTracks(itemID, count)
case instantMixDCLAP:
return getDCLAPSimilarTracks(itemID, count)
case instantMixSongAlchemy:
return getSongAlchemySimilarTracks(itemID, count)
default:
return getSimilarTracks(itemID, count)
}
}
func getSimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
apiBaseURL := getConfigString(configAPIUrl, defaultAPIUrl)
eliminateDuplicates := getConfigBool(configEliminateDuplicates, defaultEliminateDuplicates)
radiusSimilarity := getConfigBool(configRadiusSimilarity, defaultRadiusSimilarity)
params := url.Values{}
params.Set("item_id", itemID)
params.Set("n", strconv.Itoa(count))
params.Set("eliminate_duplicates", strconv.FormatBool(eliminateDuplicates))
params.Set("radius_similarity", strconv.FormatBool(radiusSimilarity))
if server := getConfigString(configServer, ""); server != "" {
params.Set("server", server)
}
apiURL := fmt.Sprintf("%s/api/similar_tracks?%s", apiBaseURL, params.Encode())
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Calling similar_tracks API: %s", apiURL))
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "GET",
URL: apiURL,
Headers: authHeaders(),
})
if err != nil {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: HTTP request failed: %v", err)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI HTTP request failed: %w", err)
}
if resp == nil {
errMsg := "[AudioMuse] ERROR: empty HTTP response"
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI returned an empty HTTP response")
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] API response status: %d", resp.StatusCode))
if resp.StatusCode != 200 {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: AudioMuse-AI returned status %d", resp.StatusCode)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI returned status %d", resp.StatusCode)
}
var tracks []audioMuseTrackResponse
if err := json.Unmarshal(resp.Body, &tracks); err != nil {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: Failed to parse similar_tracks response: %v", err)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("failed to parse AudioMuse-AI similar tracks response: %w", err)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
return tracks, nil
}
func getLyricsSimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
results, err := postAudioMuseSimilarTracks("/api/sem_grove/search", "sem_grove_search", map[string]any{"item_id": itemID, "limit": count + 1})
if err != nil {
return nil, err
}
tracks := make([]audioMuseTrackResponse, 0, len(results))
for _, track := range results {
if track.IsSeed {
continue
}
if track.Similarity >= 0 {
track.Distance = 1 - track.Similarity
}
tracks = append(tracks, track)
}
if count > 0 && len(tracks) > count {
tracks = tracks[:count]
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
return tracks, nil
}
func getHyperbolicSimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
results, err := postAudioMuseSimilarTracks("/api/hyperbolic/similar", "hyperbolic_similar", map[string]any{"item_id": itemID, "limit": count})
if err != nil {
return nil, err
}
tracks := make([]audioMuseTrackResponse, 0, len(results))
for _, track := range results {
if track.Distance > 0 {
track.Distance = track.Distance / (1 + track.Distance)
}
tracks = append(tracks, track)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
return tracks, nil
}
func getDCLAPSimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
body := map[string]any{
"item_id": itemID,
"limit": count,
"eliminate_duplicates": getConfigBool(configEliminateDuplicates, defaultEliminateDuplicates),
}
results, err := postAudioMuseSimilarTracks("/api/clap/search_by_song", "clap_search_by_song", body)
if err != nil {
return nil, err
}
tracks := make([]audioMuseTrackResponse, 0, len(results))
for _, track := range results {
if track.Similarity >= 0 {
track.Distance = 1 - track.Similarity
}
tracks = append(tracks, track)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
return tracks, nil
}
func getSongAlchemySimilarTracks(itemID string, count int) ([]audioMuseTrackResponse, error) {
body := map[string]any{
"items": []map[string]string{{"id": itemID, "op": "ADD"}},
"n": count,
"temperature": getConfigFloat(configAlchemyTemperature, defaultAlchemyTemperature),
}
tracks, err := postAudioMuseSimilarTracks("/api/alchemy", "song_alchemy", body)
if err != nil {
return nil, err
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Successfully parsed %d similar tracks", len(tracks)))
return tracks, nil
}
func apiErrorMessage(body []byte) string {
var payload struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Error != "" {
return payload.Error
}
if len(body) > 200 {
return string(body[:200])
}
return string(body)
}
func postAudioMuseSimilarTracks(endpoint, label string, body map[string]any) ([]audioMuseTrackResponse, error) {
apiBaseURL := getConfigString(configAPIUrl, defaultAPIUrl)
if server := getConfigString(configServer, ""); server != "" {
body["server"] = server
}
payload, err := json.Marshal(body)
if err != nil {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: Failed to encode %s request: %v", label, err)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("failed to encode AudioMuse-AI %s request: %w", label, err)
}
apiURL := apiBaseURL + endpoint
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Calling %s API: %s", label, apiURL))
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "POST",
URL: apiURL,
Headers: jsonHeaders(),
Body: payload,
})
if err != nil {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: HTTP request failed: %v", err)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI HTTP request failed: %w", err)
}
if resp == nil {
errMsg := "[AudioMuse] ERROR: empty HTTP response"
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI returned an empty HTTP response")
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] API response status: %d", resp.StatusCode))
if resp.StatusCode != 200 {
apiError := apiErrorMessage(resp.Body)
errMsg := fmt.Sprintf("[AudioMuse] ERROR: AudioMuse-AI returned status %d: %s", resp.StatusCode, apiError)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("AudioMuse-AI returned status %d: %s", resp.StatusCode, apiError)
}
var result audioMuseResultsResponse
if err := json.Unmarshal(resp.Body, &result); err != nil {
errMsg := fmt.Sprintf("[AudioMuse] ERROR: Failed to parse %s response: %v", label, err)
pdk.Log(pdk.LogError, errMsg)
return nil, fmt.Errorf("failed to parse AudioMuse-AI %s response: %w", label, err)
}
return result.Results, nil
}
func (p *audioMusePlugin) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if input.Song.ID == "" {
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("song.id is required")
}
count := int(input.Count)
if count <= 0 {
count = 10
}
tracks, err := p.getAudioMuseSimilarTracks(input.Song.ID, count)
if err != nil {
return sonicsimilarity.SonicSimilarityResponse{}, err
}
matches := make([]sonicsimilarity.SonicMatch, 0, len(tracks))
for _, track := range tracks {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: metadata.SongRef{
ID: track.ItemID,
Name: track.Title,
Artist: track.Author,
Album: track.Album,
},
Similarity: normalizeSimilarity(track.Distance),
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func (p *audioMusePlugin) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if input.StartSong.ID == "" || input.EndSong.ID == "" {
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("startSong.id and endSong.id are required")
}
count := int(input.Count)
if count <= 0 {
count = 25
}
apiBaseURL := getConfigString(configAPIUrl, defaultAPIUrl)
params := url.Values{}
params.Set("start_song_id", input.StartSong.ID)
params.Set("end_song_id", input.EndSong.ID)
params.Set("count", strconv.Itoa(count))
params.Set("max_steps", strconv.Itoa(count))
params.Set("path_fix_size", "false")
params.Set("mood_pct", "100")
if server := getConfigString(configServer, ""); server != "" {
params.Set("server", server)
}
apiURL := fmt.Sprintf("%s/api/find_path?%s", apiBaseURL, params.Encode())
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Calling FindSonicPath API from %s to %s: %s", input.StartSong.ID, input.EndSong.ID, apiURL))
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "GET",
URL: apiURL,
Headers: authHeaders(),
})
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("[AudioMuse] ERROR: HTTP request failed: %v", err))
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("AudioMuse-AI HTTP request failed: %w", err)
}
if resp == nil {
pdk.Log(pdk.LogError, "[AudioMuse] ERROR: empty HTTP response")
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("AudioMuse-AI returned an empty HTTP response")
}
if resp.StatusCode != 200 {
pdk.Log(pdk.LogError, fmt.Sprintf("[AudioMuse] ERROR: AudioMuse-AI returned status %d", resp.StatusCode))
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("AudioMuse-AI returned status %d", resp.StatusCode)
}
var result audioMusePathResponse
if err := json.Unmarshal(resp.Body, &result); err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("[AudioMuse] ERROR: Failed to parse FindSonicPath response: %v", err))
return sonicsimilarity.SonicSimilarityResponse{}, fmt.Errorf("failed to parse AudioMuse-AI find path response: %w", err)
}
matches := make([]sonicsimilarity.SonicMatch, 0, len(result.Path))
for _, item := range result.Path {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: metadata.SongRef{
ID: item.ItemID,
Name: item.Title,
Artist: item.Author,
Album: item.Album,
},
Similarity: -1.0,
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func normalizeSimilarity(distance float64) float64 {
if distance < 0 {
return -1
}
similarity := 1.0 - distance
if similarity < 0 {
similarity = 0
}
if similarity > 1 {
similarity = 1
}
return similarity
}
func (p *audioMusePlugin) GetSimilarSongsByArtist(input metadata.SimilarSongsByArtistRequest) (*metadata.SimilarSongsResponse, error) {
artists, err := getSimilarArtists(input.ID, true)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
// songSlices contains artist songs in alternating order: [baseArtist, relatedArtist1, baseArtist, relatedArtist2, ...]
songSlices := [][]metadata.SongRef{}
for _, a := range artists {
var artist1Songs, artist2Songs []metadata.SongRef
for _, cm := range a.ComponentMatches {
for _, s := range cm.Artist1RepresentativeSongs {
if s.ItemID == "" {
continue
}
if seen[s.ItemID] {
continue
}
seen[s.ItemID] = true
artist1Songs = append(artist1Songs, metadata.SongRef{ID: s.ItemID, Name: s.Title})
}
for _, s := range cm.Artist2RepresentativeSongs {
if s.ItemID == "" {
continue
}
if seen[s.ItemID] {
continue
}
seen[s.ItemID] = true
artist2Songs = append(artist2Songs, metadata.SongRef{ID: s.ItemID, Name: s.Title})
}
}
if len(artist1Songs) > 0 {
songSlices = append(songSlices, artist1Songs)
}
if len(artist2Songs) > 0 {
songSlices = append(songSlices, artist2Songs)
}
}
songs := make([]metadata.SongRef, 0, input.Count)
// get songs from our slices until we have enough or we ran out
artistID := 0
for len(songs) < int(input.Count) && len(songSlices) > 0 {
song := songSlices[artistID][0] // take a song
songs = append(songs, song)
songSlices[artistID] = songSlices[artistID][1:] // remove it from the pool
if len(songSlices[artistID]) == 0 {
// this slice has no more songs, remove it
songSlices = slices.Delete(songSlices, artistID, artistID+1)
if len(songSlices) == 0 {
break
}
} else {
// else, go to the next slice
artistID++
}
artistID = artistID % len(songSlices) // loop around if needed
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Returning %d artist-related songs to Navidrome", len(songs)))
return &metadata.SimilarSongsResponse{Songs: songs}, nil
}
// GetSimilarArtists implements metadata.SimilarArtistsProvider.
func (p *audioMusePlugin) GetSimilarArtists(input metadata.SimilarArtistsRequest) (*metadata.SimilarArtistsResponse, error) {
artists, err := getSimilarArtists(input.ID, false)
if err != nil {
return nil, err
}
res := &metadata.SimilarArtistsResponse{
Artists: make([]metadata.ArtistRef, 0, len(artists)),
}
seen := make(map[string]bool)
for _, a := range artists {
if a.ArtistID == "" {
continue
}
if a.ArtistID == input.ID {
continue
}
if seen[a.ArtistID] {
continue
}
seen[a.ArtistID] = true
res.Artists = append(res.Artists, metadata.ArtistRef{
ID: a.ArtistID,
Name: a.Artist,
})
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("[AudioMuse] Returning %d related artists to Navidrome", len(res.Artists)))
return res, nil
}
func main() {}