2025-04-08 17:11:09 -08:00
package external_test
import (
2026-03-30 05:35:02 -08:00
"bytes"
2025-04-08 17:11:09 -08:00
"context"
"errors"
"net/url"
2026-03-30 05:35:02 -08:00
"time"
2025-04-08 17:11:09 -08:00
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
refactor: extract song-to-library matcher to core/matcher package (#5348)
* refactor: extract matchSongsToLibrary to core/matcher package
Move the song-to-library matching algorithm from core/external into its own
core/matcher package. The Matcher struct exposes a single public method
MatchSongsToLibrary that implements a multi-phase matching algorithm
(ID > MBID > ISRC > fuzzy title+artist). Includes pre-sanitization
optimization for the fuzzy matching loop.
No behavioral changes — the algorithm is identical to the version in
core/external/provider_matching.go.
* refactor: inject matcher.Matcher via Wire instead of creating it inline
Add *matcher.Matcher as a dependency of external.NewProvider, wired via
Google Wire. Update all provider test files to pass matcher.New(ds).
This eliminates tight coupling so future consumers can reuse the matcher
without depending on the external package.
* refactor: remove old provider_matching files
Delete core/external/provider_matching.go and its tests. All matching
logic now lives in core/matcher/.
* test(matcher): restore test coverage lost in extraction
Port back 23 specs that existed in the old provider_matching_test.go
but were dropped during the extraction. Covers specificity levels,
fuzzy matching thresholds, fuzzy album matching, duration matching,
and deduplication edge cases.
* test(matcher): extract matchFieldInAnd/matchFieldInEq helpers
The four inline mock.MatchedBy closures in setupAllPhaseExpectations
all followed the same squirrel.And -> squirrel.Eq -> field-name-check
pattern. Extract into two small helpers to reduce duplication and
make the setup functions read as a concise list of phase expectations.
* refactor(matcher): address PR #5348 review feedback
- sanitizedTrack now holds *model.MediaFile instead of a value copy. Since
MediaFile is a large struct (~74 fields), this avoids the per-track copy
into sanitized[] and a second copy when findBestMatch assigns the winner.
loadTracksByTitleAndArtist updated to iterate by index and pass &tracks[i].
- loadTracksByISRC now sorts results (starred desc, rating desc, year asc,
compilation asc) so that when multiple library tracks share an ISRC the
most relevant one is picked deterministically, matching the sort order
already used by loadTracksByTitleAndArtist.
- Restored the four worked examples (MBID Priority, ISRC Priority,
Specificity Ranking, Fuzzy Title Matching) in the MatchSongsToLibrary
godoc that were dropped during the extraction.
- matcher_test.go: tests now enforce expectations via AssertExpectations in
a DeferCleanup. The old setupAllPhaseExpectations helper was replaced
with per-phase helpers (expectIDPhase/expectMBIDPhase/expectISRCPhase +
allowOtherPhases) so each test deterministically verifies which matching
phases fire. This surfaced (and fixes) a latent issue copilot flagged:
the old .Once() expectations were not actually asserted, so tests would
silently pass even when phases short-circuited unexpectedly.
2026-04-12 12:47:22 -08:00
"github.com/navidrome/navidrome/core/matcher"
2026-03-30 05:35:02 -08:00
"github.com/navidrome/navidrome/log"
2025-04-08 17:11:09 -08:00
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe ( "Provider - ArtistImage" , func ( ) {
var ds * tests . MockDataStore
var provider Provider
var mockArtistRepo * mockArtistRepo
var mockAlbumRepo * mockAlbumRepo
var mockMediaFileRepo * mockMediaFileRepo
var mockImageAgent * mockArtistImageAgent
var agentsCombined * mockAgents
var ctx context . Context
BeforeEach ( func ( ) {
DeferCleanup ( configtest . SetupConfig ( ) )
conf . Server . Agents = "mockImage" // Configure only the mock agent
ctx = GinkgoT ( ) . Context ( )
mockArtistRepo = newMockArtistRepo ( )
mockAlbumRepo = newMockAlbumRepo ( )
mockMediaFileRepo = newMockMediaFileRepo ( )
ds = & tests . MockDataStore {
MockedArtist : mockArtistRepo ,
MockedAlbum : mockAlbumRepo ,
MockedMediaFile : mockMediaFileRepo ,
}
mockImageAgent = newMockArtistImageAgent ( )
// Use the mockAgents from helper, setting the specific agent
agentsCombined = & mockAgents {
imageAgent : mockImageAgent ,
}
refactor: extract song-to-library matcher to core/matcher package (#5348)
* refactor: extract matchSongsToLibrary to core/matcher package
Move the song-to-library matching algorithm from core/external into its own
core/matcher package. The Matcher struct exposes a single public method
MatchSongsToLibrary that implements a multi-phase matching algorithm
(ID > MBID > ISRC > fuzzy title+artist). Includes pre-sanitization
optimization for the fuzzy matching loop.
No behavioral changes — the algorithm is identical to the version in
core/external/provider_matching.go.
* refactor: inject matcher.Matcher via Wire instead of creating it inline
Add *matcher.Matcher as a dependency of external.NewProvider, wired via
Google Wire. Update all provider test files to pass matcher.New(ds).
This eliminates tight coupling so future consumers can reuse the matcher
without depending on the external package.
* refactor: remove old provider_matching files
Delete core/external/provider_matching.go and its tests. All matching
logic now lives in core/matcher/.
* test(matcher): restore test coverage lost in extraction
Port back 23 specs that existed in the old provider_matching_test.go
but were dropped during the extraction. Covers specificity levels,
fuzzy matching thresholds, fuzzy album matching, duration matching,
and deduplication edge cases.
* test(matcher): extract matchFieldInAnd/matchFieldInEq helpers
The four inline mock.MatchedBy closures in setupAllPhaseExpectations
all followed the same squirrel.And -> squirrel.Eq -> field-name-check
pattern. Extract into two small helpers to reduce duplication and
make the setup functions read as a concise list of phase expectations.
* refactor(matcher): address PR #5348 review feedback
- sanitizedTrack now holds *model.MediaFile instead of a value copy. Since
MediaFile is a large struct (~74 fields), this avoids the per-track copy
into sanitized[] and a second copy when findBestMatch assigns the winner.
loadTracksByTitleAndArtist updated to iterate by index and pass &tracks[i].
- loadTracksByISRC now sorts results (starred desc, rating desc, year asc,
compilation asc) so that when multiple library tracks share an ISRC the
most relevant one is picked deterministically, matching the sort order
already used by loadTracksByTitleAndArtist.
- Restored the four worked examples (MBID Priority, ISRC Priority,
Specificity Ranking, Fuzzy Title Matching) in the MatchSongsToLibrary
godoc that were dropped during the extraction.
- matcher_test.go: tests now enforce expectations via AssertExpectations in
a DeferCleanup. The old setupAllPhaseExpectations helper was replaced
with per-phase helpers (expectIDPhase/expectMBIDPhase/expectISRCPhase +
allowOtherPhases) so each test deterministically verifies which matching
phases fire. This surfaced (and fixes) a latent issue copilot flagged:
the old .Once() expectations were not actually asserted, so tests would
silently pass even when phases short-circuited unexpectedly.
2026-04-12 12:47:22 -08:00
provider = NewProvider ( ds , agentsCombined , matcher . New ( ds ) )
2025-04-08 17:11:09 -08:00
// Default mocks for successful Get calls
mockArtistRepo . On ( "Get" , "artist-1" ) . Return ( & model . Artist { ID : "artist-1" , Name : "Artist One" } , nil ) . Maybe ( )
mockAlbumRepo . On ( "Get" , "album-1" ) . Return ( & model . Album { ID : "album-1" , Name : "Album One" , AlbumArtistID : "artist-1" } , nil ) . Maybe ( )
mockMediaFileRepo . On ( "Get" , "mf-1" ) . Return ( & model . MediaFile { ID : "mf-1" , Title : "Track One" , ArtistID : "artist-1" } , nil ) . Maybe ( )
// Default mock for non-existent entities
mockArtistRepo . On ( "Get" , "not-found" ) . Return ( nil , model . ErrNotFound ) . Maybe ( )
mockAlbumRepo . On ( "Get" , "not-found" ) . Return ( nil , model . ErrNotFound ) . Maybe ( )
mockMediaFileRepo . On ( "Get" , "not-found" ) . Return ( nil , model . ErrNotFound ) . Maybe ( )
// Default successful image agent response
mockImageAgent . On ( "GetArtistImages" , mock . Anything , "artist-1" , "Artist One" , "" ) .
Return ( [ ] agents . ExternalImage {
{ URL : "http://example.com/large.jpg" , Size : 1000 } ,
{ URL : "http://example.com/medium.jpg" , Size : 500 } ,
{ URL : "http://example.com/small.jpg" , Size : 200 } ,
} , nil ) . Maybe ( )
} )
AfterEach ( func ( ) {
mockArtistRepo . AssertExpectations ( GinkgoT ( ) )
mockAlbumRepo . AssertExpectations ( GinkgoT ( ) )
mockMediaFileRepo . AssertExpectations ( GinkgoT ( ) )
mockImageAgent . AssertExpectations ( GinkgoT ( ) )
} )
It ( "returns the largest image URL when successful" , func ( ) {
// Arrange
expectedURL , _ := url . Parse ( "http://example.com/large.jpg" )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "returns ErrNotFound if the artist is not found in the DB" , func ( ) {
// Arrange
// Act
imgURL , err := provider . ArtistImage ( ctx , "not-found" )
// Assert
Expect ( err ) . To ( MatchError ( model . ErrNotFound ) )
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "not-found" )
mockImageAgent . AssertNotCalled ( GinkgoT ( ) , "GetArtistImages" , mock . Anything , mock . Anything , mock . Anything , mock . Anything )
} )
It ( "returns the agent error if the agent fails" , func ( ) {
// Arrange
agentErr := errors . New ( "agent failure" )
mockImageAgent . Mock = mock . Mock { } // Reset default expectation
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" ) . Return ( nil , agentErr ) . Once ( )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . To ( MatchError ( model . ErrNotFound ) ) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "returns ErrNotFound if the agent returns ErrNotFound" , func ( ) {
// Arrange
mockImageAgent . Mock = mock . Mock { } // Reset default expectation
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" ) . Return ( nil , agents . ErrNotFound ) . Once ( )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . To ( MatchError ( model . ErrNotFound ) )
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "returns ErrNotFound if the agent returns no images" , func ( ) {
// Arrange
mockImageAgent . Mock = mock . Mock { } // Reset default expectation
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" ) . Return ( [ ] agents . ExternalImage { } , nil ) . Once ( )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . To ( MatchError ( model . ErrNotFound ) ) // Implementation maps empty result to ErrNotFound
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "returns context error if context is canceled before agent call" , func ( ) {
// Arrange
cctx , cancelCtx := context . WithCancel ( context . Background ( ) )
mockArtistRepo . Mock = mock . Mock { } // Reset default expectation for artist repo as well
mockArtistRepo . On ( "Get" , "artist-1" ) . Return ( & model . Artist { ID : "artist-1" , Name : "Artist One" } , nil ) . Run ( func ( args mock . Arguments ) {
cancelCtx ( ) // Cancel context *during* the DB call simulation
} ) . Once ( )
// Act
imgURL , err := provider . ArtistImage ( cctx , "artist-1" )
// Assert
Expect ( err ) . To ( MatchError ( context . Canceled ) )
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
} )
It ( "derives artist ID from MediaFile ID" , func ( ) {
// Arrange: Add mocks for the initial GetEntityByID lookups
mockArtistRepo . On ( "Get" , "mf-1" ) . Return ( nil , model . ErrNotFound ) . Once ( )
mockAlbumRepo . On ( "Get" , "mf-1" ) . Return ( nil , model . ErrNotFound ) . Once ( )
// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
expectedURL , _ := url . Parse ( "http://example.com/large.jpg" )
// Act
imgURL , err := provider . ArtistImage ( ctx , "mf-1" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-1" ) // GetEntityByID sequence
mockAlbumRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-1" ) // GetEntityByID sequence
mockMediaFileRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-1" )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" ) // Should be called after getting MF
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "derives artist ID from Album ID" , func ( ) {
// Arrange: Add mock for the initial GetEntityByID lookup
mockArtistRepo . On ( "Get" , "album-1" ) . Return ( nil , model . ErrNotFound ) . Once ( )
// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
expectedURL , _ := url . Parse ( "http://example.com/large.jpg" )
// Act
imgURL , err := provider . ArtistImage ( ctx , "album-1" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "album-1" ) // GetEntityByID sequence
mockAlbumRepo . AssertCalled ( GinkgoT ( ) , "Get" , "album-1" )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" ) // Should be called after getting Album
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "returns ErrNotFound if derived artist is not found" , func ( ) {
// Arrange
// Add mocks for the initial GetEntityByID lookups
mockArtistRepo . On ( "Get" , "mf-bad-artist" ) . Return ( nil , model . ErrNotFound ) . Once ( )
mockAlbumRepo . On ( "Get" , "mf-bad-artist" ) . Return ( nil , model . ErrNotFound ) . Once ( )
mockMediaFileRepo . On ( "Get" , "mf-bad-artist" ) . Return ( & model . MediaFile { ID : "mf-bad-artist" , ArtistID : "not-found" } , nil ) . Once ( )
// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
mockMediaFileRepo . On ( "Get" , "not-found" ) . Return ( nil , model . ErrNotFound ) . Maybe ( )
// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups
// Act
imgURL , err := provider . ArtistImage ( ctx , "mf-bad-artist" )
// Assert
Expect ( err ) . To ( MatchError ( model . ErrNotFound ) )
Expect ( imgURL ) . To ( BeNil ( ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-bad-artist" ) // GetEntityByID sequence
mockAlbumRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-bad-artist" ) // GetEntityByID sequence
mockMediaFileRepo . AssertCalled ( GinkgoT ( ) , "Get" , "mf-bad-artist" )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "not-found" )
mockImageAgent . AssertNotCalled ( GinkgoT ( ) , "GetArtistImages" , mock . Anything , mock . Anything , mock . Anything , mock . Anything )
} )
It ( "handles different image orders from agent" , func ( ) {
// Arrange
mockImageAgent . Mock = mock . Mock { } // Reset default expectation
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" ) .
Return ( [ ] agents . ExternalImage {
{ URL : "http://example.com/small.jpg" , Size : 200 } ,
{ URL : "http://example.com/large.jpg" , Size : 1000 } ,
{ URL : "http://example.com/medium.jpg" , Size : 500 } ,
} , nil ) . Once ( )
expectedURL , _ := url . Parse ( "http://example.com/large.jpg" )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) ) // Still picks the largest
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
It ( "handles agent returning only one image" , func ( ) {
// Arrange
mockImageAgent . Mock = mock . Mock { } // Reset default expectation
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" ) .
Return ( [ ] agents . ExternalImage {
{ URL : "http://example.com/medium.jpg" , Size : 500 } ,
} , nil ) . Once ( )
expectedURL , _ := url . Parse ( "http://example.com/medium.jpg" )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-1" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-1" )
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-1" , "Artist One" , "" )
} )
feat: make Unicode handling in external API calls configurable (#4277)
* feat: make Unicode handling in external API calls configurable
- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks
Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.
Signed-off-by: Deluan <deluan@navidrome.org>
* address comments
Signed-off-by: Deluan <deluan@navidrome.org>
* avoid calling str.Clean multiple times
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: apply Unicode handling pattern to auxAlbum
Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.
Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 09:08:30 -09:00
2026-03-30 05:35:02 -08:00
It ( "returns cached URL and does not call agent when info is not expired" , func ( ) {
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
cachedArtist := & model . Artist {
ID : "artist-cached" ,
Name : "Cached Artist" ,
LargeImageUrl : "http://example.com/cached-large.jpg" ,
2026-05-19 13:02:29 -08:00
ExternalInfoUpdatedAt : new ( time . Now ( ) . Add ( - 1 * time . Minute ) ) ,
2026-03-30 05:35:02 -08:00
}
mockArtistRepo . On ( "Get" , "artist-cached" ) . Return ( cachedArtist , nil ) . Maybe ( )
expectedURL , _ := url . Parse ( "http://example.com/cached-large.jpg" )
// Capture log output
var logBuf bytes . Buffer
log . SetOutput ( & logBuf )
defer log . SetOutput ( GinkgoWriter )
log . SetLevel ( log . LevelDebug )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-cached" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockImageAgent . AssertNotCalled ( GinkgoT ( ) , "GetArtistImages" , mock . Anything , "artist-cached" , mock . Anything , mock . Anything )
// Assert: background refresh was NOT enqueued
Expect ( logBuf . String ( ) ) . ToNot ( ContainSubstring ( "Artist image info expired, enqueuing background refresh" ) )
} )
It ( "returns stale URL and enqueues refresh when info is expired" , func ( ) {
// Arrange
conf . Server . DevArtistInfoTimeToLive = 1 * time . Nanosecond
staleArtist := & model . Artist {
ID : "artist-expired" ,
Name : "Expired Artist" ,
LargeImageUrl : "http://example.com/expired-large.jpg" ,
2026-05-19 13:02:29 -08:00
ExternalInfoUpdatedAt : new ( time . Now ( ) . Add ( - 1 * time . Hour ) ) ,
2026-03-30 05:35:02 -08:00
}
mockArtistRepo . On ( "Get" , "artist-expired" ) . Return ( staleArtist , nil ) . Maybe ( )
expectedURL , _ := url . Parse ( "http://example.com/expired-large.jpg" )
// Capture log output
var logBuf bytes . Buffer
log . SetOutput ( & logBuf )
defer log . SetOutput ( GinkgoWriter )
log . SetLevel ( log . LevelDebug )
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-expired" )
// Assert: returns stale URL immediately, no agent call
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockImageAgent . AssertNotCalled ( GinkgoT ( ) , "GetArtistImages" , mock . Anything , "artist-expired" , mock . Anything , mock . Anything )
// Assert: background refresh was enqueued
Expect ( logBuf . String ( ) ) . To ( ContainSubstring ( "Artist image info expired, enqueuing background refresh" ) )
} )
feat: make Unicode handling in external API calls configurable (#4277)
* feat: make Unicode handling in external API calls configurable
- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks
Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.
Signed-off-by: Deluan <deluan@navidrome.org>
* address comments
Signed-off-by: Deluan <deluan@navidrome.org>
* avoid calling str.Clean multiple times
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor: apply Unicode handling pattern to auxAlbum
Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.
Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 09:08:30 -09:00
Context ( "Unicode handling in artist names" , func ( ) {
var artistWithEnDash * model . Artist
var expectedURL * url . URL
const (
originalArtistName = "Run– D.M.C." // Artist name with en dash
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
)
BeforeEach ( func ( ) {
// Test with en dash (– ) in artist name like "Run– D.M.C."
artistWithEnDash = & model . Artist { ID : "artist-endash" , Name : originalArtistName }
mockArtistRepo . Mock = mock . Mock { } // Reset default expectations
mockArtistRepo . On ( "Get" , "artist-endash" ) . Return ( artistWithEnDash , nil ) . Once ( )
expectedURL , _ = url . Parse ( "http://example.com/rundmc.jpg" )
// Mock the image agent to return an image for the artist
mockImageAgent . On ( "GetArtistImages" , ctx , "artist-endash" , mock . AnythingOfType ( "string" ) , "" ) .
Return ( [ ] agents . ExternalImage {
{ URL : "http://example.com/rundmc.jpg" , Size : 1000 } ,
} , nil ) . Once ( )
} )
When ( "DevPreserveUnicodeInExternalCalls is true" , func ( ) {
BeforeEach ( func ( ) {
conf . Server . DevPreserveUnicodeInExternalCalls = true
} )
It ( "preserves Unicode characters in artist names" , func ( ) {
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-endash" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-endash" )
// This is the key assertion: ensure the original Unicode name is used
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-endash" , originalArtistName , "" )
} )
} )
When ( "DevPreserveUnicodeInExternalCalls is false" , func ( ) {
BeforeEach ( func ( ) {
conf . Server . DevPreserveUnicodeInExternalCalls = false
} )
It ( "normalizes Unicode characters" , func ( ) {
// Act
imgURL , err := provider . ArtistImage ( ctx , "artist-endash" )
// Assert
Expect ( err ) . ToNot ( HaveOccurred ( ) )
Expect ( imgURL ) . To ( Equal ( expectedURL ) )
mockArtistRepo . AssertCalled ( GinkgoT ( ) , "Get" , "artist-endash" )
// This assertion ensures the normalized name is used (en dash → hyphen)
mockImageAgent . AssertCalled ( GinkgoT ( ) , "GetArtistImages" , ctx , "artist-endash" , normalizedArtistName , "" )
} )
} )
} )
2025-04-08 17:11:09 -08:00
} )
// mockArtistImageAgent implementation using testify/mock
// This remains local as it's specific to testing the ArtistImage functionality
type mockArtistImageAgent struct {
mock . Mock
agents . ArtistImageRetriever // Embed interface
}
// Constructor for the mock agent
func newMockArtistImageAgent ( ) * mockArtistImageAgent {
mock := new ( mockArtistImageAgent )
// Set default AgentName if needed, although usually called via mockAgents
mock . On ( "AgentName" ) . Return ( "mockImage" ) . Maybe ( )
return mock
}
func ( m * mockArtistImageAgent ) AgentName ( ) string {
args := m . Called ( )
return args . String ( 0 )
}
func ( m * mockArtistImageAgent ) GetArtistImages ( ctx context . Context , id , artistName , mbid string ) ( [ ] agents . ExternalImage , error ) {
args := m . Called ( ctx , id , artistName , mbid )
// Need careful type assertion for potentially nil slice
var res [ ] agents . ExternalImage
if args . Get ( 0 ) != nil {
res = args . Get ( 0 ) . ( [ ] agents . ExternalImage )
}
return res , args . Error ( 1 )
}
// Ensure mockAgent implements the interface
var _ agents . ArtistImageRetriever = ( * mockArtistImageAgent ) ( nil )