From e8183fdfe0f65c7e2ddf8bf3932f3e701411def4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 19 Jul 2026 21:48:42 -0400 Subject: [PATCH] feat(db): add id canonicalization transform for the uniform-ids migration --- db/migrations/id_canonical.go | 39 ++++++++++++++++++++++++++ db/migrations/id_canonical_test.go | 32 +++++++++++++++++++++ db/migrations/migrations_suite_test.go | 16 +++++++++++ 3 files changed, 87 insertions(+) create mode 100644 db/migrations/id_canonical.go create mode 100644 db/migrations/id_canonical_test.go create mode 100644 db/migrations/migrations_suite_test.go diff --git a/db/migrations/id_canonical.go b/db/migrations/id_canonical.go new file mode 100644 index 000000000..afd6765f5 --- /dev/null +++ b/db/migrations/id_canonical.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "crypto/md5" + "encoding/hex" + "math/big" + + "github.com/navidrome/navidrome/model/id" +) + +// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding +// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged. +func canonicalID(s string) string { + switch len(s) { + case 22: + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 || v.BitLen() <= 128 { + return s + } + sum := md5.Sum([]byte(s)) + return id.Encode128(sum[:]) + case 32: + b, err := hex.DecodeString(s) + if err != nil { + return s + } + return id.Encode128(b) + case 36: + if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { + return s + } + b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:]) + if err != nil { + return s + } + return id.Encode128(b) + } + return s +} diff --git a/db/migrations/id_canonical_test.go b/db/migrations/id_canonical_test.go new file mode 100644 index 000000000..55cf8160a --- /dev/null +++ b/db/migrations/id_canonical_test.go @@ -0,0 +1,32 @@ +package migrations + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("canonicalID", func() { + DescribeTable("transforms each historical id shape", + func(in, want string) { + Expect(canonicalID(in)).To(Equal(want)) + }, + Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"), + Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"), + Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"), + Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"), + Entry("empty string passes through", "", ""), + Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"), + Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"), + Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"), + Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), + Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"), + ) + + It("is idempotent for every shape", func() { + for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz", + "e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} { + once := canonicalID(s) + Expect(canonicalID(once)).To(Equal(once)) + } + }) +}) diff --git a/db/migrations/migrations_suite_test.go b/db/migrations/migrations_suite_test.go new file mode 100644 index 000000000..bb08ad8aa --- /dev/null +++ b/db/migrations/migrations_suite_test.go @@ -0,0 +1,16 @@ +package migrations + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is omitted: the tests package imports db, which imports this package. +func TestMigrations(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Migrations Suite") +}