* 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.
365 lines
16 KiB
Go
365 lines
16 KiB
Go
package external_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/url"
|
||
|
||
"github.com/navidrome/navidrome/conf"
|
||
"github.com/navidrome/navidrome/conf/configtest"
|
||
"github.com/navidrome/navidrome/core/agents"
|
||
. "github.com/navidrome/navidrome/core/external"
|
||
"github.com/navidrome/navidrome/core/matcher"
|
||
"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 - AlbumImage", func() {
|
||
var ds *tests.MockDataStore
|
||
var provider Provider
|
||
var mockArtistRepo *mockArtistRepo
|
||
var mockAlbumRepo *mockAlbumRepo
|
||
var mockMediaFileRepo *mockMediaFileRepo
|
||
var mockAlbumAgent *mockAlbumInfoAgent
|
||
var ctx context.Context
|
||
|
||
BeforeEach(func() {
|
||
ctx = GinkgoT().Context()
|
||
DeferCleanup(configtest.SetupConfig())
|
||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||
|
||
mockArtistRepo = newMockArtistRepo()
|
||
mockAlbumRepo = newMockAlbumRepo()
|
||
mockMediaFileRepo = newMockMediaFileRepo()
|
||
|
||
ds = &tests.MockDataStore{
|
||
MockedArtist: mockArtistRepo,
|
||
MockedAlbum: mockAlbumRepo,
|
||
MockedMediaFile: mockMediaFileRepo,
|
||
}
|
||
|
||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||
|
||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||
|
||
// Default mocks
|
||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||
|
||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||
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()
|
||
})
|
||
|
||
It("returns the largest image URL when successful", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album 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).Once()
|
||
|
||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL))
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
|
||
})
|
||
|
||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
|
||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||
|
||
Expect(err).To(MatchError("data not found"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||
})
|
||
|
||
It("returns the agent error if the agent fails", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
|
||
agentErr := errors.New("agent failure")
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).To(MatchError("agent failure"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||
})
|
||
|
||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).To(MatchError("data not found"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||
})
|
||
|
||
It("returns ErrNotFound if the agent returns no images", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
|
||
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).To(MatchError("data not found"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||
})
|
||
|
||
It("returns context error if context is canceled", func() {
|
||
// Arrange
|
||
cctx, cancelCtx := context.WithCancel(ctx)
|
||
// Mock the necessary DB calls *before* canceling the context
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
// Expect the agent call even if context is cancelled, returning the context error
|
||
mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||
// Cancel the context *before* calling the function under test
|
||
cancelCtx()
|
||
|
||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||
|
||
Expect(err).To(MatchError("context canceled"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
// Agent should now be called, verify this expectation
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
|
||
})
|
||
|
||
It("derives album ID from MediaFile ID", func() {
|
||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album 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).Once()
|
||
|
||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL))
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||
})
|
||
|
||
It("handles different image orders from agent", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album 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")
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||
})
|
||
|
||
It("handles agent returning only one image", func() {
|
||
// Arrange
|
||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||
// Explicitly mock agent call for this test
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||
Return([]agents.ExternalImage{
|
||
{URL: "http://example.com/single.jpg", Size: 700},
|
||
}, nil).Once()
|
||
|
||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL))
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||
})
|
||
|
||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||
|
||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||
|
||
Expect(err).To(MatchError("data not found"))
|
||
Expect(imgURL).To(BeNil())
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||
})
|
||
|
||
Context("Unicode handling in album names", func() {
|
||
var albumWithEnDash *model.Album
|
||
var expectedURL *url.URL
|
||
|
||
const (
|
||
originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
|
||
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
|
||
)
|
||
|
||
BeforeEach(func() {
|
||
// Test with en dash (–) in album name
|
||
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
|
||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
|
||
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
|
||
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
|
||
|
||
expectedURL, _ = url.Parse("http://example.com/album.jpg")
|
||
|
||
// Mock the album agent to return an image for the album
|
||
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
|
||
Return([]agents.ExternalImage{
|
||
{URL: "http://example.com/album.jpg", Size: 1000},
|
||
}, nil).Once()
|
||
})
|
||
|
||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||
BeforeEach(func() {
|
||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||
})
|
||
|
||
It("preserves Unicode characters in album names", func() {
|
||
// Act
|
||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||
|
||
// Assert
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL))
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||
// This is the key assertion: ensure the original Unicode name is used
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
|
||
})
|
||
})
|
||
|
||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||
BeforeEach(func() {
|
||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||
})
|
||
|
||
It("normalizes Unicode characters", func() {
|
||
// Act
|
||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||
|
||
// Assert
|
||
Expect(err).ToNot(HaveOccurred())
|
||
Expect(imgURL).To(Equal(expectedURL))
|
||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|
||
// mockAlbumInfoAgent implementation
|
||
type mockAlbumInfoAgent struct {
|
||
mock.Mock
|
||
agents.AlbumInfoRetriever
|
||
agents.AlbumImageRetriever
|
||
}
|
||
|
||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||
m := new(mockAlbumInfoAgent)
|
||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||
return m
|
||
}
|
||
|
||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||
args := m.Called()
|
||
return args.String(0)
|
||
}
|
||
|
||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||
args := m.Called(ctx, name, artist, mbid)
|
||
if args.Get(0) == nil {
|
||
return nil, args.Error(1)
|
||
}
|
||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||
}
|
||
|
||
func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||
args := m.Called(ctx, name, artist, mbid)
|
||
if args.Get(0) == nil {
|
||
return nil, args.Error(1)
|
||
}
|
||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||
}
|
||
|
||
// Ensure mockAgent implements the interfaces
|
||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
|