mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 19:37:08 +02:00
* fix(plugins): build public URLs on the caller's address instead of localhost The artwork host service had no `*http.Request`, so it passed `nil` to `publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that produced `http://localhost/share/img/...`, which is useless to anything outside the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so it fell back to the Navidrome logo instead of the real cover art. `serverAddressMiddleware` already works out the client-facing scheme and host from the `X-Forwarded-*` headers. It now also records them in the request context, and `publicurl` takes a `context.Context` instead of an `*http.Request` so any caller can reach them. Extism passes the caller's context through to host functions, so plugins invoked during a request now get a reachable URL with no configuration. Switching the parameter also removes the need for a second, parallel entry point: the package previously wanted only a scheme, a host, and a context, and took a whole request to get them. `AbsoluteURL` no longer dereferences a possibly-nil request on its parse-error path. Plugin calls that start from `context.Background()` (scheduler and websocket callbacks, the buffered scrobble drain) still fall back to localhost, since they have no request to learn from. A debug log now points at `ShareURL` when that happens. * fix(publicurl): include the configured port in the localhost fallback The last-resort fallback built `http://localhost/...`, which points at port 80 and so is unreachable for a server listening anywhere else — the default 4533 included. Use `conf.Server.Port` so a consumer on the same machine can actually fetch the URL. * fix(publicurl): use https in the localhost fallback when TLS is configured The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL advertised a URL it does not answer on. Mirror the server's own switch, which requires both a certificate and a key. * refactor(publicurl): tidy the localhost fallback and its tests Use gg.If for the fallback scheme so it reads as an expression, like the BaseScheme branch above it, instead of assigning http and overwriting it. Drop two tests the ctx refactor left redundant: one asserted PublicURL "works without a request" but became a byte-identical copy of the ShareURL spec once the *http.Request parameter went away, and the two port specs differed only in the integer, where the non-default port is the stronger assertion. * refactor(conf): add TLSEnabled and use it instead of repeating the predicate Whether the server speaks HTTPS was decided inline in three unconnected places. This PR added the third, in a URL-building package that has no business inferring the transport config. Move the rule to conf, next to the fields it derives from, and call it from publicurl and the insights collector. server.Run keeps its own expression: it takes the certificate and key as parameters, and its test passes values that do not come from the config.
570 lines
17 KiB
Go
570 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)
|
|
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
|
|
}
|