feat(ui): implement RecentlyAddedByModTime support for tracks (#4046) (#4279)

* fix: implement RecentlyAddedByModTime support for mediafiles

Fixes #4046 by adding recently_added sort mapping to MediaFileRepository that respects the RecentlyAddedByModTime configuration setting. Previously, this feature only worked for albums, causing inconsistent behavior when clients requested tracks sorted by 'recently added'.

Changes include:
- Add mediaFileRecentlyAddedSort() function that returns 'updated_at' when RecentlyAddedByModTime=true, 'created_at' otherwise
- Add 'recently_added' sort mapping to mediafile repository
- Add comprehensive tests to verify both configuration scenarios

This ensures consistent sorting behavior between albums and tracks when using the RecentlyAddedByModTime feature.

* fix: update createdAt field to sort by recently added

Modified the createdAt field in the SongList component to include a sortBy
attribute set to "recently_added". This change ensures that the media files
are displayed in the order they were added, improving the user experience
when browsing through recently added items.

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

* better testing

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-30 09:14:35 -04:00
committed by GitHub
parent 91e7f7b5c9
commit e3aec6d2a9
3 changed files with 174 additions and 8 deletions

View File

@@ -9,6 +9,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@@ -74,13 +75,14 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
r.tableName = "media_file"
r.registerModel(&model.MediaFile{}, mediaFileFilter())
r.setSortMappings(map[string]string{
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"random": "random",
"created_at": "media_file.created_at",
"starred_at": "starred, starred_at",
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"random": "random",
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at",
})
return r
}
@@ -103,6 +105,13 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
return filters
})
func mediaFileRecentlyAddedSort() string {
if conf.Server.RecentlyAddedByModTime {
return "media_file.updated_at"
}
return "media_file.created_at"
}
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := r.newSelect()
query = r.withAnnotation(query, "media_file.id")

View File

@@ -5,12 +5,15 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("MediaRepository", func() {
@@ -155,4 +158,156 @@ var _ = Describe("MediaRepository", func() {
Expect(mf.PlayCount).To(Equal(int64(1)))
})
})
Context("Sort options", func() {
Context("recently_added sort", func() {
var testMediaFiles []model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Create test media files with specific timestamps
testMediaFiles = []model.MediaFile{
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "Old Song",
Path: "/test/old.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "Middle Song",
Path: "/test/middle.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "New Song",
Path: "/test/new.mp3",
},
}
// Insert test data first
for i := range testMediaFiles {
Expect(mr.Put(&testMediaFiles[i])).To(Succeed())
}
// Then manually update timestamps using direct SQL to bypass the repository logic
db := GetDBXBuilder()
// Set specific timestamps for testing
oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
// Update "Old Song": created long ago, updated recently
_, err := db.Update("media_file",
map[string]interface{}{
"created_at": oldTime,
"updated_at": newTime,
},
dbx.HashExp{"id": testMediaFiles[0].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
// Update "Middle Song": created and updated at the same middle time
_, err = db.Update("media_file",
map[string]interface{}{
"created_at": middleTime,
"updated_at": middleTime,
},
dbx.HashExp{"id": testMediaFiles[1].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
// Update "New Song": created recently, updated long ago
_, err = db.Update("media_file",
map[string]interface{}{
"created_at": newTime,
"updated_at": oldTime,
},
dbx.HashExp{"id": testMediaFiles[2].ID}).Execute()
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
// Clean up test data
for _, mf := range testMediaFiles {
_ = mr.Delete(mf.ID)
}
})
When("RecentlyAddedByModTime is false", func() {
var testRepo model.MediaFileRepository
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = false
// Create repository AFTER setting config
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
})
It("sorts by created_at", func() {
// Get results sorted by recently_added (should use created_at)
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "desc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by created_at (newest first in descending order)
Expect(results[0].Title).To(Equal("New Song")) // created 2022
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
Expect(results[2].Title).To(Equal("Old Song")) // created 2020
})
It("sorts in ascending order when specified", func() {
// Get results sorted by recently_added in ascending order
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "asc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by created_at (oldest first)
Expect(results[0].Title).To(Equal("Old Song")) // created 2020
Expect(results[1].Title).To(Equal("Middle Song")) // created 2021
Expect(results[2].Title).To(Equal("New Song")) // created 2022
})
})
When("RecentlyAddedByModTime is true", func() {
var testRepo model.MediaFileRepository
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = true
// Create repository AFTER setting config
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
testRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
})
It("sorts by updated_at", func() {
// Get results sorted by recently_added (should use updated_at)
results, err := testRepo.GetAll(model.QueryOptions{
Sort: "recently_added",
Order: "desc",
Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}},
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
// Verify sorting by updated_at (newest first in descending order)
Expect(results[0].Title).To(Equal("Old Song")) // updated 2022
Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021
Expect(results[2].Title).To(Equal("New Song")) // updated 2020
})
})
})
})
})

View File

@@ -182,7 +182,9 @@ const SongList = (props) => {
),
comment: <TextField source="comment" />,
path: <PathField source="path" />,
createdAt: <DateField source="createdAt" showTime />,
createdAt: (
<DateField source="createdAt" sortBy="recently_added" showTime />
),
}
}, [isDesktop, classes.ratingField])