musicbrainz-provider #2

Merged
Mrixs merged 72 commits from musicbrainz-provider into master 2026-08-05 20:19:16 +00:00
7 changed files with 185 additions and 94 deletions
Showing only changes of commit a7803615cd - Show all commits

View File

@@ -8,19 +8,12 @@ import (
"naviwatcher/internal/database" "naviwatcher/internal/database"
) )
// excludedStatuses contains release-group statuses that should be filtered out. // includedTypes contains release-group primary types that should be included
var excludedStatuses = map[string]bool{ // when no more specific type classification applies.
"Bootleg": true,
"Promotion": true,
"Pseudo-Release": true,
}
// includedTypes contains release-group types that should be included.
var includedTypes = map[string]bool{ var includedTypes = map[string]bool{
"Album": true, "Album": true,
"Single": true, "Single": true,
"EP": true, "EP": true,
"Compilation": true,
} }
// GetArtistReleaseGroups fetches all release groups for a given artist from MusicBrainz. // GetArtistReleaseGroups fetches all release groups for a given artist from MusicBrainz.
@@ -77,23 +70,23 @@ type FilterOptions struct {
IgnoreCompilations bool IgnoreCompilations bool
} }
// FilterReleaseGroups applies status and type filtering to a list of release groups. // FilterReleaseGroups applies type filtering to a list of release groups.
// It excludes Bootleg, Promotion, and Pseudo-Release statuses. // It includes only Album/Single/EP primary types, or release groups whose
// It includes only Album, Single, EP, and Compilation types, unless the type // secondary type list contains Single/EP/Compilation (e.g. an "Album" that is
// is disabled via FilterOptions. // also a "Compilation"). The IgnoreSingles / IgnoreCompilations toggles drop
// release groups classified as such via either primary or secondary type.
//
// Release groups carry no status in ws/2, so there is no status filtering.
func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGroup { func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGroup {
var filtered []ReleaseGroup var filtered []ReleaseGroup
for _, rg := range groups { for _, rg := range groups {
if IsStatusExcluded(rg.Status) { if !IsTypeIncluded(rg.Type) && !hasSecondaryType(rg, "Single", "EP", "Compilation") {
continue continue
} }
if !IsTypeIncluded(rg.Type) { if opts.IgnoreSingles && (rg.Type == "Single" || hasSecondaryType(rg, "Single")) {
continue continue
} }
if opts.IgnoreSingles && rg.Type == "Single" { if opts.IgnoreCompilations && (rg.Type == "Compilation" || hasSecondaryType(rg, "Compilation")) {
continue
}
if opts.IgnoreCompilations && rg.Type == "Compilation" {
continue continue
} }
filtered = append(filtered, rg) filtered = append(filtered, rg)
@@ -101,12 +94,21 @@ func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGro
return filtered return filtered
} }
// IsStatusExcluded returns true if the given status should be excluded. // hasSecondaryType reports whether any of the release group's secondary types
func IsStatusExcluded(status string) bool { // matches one of the provided values.
return excludedStatuses[status] func hasSecondaryType(rg ReleaseGroup, wanted ...string) bool {
for _, s := range rg.SecondaryTypes {
for _, w := range wanted {
if s == w {
return true
}
}
}
return false
} }
// IsTypeIncluded returns true if the given type is in the base included set. // IsTypeIncluded returns true if the given primary type is in the base
// included set (Album/Single/EP).
func IsTypeIncluded(releaseType string) bool { func IsTypeIncluded(releaseType string) bool {
return includedTypes[releaseType] return includedTypes[releaseType]
} }
@@ -118,6 +120,8 @@ func IsTypeIncluded(releaseType string) bool {
// MBID) must NOT be stored here, because artist_settings is keyed by the // MBID) must NOT be stored here, because artist_settings is keyed by the
// Navidrome ID and the foreign key / join would otherwise never match. // Navidrome ID and the foreign key / join would otherwise never match.
func (rg *ReleaseGroup) ToExternalRelease(artistID string) *database.ExternalRelease { func (rg *ReleaseGroup) ToExternalRelease(artistID string) *database.ExternalRelease {
// Only the primary type is persisted; secondary types are used transiently
// for filtering above and are not stored in the external_releases schema.
return &database.ExternalRelease{ return &database.ExternalRelease{
RGID: rg.ID, RGID: rg.ID,
ArtistID: artistID, ArtistID: artistID,

View File

@@ -12,33 +12,35 @@ import (
// ---------- FilterReleaseGroups tests ---------- // ---------- FilterReleaseGroups tests ----------
func TestFilterReleaseGroups_ExcludesBootlegPromotionPseudo(t *testing.T) { // Release groups carry no status in ws/2, so status values are irrelevant to
// filtering. These groups differ only by the (ignored) status attribute; all
// are Album/Single and should be retained.
func TestFilterReleaseGroups_StatusIsNotFiltered(t *testing.T) {
groups := []ReleaseGroup{ groups := []ReleaseGroup{
{ID: "rg-1", Title: "Official Album", Type: "Album", Status: "Official"}, {ID: "rg-1", Title: "Official Album", Type: "Album"},
{ID: "rg-2", Title: "Bootleg Live", Type: "Album", Status: "Bootleg"}, {ID: "rg-2", Title: "Bootleg Live", Type: "Album"},
{ID: "rg-3", Title: "Promo CD", Type: "Single", Status: "Promotion"}, {ID: "rg-3", Title: "Promo CD", Type: "Single"},
{ID: "rg-4", Title: "Pseudo Release", Type: "Album", Status: "Pseudo-Release"}, {ID: "rg-4", Title: "Pseudo Release", Type: "Album"},
} }
result := FilterReleaseGroups(groups, FilterOptions{}) result := FilterReleaseGroups(groups, FilterOptions{})
if len(result) != 1 { if len(result) != 4 {
t.Fatalf("FilterReleaseGroups() returned %d groups, want 1", len(result)) t.Fatalf("FilterReleaseGroups() returned %d groups, want 4", len(result))
}
if result[0].ID != "rg-1" {
t.Errorf("FilterReleaseGroups()[0].ID = %q, want %q", result[0].ID, "rg-1")
} }
} }
func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) { func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) {
groups := []ReleaseGroup{ groups := []ReleaseGroup{
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-1", Title: "Album", Type: "Album"},
{ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"}, {ID: "rg-2", Title: "Single", Type: "Single"},
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, {ID: "rg-3", Title: "EP", Type: "EP"},
{ID: "rg-4", Title: "Compilation", Type: "Compilation", Status: "Official"}, // A compilation whose primary type is Album (the common case) is
{ID: "rg-5", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, // classified via its secondary type and must be included.
{ID: "rg-6", Title: "Live", Type: "Live", Status: "Official"}, {ID: "rg-4", Title: "Greatest Hits", Type: "Album", SecondaryTypes: []string{"Compilation"}},
{ID: "rg-7", Title: "Remix", Type: "Remix", Status: "Official"}, {ID: "rg-5", Title: "Soundtrack", Type: "Soundtrack"},
{ID: "rg-6", Title: "Live", Type: "Live"},
{ID: "rg-7", Title: "Remix", Type: "Remix"},
} }
result := FilterReleaseGroups(groups, FilterOptions{}) result := FilterReleaseGroups(groups, FilterOptions{})
@@ -57,9 +59,11 @@ func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) {
func TestFilterReleaseGroups_IgnoreSingles(t *testing.T) { func TestFilterReleaseGroups_IgnoreSingles(t *testing.T) {
groups := []ReleaseGroup{ groups := []ReleaseGroup{
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-1", Title: "Album", Type: "Album"},
{ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"}, {ID: "rg-2", Title: "Single", Type: "Single"},
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, {ID: "rg-3", Title: "EP", Type: "EP"},
// Single expressed via secondary type (primary is Album).
{ID: "rg-4", Title: "Single from Album", Type: "Album", SecondaryTypes: []string{"Single"}},
} }
result := FilterReleaseGroups(groups, FilterOptions{IgnoreSingles: true}) result := FilterReleaseGroups(groups, FilterOptions{IgnoreSingles: true})
@@ -68,7 +72,7 @@ func TestFilterReleaseGroups_IgnoreSingles(t *testing.T) {
t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result)) t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result))
} }
for _, rg := range result { for _, rg := range result {
if rg.Type == "Single" { if rg.Type == "Single" || contains(rg.SecondaryTypes, "Single") {
t.Errorf("single %q should have been filtered out", rg.ID) t.Errorf("single %q should have been filtered out", rg.ID)
} }
} }
@@ -76,9 +80,9 @@ func TestFilterReleaseGroups_IgnoreSingles(t *testing.T) {
func TestFilterReleaseGroups_IgnoreCompilations(t *testing.T) { func TestFilterReleaseGroups_IgnoreCompilations(t *testing.T) {
groups := []ReleaseGroup{ groups := []ReleaseGroup{
{ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, {ID: "rg-1", Title: "Album", Type: "Album"},
{ID: "rg-2", Title: "Compilation", Type: "Compilation", Status: "Official"}, {ID: "rg-2", Title: "Greatest Hits", Type: "Album", SecondaryTypes: []string{"Compilation"}},
{ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, {ID: "rg-3", Title: "EP", Type: "EP"},
} }
result := FilterReleaseGroups(groups, FilterOptions{IgnoreCompilations: true}) result := FilterReleaseGroups(groups, FilterOptions{IgnoreCompilations: true})
@@ -87,7 +91,7 @@ func TestFilterReleaseGroups_IgnoreCompilations(t *testing.T) {
t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result)) t.Fatalf("FilterReleaseGroups() returned %d groups, want 2", len(result))
} }
for _, rg := range result { for _, rg := range result {
if rg.Type == "Compilation" { if rg.Type == "Compilation" || contains(rg.SecondaryTypes, "Compilation") {
t.Errorf("compilation %q should have been filtered out", rg.ID) t.Errorf("compilation %q should have been filtered out", rg.ID)
} }
} }
@@ -100,7 +104,6 @@ func TestReleaseGroup_ToExternalRelease(t *testing.T) {
ID: "rg-uuid-1", ID: "rg-uuid-1",
Title: "Dark Side of the Moon", Title: "Dark Side of the Moon",
Type: "Album", Type: "Album",
Status: "Official",
ArtistID: "mbid-artist-uuid-1", ArtistID: "mbid-artist-uuid-1",
ArtistName: "Pink Floyd", ArtistName: "Pink Floyd",
ReleaseDate: "1973-03-01", ReleaseDate: "1973-03-01",

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -93,13 +94,27 @@ type mbArtistCredit struct {
// mbReleaseGroup represents the XML structure of a single release-group // mbReleaseGroup represents the XML structure of a single release-group
// in the MusicBrainz release-group list response. // in the MusicBrainz release-group list response.
//
// Note: release groups do NOT carry a "status" attribute in ws/2 (status
// belongs to individual releases, not release groups), so it is intentionally
// absent here. Type classification is read from the authoritative
// <primary-type> / <secondary-type-list> elements rather than the legacy
// "type" attribute, which only reflects the primary type and cannot detect
// e.g. a compilation whose primary type is Album.
type mbReleaseGroup struct { type mbReleaseGroup struct {
ID string `xml:"id,attr"` ID string `xml:"id,attr"`
Title string `xml:"title"` Title string `xml:"title"`
Type string `xml:"type,attr"` TypeAttr string `xml:"type,attr"`
Status string `xml:"status,attr"` PrimaryType string `xml:"primary-type"`
ArtistCredit mbArtistCredit `xml:"artist-credit"` Secondary mbSecondaryTypes `xml:"secondary-type-list"`
ReleaseDate string `xml:"first-release-date"` ArtistCredit mbArtistCredit `xml:"artist-credit"`
ReleaseDate string `xml:"first-release-date"`
}
// mbSecondaryTypes captures the <secondary-type-list> element, which holds
// zero or more <secondary-type> children (e.g. Live, Compilation, Remix).
type mbSecondaryTypes struct {
Types []string `xml:"secondary-type"`
} }
// mbReleaseGroupListXML wraps the release-group-list element to properly // mbReleaseGroupListXML wraps the release-group-list element to properly
@@ -127,14 +142,22 @@ func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
Count: list.ReleaseGroupList.Count, Count: list.ReleaseGroupList.Count,
} }
for _, rg := range list.ReleaseGroupList.ReleaseGroups { for _, rg := range list.ReleaseGroupList.ReleaseGroups {
// Prefer the authoritative <primary-type> element; fall back to the
// legacy "type" attribute (which reflects the primary type) when the
// element is absent. The attribute is space-separated primary+secondary,
// so take the first token as the primary type.
primary := rg.PrimaryType
if primary == "" && rg.TypeAttr != "" {
primary = strings.Fields(rg.TypeAttr)[0]
}
result.ReleaseGroups = append(result.ReleaseGroups, ReleaseGroup{ result.ReleaseGroups = append(result.ReleaseGroups, ReleaseGroup{
ID: rg.ID, ID: rg.ID,
Title: rg.Title, Title: rg.Title,
Type: rg.Type, Type: primary,
Status: rg.Status, SecondaryTypes: rg.Secondary.Types,
ArtistID: rg.ArtistCredit.NameCredit.Artist.ID, ArtistID: rg.ArtistCredit.NameCredit.Artist.ID,
ArtistName: rg.ArtistCredit.NameCredit.Artist.Name, ArtistName: rg.ArtistCredit.NameCredit.Artist.Name,
ReleaseDate: rg.ReleaseDate, ReleaseDate: rg.ReleaseDate,
}) })
} }
return result, nil return result, nil

View File

@@ -3,14 +3,19 @@ package musicbrainz
// ReleaseGroup represents a MusicBrainz Release Group entity. // ReleaseGroup represents a MusicBrainz Release Group entity.
// This is the primary data model for the provider - we work with // This is the primary data model for the provider - we work with
// Release Groups to minimize duplicates from different releases. // Release Groups to minimize duplicates from different releases.
//
// Type holds the primary type (Album, Single, EP, Other, Broadcast, ...).
// SecondaryTypes holds secondary type classifications (Live, Compilation,
// Remix, ...). Together they drive the scanner's type filtering; release
// groups have no status, so there is no Status field.
type ReleaseGroup struct { type ReleaseGroup struct {
ID string ID string
Title string Title string
Type string Type string
Status string SecondaryTypes []string
ArtistID string ArtistID string
ArtistName string ArtistName string
ReleaseDate string ReleaseDate string
} }
// ParsedReleaseGroups holds the result of parsing a MusicBrainz // ParsedReleaseGroups holds the result of parsing a MusicBrainz

View File

@@ -137,12 +137,13 @@ func TestParseReleaseGroups_MalformedXML(t *testing.T) {
} }
} }
func TestParseReleaseGroups_WithStatus(t *testing.T) { func TestParseReleaseGroups_PrimaryAndSecondaryTypes(t *testing.T) {
data := []byte(`<?xml version="1.0" encoding="UTF-8"?> data := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#"> <metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
<release-group-list count="1"> <release-group-list count="2">
<release-group id="rg-bootleg" type="Album" status="Bootleg"> <release-group id="rg-album" type="Album">
<title>Unofficial Live Recording</title> <title>Studio Album</title>
<primary-type>Album</primary-type>
<first-release-date>2020-01-01</first-release-date> <first-release-date>2020-01-01</first-release-date>
<artist-credit> <artist-credit>
<name-credit> <name-credit>
@@ -152,6 +153,22 @@ func TestParseReleaseGroups_WithStatus(t *testing.T) {
</name-credit> </name-credit>
</artist-credit> </artist-credit>
</release-group> </release-group>
<release-group id="rg-comp" type="Album Compilation">
<title>Greatest Hits</title>
<primary-type>Album</primary-type>
<secondary-type-list>
<secondary-type>Compilation</secondary-type>
<secondary-type>Live</secondary-type>
</secondary-type-list>
<first-release-date>2021-05-05</first-release-date>
<artist-credit>
<name-credit>
<artist id="artist-uuid-2">
<name>Test Artist</name>
</artist>
</name-credit>
</artist-credit>
</release-group>
</release-group-list> </release-group-list>
</metadata>`) </metadata>`)
@@ -160,11 +177,39 @@ func TestParseReleaseGroups_WithStatus(t *testing.T) {
t.Fatalf("ParseReleaseGroups() error = %v", err) t.Fatalf("ParseReleaseGroups() error = %v", err)
} }
if len(result.ReleaseGroups) != 1 { if len(result.ReleaseGroups) != 2 {
t.Fatalf("ParseReleaseGroups() returned %d groups, want 1", len(result.ReleaseGroups)) t.Fatalf("ParseReleaseGroups() returned %d groups, want 2", len(result.ReleaseGroups))
} }
if result.ReleaseGroups[0].Status != "Bootleg" { byID := make(map[string]ReleaseGroup)
t.Errorf("ReleaseGroups[0].Status = %q, want %q", result.ReleaseGroups[0].Status, "Bootleg") for _, rg := range result.ReleaseGroups {
byID[rg.ID] = rg
}
album := byID["rg-album"]
if album.Type != "Album" {
t.Errorf("rg-album.Type = %q, want %q", album.Type, "Album")
}
if len(album.SecondaryTypes) != 0 {
t.Errorf("rg-album.SecondaryTypes = %v, want empty", album.SecondaryTypes)
}
comp := byID["rg-comp"]
if comp.Type != "Album" {
t.Errorf("rg-comp.Type = %q, want %q", comp.Type, "Album")
}
// Release groups have no status attribute; the secondary type list is the
// authoritative source for classifications like Compilation.
if !contains(comp.SecondaryTypes, "Compilation") || !contains(comp.SecondaryTypes, "Live") {
t.Errorf("rg-comp.SecondaryTypes = %v, want Compilation and Live", comp.SecondaryTypes)
} }
} }
func contains(s []string, want string) bool {
for _, v := range s {
if v == want {
return true
}
}
return false
}

View File

@@ -19,8 +19,12 @@ func mbReleaseGroupXML(id, title, rgType, status, artistID, artistName, releaseD
if status != "" { if status != "" {
statusAttr = ` status="` + status + `"` statusAttr = ` status="` + status + `"`
} }
// Release groups carry type via <primary-type>; the legacy "type" attribute
// is also emitted (ignored by the parser) for realism. Status has no meaning
// for release groups and is not parsed.
return `<release-group id="` + id + `" type="` + rgType + `"` + statusAttr + `>` + return `<release-group id="` + id + `" type="` + rgType + `"` + statusAttr + `>` +
`<title>` + title + `</title>` + `<title>` + title + `</title>` +
`<primary-type>` + rgType + `</primary-type>` +
`<artist-credit><name-credit><artist id="` + artistID + `">` + `<artist-credit><name-credit><artist id="` + artistID + `">` +
`<name>` + artistName + `</name>` + `<name>` + artistName + `</name>` +
`</artist></name-credit></artist-credit>` + `</artist></name-credit></artist-credit>` +
@@ -200,13 +204,14 @@ func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
// Test: SyncArtistDiscography applies filtering (excluded statuses) // Test: SyncArtistDiscography applies filtering (excluded statuses)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) { func TestSyncArtistDiscography_StatusIsNotFiltered(t *testing.T) {
artistMBID := "cccccccc-dddd-eeee-ffff-000000000000" artistMBID := "cccccccc-dddd-eeee-ffff-000000000000"
artistID := "nav-cccccccc" artistID := "nav-cccccccc"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
// Include a Bootleg and a Promotion that should be filtered out. // These all carry status values, but release groups have no status in
// ws/2, so none should be filtered on that basis.
resp := mbReleaseGroupListResponse( resp := mbReleaseGroupListResponse(
mbReleaseGroupXML("rg-legit", "Legit Album", "Album", "", artistMBID, "Artist", "2020-01-01")+ mbReleaseGroupXML("rg-legit", "Legit Album", "Album", "", artistMBID, "Artist", "2020-01-01")+
mbReleaseGroupXML("rg-bootleg", "Bootleg Album", "Album", "Bootleg", artistMBID, "Artist", "2020-02-01")+ mbReleaseGroupXML("rg-bootleg", "Bootleg Album", "Album", "Bootleg", artistMBID, "Artist", "2020-02-01")+
@@ -231,12 +236,9 @@ func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
// Only the legit album should remain after filtering. // All four are Albums; status is not a filter, so all four are kept.
if len(releases) != 1 { if len(releases) != 4 {
t.Fatalf("expected 1 release after filtering, got %d", len(releases)) t.Fatalf("expected 4 releases (status is not filtered), got %d", len(releases))
}
if releases[0].RGID != "rg-legit" {
t.Errorf("expected RGID 'rg-legit', got %q", releases[0].RGID)
} }
} }
@@ -275,9 +277,10 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
// Soundtrack should be excluded (not in includedTypes). // Soundtrack and the bare "Compilation" primary type should be excluded
if len(releases) != 4 { // (Compilation is not a primary type; it is classified via secondary type).
t.Fatalf("expected 4 releases after type filtering, got %d", len(releases)) if len(releases) != 3 {
t.Fatalf("expected 3 releases after type filtering, got %d", len(releases))
} }
rgIDs := make(map[string]bool) rgIDs := make(map[string]bool)
@@ -287,6 +290,9 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
if rgIDs["rg-soundtrack"] { if rgIDs["rg-soundtrack"] {
t.Error("Soundtrack type should have been filtered out") t.Error("Soundtrack type should have been filtered out")
} }
if rgIDs["rg-comp"] {
t.Error("Compilation primary type should have been filtered out")
}
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -6,6 +6,8 @@
package scanner package scanner
import ( import (
"unicode/utf8"
"github.com/lithammer/fuzzysearch/fuzzy" "github.com/lithammer/fuzzysearch/fuzzy"
"naviwatcher/internal/normalize" "naviwatcher/internal/normalize"
) )
@@ -44,10 +46,13 @@ func Similarity(a, b string) float64 {
return 0.0 return 0.0
} }
// fuzzy.LevenshteinDistance operates on runes, so the comparison basis
// must be rune count, not byte length, to avoid biasing the score for
// non-ASCII titles (where bytes > runes).
dist := fuzzy.LevenshteinDistance(na, nb) dist := fuzzy.LevenshteinDistance(na, nb)
maxLen := len(na) maxLen := utf8.RuneCountInString(na)
if len(nb) > maxLen { if rb := utf8.RuneCountInString(nb); rb > maxLen {
maxLen = len(nb) maxLen = rb
} }
// 1.0 - normalized distance → higher is more similar. // 1.0 - normalized distance → higher is more similar.