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
+124 -37
View File
@@ -43,6 +43,16 @@ class _RecentAccessView {
});
}
class _CsvImportOptions {
final bool confirmed;
final bool skipDownloaded;
const _CsvImportOptions({
required this.confirmed,
required this.skipDownloaded,
});
}
class _HomeTabState extends ConsumerState<HomeTab>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
@@ -475,19 +485,116 @@ class _HomeTabState extends ConsumerState<HomeTab>
// ignore: use_build_context_synchronously
final l10n = context.l10n;
final options = await showDialog<_CsvImportOptions>(
context: this.context,
builder: (dialogCtx) {
var skipDownloaded = true;
return StatefulBuilder(
builder: (dialogCtx, setDialogState) => AlertDialog(
title: Text(l10n.dialogImportPlaylistTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.dialogImportPlaylistMessage(tracks.length)),
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Skip already downloaded songs'),
value: skipDownloaded,
onChanged: (value) {
setDialogState(() {
skipDownloaded = value ?? true;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(
dialogCtx,
const _CsvImportOptions(
confirmed: false,
skipDownloaded: true,
),
),
child: Text(l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(
dialogCtx,
_CsvImportOptions(
confirmed: true,
skipDownloaded: skipDownloaded,
),
),
child: Text(l10n.dialogImport),
),
],
),
);
},
);
if (options == null || !options.confirmed) return;
var tracksToQueue = tracks;
var skippedDownloadedCount = 0;
if (options.skipDownloaded) {
final historyState = ref.read(downloadHistoryProvider);
tracksToQueue = [];
for (final track in tracks) {
final isDownloaded =
historyState.isDownloaded(track.id) ||
(track.isrc != null &&
historyState.getByIsrc(track.isrc!) != null);
if (isDownloaded) {
skippedDownloadedCount++;
continue;
}
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(
l10n.discographySkippedDownloaded(0, skippedDownloadedCount),
),
),
);
}
return;
}
final queueSnackbarMessage = skippedDownloadedCount > 0
? l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedDownloadedCount,
)
: l10n.snackbarAddedTracksToQueue(tracksToQueue.length);
if (!mounted) return;
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
this.context,
trackName: l10n.csvImportTracks(tracks.length),
trackName: l10n.csvImportTracks(tracksToQueue.length),
artistName: l10n.dialogImportPlaylistTitle,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
content: Text(queueSnackbarMessage),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
@@ -498,39 +605,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
},
);
} else {
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
title: Text(l10n.dialogImportPlaylistTitle),
content: Text(l10n.dialogImportPlaylistMessage(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: Text(l10n.dialogCancel),
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracksToQueue, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(queueSnackbarMessage),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
),
FilledButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: Text(l10n.dialogImport),
),
],
),
);
if (confirmed == true) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
),
),
);
}
),
);
}
}
}