diff --git a/go_backend/matching_test.go b/go_backend/matching_test.go new file mode 100644 index 00000000..f22028e1 --- /dev/null +++ b/go_backend/matching_test.go @@ -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") + } + }) +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index a74a1ea1..1632a59c 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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 + } } } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index cb6c9f5f..6140d9e9 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -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 diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 71a4e8d3..0e22b803 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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 with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); @@ -475,19 +485,116 @@ class _HomeTabState extends ConsumerState // 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 }, ); } else { - final confirmed = await showDialog( - 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: () {}, - ), - ), - ); - } + ), + ); } } }