From d79b8124674bef23283201a097b99fc29a195f52 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 14 Mar 2026 09:59:51 -0400 Subject: [PATCH] refactor(natural): replace maruel/natural with custom natural sort implementation --- core/artwork/reader_album.go | 2 +- utils/natural/natural.go | 98 ++++++++++++++++++++++++++++ utils/natural/natural_test.go | 116 ++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 utils/natural/natural.go create mode 100644 utils/natural/natural_test.go diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 36b2fff0..98a2105e 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -13,13 +13,13 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/maruel/natural" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/natural" ) type albumArtworkReader struct { diff --git a/utils/natural/natural.go b/utils/natural/natural.go new file mode 100644 index 00000000..fa0800e1 --- /dev/null +++ b/utils/natural/natural.go @@ -0,0 +1,98 @@ +// Package natural provides natural (alphanumeric) string comparison. +// When both strings have digit sequences at the same position, they are +// compared numerically (so "file2" < "file10"); otherwise bytes are +// compared one-by-one. No allocations are made. +package natural + +import "strings" + +// Compare returns a negative value if a < b, zero if a == b, +// or a positive value if a > b using natural sort ordering. +// +// When two numeric segments are numerically equal (e.g. "01" vs "1"), +// comparison continues with the remaining suffixes. If one or both +// strings end at the digit boundary, the raw strings are compared +// lexically, which makes leading zeros significant as a tie-breaker +// (e.g. "a01" < "a1", "a0" < "a00"). +func Compare(a, b string) int { + ia, ib := 0, 0 + for ia < len(a) && ib < len(b) { + ca, cb := a[ia], b[ib] + da, db := isDigit(ca), isDigit(cb) + + switch { + case da && db: + // Both are in digit sequences — compare numerically. + endA := ia + for endA < len(a) && isDigit(a[endA]) { + endA++ + } + endB := ib + for endB < len(b) && isDigit(b[endB]) { + endB++ + } + + if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { + return c + } + + // Numerically equal. If both sides have trailing data, continue + // comparing after the digit runs. Otherwise fall through to + // lexical comparison of the full remaining strings (which makes + // leading-zero differences significant as a tie-breaker). + if endA < len(a) && endB < len(b) { + ia = endA + ib = endB + continue + } + return strings.Compare(a[ia:], b[ib:]) + case da != db: + return int(ca) - int(cb) + default: + if ca != cb { + return int(ca) - int(cb) + } + ia++ + ib++ + } + } + return (len(a) - ia) - (len(b) - ib) +} + +// compareNumbers compares two digit strings numerically. +// Leading zeros are stripped before comparison. +func compareNumbers(a, b string) int { + // Strip leading zeros. + sa := stripZeros(a) + sb := stripZeros(b) + + // Different lengths after stripping means different magnitude. + if len(sa) != len(sb) { + return len(sa) - len(sb) + } + + // Same length — compare digit by digit. + for i := range len(sa) { + if sa[i] != sb[i] { + return int(sa[i]) - int(sb[i]) + } + } + return 0 +} + +// stripZeros returns s with leading '0' bytes removed. +// If s is all zeros, returns the last byte (a single "0"). +func stripZeros(s string) string { + i := 0 + for i < len(s) && s[i] == '0' { + i++ + } + if i == len(s) && len(s) > 0 { + return s[len(s)-1:] + } + return s[i:] +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go new file mode 100644 index 00000000..825a944c --- /dev/null +++ b/utils/natural/natural_test.go @@ -0,0 +1,116 @@ +package natural_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/natural" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNatural(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Natural Suite") +} + +var _ = Describe("Compare", func() { + DescribeTable("returns correct ordering", + func(a, b string, expected int) { + result := natural.Compare(a, b) + if expected < 0 { + Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b) + } else if expected > 0 { + Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b) + } else { + Expect(result).To(Equal(0), "expected %q == %q", a, b) + } + }, + // Basic string ordering + Entry("a < b", "a", "b", -1), + Entry("b > a", "b", "a", 1), + Entry("a < aa (prefix)", "a", "aa", -1), + Entry("aa > a", "aa", "a", 1), + + // Equal strings + Entry("equal strings return 0", "abc", "abc", 0), + Entry("both empty", "", "", 0), + Entry("a01 == a01", "a01", "a01", 0), + Entry("a1 == a1", "a1", "a1", 0), + + // Empty string edge cases + Entry("empty < non-empty", "", "a", -1), + Entry("non-empty > empty", "a", "", 1), + + // Numeric comparison + Entry("2 < 10 numerically", "2", "10", -1), + Entry("10 > 2 numerically", "10", "2", 1), + Entry("equal numbers", "42", "42", 0), + Entry("9 < 10", "9", "10", -1), + Entry("99 < 100", "99", "100", -1), + + // Simple numeric segments (from original library) + Entry("a0 < a1", "a0", "a1", -1), + Entry("a0 < a00", "a0", "a00", -1), + Entry("a00 < a01", "a00", "a01", -1), + Entry("a01 < a1", "a01", "a1", -1), + Entry("a01 < a2", "a01", "a2", -1), + Entry("a01x < a2x", "a01x", "a2x", -1), + Entry("a01 > a00", "a01", "a00", 1), + Entry("a2 > a01", "a2", "a01", 1), + Entry("a2x > a01x", "a2x", "a01x", 1), + + // Multiple numeric groups (from original library) + Entry("a0b00 < a00b1", "a0b00", "a00b1", -1), + Entry("a0b00 < a00b01", "a0b00", "a00b01", -1), + Entry("a00b0 < a0b00", "a00b0", "a0b00", -1), + Entry("a00b00 < a0b01", "a00b00", "a0b01", -1), + Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), + Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), + Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), + Entry("a00b00 == a0b00", "a00b00", "a0b00", 0), + + // Leading zeros at end of string — lexical tie-break + Entry("file01 < file1", "file01", "file1", -1), + + // Prefix comparison + Entry("abc < abcd", "abc", "abcd", -1), + Entry("abcd > abc", "abcd", "abc", 1), + + // Navidrome use cases: cover art sorting + Entry("cover < cover.1", "cover", "cover.1", -1), + Entry("cover.1 < cover.2", "cover.1", "cover.2", -1), + Entry("cover.2 < cover.10", "cover.2", "cover.10", -1), + + // Navidrome use cases: disc sorting + Entry("disc1 < disc2", "disc1", "disc2", -1), + Entry("disc2 < disc10", "disc2", "disc10", -1), + Entry("disc1 < disc10", "disc1", "disc10", -1), + + // Multiple numeric segments + Entry("a1b2 < a1b10", "a1b2", "a1b10", -1), + Entry("a2b1 > a1b2", "a2b1", "a1b2", 1), + + // Numbers at the start + Entry("2abc < 10abc", "2abc", "10abc", -1), + + // Numbers larger than uint64 max (from original library) + Entry("large: fewer digits < more digits", + "a99999999999999999999", "a100000000000000000000", -1), + Entry("large: digit-by-digit comparison", + "a123456789012345678901234567890", "a123456789012345678901234567891", -1), + Entry("large: more digits > fewer digits", + "a999999999999999999999", "a1000000000000000000000", -1), + Entry("large: 20 digits < 100 digits by length", + "a20000000000000000000", "a100000000000000000000", -1), + Entry("large: 100 digits > 20 digits", + "a100000000000000000000", "a20000000000000000000", 1), + Entry("large: reverse of above", + "a1000000000000000000000", "a999999999999999999999", 1), + Entry("large: equal", + "a100000000000000000000", "a100000000000000000000", 0), + Entry("large: leading zeros with trailing data", + "a00000000000000000000001x", "a1x", 0), + Entry("large: leading zeros with trailing data (2)", + "a099999999999999999999x", "a99999999999999999999x", 0), + ) +})