feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import

This commit is contained in:
zarzet
2026-02-09 10:57:52 +07:00
parent f9dd82010f
commit 5fdf1df5df
4 changed files with 504 additions and 51 deletions
+59
View File
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestQobuzTitlesMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script titles", func(t *testing.T) {
if qobuzTitlesMatch("パンツ脱げるもん!", "Warrior of the Darkness") {
t.Fatalf("expected unrelated cross-script titles to not match")
}
})
t.Run("accepts transliterated japanese title", func(t *testing.T) {
if !qobuzTitlesMatch("パンツ脱げるもん!", "Pantsu Nugeru Mon") {
t.Fatalf("expected transliterated japanese title to match")
}
})
}
func TestQobuzArtistsMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script artists", func(t *testing.T) {
if qobuzArtistsMatch("TakeponG", "陳奕迅") {
t.Fatalf("expected unrelated cross-script artists to not match")
}
})
t.Run("accepts transliterated japanese artist", func(t *testing.T) {
if !qobuzArtistsMatch("たけぽんぐ", "takepong") {
t.Fatalf("expected transliterated japanese artist to match")
}
})
}
func TestTidalTitlesMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script titles", func(t *testing.T) {
if titlesMatch("パンツ脱げるもん!", "Warrior of the Darkness") {
t.Fatalf("expected unrelated cross-script titles to not match")
}
})
t.Run("accepts transliterated japanese title", func(t *testing.T) {
if !titlesMatch("パンツ脱げるもん!", "Pantsu Nugeru Mon") {
t.Fatalf("expected transliterated japanese title to match")
}
})
}
func TestTidalArtistsMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script artists", func(t *testing.T) {
if artistsMatch("TakeponG", "陳奕迅") {
t.Fatalf("expected unrelated cross-script artists to not match")
}
})
t.Run("accepts transliterated japanese artist", func(t *testing.T) {
if !artistsMatch("たけぽんぐ", "takepong") {
t.Fatalf("expected transliterated japanese artist to match")
}
})
}
+168 -10
View File
@@ -81,13 +81,160 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
if qobuzCrossScriptEquivalent(expectedArtist, foundArtist) {
GoLog("[Qobuz] Artist names in different scripts but transliteration matched: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
}
return false
}
func qobuzNormalizeScriptAware(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = CleanToASCII(JapaneseToRomaji(normalized))
normalized = strings.Join(strings.Fields(normalized), " ")
return strings.TrimSpace(normalized)
}
func qobuzCrossScriptEquivalent(expected, found string) bool {
normExpected := qobuzNormalizeScriptAware(expected)
normFound := qobuzNormalizeScriptAware(found)
if normExpected == "" || normFound == "" {
return false
}
if normExpected == normFound {
return true
}
compactExpected := strings.ReplaceAll(normExpected, " ", "")
compactFound := strings.ReplaceAll(normFound, " ", "")
if len(compactExpected) >= 6 && len(compactFound) >= 6 {
if compactExpected == compactFound ||
strings.Contains(compactExpected, compactFound) ||
strings.Contains(compactFound, compactExpected) {
return true
}
shorterLen := len(compactExpected)
if len(compactFound) < shorterLen {
shorterLen = len(compactFound)
}
maxDistance := 1
if shorterLen >= 10 {
maxDistance = 2
}
if shorterLen >= 16 {
maxDistance = 3
}
if qobuzEditDistanceWithin(compactExpected, compactFound, maxDistance) {
if qobuzCommonPrefixLen(compactExpected, compactFound) >= 4 ||
qobuzCommonSuffixLen(compactExpected, compactFound) >= 4 {
return true
}
}
}
return false
}
func qobuzCommonPrefixLen(a, b string) int {
max := len(a)
if len(b) < max {
max = len(b)
}
count := 0
for i := 0; i < max; i++ {
if a[i] != b[i] {
break
}
count++
}
return count
}
func qobuzCommonSuffixLen(a, b string) int {
i := len(a) - 1
j := len(b) - 1
count := 0
for i >= 0 && j >= 0 {
if a[i] != b[j] {
break
}
count++
i--
j--
}
return count
}
func qobuzEditDistanceWithin(a, b string, maxDistance int) bool {
if maxDistance < 0 {
return false
}
if a == b {
return true
}
lenA := len(a)
lenB := len(b)
diff := lenA - lenB
if diff < 0 {
diff = -diff
}
if diff > maxDistance {
return false
}
prev := make([]int, lenB+1)
for j := 0; j <= lenB; j++ {
prev[j] = j
}
for i := 1; i <= lenA; i++ {
curr := make([]int, lenB+1)
curr[0] = i
minInRow := curr[0]
for j := 1; j <= lenB; j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
insertCost := curr[j-1] + 1
deleteCost := prev[j] + 1
replaceCost := prev[j-1] + cost
best := insertCost
if deleteCost < best {
best = deleteCost
}
if replaceCost < best {
best = replaceCost
}
curr[j] = best
if best < minInRow {
minInRow = best
}
}
if minInRow > maxDistance {
return false
}
prev = curr
}
return prev[lenB] <= maxDistance
}
func qobuzSplitArtists(artists string) []string {
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
@@ -177,8 +324,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin {
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
if qobuzCrossScriptEquivalent(expectedTitle, foundTitle) {
GoLog("[Qobuz] Titles in different scripts but transliteration matched: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
}
return false
@@ -702,8 +851,11 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
tracksToCheck := titleMatches
if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
if strings.TrimSpace(trackName) != "" && len(titleMatches) == 0 {
return nil, fmt.Errorf("no tracks found with matching title (expected '%s')", trackName)
}
if strings.TrimSpace(trackName) == "" {
for i := range allTracks {
tracksToCheck = append(tracksToCheck, &allTracks[i])
}
@@ -1152,10 +1304,16 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if track == nil {
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
if track != nil {
if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[Qobuz] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
track = nil
} else if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
}
}
+153 -4
View File
@@ -1196,13 +1196,160 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
spotifyLatin := isLatinScript(spotifyArtist)
tidalLatin := isLatinScript(tidalArtist)
if spotifyLatin != tidalLatin {
GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
if crossScriptEquivalent(spotifyArtist, tidalArtist) {
GoLog("[Tidal] Artist names in different scripts but transliteration matched: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
}
return false
}
func normalizeScriptAware(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = CleanToASCII(JapaneseToRomaji(normalized))
normalized = strings.Join(strings.Fields(normalized), " ")
return strings.TrimSpace(normalized)
}
func crossScriptEquivalent(expected, found string) bool {
normExpected := normalizeScriptAware(expected)
normFound := normalizeScriptAware(found)
if normExpected == "" || normFound == "" {
return false
}
if normExpected == normFound {
return true
}
compactExpected := strings.ReplaceAll(normExpected, " ", "")
compactFound := strings.ReplaceAll(normFound, " ", "")
if len(compactExpected) >= 6 && len(compactFound) >= 6 {
if compactExpected == compactFound ||
strings.Contains(compactExpected, compactFound) ||
strings.Contains(compactFound, compactExpected) {
return true
}
shorterLen := len(compactExpected)
if len(compactFound) < shorterLen {
shorterLen = len(compactFound)
}
maxDistance := 1
if shorterLen >= 10 {
maxDistance = 2
}
if shorterLen >= 16 {
maxDistance = 3
}
if editDistanceWithin(compactExpected, compactFound, maxDistance) {
if commonPrefixLen(compactExpected, compactFound) >= 4 ||
commonSuffixLen(compactExpected, compactFound) >= 4 {
return true
}
}
}
return false
}
func commonPrefixLen(a, b string) int {
max := len(a)
if len(b) < max {
max = len(b)
}
count := 0
for i := 0; i < max; i++ {
if a[i] != b[i] {
break
}
count++
}
return count
}
func commonSuffixLen(a, b string) int {
i := len(a) - 1
j := len(b) - 1
count := 0
for i >= 0 && j >= 0 {
if a[i] != b[j] {
break
}
count++
i--
j--
}
return count
}
func editDistanceWithin(a, b string, maxDistance int) bool {
if maxDistance < 0 {
return false
}
if a == b {
return true
}
lenA := len(a)
lenB := len(b)
diff := lenA - lenB
if diff < 0 {
diff = -diff
}
if diff > maxDistance {
return false
}
prev := make([]int, lenB+1)
for j := 0; j <= lenB; j++ {
prev[j] = j
}
for i := 1; i <= lenA; i++ {
curr := make([]int, lenB+1)
curr[0] = i
minInRow := curr[0]
for j := 1; j <= lenB; j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
insertCost := curr[j-1] + 1
deleteCost := prev[j] + 1
replaceCost := prev[j-1] + cost
best := insertCost
if deleteCost < best {
best = deleteCost
}
if replaceCost < best {
best = replaceCost
}
curr[j] = best
if best < minInRow {
minInRow = best
}
}
if minInRow > maxDistance {
return false
}
prev = curr
}
return prev[lenB] <= maxDistance
}
func splitArtists(artists string) []string {
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
@@ -1292,8 +1439,10 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin {
GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
if crossScriptEquivalent(expectedTitle, foundTitle) {
GoLog("[Tidal] Titles in different scripts but transliteration matched: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
}
return false