Merge branch 'master' into feat/missing-file-remap-cli

This commit is contained in:
Deluan Quintão 2026-08-17 20:21:36 -04:00 committed by GitHub
commit ef742c5f60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 99 additions and 4 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() {

View File

@ -2,6 +2,7 @@ package persistence
import (
"database/sql"
"slices"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -224,10 +225,14 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
return r.addMediaFileIds(clauses)
}
// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks.
const deleteChunkSize = 200
func (r *playlistTrackRepository) Delete(ids ...string) error {
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
if err != nil {
return err
for chunk := range slices.Chunk(ids, deleteChunkSize) {
if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil {
return err
}
}
return r.playlistRepo.renumber(r.playlistId)

View File

@ -1,6 +1,8 @@
package persistence
import (
"strconv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -8,6 +10,9 @@ import (
. "github.com/onsi/gomega"
)
// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver
const sqliteMaxVariables = 32766
var _ = Describe("PlaylistTrackRepository", func() {
var repo model.PlaylistTrackRepository
@ -72,4 +77,49 @@ var _ = Describe("PlaylistTrackRepository", func() {
To(Equal([]string{songRadioactivity.ID}))
})
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1
positionsUpTo := func(n int) []string {
positions := make([]string, 0, n)
for i := 1; i <= n; i++ {
positions = append(positions, strconv.Itoa(i))
}
return positions
}
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
songIds := make([]string, numTracks)
for i := range songIds {
songIds[i] = songDayInALife.ID
}
Expect(tracks.Add(songIds)).To(Equal(numTracks))
})
It("removes positions spanning several chunks, and renumbers what is left", func() {
Expect(tracks.Delete(positionsUpTo(numTracks - 1)...)).To(Succeed())
Expect(tracks.CountAll()).To(Equal(int64(1)))
remaining, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1")
})
It("accepts more ids than SQLite allows as bind variables", func() {
Expect(tracks.Delete(positionsUpTo(sqliteMaxVariables + 100)...)).To(Succeed())
Expect(tracks.CountAll()).To(BeZero())
})
})
})