feat(db): add id canonicalization transform for the uniform-ids migration

This commit is contained in:
Deluan 2026-07-19 21:48:42 -04:00
parent 8337b5a0b9
commit e8183fdfe0
3 changed files with 87 additions and 0 deletions

View File

@ -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
}

View File

@ -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))
}
})
})

View File

@ -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")
}