mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-08 15:44:00 +02:00
bb06ab7e12
- Remove unused Go functions: buildRawAPEItem, loadCredentials, scanAudioFile, scanAudioFileWithKnownModTimeAndDisplayName, readM4AIndexValue, musixmatchSearchResponse/LyricsResponse structs - Remove unused Go fields: downloadDir, utlsTransport.mu/h2Transports - Lowercase Go error messages per convention (golint/ST1005) - Simplify LyricsLine conversion and artistsMatch return - Add Dart analysis rules: always_declare_return_types, avoid_types_as_parameter_names, strict_top_level_inference, type_annotate_public_apis - Suppress SA1019 lint for required blowfish import
407 lines
9.9 KiB
Go
407 lines
9.9 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
|
switch r {
|
|
case 'đ':
|
|
b.WriteString("dj")
|
|
case 'ß':
|
|
b.WriteString("ss")
|
|
case 'æ':
|
|
b.WriteString("ae")
|
|
case 'œ':
|
|
b.WriteString("oe")
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
func normalizeLooseTitle(title string) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(title))
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(trimmed))
|
|
|
|
for _, r := range trimmed {
|
|
switch {
|
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
|
b.WriteRune(r)
|
|
case unicode.IsSpace(r):
|
|
b.WriteByte(' ')
|
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
|
b.WriteByte(' ')
|
|
default:
|
|
}
|
|
}
|
|
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|
|
|
|
func normalizeLooseArtistName(name string) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
decomposed := norm.NFD.String(trimmed)
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(decomposed))
|
|
|
|
for _, r := range decomposed {
|
|
switch {
|
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
|
continue
|
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
|
writeNormalizedArtistRune(&b, r)
|
|
case unicode.IsSpace(r):
|
|
b.WriteByte(' ')
|
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
|
b.WriteByte(' ')
|
|
default:
|
|
}
|
|
}
|
|
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|
|
|
|
func hasAlphaNumericRunes(value string) bool {
|
|
for _, r := range value {
|
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeSymbolOnlyTitle(title string) string {
|
|
trimmed := strings.TrimSpace(strings.ToLower(title))
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(trimmed))
|
|
|
|
for _, r := range trimmed {
|
|
switch {
|
|
case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r):
|
|
continue
|
|
// Drop combining marks such as emoji variation selectors.
|
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
|
continue
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func artistsMatch(expectedArtist, foundArtist string) bool {
|
|
normExpected := normalizeLooseArtistName(expectedArtist)
|
|
normFound := normalizeLooseArtistName(foundArtist)
|
|
|
|
if normExpected == normFound {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normExpected, normFound) ||
|
|
strings.Contains(normFound, normExpected) {
|
|
return true
|
|
}
|
|
|
|
expectedArtists := splitArtists(normExpected)
|
|
foundArtists := splitArtists(normFound)
|
|
|
|
for _, expected := range expectedArtists {
|
|
for _, found := range foundArtists {
|
|
if expected == found {
|
|
return true
|
|
}
|
|
if strings.Contains(expected, found) ||
|
|
strings.Contains(found, expected) {
|
|
return true
|
|
}
|
|
if sameWordsUnordered(expected, found) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return isLatinScript(expectedArtist) != isLatinScript(foundArtist)
|
|
}
|
|
|
|
func splitArtists(artists string) []string {
|
|
normalized := artists
|
|
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
|
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
|
|
|
parts := strings.Split(normalized, "|")
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sameWordsUnordered(a, b string) bool {
|
|
wordsA := strings.Fields(a)
|
|
wordsB := strings.Fields(b)
|
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
|
return false
|
|
}
|
|
|
|
sortedA := make([]string, len(wordsA))
|
|
sortedB := make([]string, len(wordsB))
|
|
copy(sortedA, wordsA)
|
|
copy(sortedB, wordsB)
|
|
|
|
for i := 0; i < len(sortedA)-1; i++ {
|
|
for j := i + 1; j < len(sortedA); j++ {
|
|
if sortedA[i] > sortedA[j] {
|
|
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
|
}
|
|
if sortedB[i] > sortedB[j] {
|
|
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := range sortedA {
|
|
if sortedA[i] != sortedB[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func titlesMatch(expectedTitle, foundTitle string) bool {
|
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
|
|
|
if normExpected == normFound {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normExpected, normFound) ||
|
|
strings.Contains(normFound, normExpected) {
|
|
return true
|
|
}
|
|
|
|
cleanExpected := cleanTitle(normExpected)
|
|
cleanFound := cleanTitle(normFound)
|
|
if cleanExpected == cleanFound {
|
|
return true
|
|
}
|
|
|
|
if cleanExpected != "" && cleanFound != "" {
|
|
if strings.Contains(cleanExpected, cleanFound) ||
|
|
strings.Contains(cleanFound, cleanExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
coreExpected := extractCoreTitle(normExpected)
|
|
coreFound := extractCoreTitle(normFound)
|
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
|
return true
|
|
}
|
|
|
|
looseExpected := normalizeLooseTitle(normExpected)
|
|
looseFound := normalizeLooseTitle(normFound)
|
|
if looseExpected != "" && looseFound != "" {
|
|
if looseExpected == looseFound {
|
|
return true
|
|
}
|
|
if strings.Contains(looseExpected, looseFound) ||
|
|
strings.Contains(looseFound, looseExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
|
strings.TrimSpace(expectedTitle) != "" &&
|
|
strings.TrimSpace(foundTitle) != "" {
|
|
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
|
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
|
if expectedSymbols != "" &&
|
|
foundSymbols != "" &&
|
|
expectedSymbols == foundSymbols {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func extractCoreTitle(title string) string {
|
|
parenIdx := strings.Index(title, "(")
|
|
bracketIdx := strings.Index(title, "[")
|
|
dashIdx := strings.Index(title, " - ")
|
|
|
|
cutIdx := len(title)
|
|
if parenIdx > 0 && parenIdx < cutIdx {
|
|
cutIdx = parenIdx
|
|
}
|
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
|
cutIdx = bracketIdx
|
|
}
|
|
if dashIdx > 0 && dashIdx < cutIdx {
|
|
cutIdx = dashIdx
|
|
}
|
|
|
|
return strings.TrimSpace(title[:cutIdx])
|
|
}
|
|
|
|
func cleanTitle(title string) string {
|
|
cleaned := title
|
|
|
|
versionPatterns := []string{
|
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
|
"album version", "radio edit", "original mix", "extended",
|
|
"club mix", "remix", "live", "acoustic", "demo",
|
|
}
|
|
|
|
for {
|
|
startParen := strings.LastIndex(cleaned, "(")
|
|
endParen := strings.LastIndex(cleaned, ")")
|
|
if startParen >= 0 && endParen > startParen {
|
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
for {
|
|
startBracket := strings.LastIndex(cleaned, "[")
|
|
endBracket := strings.LastIndex(cleaned, "]")
|
|
if startBracket >= 0 && endBracket > startBracket {
|
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
dashPatterns := []string{
|
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
|
" - live", " - acoustic", " - demo", " - remix",
|
|
}
|
|
for _, pattern := range dashPatterns {
|
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
|
}
|
|
}
|
|
|
|
for strings.Contains(cleaned, " ") {
|
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
|
}
|
|
|
|
return strings.TrimSpace(cleaned)
|
|
}
|
|
|
|
func isLatinScript(value string) bool {
|
|
for _, r := range value {
|
|
if r < 128 {
|
|
continue
|
|
}
|
|
if (r >= 0x0100 && r <= 0x024F) ||
|
|
(r >= 0x1E00 && r <= 0x1EFF) ||
|
|
(r >= 0x00C0 && r <= 0x00FF) {
|
|
continue
|
|
}
|
|
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
(r >= 0x3040 && r <= 0x309F) ||
|
|
(r >= 0x30A0 && r <= 0x30FF) ||
|
|
(r >= 0xAC00 && r <= 0xD7AF) ||
|
|
(r >= 0x0600 && r <= 0x06FF) ||
|
|
(r >= 0x0400 && r <= 0x04FF) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type resolvedTrackInfo struct {
|
|
Title string
|
|
ArtistName string
|
|
ISRC string
|
|
Duration int
|
|
SkipNameVerification bool
|
|
}
|
|
|
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
|
exactISRCMatch := req.ISRC != "" &&
|
|
resolved.ISRC != "" &&
|
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
|
|
|
if !exactISRCMatch && !resolved.SkipNameVerification {
|
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
|
logPrefix, req.ArtistName, resolved.ArtistName)
|
|
return false
|
|
}
|
|
|
|
if req.TrackName != "" && resolved.Title != "" &&
|
|
!titlesMatch(req.TrackName, resolved.Title) {
|
|
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
|
logPrefix, req.TrackName, resolved.Title)
|
|
return false
|
|
}
|
|
}
|
|
|
|
expectedDurationSec := req.DurationMS / 1000
|
|
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
|
diff := expectedDurationSec - resolved.Duration
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff > 10 {
|
|
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
|
logPrefix, expectedDurationSec, resolved.Duration)
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|