From c4b05dac28067c8c47e8fd40f09b1c5a1235ce21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org>
Date: Thu, 9 May 2024 07:08:15 -0400
Subject: [PATCH] Make sorting lists by name/title case-insensitive (#2993)

* Make sort by order_* fields case-insensitive.

* Sort internet radios by name case-insensitive
---
 core/external_metadata.go                |  3 +--
 persistence/album_repository.go          |  1 +
 persistence/mediafile_repository.go      |  1 +
 persistence/playlist_track_repository.go |  2 ++
 persistence/radio_repository.go          |  3 +++
 scanner/mapping.go                       | 14 ++++--------
 scanner/mapping_internal_test.go         | 16 -------------
 utils/strings.go                         | 11 +++++++++
 utils/strings_test.go                    | 29 ++++++++++++++++++++++++
 9 files changed, 52 insertions(+), 28 deletions(-)

diff --git a/core/external_metadata.go b/core/external_metadata.go
index 33206bdeb..368e1cce1 100644
--- a/core/external_metadata.go
+++ b/core/external_metadata.go
@@ -9,7 +9,6 @@ import (
 	"time"
 
 	"github.com/Masterminds/squirrel"
-	"github.com/deluan/sanitize"
 	"github.com/navidrome/navidrome/conf"
 	"github.com/navidrome/navidrome/core/agents"
 	_ "github.com/navidrome/navidrome/core/agents/lastfm"
@@ -414,7 +413,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
 				squirrel.Eq{"artist_id": artistID},
 				squirrel.Eq{"album_artist_id": artistID},
 			},
-			squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
+			squirrel.Like{"order_title": utils.SanitizeFieldForSorting(title)},
 		},
 		Sort: "starred desc, rating desc, year asc, compilation asc ",
 		Max:  1,
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index fa9f7f1d8..e23d18974 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -85,6 +85,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
 		r.sortMappings = map[string]string{
 			"name":           "order_album_name asc, order_album_artist_name asc",
 			"artist":         "compilation asc, order_album_artist_name asc, order_album_name asc",
+			"albumArtist":    "compilation asc, order_album_artist_name asc, order_album_name asc",
 			"max_year":       "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
 			"random":         "RANDOM()",
 			"recently_added": recentlyAddedSort(),
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index 9766e57d3..5c018f34a 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -41,6 +41,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
 		}
 	} else {
 		r.sortMappings = map[string]string{
+			"title":     "order_title",
 			"artist":    "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
 			"album":     "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
 			"random":    "RANDOM()",
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index beaa749f4..79e722ea9 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -30,10 +30,12 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
 		"id":     "playlist_tracks.id",
 		"artist": "order_artist_name asc",
 		"album":  "order_album_name asc, order_album_artist_name asc",
+		"title":  "order_title",
 	}
 	if conf.Server.PreferSortTags {
 		p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
 		p.sortMappings["album"] = "COALESCE(NULLIF(sort_album_name,''),order_album_name)"
+		p.sortMappings["title"] = "COALESCE(NULLIF(sort_title,''),title)"
 	}
 
 	pls, err := r.Get(playlistId)
diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go
index 0c0f64d3c..19a6474bf 100644
--- a/persistence/radio_repository.go
+++ b/persistence/radio_repository.go
@@ -26,6 +26,9 @@ func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioReposito
 	r.filterMappings = map[string]filterFunc{
 		"name": containsFilter,
 	}
+	r.sortMappings = map[string]string{
+		"name": "(name collate nocase), name",
+	}
 	return r
 }
 
diff --git a/scanner/mapping.go b/scanner/mapping.go
index 26c6adbc1..3e56f3bc5 100644
--- a/scanner/mapping.go
+++ b/scanner/mapping.go
@@ -7,7 +7,6 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/deluan/sanitize"
 	"github.com/navidrome/navidrome/conf"
 	"github.com/navidrome/navidrome/consts"
 	"github.com/navidrome/navidrome/model"
@@ -56,10 +55,10 @@ func (s MediaFileMapper) ToMediaFile(md metadata.Tags) model.MediaFile {
 	mf.SortAlbumName = md.SortAlbum()
 	mf.SortArtistName = md.SortArtist()
 	mf.SortAlbumArtistName = md.SortAlbumArtist()
-	mf.OrderTitle = strings.TrimSpace(sanitize.Accents(mf.Title))
-	mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album)
-	mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
-	mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
+	mf.OrderTitle = utils.SanitizeFieldForSorting(mf.Title)
+	mf.OrderAlbumName = utils.SanitizeFieldForSortingNoArticle(mf.Album)
+	mf.OrderArtistName = utils.SanitizeFieldForSortingNoArticle(mf.Artist)
+	mf.OrderAlbumArtistName = utils.SanitizeFieldForSortingNoArticle(mf.AlbumArtist)
 	mf.CatalogNum = md.CatalogNum()
 	mf.MbzRecordingID = md.MbzRecordingID()
 	mf.MbzReleaseTrackID = md.MbzReleaseTrackID()
@@ -81,11 +80,6 @@ func (s MediaFileMapper) ToMediaFile(md metadata.Tags) model.MediaFile {
 	return *mf
 }
 
-func sanitizeFieldForSorting(originalValue string) string {
-	v := strings.TrimSpace(sanitize.Accents(originalValue))
-	return utils.NoArticle(v)
-}
-
 func (s MediaFileMapper) mapTrackTitle(md metadata.Tags) string {
 	if md.Title() == "" {
 		s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator))
diff --git a/scanner/mapping_internal_test.go b/scanner/mapping_internal_test.go
index b44f2bcfb..882af1611 100644
--- a/scanner/mapping_internal_test.go
+++ b/scanner/mapping_internal_test.go
@@ -3,7 +3,6 @@ package scanner
 import (
 	"context"
 
-	"github.com/navidrome/navidrome/conf"
 	"github.com/navidrome/navidrome/model"
 	"github.com/navidrome/navidrome/scanner/metadata"
 	"github.com/navidrome/navidrome/tests"
@@ -161,19 +160,4 @@ var _ = Describe("mapping", func() {
 			})
 		})
 	})
-
-	Describe("sanitizeFieldForSorting", func() {
-		BeforeEach(func() {
-			conf.Server.IgnoredArticles = "The O"
-		})
-		It("sanitize accents", func() {
-			Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
-		})
-		It("removes articles", func() {
-			Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
-		})
-		It("removes accented articles", func() {
-			Expect(sanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("Blesq Blom"))
-		})
-	})
 })
diff --git a/utils/strings.go b/utils/strings.go
index 1bd26b10a..42866a48c 100644
--- a/utils/strings.go
+++ b/utils/strings.go
@@ -3,6 +3,7 @@ package utils
 import (
 	"strings"
 
+	"github.com/deluan/sanitize"
 	"github.com/navidrome/navidrome/conf"
 )
 
@@ -32,3 +33,13 @@ func LongestCommonPrefix(list []string) string {
 	}
 	return list[0]
 }
+
+func SanitizeFieldForSorting(originalValue string) string {
+	v := strings.TrimSpace(sanitize.Accents(originalValue))
+	return strings.ToLower(v)
+}
+
+func SanitizeFieldForSortingNoArticle(originalValue string) string {
+	v := strings.TrimSpace(sanitize.Accents(originalValue))
+	return strings.ToLower(NoArticle(v))
+}
diff --git a/utils/strings_test.go b/utils/strings_test.go
index 4b24dab0c..0d7b72e61 100644
--- a/utils/strings_test.go
+++ b/utils/strings_test.go
@@ -35,6 +35,35 @@ var _ = Describe("Strings", func() {
 		})
 	})
 
+	Describe("sanitizeFieldForSorting", func() {
+		BeforeEach(func() {
+			conf.Server.IgnoredArticles = "The O"
+		})
+		It("sanitize accents", func() {
+			Expect(SanitizeFieldForSorting("Céu")).To(Equal("ceu"))
+		})
+		It("removes articles", func() {
+			Expect(SanitizeFieldForSorting("The Beatles")).To(Equal("the beatles"))
+		})
+		It("removes accented articles", func() {
+			Expect(SanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("o blesq blom"))
+		})
+	})
+	Describe("SanitizeFieldForSortingNoArticle", func() {
+		BeforeEach(func() {
+			conf.Server.IgnoredArticles = "The O"
+		})
+		It("sanitize accents", func() {
+			Expect(SanitizeFieldForSortingNoArticle("Céu")).To(Equal("ceu"))
+		})
+		It("removes articles", func() {
+			Expect(SanitizeFieldForSortingNoArticle("The Beatles")).To(Equal("beatles"))
+		})
+		It("removes accented articles", func() {
+			Expect(SanitizeFieldForSortingNoArticle("Õ Blésq Blom")).To(Equal("blesq blom"))
+		})
+	})
+
 	Describe("LongestCommonPrefix", func() {
 		It("finds the longest common prefix", func() {
 			Expect(LongestCommonPrefix(testPaths)).To(Equal("/Music/iTunes 1/iTunes Media/Music/"))