fix(subsonic): always emit required created field on AlbumID3 (#5340)

* fix(subsonic): always emit required `created` field on AlbumID3

Strict OpenSubsonic clients (e.g. Navic via dev.zt64.subsonic) reject
search3/getAlbum/getAlbumList2 responses that omit the `created` field,
which the spec marks as required. Navidrome was dropping it whenever
the album's CreatedAt was zero.

Root cause was threefold:

1. buildAlbumID3/childFromAlbum conditionally emitted `created`, so a
   zero CreatedAt became a missing JSON key.
2. ToAlbum's `older()` helper treated a zero BirthTime as the minimum,
   so a single track with missing filesystem birth time could poison
   the album aggregation.
3. phase_1_folders' CopyAttributes copied `created_at` from the previous
   album row unconditionally, propagating an already-zero value forward
   on every metadata-driven album ID change. Since sql_base_repository
   drops `created_at` on UPDATE, a poisoned row could never self-heal.

Fixes:
- Always emit `created`, falling back to UpdatedAt/ImportedAt when
  CreatedAt is zero. Adds albumCreatedAt() helper used by both
  buildAlbumID3 and childFromAlbum.
- Guard `older()` against a zero second argument.
- Skip the CopyAttributes call in phase_1_folders when the previous
  album's created_at is zero, so the freshly-computed value survives.
- New migration backfills existing broken rows from media_file.birth_time
  (falling back to updated_at).

Tested against a real DB: repaired 605/6922 affected rows, no side
effects on healthy rows.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): return albumCreatedAt by value to avoid heap escape

Returning *time.Time from albumCreatedAt caused Go escape analysis to
move the entire model.Album parameter to the heap, since the returned
pointer aliased a field of the value receiver. For hot endpoints like
getAlbumList2 and search3, this meant one full-struct heap allocation
per album result.

Return time.Time by value and let callers wrap it with gg.P() to take
the address locally. Only the small time.Time value escapes; the
model.Album struct stays on the stack. Also corrects the doc comment
to reflect the actual guarantee ("best-effort" rather than "non-zero"),
matching the test case that exercises the all-zero fallback.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-04-10 19:29:20 -04:00 committed by GitHub
parent 4570dec675
commit 9b0bfc606b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 126 additions and 5 deletions

View file

@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
}
to := make(map[string]any)
for _, col := range columns {
to[col] = from[col]
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
}
to[col] = v
}
if len(to) == 0 {
return nil
}
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
return err

View file

@ -41,6 +41,32 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("CopyAttributes", func() {
var srcTime, dstTime time.Time
BeforeEach(func() {
srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC)
Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed())
Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed())
Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}}))
})
})
It("copies a valid created_at from source to destination", func() {
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
got, err := albumRepo.Get("copy-dst")
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second))
})
It("leaves destination untouched when source created_at is zero", func() {
Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed())
got, err := albumRepo.Get("copy-dst")
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
})
Describe("GetAll", func() {
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
albums, err := albumRepo.GetAll(opts...)