mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 19:07:12 +02:00
* refactor: add Tags.First and slice.GroupOrdered helpers
Tags.First returns the first value of a tag or an empty string, replacing the inline len-check-then-index pattern in FullTitle, FullAlbumName, Album.FullName and the Subsonic album version mapping.
slice.GroupOrdered is slice.Group returning the groups in first-seen order, for callers that need a deterministic order the map-based Group cannot give.
* fix(archiver): give same-named albums their own folder in artist zips
Artist zips put every album in a folder named after the album, so two albums with the same name (an original and a deluxe edition, or names that only differ in characters the sanitizer replaces) were merged into one folder, with tracks mixed together and duplicate zip entries when file names collided.
The folder is now named after FullAlbumName(), so with AppendAlbumVersion on (the default) the version is part of the name, matching what clients display. Albums whose sanitized names still clash get a " [suffix]" taken from the first field that has a distinct, non-empty value for all of them: album version, year, release type, record label, catalog number, then a short album id. This follows the shape of beets' %aunique{} path function.
Albums are also grouped with slice.GroupOrdered instead of a map, so the zip is deterministic.
* fix(archiver): use the release year to tell same-named albums apart
Taggers often write an edition's date to the Date tag next to an original date, and the scanner then stores the original year in Year and the edition's year in ReleaseYear. Reissues of the same album therefore share Year, so the year disambiguator could not tell them apart and they fell through to the album id suffix. Prefer ReleaseYear and fall back to Year when it is not set.
Found by downloading an artist zip from a live server built from this branch.
* fix(archiver): let one clashing album keep the plain folder name
A disambiguator was only accepted when every clashing album had a non-empty value, so an original and its deluxe edition (with the version not appended to the name) fell through to the album id suffix. Accept a field whose values are distinct across the group even when one of them is empty, as beets' %aunique{} does: that album keeps the plain name, which the suffixed folders cannot clash with. Two or more empty values still count as a tie.
* test(archiver): refactor tests for album naming conventions and query order
568 lines
17 KiB
Go
568 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)
|
|
}
|
|
}
|
|
|
|
// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty
|
|
// string + the field's omitempty tag) when artwork is known absent.
|
|
func coverArtOrEmpty(id model.ArtworkID, absent bool) string {
|
|
if absent {
|
|
return ""
|
|
}
|
|
return id.String()
|
|
}
|
|
|
|
func toArtist(r *http.Request, a model.Artist) responses.Artist {
|
|
artist := responses.Artist{
|
|
Id: a.ID,
|
|
Name: a.Name,
|
|
UserRating: int32(a.Rating),
|
|
CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent),
|
|
}
|
|
if !a.ImageAbsent {
|
|
artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), 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: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent),
|
|
UserRating: int32(a.Rating),
|
|
}
|
|
if !a.ImageAbsent {
|
|
artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600)
|
|
}
|
|
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 = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent)
|
|
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 = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent)
|
|
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 = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent)
|
|
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)
|
|
dir.Version = album.Tags.First(model.TagAlbumVersion)
|
|
|
|
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
|
|
}
|