mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 14:18:42 +01:00
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
575 lines
17 KiB
Go
575 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(mf.BirthTime)
|
|
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)
|
|
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 returns a best-effort timestamp for the album's `created`
|
|
// field, which is required by the OpenSubsonic spec but may be zero on legacy
|
|
// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all
|
|
// three are unset.
|
|
func albumCreatedAt(al model.Album) time.Time {
|
|
if !al.CreatedAt.IsZero() {
|
|
return al.CreatedAt
|
|
}
|
|
if !al.UpdatedAt.IsZero() {
|
|
return al.UpdatedAt
|
|
}
|
|
return al.ImportedAt
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
|
|
lines := make([]responses.Line, len(lyrics.Line))
|
|
|
|
for i, line := range lyrics.Line {
|
|
lines[i] = responses.Line{
|
|
Start: line.Start,
|
|
Value: line.Value,
|
|
}
|
|
}
|
|
|
|
structured := responses.StructuredLyric{
|
|
DisplayArtist: lyrics.DisplayArtist,
|
|
DisplayTitle: lyrics.DisplayTitle,
|
|
Lang: lyrics.Lang,
|
|
Line: lines,
|
|
Offset: lyrics.Offset,
|
|
Synced: lyrics.Synced,
|
|
}
|
|
|
|
if structured.DisplayArtist == "" {
|
|
structured.DisplayArtist = mf.Artist
|
|
}
|
|
if structured.DisplayTitle == "" {
|
|
structured.DisplayTitle = mf.Title
|
|
}
|
|
|
|
return structured
|
|
}
|
|
|
|
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
|
|
lyricList := make(responses.StructuredLyrics, len(lyricsList))
|
|
|
|
for i, lyrics := range lyricsList {
|
|
lyricList[i] = buildStructuredLyric(mf, lyrics)
|
|
}
|
|
|
|
res := &responses.LyricsList{
|
|
StructuredLyrics: lyricList,
|
|
}
|
|
return res
|
|
}
|
|
|
|
// 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
|
|
}
|