fix: address code review findings
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user