feat: new "Subsonic Minimal Clients" configuration option (#4850)

* Add `.editorconfig` file

Hints to users how to properly indent Go files (my setup was defaulting
to 2 spaces).

* Add Subsonic API minimal config option

This will allow users to specify clients which can operate with or need
the minimum required fields as per the [SubSonic API
spec](https://subsonic.org/pages/api.jsp).

* Return only required fields for Child Objects

For a minimal client, only return the required fields for Child Objects.

* Return only required fields for Playlist objects

* refactor: simplify client list checks and improve playlist response handling

Signed-off-by: Deluan <deluan@navidrome.org>

* test: add unit tests for client list checks and playlist building logic

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: revert Child.IsVideo and Playlist.Public fields from pointer to boolean, and add omitempty to XML tag

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Matthew Simpson
2026-01-16 10:55:21 +00:00
committed by GitHub
parent 032cfa2a4d
commit 9ab0c2dc67
36 changed files with 360 additions and 60 deletions
+1
View File
@@ -153,6 +153,7 @@ type subsonicOptions struct {
ArtistParticipations bool ArtistParticipations bool
DefaultReportRealPath bool DefaultReportRealPath bool
LegacyClients string LegacyClients string
MinimalClients string
} }
type TagConf struct { type TagConf struct {
+22 -3
View File
@@ -166,11 +166,30 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
return return
} }
func isClientInList(clientList, client string) bool {
if clientList == "" || client == "" {
return false
}
clients := strings.Split(clientList, ",")
for _, c := range clients {
if strings.TrimSpace(c) == client {
return true
}
}
return false
}
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child { func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
child := responses.Child{} child := responses.Child{}
child.Id = mf.ID child.Id = mf.ID
child.Title = mf.FullTitle() child.Title = mf.FullTitle()
child.IsDir = false child.IsDir = false
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
return child
}
child.Parent = mf.AlbumID child.Parent = mf.AlbumID
child.Album = mf.Album child.Album = mf.Album
child.Year = int32(mf.Year) child.Year = int32(mf.Year)
@@ -183,7 +202,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.BitRate = int32(mf.BitRate) child.BitRate = int32(mf.BitRate)
child.CoverArt = mf.CoverArtID().String() child.CoverArt = mf.CoverArtID().String()
child.ContentType = mf.ContentType() child.ContentType = mf.ContentType()
player, ok := request.PlayerFrom(ctx)
if ok && player.ReportRealPath { if ok && player.ReportRealPath {
child.Path = mf.AbsolutePath() child.Path = mf.AbsolutePath()
} else { } else {
@@ -211,8 +230,8 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
} }
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild { func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
player, _ := request.PlayerFrom(ctx) player, ok := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
return nil return nil
} }
child := responses.OpenSubsonicChild{} child := responses.OpenSubsonicChild{}
+184
View File
@@ -169,6 +169,190 @@ var _ = Describe("helpers", func() {
}) })
}) })
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"))
})
})
})
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 minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "minimal-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns nil", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).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 OpenSubsonic child fields", func() {
osChild := osChildFromMediaFile(ctx, mf)
Expect(osChild).ToNot(BeNil())
Expect(osChild.Comment).To(Equal("Test Comment"))
})
})
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 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() { Describe("selectedMusicFolderIds", func() {
var user model.User var user model.User
var ctx context.Context var ctx context.Context
+16 -7
View File
@@ -7,8 +7,10 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
@@ -23,7 +25,7 @@ func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
} }
response := newResponse() response := newResponse()
response.Playlists = &responses.Playlists{ response.Playlists = &responses.Playlists{
Playlist: slice.Map(allPls, api.buildPlaylist), Playlist: slice.MapWithArg(allPls, ctx, api.buildPlaylist),
} }
return response, nil return response, nil
} }
@@ -51,7 +53,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
response := newResponse() response := newResponse()
response.Playlist = &responses.PlaylistWithSongs{ response.Playlist = &responses.PlaylistWithSongs{
Playlist: api.buildPlaylist(*pls), Playlist: api.buildPlaylist(ctx, *pls),
} }
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile) response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
return response, nil return response, nil
@@ -152,21 +154,28 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error)
return newResponse(), nil return newResponse(), nil
} }
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist { func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) responses.Playlist {
pls := responses.Playlist{} pls := responses.Playlist{}
pls.Id = p.ID pls.Id = p.ID
pls.Name = p.Name pls.Name = p.Name
pls.Comment = p.Comment
pls.SongCount = int32(p.SongCount) pls.SongCount = int32(p.SongCount)
pls.Owner = p.OwnerName
pls.Duration = int32(p.Duration) pls.Duration = int32(p.Duration)
pls.Public = p.Public
pls.Created = p.CreatedAt pls.Created = p.CreatedAt
pls.CoverArt = p.CoverArtID().String()
if p.IsSmartPlaylist() { if p.IsSmartPlaylist() {
pls.Changed = time.Now() pls.Changed = time.Now()
} else { } else {
pls.Changed = p.UpdatedAt pls.Changed = p.UpdatedAt
} }
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
return pls
}
pls.Comment = p.Comment
pls.Owner = p.OwnerName
pls.Public = p.Public
pls.CoverArt = p.CoverArtID().String()
return pls return pls
} }
+105
View File
@@ -2,9 +2,12 @@ package subsonic
import ( import (
"context" "context"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@@ -12,6 +15,108 @@ import (
var _ core.Playlists = (*fakePlaylists)(nil) var _ core.Playlists = (*fakePlaylists)(nil)
var _ = Describe("buildPlaylist", func() {
var router *Router
var ds model.DataStore
var ctx context.Context
var playlist model.Playlist
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
playlist = model.Playlist{
ID: "pls-1",
Name: "My Playlist",
Comment: "Test comment",
OwnerName: "admin",
Public: true,
SongCount: 10,
Duration: 600,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
})
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() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
// These should not be set
Expect(result.Comment).To(BeEmpty())
Expect(result.Owner).To(BeEmpty())
Expect(result.Public).To(BeFalse())
Expect(result.CoverArt).To(BeEmpty())
})
})
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() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
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() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
Context("when no player in context", func() {
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
})
var _ = Describe("UpdatePlaylist", func() { var _ = Describe("UpdatePlaylist", func() {
var router *Router var router *Router
var ds model.DataStore var ds model.DataStore
@@ -9,7 +9,6 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"isVideo": false,
"bpm": 0, "bpm": 0,
"comment": "", "comment": "",
"sortName": "sort name", "sortName": "sort name",
@@ -1,6 +1,6 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList> <albumList>
<album id="1" isDir="false" isVideo="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit"> <album id="1" isDir="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
<genres name="Genre 1"></genres> <genres name="Genre 1"></genres>
<genres name="Genre 2"></genres> <genres name="Genre 2"></genres>
<moods>mood1</moods> <moods>mood1</moods>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
] ]
} }
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList> <albumList>
<album id="1" isDir="false" title="title" isVideo="false"></album> <album id="1" isDir="false" title="title"></album>
</albumList> </albumList>
</subsonic-response> </subsonic-response>
@@ -93,7 +93,6 @@
"transcodedSuffix": "mp3", "transcodedSuffix": "mp3",
"duration": 146, "duration": 146,
"bitRate": 320, "bitRate": 320,
"isVideo": false,
"bpm": 127, "bpm": 127,
"comment": "a comment", "comment": "a comment",
"sortName": "sorted song", "sortName": "sorted song",
@@ -185,7 +184,6 @@
"transcodedSuffix": "mp3", "transcodedSuffix": "mp3",
"duration": 146, "duration": 146,
"bitRate": 320, "bitRate": 320,
"isVideo": false,
"bpm": 0, "bpm": 0,
"comment": "", "comment": "",
"sortName": "", "sortName": "",
@@ -15,7 +15,7 @@
<moods>sad</moods> <moods>sad</moods>
<artists id="1" name="artist1"></artists> <artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists> <artists id="2" name="artist2"></artists>
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean"> <song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc> <isrc>ISRC-1</isrc>
<genres name="rock"></genres> <genres name="rock"></genres>
<genres name="progressive"></genres> <genres name="progressive"></genres>
@@ -33,7 +33,7 @@
<artist id="2" name="artist2"></artist> <artist id="2" name="artist2"></artist>
</contributors> </contributors>
</song> </song>
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false"> <song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320">
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
</song> </song>
</album> </album>
@@ -10,8 +10,7 @@
"entry": { "entry": {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
}, },
"position": 123, "position": 123,
"username": "user2", "username": "user2",
@@ -1,7 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<bookmarks> <bookmarks>
<bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"> <bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z">
<entry id="1" isDir="false" title="title" isVideo="false"></entry> <entry id="1" isDir="false" title="title"></entry>
</bookmark> </bookmark>
</bookmarks> </bookmarks>
</subsonic-response> </subsonic-response>
@@ -24,7 +24,6 @@
"transcodedSuffix": "mp3", "transcodedSuffix": "mp3",
"duration": 146, "duration": 146,
"bitRate": 320, "bitRate": 320,
"isVideo": false,
"bpm": 127, "bpm": 127,
"comment": "a comment", "comment": "a comment",
"sortName": "sorted title", "sortName": "sorted title",
@@ -116,7 +115,6 @@
{ {
"id": "", "id": "",
"isDir": false, "isDir": false,
"isVideo": false,
"bpm": 0, "bpm": 0,
"comment": "", "comment": "",
"sortName": "", "sortName": "",
@@ -1,6 +1,6 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="1" name="N"> <directory id="1" name="N">
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean"> <child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc> <isrc>ISRC-1</isrc>
<isrc>ISRC-2</isrc> <isrc>ISRC-2</isrc>
<genres name="rock"></genres> <genres name="rock"></genres>
@@ -25,7 +25,7 @@
<artist id="4" name="composer2"></artist> <artist id="4" name="composer2"></artist>
</contributors> </contributors>
</child> </child>
<child id="" isDir="false" isVideo="false"> <child id="" isDir="false">
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
</child> </child>
</directory> </directory>
@@ -8,8 +8,7 @@
"child": [ "child": [
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false
"isVideo": false
} }
], ],
"id": "", "id": "",
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="" name=""> <directory id="" name="">
<child id="1" isDir="false" isVideo="false"></child> <child id="1" isDir="false"></child>
</directory> </directory>
</subsonic-response> </subsonic-response>
@@ -9,7 +9,6 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"isVideo": false,
"bpm": 0, "bpm": 0,
"comment": "", "comment": "",
"sortName": "", "sortName": "",
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="" name=""> <directory id="" name="">
<child id="1" isDir="false" isVideo="false"></child> <child id="1" isDir="false"></child>
</directory> </directory>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
], ],
"id": "1", "id": "1",
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="1" name="N"> <directory id="1" name="N">
<child id="1" isDir="false" title="title" isVideo="false"></child> <child id="1" isDir="false" title="title"></child>
</directory> </directory>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
], ],
"current": "111", "current": "111",
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"> <playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
<entry id="1" isDir="false" title="title" isVideo="false"></entry> <entry id="1" isDir="false" title="title"></entry>
</playQueue> </playQueue>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
], ],
"currentIndex": 0, "currentIndex": 0,
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"> <playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
<entry id="1" isDir="false" title="title" isVideo="false"></entry> <entry id="1" isDir="false" title="title"></entry>
</playQueueByIndex> </playQueueByIndex>
</subsonic-response> </subsonic-response>
@@ -23,7 +23,6 @@
"name": "bbb", "name": "bbb",
"songCount": 0, "songCount": 0,
"duration": 0, "duration": 0,
"public": false,
"created": "0001-01-01T00:00:00Z", "created": "0001-01-01T00:00:00Z",
"changed": "0001-01-01T00:00:00Z" "changed": "0001-01-01T00:00:00Z"
} }
@@ -1,6 +1,6 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playlists> <playlists>
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist> <playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist>
<playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist> <playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
</playlists> </playlists>
</subsonic-response> </subsonic-response>
@@ -14,8 +14,7 @@
"title": "title", "title": "title",
"album": "album", "album": "album",
"artist": "artist", "artist": "artist",
"duration": 120, "duration": 120
"isVideo": false
}, },
{ {
"id": "2", "id": "2",
@@ -23,8 +22,7 @@
"title": "title 2", "title": "title 2",
"album": "album", "album": "album",
"artist": "artist", "artist": "artist",
"duration": 300, "duration": 300
"isVideo": false
} }
], ],
"id": "ABC123", "id": "ABC123",
@@ -1,8 +1,8 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<shares> <shares>
<share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2"> <share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2">
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false"></entry> <entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120"></entry>
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false"></entry> <entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300"></entry>
</share> </share>
</shares> </shares>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
] ]
} }
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<similarSongs> <similarSongs>
<song id="1" isDir="false" title="title" isVideo="false"></song> <song id="1" isDir="false" title="title"></song>
</similarSongs> </similarSongs>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
] ]
} }
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<similarSongs2> <similarSongs2>
<song id="1" isDir="false" title="title" isVideo="false"></song> <song id="1" isDir="false" title="title"></song>
</similarSongs2> </similarSongs2>
</subsonic-response> </subsonic-response>
@@ -9,8 +9,7 @@
{ {
"id": "1", "id": "1",
"isDir": false, "isDir": false,
"title": "title", "title": "title"
"isVideo": false
} }
] ]
} }
@@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<topSongs> <topSongs>
<song id="1" isDir="false" title="title" isVideo="false"></song> <song id="1" isDir="false" title="title"></song>
</topSongs> </topSongs>
</subsonic-response> </subsonic-response>
+3 -3
View File
@@ -161,7 +161,7 @@ type Child struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"` Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"` IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"` BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
/* /*
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 --> <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
@@ -177,7 +177,7 @@ type OpenSubsonicChild struct {
SortName string `xml:"sortName,attr,omitempty" json:"sortName"` SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"` MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"` Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"` Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"` ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"` ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
@@ -308,7 +308,7 @@ type Playlist struct {
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"` SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"` Duration int32 `xml:"duration,attr" json:"duration"`
Public bool `xml:"public,attr" json:"public"` Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Created time.Time `xml:"created,attr" json:"created"` Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"` Changed time.Time `xml:"changed,attr" json:"changed"`