diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index eee6444c1..d12dd71ba 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -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") diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 9dbb8080f..b364ca2e8 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -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 + }) + }) + + }) + }) }) diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 2a2807964..f067e11d2 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -182,7 +182,9 @@ const SongList = (props) => { ), comment: , path: , - createdAt: , + createdAt: ( + + ), } }, [isDesktop, classes.ratingField])