mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 38e088dda3244d37191bc9febf594cbe3cf0c8b9 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e
This commit is contained in:
commit
5980f80d42
@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Repairs album.created_at values stored in RFC3339 T-format ("2026-07-24T17:05:39.706028243Z").
|
||||
-- 20260316000000_normalize_timestamps already did a one-off pass over every timestamp column, but
|
||||
-- albumRepository.CopyAttributes kept re-introducing the T-format on this one column: it read the
|
||||
-- value into a Go string (go-sqlite3 decodes `datetime` columns as time.Time, and database/sql then
|
||||
-- formats that as RFC3339Nano) and wrote it straight back. It runs whenever an album ID changes
|
||||
-- because its tags were edited, which is routine for beets/Picard users.
|
||||
--
|
||||
-- Since "Recently Added" compares these timestamps as raw strings (see
|
||||
-- 20260629123100_recently_added_plain_indexes) and 'T' (ASCII 84) sorts above ' ' (ASCII 32),
|
||||
-- every affected album stays pinned to the top of the list.
|
||||
|
||||
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00')
|
||||
WHERE created_at LIKE '%T%';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
SELECT 1;
|
||||
@ -278,26 +278,32 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
|
||||
}
|
||||
|
||||
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
var from dbx.NullStringMap
|
||||
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
if len(columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
var from struct{ ID string }
|
||||
err := r.queryOne(Select("id").From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting album to copy fields from: %w", err)
|
||||
}
|
||||
to := make(map[string]any)
|
||||
// The values are copied entirely in SQL, never round-tripped through Go: go-sqlite3 decodes
|
||||
// `datetime` columns into time.Time, and scanning that into a string reformats it as RFC3339
|
||||
// ("2026-07-24T17:05:39.706028243Z"). Writing it back would store a T-separated timestamp,
|
||||
// which string-sorts above the space-separated format the driver writes, pinning the album to
|
||||
// the top of "Recently Added" forever.
|
||||
upd := Update(r.tableName).Where(Eq{"id": toID})
|
||||
for _, col := range columns {
|
||||
v := from[col]
|
||||
// created_at is aggregated from song birth_times and must never be
|
||||
// overwritten with a zero/poisoned value, or it propagates forward on
|
||||
// every metadata-driven album ID change.
|
||||
if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
|
||||
continue
|
||||
src := fmt.Sprintf("(select %[1]s from %[2]s where id = ?)", col, r.tableName)
|
||||
if col == "created_at" {
|
||||
// created_at is aggregated from song birth_times and must never be
|
||||
// overwritten with a zero/poisoned value, or it propagates forward on
|
||||
// every metadata-driven album ID change.
|
||||
src = fmt.Sprintf("coalesce(nullif((select %[1]s from %[2]s where id = ? and %[1]s not like '0001-%%'), ''), %[1]s)",
|
||||
col, r.tableName)
|
||||
}
|
||||
to[col] = v
|
||||
upd = upd.Set(col, Expr(src, fromID))
|
||||
}
|
||||
if len(to) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
|
||||
_, err = r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,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
|
||||
|
||||
@ -66,6 +76,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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user