diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 8e7662b8f..6a940a320 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -131,6 +131,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"recently_played": recentlyPlayedFilter,
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
+ "played": annotationBoolFilter("play_count"),
"missing": booleanFilter,
"genre_id": genreFilter(AlbumGenres),
"role_total_id": allRolesFilter,
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 7652e703d..0235805c0 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -417,6 +417,80 @@ var _ = Describe("AlbumRepository", func() {
}
})
})
+
+ Describe("played", func() {
+ var playedAlbum model.Album
+
+ BeforeEach(func() {
+ playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1}
+ Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed())
+ Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID}))
+ _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID}))
+ })
+
+ It("false includes items without annotations", func() {
+ res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
+ Filters: map[string]any{"played": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
+ Filters: map[string]any{"played": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+
+ It("true includes items with play count", func() {
+ res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
+ Filters: map[string]any{"played": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == playedAlbum.ID {
+ found = true
+ Expect(a.PlayCount).To(BeNumerically(">", 0))
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter")
+ })
+
+ It("false excludes items with play count", func() {
+ res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
+ Filters: map[string]any{"played": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(playedAlbum.ID))
+ }
+ })
+ })
})
Describe("Album.PlayCount", func() {
diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go
index ec28c610a..6761331d9 100644
--- a/persistence/sql_annotations_test.go
+++ b/persistence/sql_annotations_test.go
@@ -91,6 +91,8 @@ var _ = Describe("Annotation Filters", func() {
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
+ Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)),
+ Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)),
)
It("returns nil if value is not a string", func() {
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index 3cb8a648e..81e4dcd14 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -163,6 +163,7 @@ const AlbumFilter = (props) => {
/>
+
{config.enableFavourites && (