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

558 lines
17 KiB
Go

package subsonic
import (
"cmp"
"context"
"errors"
"fmt"
"mime"
"net/http"
"slices"
"sort"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func newResponse() *responses.Subsonic {
return &responses.Subsonic{
Status: responses.StatusOK,
Version: Version,
Type: consts.AppName,
ServerVersion: consts.Version,
OpenSubsonic: true,
}
}
type subError struct {
code int32
messages []any
}
func newError(code int32, message ...any) error {
return subError{
code: code,
messages: message,
}
}
// errSubsonic and Unwrap are used to allow `errors.Is(err, errSubsonic)` to work
var errSubsonic = errors.New("subsonic API error")
func (e subError) Unwrap() error {
return fmt.Errorf("%w: %d", errSubsonic, e.code)
}
func (e subError) Error() string {
var msg string
if len(e.messages) == 0 {
msg = responses.ErrorMsg(e.code)
} else {
msg = fmt.Sprintf(e.messages[0].(string), e.messages[1:]...)
}
return msg
}
func getUser(ctx context.Context) model.User {
user, ok := request.UserFrom(ctx)
if ok {
return user
}
return model.User{}
}
func sortName(sortName, orderName string) string {
if conf.Server.PreferSortTags {
return cmp.Or(
sortName,
orderName,
)
}
return orderName
}
func getArtistAlbumCount(a *model.Artist) int32 {
// If ArtistParticipations are set, then `getArtist` will return albums
// where the artist is an album artist OR artist. Use the custom stat
// main credit for this calculation.
// Otherwise, return just the roles as album artist (precise)
if conf.Server.Subsonic.ArtistParticipations {
mainCreditStats := a.Stats[model.RoleMainCredit]
return int32(mainCreditStats.AlbumCount)
} else {
albumStats := a.Stats[model.RoleAlbumArtist]
return int32(albumStats.AlbumCount)
}
}
func toArtist(r *http.Request, a model.Artist) responses.Artist {
artist := responses.Artist{
Id: a.ID,
Name: a.Name,
UserRating: int32(a.Rating),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
return artist
}
func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
artist := responses.ArtistID3{
Id: a.ID,
Name: a.Name,
AlbumCount: getArtistAlbumCount(&a),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
artist.OpenSubsonicArtistID3 = toOSArtistID3(r.Context(), a)
return artist
}
func toOSArtistID3(ctx context.Context, a model.Artist) *responses.OpenSubsonicArtistID3 {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
artist := responses.OpenSubsonicArtistID3{
MusicBrainzId: a.MbzArtistID,
SortName: sortName(a.SortArtistName, a.OrderArtistName),
}
artist.Roles = slice.Map(a.Roles(), func(r model.Role) string { return r.String() })
return &artist
}
func toGenres(genres model.Genres) *responses.Genres {
response := make([]responses.Genre, len(genres))
for i, g := range genres {
response[i] = responses.Genre{
Name: g.Name,
SongCount: int32(g.SongCount),
AlbumCount: int32(g.AlbumCount),
}
}
return &responses.Genres{Genre: response}
}
func toItemGenres(genres model.Genres) []responses.ItemGenre {
itemGenres := make([]responses.ItemGenre, len(genres))
for i, g := range genres {
itemGenres[i] = responses.ItemGenre{Name: g.Name}
}
return itemGenres
}
func getTranscoding(ctx context.Context) (format string, bitRate int) {
if trc, ok := request.TranscodingFrom(ctx); ok {
format = trc.TargetFormat
}
if plr, ok := request.PlayerFrom(ctx); ok {
bitRate = plr.MaxBitRate
}
return
}
func isClientInList(clientList, client string) bool {
if clientList == "" || client == "" {
return false
}
clients := strings.SplitSeq(clientList, ",")
for c := range clients {
if strings.TrimSpace(c) == client {
return true
}
}
return false
}
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
child := responses.Child{}
child.Id = mf.ID
child.Title = mf.FullTitle()
child.IsDir = false
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
return child
}
child.Parent = mf.AlbumID
child.Album = mf.FullAlbumName()
child.Year = int32(mf.Year)
child.Artist = mf.Artist
child.Genre = mf.Genre
child.Track = int32(mf.TrackNumber)
child.Duration = int32(mf.Duration)
child.Size = mf.Size
child.Suffix = mf.Suffix
child.BitRate = int32(mf.BitRate)
child.CoverArt = mf.CoverArtID().String()
child.ContentType = mf.ContentType()
if ok && player.ReportRealPath {
child.Path = mf.AbsolutePath()
} else {
child.Path = fakePath(mf)
}
child.DiscNumber = int32(mf.DiscNumber)
child.Created = new(mediaFileCreatedAt(mf))
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
child.PlayCount = mf.PlayCount
if mf.Starred {
child.Starred = mf.StarredAt
}
child.UserRating = int32(mf.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = mf.AverageRating
}
format, _ := getTranscoding(ctx)
if mf.Suffix != "" && format != "" && mf.Suffix != format {
child.TranscodedSuffix = format
child.TranscodedContentType = mime.TypeByExtension("." + format)
}
child.BookmarkPosition = mf.BookmarkPosition
child.OpenSubsonicChild = osChildFromMediaFile(ctx, mf)
return child
}
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
child := responses.OpenSubsonicChild{}
if mf.PlayCount > 0 {
child.Played = mf.PlayDate
}
child.Comment = mf.Comment
child.SortName = sortName(mf.SortTitle, mf.OrderTitle)
child.BPM = int32(gg.V(mf.BPM))
child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID
child.Isrc = mf.Tags.Values(model.TagISRC)
child.ReplayGain = responses.ReplayGain{
TrackGain: mf.RGTrackGain,
AlbumGain: mf.RGAlbumGain,
TrackPeak: mf.RGTrackPeak,
AlbumPeak: mf.RGAlbumPeak,
}
child.ChannelCount = int32(mf.Channels)
child.SamplingRate = int32(mf.SampleRate)
child.BitDepth = int32(gg.V(mf.BitDepth))
child.Genres = toItemGenres(mf.Genres)
child.Moods = mf.Tags.Values(model.TagMood)
child.Groupings = mf.Tags.Values(model.TagGrouping)
child.DisplayArtist = mf.Artist
child.Artists = artistRefs(mf.Participants[model.RoleArtist])
child.DisplayAlbumArtist = mf.AlbumArtist
child.AlbumArtists = artistRefs(mf.Participants[model.RoleAlbumArtist])
var contributors []responses.Contributor
child.DisplayComposer = mf.Participants[model.RoleComposer].Join(consts.ArtistJoiner)
for role, participants := range mf.Participants {
if role == model.RoleArtist || role == model.RoleAlbumArtist {
continue
}
for _, participant := range participants {
contributors = append(contributors, responses.Contributor{
Role: role.String(),
SubRole: participant.SubRole,
Artist: responses.ArtistID3Ref{
Id: participant.ID,
Name: participant.Name,
},
})
}
}
child.Contributors = contributors
child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus)
child.Works = slice.Map(mf.Works(), func(w model.Work) responses.Work {
return responses.Work{Name: w.Name, MusicBrainzId: w.MbzWorkID}
})
child.Movements = slice.Map(mf.Movements(), func(m model.Movement) responses.Movement {
return responses.Movement{Name: m.Name, Number: m.Number, Count: m.Count}
})
return &child
}
func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref {
return slice.Map(participants, func(p model.Participant) responses.ArtistID3Ref {
return responses.ArtistID3Ref{
Id: p.ID,
Name: p.Name,
}
})
}
func fakePath(mf model.MediaFile) string {
builder := strings.Builder{}
builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.FullAlbumName())))
if mf.DiscNumber != 0 {
builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber))
}
if mf.TrackNumber != 0 {
builder.WriteString(fmt.Sprintf("%02d - ", mf.TrackNumber))
}
builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.FullTitle()), mf.Suffix))
return builder.String()
}
func sanitizeSlashes(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
// albumCreatedAt mirrors the column used by recentlyAddedSort so clients can
// reproduce the "recently added" order locally: UpdatedAt when
// RecentlyAddedByModTime is set, CreatedAt otherwise. The other timestamps are
// fallbacks for legacy rows; returns zero only when all three are unset.
func albumCreatedAt(al model.Album) time.Time {
candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt}
if conf.Server.RecentlyAddedByModTime {
candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt}
}
for _, t := range candidates {
if !t.IsZero() {
return t
}
}
return time.Time{}
}
// mediaFileCreatedAt is the song counterpart of albumCreatedAt, tracking
// mediaFileRecentlyAddedSort; BirthTime is the legacy fallback.
func mediaFileCreatedAt(mf model.MediaFile) time.Time {
candidates := []time.Time{mf.CreatedAt, mf.UpdatedAt, mf.BirthTime}
if conf.Server.RecentlyAddedByModTime {
candidates = []time.Time{mf.UpdatedAt, mf.CreatedAt, mf.BirthTime}
}
for _, t := range candidates {
if !t.IsZero() {
return t
}
}
return time.Time{}
}
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child := responses.Child{}
child.Id = al.ID
child.IsDir = true
fullName := al.FullName()
child.Title = fullName
child.Name = fullName
child.Album = fullName
child.Artist = al.AlbumArtist
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
child.Created = new(albumCreatedAt(al))
child.Parent = al.AlbumArtistID
child.ArtistId = al.AlbumArtistID
child.Duration = int32(al.Duration)
child.SongCount = int32(al.SongCount)
if al.Starred {
child.Starred = al.StarredAt
}
child.PlayCount = al.PlayCount
child.UserRating = int32(al.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = al.AverageRating
}
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
return child
}
func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
child := responses.OpenSubsonicChild{}
if al.PlayCount > 0 {
child.Played = al.PlayDate
}
child.MediaType = responses.MediaTypeAlbum
child.MusicBrainzId = al.MbzAlbumID
child.Genres = toItemGenres(al.Genres)
child.Moods = al.Tags.Values(model.TagMood)
child.Groupings = al.Tags.Values(model.TagGrouping)
child.DisplayArtist = al.AlbumArtist
child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist])
child.DisplayAlbumArtist = al.AlbumArtist
child.AlbumArtists = artistRefs(al.Participants[model.RoleAlbumArtist])
child.ExplicitStatus = mapExplicitStatus(al.ExplicitStatus)
child.SortName = sortName(al.SortAlbumName, al.OrderAlbumName)
return &child
}
// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate
func toItemDate(date string) responses.ItemDate {
itemDate := responses.ItemDate{}
if date == "" {
return itemDate
}
parts := strings.Split(date, "-")
if len(parts) > 2 {
itemDate.Day = number.ParseInt[int32](parts[2])
}
if len(parts) > 1 {
itemDate.Month = number.ParseInt[int32](parts[1])
}
itemDate.Year = number.ParseInt[int32](parts[0])
return itemDate
}
func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
if len(a.Discs) == 0 {
return nil
}
var discTitles []responses.DiscTitle
// Hoist UpdatedAt to a single stack-local so &updatedAt doesn't force the
// whole model.Album parameter onto the heap.
updatedAt := a.UpdatedAt
for num, title := range a.Discs {
artID := model.NewArtworkID(model.KindDiscArtwork,
model.DiscArtworkID(a.ID, num), &updatedAt)
discTitles = append(discTitles, responses.DiscTitle{
Disc: int32(num),
Title: title,
CoverArt: artID.String(),
})
}
if len(discTitles) == 1 && discTitles[0].Title == "" {
return nil
}
sort.Slice(discTitles, func(i, j int) bool {
return discTitles[i].Disc < discTitles[j].Disc
})
return discTitles
}
func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir := responses.AlbumID3{}
dir.Id = album.ID
dir.Name = album.FullName()
dir.Artist = album.AlbumArtist
dir.ArtistId = album.AlbumArtistID
dir.CoverArt = album.CoverArtID().String()
dir.SongCount = int32(album.SongCount)
dir.Duration = int32(album.Duration)
dir.PlayCount = album.PlayCount
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
dir.Created = albumCreatedAt(album)
if album.Starred {
dir.Starred = album.StarredAt
}
dir.OpenSubsonicAlbumID3 = buildOSAlbumID3(ctx, album)
return dir
}
func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubsonicAlbumID3 {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
dir := responses.OpenSubsonicAlbumID3{}
if album.PlayCount > 0 {
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
if conf.Server.Subsonic.EnableAverageRating {
dir.AverageRating = album.AverageRating
}
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
return responses.RecordLabel{Name: s}
})
dir.MusicBrainzId = album.MbzAlbumID
dir.Genres = toItemGenres(album.Genres)
dir.Artists = artistRefs(album.Participants[model.RoleAlbumArtist])
dir.DisplayArtist = album.AlbumArtist
dir.ReleaseTypes = album.Tags.Values(model.TagReleaseType)
dir.Moods = album.Tags.Values(model.TagMood)
dir.SortName = sortName(album.SortAlbumName, album.OrderAlbumName)
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
dir.ReleaseDate = toItemDate(album.ReleaseDate)
dir.IsCompilation = album.Compilation
dir.DiscTitles = buildDiscSubtitles(album)
dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus)
if len(album.Tags.Values(model.TagAlbumVersion)) > 0 {
dir.Version = album.Tags.Values(model.TagAlbumVersion)[0]
}
return &dir
}
func mapExplicitStatus(explicitStatus string) string {
switch explicitStatus {
case "c":
return "clean"
case "e":
return "explicit"
}
return ""
}
// getUserAccessibleLibraries returns the list of libraries the current user has access to.
func getUserAccessibleLibraries(ctx context.Context) []model.Library {
user := getUser(ctx)
return user.Libraries
}
// selectedMusicFolderIds retrieves the music folder IDs from the request parameters.
// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context).
// If the parameter is required and not present, it returns an error.
// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound.
func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) {
p := req.Params(r)
musicFolderIds, err := p.Ints("musicFolderId")
// If the parameter is not present, it returns an error if it is required.
if errors.Is(err, req.ErrMissingParam) && required {
return nil, err
}
// Get user's accessible libraries for validation
libraries := getUserAccessibleLibraries(r.Context())
accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID })
if len(musicFolderIds) > 0 {
// Validate all provided library IDs - if any are invalid, return an error
for _, id := range musicFolderIds {
if !slices.Contains(accessibleLibraryIds, id) {
return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id)
}
}
return musicFolderIds, nil
}
// If no musicFolderId is provided, return all libraries the user has access to.
return accessibleLibraryIds, nil
}