🏡 index : quine-global/quintodrome.git

package matcher

import (
	"github.com/navidrome/navidrome/core/agents"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("similarityRatio", func() {
	It("returns 1.0 for identical strings", func() {
		Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
	})

	It("returns 0.0 for empty strings", func() {
		Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
		Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
	})

	It("returns high similarity for remastered suffix", func() {
		ratio := similarityRatio("paranoid android", "paranoid android remastered")
		Expect(ratio).To(BeNumerically(">=", 0.85))
	})

	It("returns high similarity for suffix additions like (Live)", func() {
		ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
		Expect(ratio).To(BeNumerically(">=", 0.90))
	})

	It("returns high similarity for 'yesterday' variants (common prefix)", func() {
		ratio := similarityRatio("yesterday", "yesterday once more")
		Expect(ratio).To(BeNumerically(">=", 0.85))
	})

	It("returns low similarity for same suffix", func() {
		ratio := similarityRatio("postman (live)", "taxman (live)")
		Expect(ratio).To(BeNumerically("<", 0.85))
	})

	It("handles unicode characters", func() {
		ratio := similarityRatio("dont stop believin", "don't stop believin'")
		Expect(ratio).To(BeNumerically(">=", 0.85))
	})

	It("returns low similarity for completely different strings", func() {
		ratio := similarityRatio("abc", "xyz")
		Expect(ratio).To(BeNumerically("<", 0.5))
	})

	It("is symmetric", func() {
		ratio1 := similarityRatio("hello world", "hello")
		ratio2 := similarityRatio("hello", "hello world")
		Expect(ratio1).To(Equal(ratio2))
	})
})

var _ = Describe("matcher internals", func() {
	It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() {
		q := songQuery{
			title:     "song",
			artists:   []queryArtist{{mbid: "artist-mbid-1"}},
			albumMBID: "album-mbid-1",
		}
		mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty
		t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}})
		Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))
	})

	It("computeSpecificityLevel maximizes the level over all query artists", func() {
		// First artist does not match; second matches by name with a matching album → level 3.
		q := songQuery{
			title: "song",
			album: "violator",
			artists: []queryArtist{
				{name: "no match"},
				{name: "depeche mode"},
			},
		}
		mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"}
		t := newSanitizedTrack(&mf, nil, nil)
		Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3))
	})

	It("scores MBID specificity for any credited artist, not just the last", func() {
		q := songQuery{
			title: "song",
			artists: []queryArtist{
				{name: "drake", mbid: "mbz-drake"},
				{name: "future", mbid: "mbz-future"},
			},
			album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+
		}
		// Track credits BOTH MBIDs; with the old last-wins string this would only match one.
		t := sanitizedTrack{
			mf:          &model.MediaFile{},
			title:       "song",
			artist:      "drake",
			album:       "some other album",
			artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}},
		}
		// Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is
		// reached via mbz-future too, which the old code would have dropped.
		Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2))
	})

	It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() {
		// A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the
		// strongest identity signal, so the track's album still elevates specificity above 0.
		q := songQuery{
			title:     "song",
			artists:   []queryArtist{{id: "artist-1"}},
			album:     "violator",
			albumMBID: "album-mbid-1",
		}
		// Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin).
		mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"}
		t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil)
		// Album MBID matches → level 5 via the ID identity, where the old code scored 0.
		Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))

		// Same artist, album name matches but no album MBID → level 4 via the ID identity.
		q.albumMBID = ""
		mf2 := model.MediaFile{Title: "Song", Album: "Violator"}
		t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil)
		Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4))
	})
})

var _ = Describe("groupQueries", func() {
	It("builds one query per unmatched song carrying all artists", func() {
		songs := []agents.Song{
			{Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].index).To(Equal(0))
		Expect(queries[0].query.title).To(Equal("song a"))
		Expect(queries[0].query.artists).To(HaveLen(2))
		Expect(queries[0].query.artists[0].name).To(Equal("drake"))
		Expect(queries[0].query.artists[1].name).To(Equal("future"))
	})

	It("strips leading articles from artist names", func() {
		songs := []agents.Song{
			{Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].query.artists).To(HaveLen(1))
		Expect(queries[0].query.artists[0].name).To(Equal("drake"))
	})

	It("keeps an artist that carries only an ID (empty name)", func() {
		songs := []agents.Song{
			{Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].query.artists).To(HaveLen(1))
		Expect(queries[0].query.artists[0].id).To(Equal("ar-x"))
		Expect(queries[0].query.artists[0].name).To(Equal(""))
	})

	It("keeps an artist that carries only an MBID (empty id and name)", func() {
		// ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display
		// name; the MBID is a usable identity signal and must not be dropped.
		songs := []agents.Song{
			{Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].query.artists).To(HaveLen(1))
		Expect(queries[0].query.artists[0].id).To(Equal(""))
		Expect(queries[0].query.artists[0].name).To(Equal(""))
		Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future"))
	})

	It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() {
		songs := []agents.Song{
			{Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].query.artists).To(HaveLen(1))
		Expect(queries[0].query.artists[0].name).To(Equal("future"))
	})

	It("skips already-matched songs and songs with no usable artist", func() {
		songs := []agents.Song{
			{Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}},
			{Name: "No Artist"},
			{Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}},
		}
		queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}})
		Expect(queries).To(HaveLen(1))
		Expect(queries[0].index).To(Equal(2))
		Expect(queries[0].query.artists[0].name).To(Equal("future"))
	})
})

var _ = Describe("bucketTracks", func() {
	It("scores tracks by how many of the query's artists they credit (overlap)", func() {
		r := resolvedArtists{
			byQuery: map[int]map[string]struct{}{
				0: {"ar-1": {}, "ar-2": {}},
			},
			mbid: map[string]string{},
		}
		trackA := model.MediaFile{ID: "a", Title: "A",
			Participants: artistParticipants(
				model.Artist{ID: "ar-1", OrderArtistName: "one"},
				model.Artist{ID: "ar-2", OrderArtistName: "two"},
			),
		}
		trackB := model.MediaFile{ID: "b", Title: "B",
			Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}),
		}
		byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB})
		Expect(byQuery[0]).To(HaveLen(2))
		overlaps := map[string]int{}
		for _, st := range byQuery[0] {
			overlaps[st.mf.ID] = st.overlap
		}
		Expect(overlaps["a"]).To(Equal(2))
		Expect(overlaps["b"]).To(Equal(1))
	})
})

var _ = Describe("resolveArtists ID fast-path", func() {
	It("owns an artist supplied by ID without a name match", func() {
		ctx := GinkgoT().Context()
		artistRepo := tests.CreateMockArtistRepo()
		artistRepo.SetData(model.Artists{
			{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"},
		})
		ds := &tests.MockDataStore{MockedArtist: artistRepo}
		m := New(ds)

		queries := []indexedQuery{
			{index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}},
		}
		res, err := m.resolveArtists(ctx, queries)
		Expect(err).ToNot(HaveOccurred())
		Expect(res.byQuery[0]).To(HaveKey("ar-x"))
		Expect(res.allIDs).To(ContainElement("ar-x"))
		Expect(res.mbid["ar-x"]).To(Equal("mbz-x"))
	})
})

// artistParticipants builds a Participants map crediting the given artists under RoleArtist.
func artistParticipants(artists ...model.Artist) model.Participants {
	list := make(model.ParticipantList, len(artists))
	for i, a := range artists {
		list[i] = model.Participant{Artist: a}
	}
	return model.Participants{model.RoleArtist: list}
}