navidrome/server/subsonic/helpers_test.go
Deluan Quintão 0fab1861a0
fix(subsonic): make "recently added" order reproducible and consistent with RecentlyAddedByModTime (#5678)
* fix(subsonic): align album `created` with RecentlyAddedByModTime sort

The album `created` attribute returned by search3, getAlbumList2 and the
other album endpoints was always sourced from the album's CreatedAt (oldest
song birth time), while the "recently added" sort is governed by
RecentlyAddedByModTime: it orders by album.updated_at when that option is
enabled and album.created_at otherwise.

As a result, when RecentlyAddedByModTime was enabled, clients that cache
album results and sort locally by `created` (e.g. for a "Date added" view)
could not reproduce the order returned by getAlbumList2?type=newest, since
the exposed value did not match the column driving the sort.

Make albumCreatedAt config-aware so the primary timestamp it returns mirrors
recentlyAddedSort: UpdatedAt when RecentlyAddedByModTime is set, CreatedAt
otherwise. The existing zero-value fallback chain is preserved so this
required OpenSubsonic field is never emitted as zero on legacy rows.

Note: this is a behavior change for the contractual `created` attribute. With
RecentlyAddedByModTime enabled, an album's reported `created` now reflects the
newest song modification time and can change when files are modified.

Scope is limited to albums; the song-level `created` (BirthTime) is unchanged.

* fix(subsonic): order Recently Added by full-precision timestamp with tiebreak

The recently_added sort wrapped the timestamp in datetime(), truncating it
to whole seconds, and had no secondary sort key. Album timestamps carry
sub-second precision (aggregated from song file birth-times), so on a fresh
scan many albums tie at the second; SQLite then returns ties in query-plan
order, which changes when a library filter is applied. This made the web UI
"Recently Added" order invert between library selections and diverge from
getAlbumList2?type=newest, and clients receiving the full-precision created
value could never reproduce the server order.

Sort on the raw, full-precision column with an album.id / media_file.id
tiebreak instead. A new migration swaps the album datetime() expression
indexes (and the plain media_file indexes) for composite (col, id) indexes
that cover the new sort. Timestamps were already normalized to space-format
by 20260316000000_normalize_timestamps, so raw-string comparison is safe.

* fix(subsonic): make song created follow RecentlyAddedByModTime

The song Created field returned BirthTime (file ctime), but the recently_added
sort (shared by the Subsonic and native APIs, and the web UI) orders by
created_at, or updated_at when RecentlyAddedByModTime is set. A Subsonic client,
which can only sort by the created value it receives, could therefore never
reproduce the server's "recently added" order in either mode.

Add mediaFileCreatedAt mirroring albumCreatedAt and use it for child.Created:
CreatedAt by default, UpdatedAt under RecentlyAddedByModTime, with BirthTime as
a legacy fallback. This aligns song created with the sort column, matching how
album created already works and what the native API/web UI present.
2026-06-29 16:18:29 -04:00

782 lines
25 KiB
Go

package subsonic
import (
"context"
"encoding/json"
"net/http/httptest"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("helpers", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
})
Describe("fakePath", func() {
var mf model.MediaFile
BeforeEach(func() {
mf.AlbumArtist = "Brock Berrigan"
mf.Album = "Point Pleasant"
mf.Title = "Split Decision"
mf.Suffix = "flac"
})
When("TrackNumber is not available", func() {
It("does not add any number to the filename", func() {
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/Split Decision.flac"))
})
})
When("TrackNumber is available", func() {
It("adds the trackNumber to the path", func() {
mf.TrackNumber = 4
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/04 - Split Decision.flac"))
})
})
When("TrackNumber and DiscNumber are available", func() {
It("adds the trackNumber to the path", func() {
mf.TrackNumber = 4
mf.DiscNumber = 1
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/01-04 - Split Decision.flac"))
})
})
})
Describe("sanitizeSlashes", func() {
It("maps / to _", func() {
Expect(sanitizeSlashes("AC/DC")).To(Equal("AC_DC"))
})
})
Describe("sortName", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("PreferSortTags is false", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = false
})
It("returns the order name even if sort name is provided", func() {
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Order Album Name"))
})
It("returns the order name if sort name is empty", func() {
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
})
})
When("PreferSortTags is true", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("returns the sort name if provided", func() {
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Sort Album Name"))
})
It("returns the order name if sort name is empty", func() {
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
})
})
It("returns an empty string if both sort name and order name are empty", func() {
Expect(sortName("", "")).To(Equal(""))
})
})
Describe("buildDiscTitles", func() {
It("should return nil when album has no discs", func() {
album := model.Album{}
Expect(buildDiscSubtitles(album)).To(BeNil())
})
It("should return nil when album has only one disc without title", func() {
album := model.Album{
Discs: map[int]string{
1: "",
},
}
Expect(buildDiscSubtitles(album)).To(BeNil())
})
It("should return the disc title with cover art for a single disc", func() {
updatedAt := time.Now().Truncate(time.Second)
album := model.Album{
ID: "album1",
UpdatedAt: updatedAt,
Discs: map[int]string{
1: "Special Edition",
},
}
result := buildDiscSubtitles(album)
Expect(result).To(HaveLen(1))
Expect(result[0].Disc).To(Equal(int32(1)))
Expect(result[0].Title).To(Equal("Special Edition"))
expectedArtID := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt)
Expect(result[0].CoverArt).To(Equal(expectedArtID.String()))
})
It("should return correct disc titles with cover art when album has multiple discs", func() {
updatedAt := time.Now().Truncate(time.Second)
album := model.Album{
ID: "album1",
UpdatedAt: updatedAt,
Discs: map[int]string{
1: "Disc 1",
2: "Disc 2",
},
}
result := buildDiscSubtitles(album)
Expect(result).To(HaveLen(2))
Expect(result[0].Disc).To(Equal(int32(1)))
Expect(result[0].Title).To(Equal("Disc 1"))
expectedArtID1 := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt)
Expect(result[0].CoverArt).To(Equal(expectedArtID1.String()))
Expect(result[1].Disc).To(Equal(int32(2)))
Expect(result[1].Title).To(Equal("Disc 2"))
expectedArtID2 := model.NewArtworkID(model.KindDiscArtwork, "album1:2", &updatedAt)
Expect(result[1].CoverArt).To(Equal(expectedArtID2.String()))
})
})
DescribeTable("toItemDate",
func(date string, expected responses.ItemDate) {
Expect(toItemDate(date)).To(Equal(expected))
},
Entry("1994-02-04", "1994-02-04", responses.ItemDate{Year: 1994, Month: 2, Day: 4}),
Entry("1994-02", "1994-02", responses.ItemDate{Year: 1994, Month: 2}),
Entry("1994", "1994", responses.ItemDate{Year: 1994}),
Entry("19940201", "", responses.ItemDate{}),
Entry("", "", responses.ItemDate{}),
)
DescribeTable("mapExplicitStatus",
func(explicitStatus string, expected string) {
Expect(mapExplicitStatus(explicitStatus)).To(Equal(expected))
},
Entry("returns \"clean\" when the db value is \"c\"", "c", "clean"),
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"),
Entry("returns an empty string when the db value is \"\"", "", ""),
Entry("returns an empty string when there are unexpected values on the db", "abc", ""))
Describe("getArtistAlbumCount", func() {
artist := model.Artist{
Stats: map[model.Role]model.ArtistStats{
model.RoleAlbumArtist: {
AlbumCount: 3,
},
model.RoleMainCredit: {
AlbumCount: 4,
},
},
}
It("Handles album count without artist participations", func() {
conf.Server.Subsonic.ArtistParticipations = false
result := getArtistAlbumCount(&artist)
Expect(result).To(Equal(int32(3)))
})
It("Handles album count without with participations", func() {
conf.Server.Subsonic.ArtistParticipations = true
result := getArtistAlbumCount(&artist)
Expect(result).To(Equal(int32(4)))
})
})
DescribeTable("isClientInList",
func(list, client string, expected bool) {
Expect(isClientInList(list, client)).To(Equal(expected))
},
Entry("returns false when clientList is empty", "", "some-client", false),
Entry("returns false when client is empty", "client1,client2", "", false),
Entry("returns false when both are empty", "", "", false),
Entry("returns true when client matches single entry", "my-client", "my-client", true),
Entry("returns true when client matches first in list", "client1,client2,client3", "client1", true),
Entry("returns true when client matches middle in list", "client1,client2,client3", "client2", true),
Entry("returns true when client matches last in list", "client1,client2,client3", "client3", true),
Entry("returns false when client does not match", "client1,client2", "client3", false),
Entry("trims whitespace from client list entries", "client1, client2 , client3", "client2", true),
Entry("does not trim the client parameter", "client1,client2", " client1", false),
)
Describe("childFromMediaFile", func() {
var mf model.MediaFile
var ctx context.Context
BeforeEach(func() {
mf = model.MediaFile{
ID: "mf-1",
Title: "Test Song",
Album: "Test Album",
AlbumID: "album-1",
Artist: "Test Artist",
ArtistID: "artist-1",
Year: 2023,
Genre: "Rock",
TrackNumber: 5,
Duration: 180.5,
Size: 5000000,
Suffix: "mp3",
BitRate: 320,
}
ctx = context.Background()
})
Context("with minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "minimal-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns only basic fields", func() {
child := childFromMediaFile(ctx, mf)
Expect(child.Id).To(Equal("mf-1"))
Expect(child.Title).To(Equal("Test Song"))
Expect(child.IsDir).To(BeFalse())
// These should not be set
Expect(child.Album).To(BeEmpty())
Expect(child.Artist).To(BeEmpty())
Expect(child.Parent).To(BeEmpty())
Expect(child.Year).To(BeZero())
Expect(child.Genre).To(BeEmpty())
Expect(child.Track).To(BeZero())
Expect(child.Duration).To(BeZero())
Expect(child.Size).To(BeZero())
Expect(child.Suffix).To(BeEmpty())
Expect(child.BitRate).To(BeZero())
Expect(child.CoverArt).To(BeEmpty())
Expect(child.ContentType).To(BeEmpty())
Expect(child.Path).To(BeEmpty())
})
It("does not include OpenSubsonic extension", func() {
child := childFromMediaFile(ctx, mf)
Expect(child.OpenSubsonicChild).To(BeNil())
})
})
Context("with non-minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "regular-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
child := childFromMediaFile(ctx, mf)
Expect(child.Id).To(Equal("mf-1"))
Expect(child.Title).To(Equal("Test Song"))
Expect(child.IsDir).To(BeFalse())
Expect(child.Album).To(Equal("Test Album"))
Expect(child.Artist).To(Equal("Test Artist"))
Expect(child.Parent).To(Equal("album-1"))
Expect(child.Year).To(Equal(int32(2023)))
Expect(child.Genre).To(Equal("Rock"))
Expect(child.Track).To(Equal(int32(5)))
Expect(child.Duration).To(Equal(int32(180)))
Expect(child.Size).To(Equal(int64(5000000)))
Expect(child.Suffix).To(Equal("mp3"))
Expect(child.BitRate).To(Equal(int32(320)))
})
})
Context("when minimal clients list is empty", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = ""
player := model.Player{Client: "any-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
child := childFromMediaFile(ctx, mf)
Expect(child.Album).To(Equal("Test Album"))
Expect(child.Artist).To(Equal("Test Artist"))
})
})
Context("when no player in context", func() {
It("returns all fields", func() {
child := childFromMediaFile(ctx, mf)
Expect(child.Album).To(Equal("Test Album"))
Expect(child.Artist).To(Equal("Test Artist"))
})
})
Context("when MediaFile has an empty title", func() {
It("still includes the title field in the response", func() {
mf.Title = ""
child := childFromMediaFile(ctx, mf)
Expect(child.Title).To(Equal(""))
})
})
})
Describe("osChildFromMediaFile", func() {
var mf model.MediaFile
var ctx context.Context
BeforeEach(func() {
mf = model.MediaFile{
ID: "mf-1",
Title: "Test Song",
Artist: "Test Artist",
Comment: "Test Comment",
}
ctx = context.Background()
})
Context("with legacy client", func() {
BeforeEach(func() {
conf.Server.Subsonic.LegacyClients = "legacy-client"
player := model.Player{Client: "legacy-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns nil", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).To(BeNil())
})
})
Context("with non-legacy client", func() {
BeforeEach(func() {
conf.Server.Subsonic.LegacyClients = "legacy-client"
player := model.Player{Client: "regular-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns OpenSubsonic child fields", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
Expect(osChild.Comment).To(Equal("Test Comment"))
})
It("populates works and movements from tags", func() {
mf.Tags = model.Tags{
model.TagWork: {"Symphony No. 5"},
model.TagMusicBrainzWorkID: {"abc-123"},
model.TagMovementName: {"I. Allegro"},
model.TagMovementNumber: {"1"},
model.TagMovementTotal: {"4"},
}
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
Expect(osChild.Works).To(Equal(responses.Array[responses.Work]{
{Name: "Symphony No. 5", MusicBrainzId: "abc-123"},
}))
Expect(osChild.Movements).To(Equal(responses.Array[responses.Movement]{
{Name: "I. Allegro", Number: 1, Count: 4},
}))
})
It("returns empty works and movements when no classical tags are present", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
Expect(osChild.Works).To(BeEmpty())
Expect(osChild.Movements).To(BeEmpty())
})
It("serializes works and movements to spec-compliant JSON", func() {
mf.Tags = model.Tags{
model.TagWork: {"Symphony No. 5"},
model.TagMovementName: {"I. Allegro"},
model.TagMovementNumber: {"1"},
}
osChild := osChildFromMediaFile(ctx, mf)
data, err := json.Marshal(osChild)
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(json.Unmarshal(data, &got)).To(Succeed())
// Required name present; optional musicBrainzId/count omitted (omitempty); number present.
Expect(got).To(HaveKeyWithValue("works", []any{
map[string]any{"name": "Symphony No. 5"},
}))
Expect(got).To(HaveKeyWithValue("movements", []any{
map[string]any{"name": "I. Allegro", "number": float64(1)},
}))
})
})
Context("when legacy clients list is empty", func() {
BeforeEach(func() {
conf.Server.Subsonic.LegacyClients = ""
player := model.Player{Client: "any-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns OpenSubsonic child fields", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
})
})
Context("when no player in context", func() {
It("returns OpenSubsonic child fields", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
})
})
})
Describe("selectedMusicFolderIds", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
Context("when musicFolderId parameter is provided", func() {
It("should return the specified musicFolderId values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 3}))
})
It("should ignore invalid musicFolderId parameter values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{2})) // Only valid ID is returned
})
It("should return error when any library ID is not accessible", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible"))
Expect(ids).To(BeNil())
})
})
Context("when musicFolderId parameter is not provided", func() {
Context("and required is false", func() {
It("should return all user's library IDs", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
It("should return empty slice when user has no libraries", func() {
userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}}
ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs)
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctxWithoutLibs)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{}))
})
})
Context("and required is true", func() {
It("should return ErrMissingParam error", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, true)
Expect(err).To(MatchError(req.ErrMissingParam))
Expect(ids).To(BeNil())
})
})
})
Context("when musicFolderId parameter is empty", func() {
It("should return all user's library IDs even when empty parameter is provided", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
})
Context("when all musicFolderId parameters are invalid", func() {
It("should return all user libraries when all musicFolderId parameters are invalid", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries
})
})
})
Describe("AverageRating in responses", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
conf.Server.Subsonic.EnableAverageRating = true
})
Describe("childFromMediaFile", func() {
It("includes averageRating when set", func() {
mf := model.MediaFile{
ID: "mf-avg-1",
Title: "Test Song",
Annotations: model.Annotations{
AverageRating: 4.5,
},
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(4.5))
})
It("returns 0 for averageRating when not set", func() {
mf := model.MediaFile{
ID: "mf-avg-2",
Title: "Test Song No Rating",
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(0.0))
})
})
Describe("childFromAlbum", func() {
It("includes averageRating when set", func() {
al := model.Album{
ID: "al-avg-1",
Name: "Test Album",
Annotations: model.Annotations{
AverageRating: 3.75,
},
}
child := childFromAlbum(ctx, al)
Expect(child.AverageRating).To(Equal(3.75))
})
It("returns 0 for averageRating when not set", func() {
al := model.Album{
ID: "al-avg-2",
Name: "Test Album No Rating",
}
child := childFromAlbum(ctx, al)
Expect(child.AverageRating).To(Equal(0.0))
})
})
Describe("toArtist", func() {
It("includes averageRating when set", func() {
conf.Server.Subsonic.EnableAverageRating = true
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-avg-1",
Name: "Test Artist",
Annotations: model.Annotations{
AverageRating: 5.0,
},
}
artist := toArtist(r, a)
Expect(artist.AverageRating).To(Equal(5.0))
})
})
Describe("toArtistID3", func() {
It("includes averageRating when set", func() {
conf.Server.Subsonic.EnableAverageRating = true
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-avg-2",
Name: "Test Artist ID3",
Annotations: model.Annotations{
AverageRating: 2.5,
},
}
artist := toArtistID3(r, a)
Expect(artist.AverageRating).To(Equal(2.5))
})
})
Describe("buildAlbumID3 Created field", func() {
When("RecentlyAddedByModTime is false", func() {
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = false
})
It("uses CreatedAt when set", func() {
t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
al := model.Album{ID: "a1", Name: "A", CreatedAt: t}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(t))
})
It("falls back to UpdatedAt when CreatedAt is zero", func() {
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(updated))
})
It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() {
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
al := model.Album{ID: "a3", Name: "A", ImportedAt: imported}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(imported))
})
It("leaves Created as zero time when all timestamps are zero", func() {
al := model.Album{ID: "a4", Name: "A"}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created.IsZero()).To(BeTrue())
})
})
When("RecentlyAddedByModTime is true", func() {
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = true
})
It("uses UpdatedAt even when CreatedAt is also set", func() {
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC)
al := model.Album{ID: "a5", Name: "A", CreatedAt: created, UpdatedAt: updated}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(updated))
})
It("falls back to CreatedAt when UpdatedAt is zero", func() {
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
al := model.Album{ID: "a6", Name: "A", CreatedAt: created}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(created))
})
It("falls back to ImportedAt when UpdatedAt and CreatedAt are zero", func() {
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
al := model.Album{ID: "a7", Name: "A", ImportedAt: imported}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).To(Equal(imported))
})
It("leaves Created as zero time when all timestamps are zero", func() {
al := model.Album{ID: "a8", Name: "A"}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created.IsZero()).To(BeTrue())
})
})
})
Describe("childFromMediaFile Created field", func() {
birth := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC)
When("RecentlyAddedByModTime is false", func() {
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = false
})
It("uses CreatedAt, not BirthTime", func() {
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
mf := model.MediaFile{ID: "s1", BirthTime: birth, CreatedAt: created}
child := childFromMediaFile(ctx, mf)
Expect(*child.Created).To(Equal(created))
})
It("falls back to UpdatedAt when CreatedAt is zero", func() {
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
mf := model.MediaFile{ID: "s2", BirthTime: birth, UpdatedAt: updated}
child := childFromMediaFile(ctx, mf)
Expect(*child.Created).To(Equal(updated))
})
It("falls back to BirthTime when CreatedAt and UpdatedAt are zero", func() {
mf := model.MediaFile{ID: "s3", BirthTime: birth}
child := childFromMediaFile(ctx, mf)
Expect(*child.Created).To(Equal(birth))
})
})
When("RecentlyAddedByModTime is true", func() {
BeforeEach(func() {
conf.Server.RecentlyAddedByModTime = true
})
It("uses UpdatedAt even when CreatedAt is also set", func() {
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC)
mf := model.MediaFile{ID: "s4", BirthTime: birth, CreatedAt: created, UpdatedAt: updated}
child := childFromMediaFile(ctx, mf)
Expect(*child.Created).To(Equal(updated))
})
It("falls back to CreatedAt when UpdatedAt is zero", func() {
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
mf := model.MediaFile{ID: "s5", BirthTime: birth, CreatedAt: created}
child := childFromMediaFile(ctx, mf)
Expect(*child.Created).To(Equal(created))
})
})
})
Describe("EnableAverageRating config", func() {
It("excludes averageRating when disabled", func() {
conf.Server.Subsonic.EnableAverageRating = false
mf := model.MediaFile{
ID: "mf-cfg-1",
Title: "Test Song",
Annotations: model.Annotations{
AverageRating: 4.5,
},
}
child := childFromMediaFile(ctx, mf)
Expect(child.AverageRating).To(Equal(0.0))
al := model.Album{
ID: "al-cfg-1",
Name: "Test Album",
Annotations: model.Annotations{
AverageRating: 3.75,
},
}
albumChild := childFromAlbum(ctx, al)
Expect(albumChild.AverageRating).To(Equal(0.0))
r := httptest.NewRequest("GET", "/test", nil)
a := model.Artist{
ID: "ar-cfg-1",
Name: "Test Artist",
Annotations: model.Annotations{
AverageRating: 5.0,
},
}
artist := toArtist(r, a)
Expect(artist.AverageRating).To(Equal(0.0))
artistID3 := toArtistID3(r, a)
Expect(artistID3.AverageRating).To(Equal(0.0))
})
})
})
})