mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-09 14:18:11 +02:00
feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user