fix(db): keep album created_at in the driver's timestamp format when copying (#5867)

* fix(db): keep album created_at in the driver's timestamp format when
copying

Signed-off-by: IgorPolyakov <igorpolyakov@protonmail.com>

* fix(db): move created_at renormalize migration after merged migrations

The migration was versioned 20260813140000, which is older than
20260815015320 (already merged). goose.UpContext runs without
WithAllowMissing, so any database that already applied the newer
migration would fail with "found 1 missing migrations" and db.Init
would log.Fatal on startup.

---------

Signed-off-by: IgorPolyakov <igorpolyakov@protonmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
hotorcelexo 2026-08-16 21:14:41 +03:00 committed by GitHub
parent 5b758fc20c
commit ea1e2b95a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 1 deletions

View File

@ -0,0 +1,11 @@
-- +goose Up
-- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes.
-- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings.
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00')
WHERE created_at LIKE '%T%';
-- +goose Down
SELECT 1;

View File

@ -329,8 +329,11 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
// Cast values to text so go-sqlite3 does not decode datetime columns as time.Time
// and reformat them as RFC3339 when written back.
sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) })
var from dbx.NullStringMap
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from)
if err != nil {
return fmt.Errorf("getting album to copy fields from: %w", err)
}

View File

@ -19,6 +19,16 @@ import (
. "github.com/onsi/gomega"
)
// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of
// `datetime` columns into time.Time.
func rawColumn(r sqlRepository, id, column string) string {
var res struct{ Value string }
sel := squirrel.Select("cast(" + column + " as text) as value").
From(r.tableName).Where(squirrel.Eq{"id": id})
ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed())
return res.Value
}
var _ = Describe("AlbumRepository", func() {
var albumRepo *albumRepository
var ctx context.Context
@ -69,6 +79,22 @@ var _ = Describe("AlbumRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
It("returns not found and leaves destination untouched when source does not exist", func() {
err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at")
Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue())
got, getErr := albumRepo.Get("copy-dst")
Expect(getErr).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
It("keeps the copied created_at in the driver's space-separated format", func() {
// Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"),
// which string-sorts above every space-format timestamp and pins the album to the
// top of "Recently Added".
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).
To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at")))
Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T"))
})
})
Describe("GetCursor", func() {