package sonic import ( "context" "fmt" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) const capabilitySonicSimilarity = "SonicSimilarity" type SimilarResult struct { Song agents.Song Similarity float64 } type SimilarMatch struct { MediaFile model.MediaFile Similarity float64 } type Provider interface { GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error) FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error) } type PluginLoader interface { PluginNames(capability string) []string LoadSonicSimilarity(name string) (Provider, bool) } type Sonic struct { ds model.DataStore pluginLoader PluginLoader matcher *matcher.Matcher } func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic { return &Sonic{ ds: ds, pluginLoader: pluginLoader, matcher: matcher, } } // Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. type Engine interface { HasProvider() bool GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) } var _ Engine = (*Sonic)(nil) func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } func (s *Sonic) loadProvider() (Provider, error) { names := s.pluginLoader.PluginNames(capabilitySonicSimilarity) if len(names) == 0 { return nil, model.ErrNotFound } provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0]) if !ok { return nil, model.ErrNotFound } return provider, nil } func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) { songs := make([]agents.Song, len(results)) for i, r := range results { songs[i] = r.Song } matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs) if err != nil { return nil, fmt.Errorf("matching songs to library: %w", err) } var matches []SimilarMatch for i, r := range results { if mf, ok := matchMap[i]; ok { matches = append(matches, SimilarMatch{ MediaFile: mf, Similarity: r.Similarity, }) } } return matches, nil } func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) { provider, err := s.loadProvider() if err != nil { return nil, err } mf, err := s.ds.MediaFile(ctx).Get(id) if err != nil { return nil, fmt.Errorf("getting media file %s: %w", id, err) } results, err := provider.GetSonicSimilarTracks(ctx, mf, count) if err != nil { log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err) return nil, err } return s.resolveMatches(ctx, results) } func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) { provider, err := s.loadProvider() if err != nil { return nil, err } startMF, err := s.ds.MediaFile(ctx).Get(startID) if err != nil { return nil, fmt.Errorf("getting start media file %s: %w", startID, err) } endMF, err := s.ds.MediaFile(ctx).Get(endID) if err != nil { return nil, fmt.Errorf("getting end media file %s: %w", endID, err) } results, err := provider.FindSonicPath(ctx, startMF, endMF, count) if err != nil { log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err) return nil, err } return s.resolveMatches(ctx, results) }