update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
285
server/subsonic/album_lists.go
Normal file
285
server/subsonic/album_lists.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
|
||||
p := req.Params(r)
|
||||
typ, err := p.String("type")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var opts filter.Options
|
||||
switch typ {
|
||||
case "newest":
|
||||
opts = filter.AlbumsByNewest()
|
||||
case "recent":
|
||||
opts = filter.AlbumsByRecent()
|
||||
case "random":
|
||||
opts = filter.AlbumsByRandom()
|
||||
case "alphabeticalByName":
|
||||
opts = filter.AlbumsByName()
|
||||
case "alphabeticalByArtist":
|
||||
opts = filter.AlbumsByArtist()
|
||||
case "frequent":
|
||||
opts = filter.AlbumsByFrequent()
|
||||
case "starred":
|
||||
opts = filter.ByStarred()
|
||||
case "highest":
|
||||
opts = filter.ByRating()
|
||||
case "byGenre":
|
||||
genre, err := p.String("genre")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
opts = filter.ByGenre(genre)
|
||||
case "byYear":
|
||||
fromYear, err := p.Int("fromYear")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
toYear, err := p.Int("toYear")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
opts = filter.AlbumsByYear(fromYear, toYear)
|
||||
default:
|
||||
log.Error(r, "albumList type not implemented", "type", typ)
|
||||
return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ)
|
||||
}
|
||||
|
||||
// Get optional library IDs from musicFolderId parameter
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
|
||||
|
||||
opts.Offset = p.IntOr("offset", 0)
|
||||
opts.Max = min(p.IntOr("size", 10), 500)
|
||||
albums, err := api.ds.Album(r.Context()).GetAll(opts)
|
||||
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving albums", err)
|
||||
return nil, 0, newError(responses.ErrorGeneric, "internal error")
|
||||
}
|
||||
|
||||
count, err := api.ds.Album(r.Context()).CountAll(opts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error counting albums", err)
|
||||
return nil, 0, newError(responses.ErrorGeneric, "internal error")
|
||||
}
|
||||
|
||||
return albums, count, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, count, err := api.getAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w.Header().Set("x-total-count", strconv.Itoa(int(count)))
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumList = &responses.AlbumList{
|
||||
Album: slice.MapWithArg(albums, r.Context(), childFromAlbum),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, pageCount, err := api.getAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w.Header().Set("x-total-count", strconv.FormatInt(pageCount, 10))
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumList2 = &responses.AlbumList2{
|
||||
Album: slice.MapWithArg(albums, r.Context(), buildAlbumID3),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get optional library IDs from musicFolderId parameter
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Prepare variables to capture results from parallel execution
|
||||
var artists model.Artists
|
||||
var albums model.Albums
|
||||
var mediaFiles model.MediaFiles
|
||||
|
||||
// Execute all three queries in parallel for better performance
|
||||
err = run.Parallel(
|
||||
// Query starred artists
|
||||
func() error {
|
||||
artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds)
|
||||
var err error
|
||||
artists, err = api.ds.Artist(ctx).GetAll(artistOpts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred artists", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
// Query starred albums
|
||||
func() error {
|
||||
albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
|
||||
var err error
|
||||
albums, err = api.ds.Album(ctx).GetAll(albumOpts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred albums", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
// Query starred media files
|
||||
func() error {
|
||||
mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
|
||||
var err error
|
||||
mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred mediaFiles", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
)()
|
||||
|
||||
// Return the first error if any occurred
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return artists, albums, mediaFiles, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
|
||||
artists, albums, mediaFiles, err := api.getStarredItems(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Starred = &responses.Starred{}
|
||||
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
|
||||
response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum)
|
||||
response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
|
||||
artists, albums, mediaFiles, err := api.getStarredItems(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Starred2 = &responses.Starred2{}
|
||||
response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3)
|
||||
response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3)
|
||||
response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
npInfo, err := api.scrobbler.GetNowPlaying(ctx)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving now playing list", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
var i int32
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
|
||||
return responses.NowPlayingEntry{
|
||||
Child: childFromMediaFile(ctx, np.MediaFile),
|
||||
UserName: np.Username,
|
||||
MinutesAgo: int32(time.Since(np.Start).Minutes()),
|
||||
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
|
||||
PlayerName: np.PlayerName,
|
||||
}
|
||||
})
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
size := min(p.IntOr("size", 10), 500)
|
||||
genre, _ := p.String("genre")
|
||||
fromYear := p.IntOr("fromYear", 0)
|
||||
toYear := p.IntOr("toYear", 0)
|
||||
|
||||
// Get optional library IDs from musicFolderId parameter
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := filter.SongsByRandom(genre, fromYear, toYear)
|
||||
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
|
||||
|
||||
songs, err := api.getSongs(r.Context(), 0, size, opts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
count := min(p.IntOr("count", 10), 500)
|
||||
offset := p.IntOr("offset", 0)
|
||||
genre, _ := p.String("genre")
|
||||
|
||||
// Get optional library IDs from musicFolderId parameter
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := filter.ByGenre(genre)
|
||||
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
|
||||
|
||||
ctx := r.Context()
|
||||
songs, err := api.getSongs(ctx, offset, count, opts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.SongsByGenre = &responses.Songs{}
|
||||
response.SongsByGenre.Songs = slice.MapWithArg(songs, ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) getSongs(ctx context.Context, offset, size int, opts filter.Options) (model.MediaFiles, error) {
|
||||
opts.Offset = offset
|
||||
opts.Max = size
|
||||
return api.ds.MediaFile(ctx).GetAll(opts)
|
||||
}
|
||||
542
server/subsonic/album_lists_test.go
Normal file
542
server/subsonic/album_lists_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album Lists", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
var mockRepo *tests.MockAlbumRepo
|
||||
var w *httptest.ResponseRecorder
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
Describe("GetAlbumList", func() {
|
||||
It("should return list of the type specified", func() {
|
||||
r := newGetRequest("type=newest", "offset=10", "size=20")
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
resp, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList.Album[0].Id).To(Equal("1"))
|
||||
Expect(resp.AlbumList.Album[1].Id).To(Equal("2"))
|
||||
Expect(w.Header().Get("x-total-count")).To(Equal("2"))
|
||||
Expect(mockRepo.Options.Offset).To(Equal(10))
|
||||
Expect(mockRepo.Options.Max).To(Equal(20))
|
||||
})
|
||||
|
||||
It("should fail if missing type parameter", func() {
|
||||
r := newGetRequest()
|
||||
_, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).To(MatchError(req.ErrMissingParam))
|
||||
})
|
||||
|
||||
It("should return error if call fails", func() {
|
||||
mockRepo.SetError(true)
|
||||
r := newGetRequest("type=newest")
|
||||
|
||||
_, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).To(MatchError(errSubsonic))
|
||||
var subErr subError
|
||||
errors.As(err, &subErr)
|
||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter albums by specific library when musicFolderId is provided", func() {
|
||||
r := newGetRequest("type=newest", "musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?)"))
|
||||
Expect(args).To(ContainElement(1))
|
||||
})
|
||||
|
||||
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
|
||||
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2))
|
||||
})
|
||||
|
||||
It("should return all accessible albums when no musicFolderId is provided", func() {
|
||||
r := newGetRequest("type=newest")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2, 3))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAlbumList2", func() {
|
||||
It("should return list of the type specified", func() {
|
||||
r := newGetRequest("type=newest", "offset=10", "size=20")
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
resp, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList2.Album[0].Id).To(Equal("1"))
|
||||
Expect(resp.AlbumList2.Album[1].Id).To(Equal("2"))
|
||||
Expect(w.Header().Get("x-total-count")).To(Equal("2"))
|
||||
Expect(mockRepo.Options.Offset).To(Equal(10))
|
||||
Expect(mockRepo.Options.Max).To(Equal(20))
|
||||
})
|
||||
|
||||
It("should fail if missing type parameter", func() {
|
||||
r := newGetRequest()
|
||||
|
||||
_, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).To(MatchError(req.ErrMissingParam))
|
||||
})
|
||||
|
||||
It("should return error if call fails", func() {
|
||||
mockRepo.SetError(true)
|
||||
r := newGetRequest("type=newest")
|
||||
|
||||
_, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).To(MatchError(errSubsonic))
|
||||
var subErr subError
|
||||
errors.As(err, &subErr)
|
||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter albums by specific library when musicFolderId is provided", func() {
|
||||
r := newGetRequest("type=newest", "musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList2.Album).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
Expect(mockRepo.Options.Filters).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
|
||||
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList2.Album).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
Expect(mockRepo.Options.Filters).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should return all accessible albums when no musicFolderId is provided", func() {
|
||||
r := newGetRequest("type=newest")
|
||||
r = r.WithContext(ctx)
|
||||
mockRepo.SetData(model.Albums{
|
||||
{ID: "1"}, {ID: "2"},
|
||||
})
|
||||
|
||||
resp, err := router.GetAlbumList2(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.AlbumList2.Album).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetRandomSongs", func() {
|
||||
var mockMediaFileRepo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("should return random songs", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("size=2")
|
||||
|
||||
resp, err := router.GetRandomSongs(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter songs by specific library when musicFolderId is provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("size=2", "musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetRandomSongs(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?)"))
|
||||
Expect(args).To(ContainElement(1))
|
||||
})
|
||||
|
||||
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetRandomSongs(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2))
|
||||
})
|
||||
|
||||
It("should return all accessible songs when no musicFolderId is provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("size=2")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetRandomSongs(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2, 3))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSongsByGenre", func() {
|
||||
var mockMediaFileRepo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("should return songs by genre", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("count=2", "genre=rock")
|
||||
|
||||
resp, err := router.GetSongsByGenre(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter songs by specific library when musicFolderId is provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetSongsByGenre(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?)"))
|
||||
Expect(args).To(ContainElement(1))
|
||||
})
|
||||
|
||||
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetSongsByGenre(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2))
|
||||
})
|
||||
|
||||
It("should return all accessible songs when no musicFolderId is provided", func() {
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
})
|
||||
r := newGetRequest("count=2", "genre=rock")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetSongsByGenre(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
|
||||
// Verify that library filter was applied
|
||||
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
|
||||
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
|
||||
Expect(args).To(ContainElements(1, 2, 3))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetStarred", func() {
|
||||
var mockArtistRepo *tests.MockArtistRepo
|
||||
var mockAlbumRepo *tests.MockAlbumRepo
|
||||
var mockMediaFileRepo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("should return starred items", func() {
|
||||
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
|
||||
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
|
||||
r := newGetRequest()
|
||||
|
||||
resp, err := router.GetStarred(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Starred.Artist).To(HaveLen(1))
|
||||
Expect(resp.Starred.Album).To(HaveLen(1))
|
||||
Expect(resp.Starred.Song).To(HaveLen(1))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter starred items by specific library when musicFolderId is provided", func() {
|
||||
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
|
||||
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
|
||||
r := newGetRequest("musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetStarred(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Starred.Artist).To(HaveLen(1))
|
||||
Expect(resp.Starred.Album).To(HaveLen(1))
|
||||
Expect(resp.Starred.Song).To(HaveLen(1))
|
||||
// Verify that library filter was applied to all types
|
||||
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
|
||||
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
|
||||
Expect(artistArgs).To(ContainElement(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetStarred2", func() {
|
||||
var mockArtistRepo *tests.MockArtistRepo
|
||||
var mockAlbumRepo *tests.MockAlbumRepo
|
||||
var mockMediaFileRepo *tests.MockMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
})
|
||||
|
||||
It("should return starred items in ID3 format", func() {
|
||||
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
|
||||
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
|
||||
r := newGetRequest()
|
||||
|
||||
resp, err := router.GetStarred2(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Starred2.Artist).To(HaveLen(1))
|
||||
Expect(resp.Starred2.Album).To(HaveLen(1))
|
||||
Expect(resp.Starred2.Song).To(HaveLen(1))
|
||||
})
|
||||
|
||||
Context("with musicFolderId parameter", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
It("should filter starred items by specific library when musicFolderId is provided", func() {
|
||||
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
|
||||
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
|
||||
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
|
||||
r := newGetRequest("musicFolderId=1")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := router.GetStarred2(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Starred2.Artist).To(HaveLen(1))
|
||||
Expect(resp.Starred2.Album).To(HaveLen(1))
|
||||
Expect(resp.Starred2.Song).To(HaveLen(1))
|
||||
// Verify that library filter was applied to all types
|
||||
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
|
||||
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
|
||||
Expect(artistArgs).To(ContainElement(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
361
server/subsonic/api.go
Normal file
361
server/subsonic/api.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
const Version = "1.16.1"
|
||||
|
||||
type handler = func(*http.Request) (*responses.Subsonic, error)
|
||||
type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
r.Use(recordStats(api.metrics))
|
||||
}
|
||||
|
||||
r.Use(postFormToQueryParams)
|
||||
|
||||
// Public
|
||||
h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(checkRequiredParameters)
|
||||
r.Use(authenticate(api.ds))
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "ping", api.Ping)
|
||||
h(r, "getLicense", api.GetLicense)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getMusicFolders", api.GetMusicFolders)
|
||||
h(r, "getIndexes", api.GetIndexes)
|
||||
h(r, "getArtists", api.GetArtists)
|
||||
h(r, "getGenres", api.GetGenres)
|
||||
h(r, "getMusicDirectory", api.GetMusicDirectory)
|
||||
h(r, "getArtist", api.GetArtist)
|
||||
h(r, "getAlbum", api.GetAlbum)
|
||||
h(r, "getSong", api.GetSong)
|
||||
h(r, "getAlbumInfo", api.GetAlbumInfo)
|
||||
h(r, "getAlbumInfo2", api.GetAlbumInfo)
|
||||
h(r, "getArtistInfo", api.GetArtistInfo)
|
||||
h(r, "getArtistInfo2", api.GetArtistInfo2)
|
||||
h(r, "getTopSongs", api.GetTopSongs)
|
||||
h(r, "getSimilarSongs", api.GetSimilarSongs)
|
||||
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "getAlbumList", api.GetAlbumList)
|
||||
hr(r, "getAlbumList2", api.GetAlbumList2)
|
||||
h(r, "getStarred", api.GetStarred)
|
||||
h(r, "getStarred2", api.GetStarred2)
|
||||
if conf.Server.EnableNowPlaying {
|
||||
h(r, "getNowPlaying", api.GetNowPlaying)
|
||||
} else {
|
||||
h501(r, "getNowPlaying")
|
||||
}
|
||||
h(r, "getRandomSongs", api.GetRandomSongs)
|
||||
h(r, "getSongsByGenre", api.GetSongsByGenre)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "setRating", api.SetRating)
|
||||
h(r, "star", api.Star)
|
||||
h(r, "unstar", api.Unstar)
|
||||
h(r, "scrobble", api.Scrobble)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getPlaylists", api.GetPlaylists)
|
||||
h(r, "getPlaylist", api.GetPlaylist)
|
||||
h(r, "createPlaylist", api.CreatePlaylist)
|
||||
h(r, "deletePlaylist", api.DeletePlaylist)
|
||||
h(r, "updatePlaylist", api.UpdatePlaylist)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getBookmarks", api.GetBookmarks)
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "search2", api.Search2)
|
||||
h(r, "search3", api.Search3)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getUser", api.GetUser)
|
||||
h(r, "getUsers", api.GetUsers)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getScanStatus", api.GetScanStatus)
|
||||
h(r, "startScan", api.StartScan)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "getAvatar", api.GetAvatar)
|
||||
h(r, "getLyrics", api.GetLyrics)
|
||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||
hr(r, "stream", api.Stream)
|
||||
hr(r, "download", api.Download)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
// configure request throttling
|
||||
if conf.Server.DevArtworkMaxRequests > 0 {
|
||||
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
|
||||
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout))
|
||||
}
|
||||
hr(r, "getCoverArt", api.GetCoverArt)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "createInternetRadioStation", api.CreateInternetRadio)
|
||||
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
|
||||
h(r, "getInternetRadioStations", api.GetInternetRadios)
|
||||
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
|
||||
})
|
||||
if conf.Server.EnableSharing {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getShares", api.GetShares)
|
||||
h(r, "createShare", api.CreateShare)
|
||||
h(r, "updateShare", api.UpdateShare)
|
||||
h(r, "deleteShare", api.DeleteShare)
|
||||
})
|
||||
} else {
|
||||
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
||||
}
|
||||
|
||||
if conf.Server.Jukebox.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "jukeboxControl", api.JukeboxControl)
|
||||
})
|
||||
} else {
|
||||
h501(r, "jukeboxControl")
|
||||
}
|
||||
|
||||
// Not Implemented (yet?)
|
||||
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
|
||||
"deletePodcastEpisode", "downloadPodcastEpisode")
|
||||
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
|
||||
|
||||
// Deprecated/Won't implement/Out of scope endpoints
|
||||
h410(r, "search")
|
||||
h410(r, "getChatMessages", "addChatMessage")
|
||||
h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls")
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// Add a Subsonic handler
|
||||
func h(r chi.Router, path string, f handler) {
|
||||
hr(r, path, func(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
return f(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...)
|
||||
func hr(r chi.Router, path string, f handlerRaw) {
|
||||
handle := func(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := f(w, r)
|
||||
if err != nil {
|
||||
sendError(w, r, err)
|
||||
return
|
||||
}
|
||||
if r.Context().Err() != nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err())
|
||||
}
|
||||
return
|
||||
}
|
||||
if res != nil {
|
||||
sendResponse(w, r, res)
|
||||
}
|
||||
}
|
||||
addHandler(r, path, handle)
|
||||
}
|
||||
|
||||
// Add a handler that returns 501 - Not implemented. Used to signal that an endpoint is not implemented yet
|
||||
func h501(r chi.Router, paths ...string) {
|
||||
for _, path := range paths {
|
||||
handle := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte("This endpoint is not implemented, but may be in future releases"))
|
||||
}
|
||||
addHandler(r, path, handle)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a handler that returns 410 - Gone. Used to signal that an endpoint will not be implemented
|
||||
func h410(r chi.Router, paths ...string) {
|
||||
for _, path := range paths {
|
||||
handle := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = w.Write([]byte("This endpoint will not be implemented"))
|
||||
}
|
||||
addHandler(r, path, handle)
|
||||
}
|
||||
}
|
||||
|
||||
func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r *http.Request)) {
|
||||
r.HandleFunc("/"+path, handle)
|
||||
r.HandleFunc("/"+path+".view", handle)
|
||||
}
|
||||
|
||||
func mapToSubsonicError(err error) subError {
|
||||
switch {
|
||||
case errors.Is(err, errSubsonic): // do nothing
|
||||
case errors.Is(err, req.ErrMissingParam):
|
||||
err = newError(responses.ErrorMissingParameter, err.Error())
|
||||
case errors.Is(err, req.ErrInvalidParam):
|
||||
err = newError(responses.ErrorGeneric, err.Error())
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
err = newError(responses.ErrorDataNotFound, "data not found")
|
||||
default:
|
||||
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
|
||||
}
|
||||
var subErr subError
|
||||
errors.As(err, &subErr)
|
||||
return subErr
|
||||
}
|
||||
|
||||
func sendError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
subErr := mapToSubsonicError(err)
|
||||
response := newResponse()
|
||||
response.Status = responses.StatusFailed
|
||||
response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()}
|
||||
|
||||
sendResponse(w, r, response)
|
||||
}
|
||||
|
||||
func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
|
||||
p := req.Params(r)
|
||||
f, _ := p.String("f")
|
||||
var response []byte
|
||||
var err error
|
||||
switch f {
|
||||
case "json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
||||
response, err = json.Marshal(wrapper)
|
||||
case "jsonp":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
callback, _ := p.String("callback")
|
||||
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
||||
response, err = json.Marshal(wrapper)
|
||||
response = []byte(fmt.Sprintf("%s(%s)", callback, response))
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
response, err = xml.Marshal(payload)
|
||||
}
|
||||
// This should never happen, but if it does, we need to know
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshalling response", "format", f, err)
|
||||
sendError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Status == responses.StatusOK {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response))
|
||||
} else {
|
||||
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK")
|
||||
}
|
||||
} else {
|
||||
log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message)
|
||||
}
|
||||
|
||||
statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32)
|
||||
|
||||
if ok && statusPointer != nil {
|
||||
if payload.Status == responses.StatusOK {
|
||||
*statusPointer = 0
|
||||
} else {
|
||||
*statusPointer = payload.Error.Code
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
|
||||
}
|
||||
}
|
||||
17
server/subsonic/api_suite_test.go
Normal file
17
server/subsonic/api_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSubsonicApi(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Subsonic API Suite")
|
||||
}
|
||||
127
server/subsonic/api_test.go
Normal file
127
server/subsonic/api_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
var _ = Describe("sendResponse", func() {
|
||||
var (
|
||||
w *httptest.ResponseRecorder
|
||||
r *http.Request
|
||||
payload *responses.Subsonic
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/somepath", nil)
|
||||
payload = &responses.Subsonic{
|
||||
Status: responses.StatusOK,
|
||||
Version: "1.16.1",
|
||||
}
|
||||
})
|
||||
|
||||
When("format is JSON", func() {
|
||||
It("should set Content-Type to application/json and return the correct body", func() {
|
||||
q := r.URL.Query()
|
||||
q.Add("f", "json")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
sendResponse(w, r, payload)
|
||||
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
Expect(w.Body.String()).NotTo(BeEmpty())
|
||||
|
||||
var wrapper responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
|
||||
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
|
||||
})
|
||||
})
|
||||
|
||||
When("format is JSONP", func() {
|
||||
It("should set Content-Type to application/javascript and return the correct callback body", func() {
|
||||
q := r.URL.Query()
|
||||
q.Add("f", "jsonp")
|
||||
q.Add("callback", "testCallback")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
sendResponse(w, r, payload)
|
||||
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/javascript"))
|
||||
body := w.Body.String()
|
||||
Expect(body).To(SatisfyAll(
|
||||
HavePrefix("testCallback("),
|
||||
HaveSuffix(")"),
|
||||
))
|
||||
|
||||
// Extract JSON from the JSONP response
|
||||
jsonBody := body[strings.Index(body, "(")+1 : strings.LastIndex(body, ")")]
|
||||
var wrapper responses.JsonWrapper
|
||||
err := json.Unmarshal([]byte(jsonBody), &wrapper)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
|
||||
})
|
||||
})
|
||||
|
||||
When("format is XML or unspecified", func() {
|
||||
It("should set Content-Type to application/xml and return the correct body", func() {
|
||||
// No format specified, expecting XML by default
|
||||
sendResponse(w, r, payload)
|
||||
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/xml"))
|
||||
var subsonicResponse responses.Subsonic
|
||||
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(subsonicResponse.Status).To(Equal(payload.Status))
|
||||
Expect(subsonicResponse.Version).To(Equal(payload.Version))
|
||||
})
|
||||
})
|
||||
|
||||
When("an error occurs during marshalling", func() {
|
||||
It("should return a fail response", func() {
|
||||
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
|
||||
// An +Inf value will cause an error when marshalling to JSON
|
||||
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))}
|
||||
q := r.URL.Query()
|
||||
q.Add("f", "json")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
sendResponse(w, r, payload)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var wrapper responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
|
||||
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("json: unsupported value: +Inf"))
|
||||
})
|
||||
})
|
||||
|
||||
It("updates status pointer when an error occurs", func() {
|
||||
pointer := int32(0)
|
||||
|
||||
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
payload.Status = responses.StatusFailed
|
||||
payload.Error = &responses.Error{Code: responses.ErrorDataNotFound}
|
||||
|
||||
sendResponse(w, r, payload)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
Expect(pointer).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
})
|
||||
208
server/subsonic/bookmarks.go
Normal file
208
server/subsonic/bookmarks.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
bookmarks, err := repo.GetBookmarks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Bookmarks = &responses.Bookmarks{}
|
||||
response.Bookmarks.Bookmark = slice.Map(bookmarks, func(bmk model.Bookmark) responses.Bookmark {
|
||||
return responses.Bookmark{
|
||||
Entry: childFromMediaFile(r.Context(), bmk.Item),
|
||||
Position: bmk.Position,
|
||||
Username: user.UserName,
|
||||
Comment: bmk.Comment,
|
||||
Created: bmk.CreatedAt,
|
||||
Changed: bmk.UpdatedAt,
|
||||
}
|
||||
})
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment, _ := p.String("comment")
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
err = repo.AddBookmark(id, comment, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
err = repo.DeleteBookmark(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
var currentID string
|
||||
if pq.Current >= 0 && pq.Current < len(pq.Items) {
|
||||
currentID = pq.Items[pq.Current].ID
|
||||
}
|
||||
response.PlayQueue = &responses.PlayQueue{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
currentID, _ := p.String("current")
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
currentIndex := 0
|
||||
for i, id := range ids {
|
||||
if id == currentID {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err := repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
var index *int
|
||||
if len(pq.Items) > 0 {
|
||||
index = &pq.Current
|
||||
}
|
||||
|
||||
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
CurrentIndex: index,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
var err error
|
||||
var currentIndex int
|
||||
|
||||
if len(ids) > 0 {
|
||||
currentIndex, err = p.Int("currentIndex")
|
||||
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
470
server/subsonic/browsing.go
Normal file
470
server/subsonic/browsing.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
|
||||
libraries := getUserAccessibleLibraries(r.Context())
|
||||
|
||||
folders := make([]responses.MusicFolder, len(libraries))
|
||||
for i, f := range libraries {
|
||||
folders[i].Id = int32(f.ID)
|
||||
folders[i].Name = f.Name
|
||||
}
|
||||
response := newResponse()
|
||||
response.MusicFolders = &responses.MusicFolders{Folders: folders}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving last scan start time", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
lastScan := time.Now()
|
||||
if lastScanStr != "" {
|
||||
lastScan, err = time.Parse(time.RFC3339, lastScanStr)
|
||||
}
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
if lastScan.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(indexes) == 0 {
|
||||
log.Debug(ctx, "No artists found in library", "libId", libIds)
|
||||
return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty")
|
||||
}
|
||||
}
|
||||
|
||||
return indexes, lastScan.UnixMilli(), err
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &responses.Indexes{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: modified,
|
||||
}
|
||||
|
||||
res.Index = make([]responses.Index, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
res.Index[i].Name = idx.ID
|
||||
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &responses.Artists{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: modified,
|
||||
}
|
||||
|
||||
res.Index = make([]responses.IndexID3, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
res.Index[i].Name = idx.ID
|
||||
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtistID3)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
||||
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
|
||||
|
||||
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Indexes = res
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
|
||||
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
||||
|
||||
res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Artist = res
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, _ := p.String("id")
|
||||
ctx := r.Context()
|
||||
|
||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(r, "Requested ID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dir *responses.Directory
|
||||
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
dir, err = api.buildArtistDirectory(ctx, v)
|
||||
case *model.Album:
|
||||
dir, err = api.buildAlbumDirectory(ctx, v)
|
||||
default:
|
||||
log.Error(r, "Requested ID of invalid type", "id", id, "entity", v)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Directory = dir
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, _ := p.String("id")
|
||||
ctx := r.Context()
|
||||
|
||||
artist, err := api.ds.Artist(ctx).Get(id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Requested ArtistID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Artist not found")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving artist", "id", id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.ArtistWithAlbumsID3, err = api.buildArtist(r, artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving albums by artist", "id", artist.ID, "name", artist.Name, err)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, _ := p.String("id")
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
album, err := api.ds.Album(ctx).Get(id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Requested AlbumID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Album not found")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving album", "id", id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumWithSongsID3 = api.buildAlbum(ctx, album, mfs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
ctx := r.Context()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
album, err := api.provider.UpdateAlbumInfo(ctx, id)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumInfo = &responses.AlbumInfo{}
|
||||
response.AlbumInfo.Notes = album.Description
|
||||
response.AlbumInfo.SmallImageUrl = public.ImageURL(r, album.CoverArtID(), 300)
|
||||
response.AlbumInfo.MediumImageUrl = public.ImageURL(r, album.CoverArtID(), 600)
|
||||
response.AlbumInfo.LargeImageUrl = public.ImageURL(r, album.CoverArtID(), 1200)
|
||||
|
||||
response.AlbumInfo.LastFmUrl = album.ExternalUrl
|
||||
response.AlbumInfo.MusicBrainzID = album.MbzAlbumID
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, _ := p.String("id")
|
||||
ctx := r.Context()
|
||||
|
||||
mf, err := api.ds.MediaFile(ctx).Get(id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(r, "Requested MediaFileID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Song not found")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving MediaFile", "id", id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
child := childFromMediaFile(ctx, *mf)
|
||||
response.Song = &child
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
for i, g := range genres {
|
||||
if g.Name == "" {
|
||||
genres[i].Name = "<Empty>"
|
||||
}
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Genres = toGenres(genres)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *model.Artists, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
count := p.IntOr("count", 20)
|
||||
includeNotPresent := p.BoolOr("includeNotPresent", false)
|
||||
|
||||
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
base := responses.ArtistInfoBase{}
|
||||
base.Biography = artist.Biography
|
||||
base.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300)
|
||||
base.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600)
|
||||
base.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 1200)
|
||||
base.LastFmUrl = artist.ExternalUrl
|
||||
base.MusicBrainzID = artist.MbzArtistID
|
||||
|
||||
return &base, &artist.SimilarArtists, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
|
||||
base, similarArtists, err := api.getArtistInfo(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.ArtistInfo = &responses.ArtistInfo{}
|
||||
response.ArtistInfo.ArtistInfoBase = *base
|
||||
|
||||
for _, s := range *similarArtists {
|
||||
similar := toArtist(r, s)
|
||||
if s.ID == "" {
|
||||
similar.Id = "-1"
|
||||
}
|
||||
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) {
|
||||
base, similarArtists, err := api.getArtistInfo(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.ArtistInfo2 = &responses.ArtistInfo2{}
|
||||
response.ArtistInfo2.ArtistInfoBase = *base
|
||||
|
||||
for _, s := range *similarArtists {
|
||||
similar := toArtistID3(r, s)
|
||||
if s.ID == "" {
|
||||
similar.Id = "-1"
|
||||
}
|
||||
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.IntOr("count", 50)
|
||||
|
||||
songs, err := api.provider.ArtistRadio(ctx, id, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.SimilarSongs = &responses.SimilarSongs{
|
||||
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error) {
|
||||
res, err := api.GetSimilarSongs(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.SimilarSongs2 = &responses.SimilarSongs2{
|
||||
Song: res.SimilarSongs.Song,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
artist, err := p.String("artist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.IntOr("count", 50)
|
||||
|
||||
songs, err := api.provider.TopSongs(ctx, artist, count)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.TopSongs = &responses.TopSongs{
|
||||
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = artist.ID
|
||||
dir.Name = artist.Name
|
||||
dir.PlayCount = artist.PlayCount
|
||||
if artist.PlayCount > 0 {
|
||||
dir.Played = artist.PlayDate
|
||||
}
|
||||
dir.AlbumCount = getArtistAlbumCount(artist)
|
||||
dir.UserRating = int32(artist.Rating)
|
||||
if artist.Starred {
|
||||
dir.Starred = artist.StarredAt
|
||||
}
|
||||
|
||||
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*responses.ArtistWithAlbumsID3, error) {
|
||||
ctx := r.Context()
|
||||
a := &responses.ArtistWithAlbumsID3{}
|
||||
a.ArtistID3 = toArtistID3(r, *artist)
|
||||
|
||||
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = album.ID
|
||||
dir.Name = album.Name
|
||||
dir.Parent = album.AlbumArtistID
|
||||
dir.PlayCount = album.PlayCount
|
||||
if album.PlayCount > 0 {
|
||||
dir.Played = album.PlayDate
|
||||
}
|
||||
dir.UserRating = int32(album.Rating)
|
||||
dir.SongCount = int32(album.SongCount)
|
||||
dir.CoverArt = album.CoverArtID().String()
|
||||
if album.Starred {
|
||||
dir.Starred = album.StarredAt
|
||||
}
|
||||
|
||||
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 {
|
||||
dir := &responses.AlbumWithSongsID3{}
|
||||
dir.AlbumID3 = buildAlbumID3(ctx, *album)
|
||||
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
return dir
|
||||
}
|
||||
160
server/subsonic/browsing_test.go
Normal file
160
server/subsonic/browsing_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
|
||||
libraries := make([]model.Library, len(libraryIDs))
|
||||
for i, id := range libraryIDs {
|
||||
libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)}
|
||||
}
|
||||
user := model.User{
|
||||
ID: userID,
|
||||
Libraries: libraries,
|
||||
}
|
||||
return request.WithUser(ctx, user)
|
||||
}
|
||||
|
||||
var _ = Describe("Browsing", func() {
|
||||
var api *Router
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
api = &Router{ds: ds}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Describe("GetMusicFolders", func() {
|
||||
It("should return all libraries the user has access", func() {
|
||||
// Create mock user with libraries
|
||||
ctx := contextWithUser(ctx, "user-id", 1, 2, 3)
|
||||
|
||||
// Create request
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetMusicFolders(r)
|
||||
|
||||
// Verify results
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.MusicFolders).ToNot(BeNil())
|
||||
Expect(response.MusicFolders.Folders).To(HaveLen(3))
|
||||
Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1"))
|
||||
Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2"))
|
||||
Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndexes", func() {
|
||||
It("should validate user access to the specified musicFolderId", func() {
|
||||
// Create mock user with access to library 1 only
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
|
||||
// Create request with musicFolderId=2 (not accessible)
|
||||
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetIndexes(r)
|
||||
|
||||
// Should return error due to lack of access
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("should default to first accessible library when no musicFolderId specified", func() {
|
||||
// Create mock user with access to libraries 2 and 3
|
||||
ctx = contextWithUser(ctx, "user-id", 2, 3)
|
||||
|
||||
// Setup minimal mock library data for working tests
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(model.Libraries{
|
||||
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
|
||||
{ID: 3, Name: "Test Library 3", Path: "/music/library3"},
|
||||
})
|
||||
|
||||
// Setup mock artist data
|
||||
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockArtistRepo.SetData(model.Artists{
|
||||
{ID: "1", Name: "Test Artist 1"},
|
||||
{ID: "2", Name: "Test Artist 2"},
|
||||
})
|
||||
|
||||
// Create request without musicFolderId
|
||||
r := httptest.NewRequest("GET", "/rest/getIndexes", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetIndexes(r)
|
||||
|
||||
// Should succeed and use first accessible library (2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.Indexes).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtists", func() {
|
||||
It("should validate user access to the specified musicFolderId", func() {
|
||||
// Create mock user with access to library 1 only
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
|
||||
// Create request with musicFolderId=3 (not accessible)
|
||||
r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetArtists(r)
|
||||
|
||||
// Should return error due to lack of access
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("should default to first accessible library when no musicFolderId specified", func() {
|
||||
// Create mock user with access to libraries 1 and 2
|
||||
ctx = contextWithUser(ctx, "user-id", 1, 2)
|
||||
|
||||
// Setup minimal mock library data for working tests
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
|
||||
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
|
||||
})
|
||||
|
||||
// Setup mock artist data
|
||||
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockArtistRepo.SetData(model.Artists{
|
||||
{ID: "1", Name: "Test Artist 1"},
|
||||
{ID: "2", Name: "Test Artist 2"},
|
||||
})
|
||||
|
||||
// Create request without musicFolderId
|
||||
r := httptest.NewRequest("GET", "/rest/getArtists", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetArtists(r)
|
||||
|
||||
// Should succeed and use first accessible library (1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.Artist).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
182
server/subsonic/filter/filters.go
Normal file
182
server/subsonic/filter/filters.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
type Options = model.QueryOptions
|
||||
|
||||
var defaultFilters = Eq{"missing": false}
|
||||
|
||||
func addDefaultFilters(options Options) Options {
|
||||
if options.Filters == nil {
|
||||
options.Filters = defaultFilters
|
||||
} else {
|
||||
options.Filters = And{defaultFilters, options.Filters}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func AlbumsByNewest() Options {
|
||||
return addDefaultFilters(addDefaultFilters(Options{Sort: "recently_added", Order: "desc"}))
|
||||
}
|
||||
|
||||
func AlbumsByRecent() Options {
|
||||
return addDefaultFilters(Options{Sort: "playDate", Order: "desc", Filters: Gt{"play_date": time.Time{}}})
|
||||
}
|
||||
|
||||
func AlbumsByFrequent() Options {
|
||||
return addDefaultFilters(Options{Sort: "playCount", Order: "desc", Filters: Gt{"play_count": 0}})
|
||||
}
|
||||
|
||||
func AlbumsByRandom() Options {
|
||||
return addDefaultFilters(Options{Sort: "random"})
|
||||
}
|
||||
|
||||
func AlbumsByName() Options {
|
||||
return addDefaultFilters(Options{Sort: "name"})
|
||||
}
|
||||
|
||||
func AlbumsByArtist() Options {
|
||||
return addDefaultFilters(Options{Sort: "artist"})
|
||||
}
|
||||
|
||||
func AlbumsByArtistID(artistId string) Options {
|
||||
filters := []Sqlizer{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
|
||||
}
|
||||
if conf.Server.Subsonic.ArtistParticipations {
|
||||
filters = append(filters,
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
|
||||
)
|
||||
}
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "max_year",
|
||||
Filters: Or(filters),
|
||||
})
|
||||
}
|
||||
|
||||
func AlbumsByYear(fromYear, toYear int) Options {
|
||||
orderOption := ""
|
||||
if fromYear > toYear {
|
||||
fromYear, toYear = toYear, fromYear
|
||||
orderOption = "desc"
|
||||
}
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "max_year",
|
||||
Order: orderOption,
|
||||
Filters: Or{
|
||||
And{
|
||||
GtOrEq{"min_year": fromYear},
|
||||
LtOrEq{"min_year": toYear},
|
||||
},
|
||||
And{
|
||||
GtOrEq{"max_year": fromYear},
|
||||
LtOrEq{"max_year": toYear},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func SongsByAlbum(albumId string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Filters: Eq{"album_id": albumId},
|
||||
Sort: "album",
|
||||
})
|
||||
}
|
||||
|
||||
func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
options := Options{
|
||||
Sort: "random",
|
||||
}
|
||||
ff := And{}
|
||||
if genre != "" {
|
||||
ff = append(ff, filterByGenre(genre))
|
||||
}
|
||||
if fromYear != 0 {
|
||||
ff = append(ff, GtOrEq{"year": fromYear})
|
||||
}
|
||||
if toYear != 0 {
|
||||
ff = append(ff, LtOrEq{"year": toYear})
|
||||
}
|
||||
options.Filters = ff
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "lyrics, updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{
|
||||
Eq{"title": title},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
|
||||
if len(musicFolderIds) == 0 {
|
||||
return opts
|
||||
}
|
||||
|
||||
libraryFilter := Eq{"library_id": musicFolderIds}
|
||||
if opts.Filters == nil {
|
||||
opts.Filters = libraryFilter
|
||||
} else {
|
||||
opts.Filters = And{opts.Filters, libraryFilter}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists
|
||||
// that are associated with the specified music folders are included in the results.
|
||||
func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
|
||||
if len(musicFolderIds) == 0 {
|
||||
return opts
|
||||
}
|
||||
|
||||
artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds}
|
||||
if opts.Filters == nil {
|
||||
opts.Filters = artistLibraryFilter
|
||||
} else {
|
||||
opts.Filters = And{opts.Filters, artistLibraryFilter}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func ByGenre(genre string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "name",
|
||||
Filters: filterByGenre(genre),
|
||||
})
|
||||
}
|
||||
|
||||
func filterByGenre(genre string) Sqlizer {
|
||||
return persistence.Exists(`json_tree(tags, "$.genre")`, And{
|
||||
Like{"value": genre},
|
||||
NotEq{"atom": nil},
|
||||
})
|
||||
}
|
||||
|
||||
func ByRating() Options {
|
||||
return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}})
|
||||
}
|
||||
|
||||
func ByStarred() Options {
|
||||
return addDefaultFilters(Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}})
|
||||
}
|
||||
|
||||
func ArtistsByStarred() Options {
|
||||
return Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}}
|
||||
}
|
||||
515
server/subsonic/helpers.go
Normal file
515
server/subsonic/helpers.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"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 []interface{}
|
||||
}
|
||||
|
||||
func newError(code int32, message ...interface{}) 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: public.ImageURL(r, a.CoverArtID(), 600),
|
||||
}
|
||||
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: public.ImageURL(r, a.CoverArtID(), 600),
|
||||
UserRating: int32(a.Rating),
|
||||
}
|
||||
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 childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
child.Title = mf.FullTitle()
|
||||
child.IsDir = false
|
||||
child.Parent = mf.AlbumID
|
||||
child.Album = mf.Album
|
||||
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()
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && player.ReportRealPath {
|
||||
child.Path = mf.AbsolutePath()
|
||||
} else {
|
||||
child.Path = fakePath(mf)
|
||||
}
|
||||
child.DiscNumber = int32(mf.DiscNumber)
|
||||
child.Created = &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)
|
||||
|
||||
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, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(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(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(mf.BitDepth)
|
||||
child.Genres = toItemGenres(mf.Genres)
|
||||
child.Moods = mf.Tags.Values(model.TagMood)
|
||||
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.Album)))
|
||||
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, "/", "_")
|
||||
}
|
||||
|
||||
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
child.IsDir = true
|
||||
child.Title = al.Name
|
||||
child.Name = al.Name
|
||||
child.Album = al.Name
|
||||
child.Artist = al.AlbumArtist
|
||||
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||
child.Genre = al.Genre
|
||||
child.CoverArt = al.CoverArtID().String()
|
||||
child.Created = &al.CreatedAt
|
||||
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)
|
||||
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.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
|
||||
for num, title := range a.Discs {
|
||||
discTitles = append(discTitles, responses.DiscTitle{Disc: int32(num), Title: title})
|
||||
}
|
||||
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.Name
|
||||
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
|
||||
if !album.CreatedAt.IsZero() {
|
||||
dir.Created = &album.CreatedAt
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
275
server/subsonic/helpers_test.go
Normal file
275
server/subsonic/helpers_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("helpers", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
Describe("fakePath", func() {
|
||||
var mf model.MediaFile
|
||||
BeforeEach(func() {
|
||||
mf.AlbumArtist = "Brock Berrigan"
|
||||
mf.Album = "Point Pleasant"
|
||||
mf.Title = "Split Decision"
|
||||
mf.Suffix = "flac"
|
||||
})
|
||||
When("TrackNumber is not available", func() {
|
||||
It("does not add any number to the filename", func() {
|
||||
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/Split Decision.flac"))
|
||||
})
|
||||
})
|
||||
When("TrackNumber is available", func() {
|
||||
It("adds the trackNumber to the path", func() {
|
||||
mf.TrackNumber = 4
|
||||
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/04 - Split Decision.flac"))
|
||||
})
|
||||
})
|
||||
When("TrackNumber and DiscNumber are available", func() {
|
||||
It("adds the trackNumber to the path", func() {
|
||||
mf.TrackNumber = 4
|
||||
mf.DiscNumber = 1
|
||||
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/01-04 - Split Decision.flac"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sanitizeSlashes", func() {
|
||||
It("maps / to _", func() {
|
||||
Expect(sanitizeSlashes("AC/DC")).To(Equal("AC_DC"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sortName", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the order name even if sort name is provided", func() {
|
||||
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
It("returns the order name if sort name is empty", func() {
|
||||
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
})
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the sort name if provided", func() {
|
||||
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Sort Album Name"))
|
||||
})
|
||||
|
||||
It("returns the order name if sort name is empty", func() {
|
||||
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
})
|
||||
It("returns an empty string if both sort name and order name are empty", func() {
|
||||
Expect(sortName("", "")).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildDiscTitles", func() {
|
||||
It("should return nil when album has no discs", func() {
|
||||
album := model.Album{}
|
||||
Expect(buildDiscSubtitles(album)).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return nil when album has only one disc without title", func() {
|
||||
album := model.Album{
|
||||
Discs: map[int]string{
|
||||
1: "",
|
||||
},
|
||||
}
|
||||
Expect(buildDiscSubtitles(album)).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return the disc title for a single disc", func() {
|
||||
album := model.Album{
|
||||
Discs: map[int]string{
|
||||
1: "Special Edition",
|
||||
},
|
||||
}
|
||||
Expect(buildDiscSubtitles(album)).To(Equal([]responses.DiscTitle{{Disc: 1, Title: "Special Edition"}}))
|
||||
})
|
||||
|
||||
It("should return correct disc titles when album has discs with valid disc numbers", func() {
|
||||
album := model.Album{
|
||||
Discs: map[int]string{
|
||||
1: "Disc 1",
|
||||
2: "Disc 2",
|
||||
},
|
||||
}
|
||||
expected := []responses.DiscTitle{
|
||||
{Disc: 1, Title: "Disc 1"},
|
||||
{Disc: 2, Title: "Disc 2"},
|
||||
}
|
||||
Expect(buildDiscSubtitles(album)).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("toItemDate",
|
||||
func(date string, expected responses.ItemDate) {
|
||||
Expect(toItemDate(date)).To(Equal(expected))
|
||||
},
|
||||
Entry("1994-02-04", "1994-02-04", responses.ItemDate{Year: 1994, Month: 2, Day: 4}),
|
||||
Entry("1994-02", "1994-02", responses.ItemDate{Year: 1994, Month: 2}),
|
||||
Entry("1994", "1994", responses.ItemDate{Year: 1994}),
|
||||
Entry("19940201", "", responses.ItemDate{}),
|
||||
Entry("", "", responses.ItemDate{}),
|
||||
)
|
||||
|
||||
DescribeTable("mapExplicitStatus",
|
||||
func(explicitStatus string, expected string) {
|
||||
Expect(mapExplicitStatus(explicitStatus)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns \"clean\" when the db value is \"c\"", "c", "clean"),
|
||||
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"),
|
||||
Entry("returns an empty string when the db value is \"\"", "", ""),
|
||||
Entry("returns an empty string when there are unexpected values on the db", "abc", ""))
|
||||
|
||||
Describe("getArtistAlbumCount", func() {
|
||||
artist := model.Artist{
|
||||
Stats: map[model.Role]model.ArtistStats{
|
||||
model.RoleAlbumArtist: {
|
||||
AlbumCount: 3,
|
||||
},
|
||||
model.RoleMainCredit: {
|
||||
AlbumCount: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
It("Handles album count without artist participations", func() {
|
||||
conf.Server.Subsonic.ArtistParticipations = false
|
||||
result := getArtistAlbumCount(&artist)
|
||||
Expect(result).To(Equal(int32(3)))
|
||||
})
|
||||
|
||||
It("Handles album count without with participations", func() {
|
||||
conf.Server.Subsonic.ArtistParticipations = true
|
||||
result := getArtistAlbumCount(&artist)
|
||||
Expect(result).To(Equal(int32(4)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("selectedMusicFolderIds", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
user = model.User{
|
||||
ID: "test-user",
|
||||
Libraries: []model.Library{
|
||||
{ID: 1, Name: "Library 1"},
|
||||
{ID: 2, Name: "Library 2"},
|
||||
{ID: 3, Name: "Library 3"},
|
||||
},
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), user)
|
||||
})
|
||||
|
||||
Context("when musicFolderId parameter is provided", func() {
|
||||
It("should return the specified musicFolderId values", func() {
|
||||
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{1, 3}))
|
||||
})
|
||||
|
||||
It("should ignore invalid musicFolderId parameter values", func() {
|
||||
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{2})) // Only valid ID is returned
|
||||
})
|
||||
|
||||
It("should return error when any library ID is not accessible", func() {
|
||||
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible"))
|
||||
Expect(ids).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when musicFolderId parameter is not provided", func() {
|
||||
Context("and required is false", func() {
|
||||
It("should return all user's library IDs", func() {
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{1, 2, 3}))
|
||||
})
|
||||
|
||||
It("should return empty slice when user has no libraries", func() {
|
||||
userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}}
|
||||
ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs)
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
r = r.WithContext(ctxWithoutLibs)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("and required is true", func() {
|
||||
It("should return ErrMissingParam error", func() {
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, true)
|
||||
Expect(err).To(MatchError(req.ErrMissingParam))
|
||||
Expect(ids).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("when musicFolderId parameter is empty", func() {
|
||||
It("should return all user's library IDs even when empty parameter is provided", func() {
|
||||
r := httptest.NewRequest("GET", "/test?musicFolderId=", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{1, 2, 3}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when all musicFolderId parameters are invalid", func() {
|
||||
It("should return all user libraries when all musicFolderId parameters are invalid", func() {
|
||||
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ids, err := selectedMusicFolderIds(r, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
136
server/subsonic/jukebox.go
Normal file
136
server/subsonic/jukebox.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionGet = "get"
|
||||
ActionStatus = "status"
|
||||
ActionSet = "set"
|
||||
ActionStart = "start"
|
||||
ActionStop = "stop"
|
||||
ActionSkip = "skip"
|
||||
ActionAdd = "add"
|
||||
ActionClear = "clear"
|
||||
ActionRemove = "remove"
|
||||
ActionShuffle = "shuffle"
|
||||
ActionSetGain = "setGain"
|
||||
)
|
||||
|
||||
func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
user := getUser(ctx)
|
||||
p := req.Params(r)
|
||||
|
||||
if !conf.Server.Jukebox.Enabled {
|
||||
return nil, newError(responses.ErrorGeneric, "Jukebox is disabled")
|
||||
}
|
||||
|
||||
if conf.Server.Jukebox.AdminOnly && !user.IsAdmin {
|
||||
return nil, newError(responses.ErrorAuthorizationFail, "Jukebox is admin only")
|
||||
}
|
||||
|
||||
actionString, err := p.String("action")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pb, err := api.playback.GetDeviceForUser(user.UserName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info(ctx, "JukeboxControl request received", "action", actionString)
|
||||
|
||||
switch actionString {
|
||||
case ActionGet:
|
||||
mediafiles, status, err := pb.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
playlist := responses.JukeboxPlaylist{
|
||||
JukeboxStatus: *deviceStatusToJukeboxStatus(status),
|
||||
Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile),
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.JukeboxPlaylist = &playlist
|
||||
return response, nil
|
||||
case ActionStatus:
|
||||
return createResponse(pb.Status(ctx))
|
||||
case ActionSet:
|
||||
ids, _ := p.Strings("id")
|
||||
return createResponse(pb.Set(ctx, ids))
|
||||
case ActionStart:
|
||||
return createResponse(pb.Start(ctx))
|
||||
case ActionStop:
|
||||
return createResponse(pb.Stop(ctx))
|
||||
case ActionSkip:
|
||||
index, err := p.Int("index")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
offset := p.IntOr("offset", 0)
|
||||
return createResponse(pb.Skip(ctx, index, offset))
|
||||
case ActionAdd:
|
||||
ids, _ := p.Strings("id")
|
||||
return createResponse(pb.Add(ctx, ids))
|
||||
case ActionClear:
|
||||
return createResponse(pb.Clear(ctx))
|
||||
case ActionRemove:
|
||||
index, err := p.Int("index")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return createResponse(pb.Remove(ctx, index))
|
||||
case ActionShuffle:
|
||||
return createResponse(pb.Shuffle(ctx))
|
||||
case ActionSetGain:
|
||||
gainStr, err := p.String("gain")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter gain, err: %s", err)
|
||||
}
|
||||
|
||||
gain, err := strconv.ParseFloat(gainStr, 32)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "error parsing gain float value, err: %s", err)
|
||||
}
|
||||
|
||||
return createResponse(pb.SetGain(ctx, float32(gain)))
|
||||
default:
|
||||
return nil, newError(responses.ErrorMissingParameter, "Unknown action: %s", actionString)
|
||||
}
|
||||
}
|
||||
|
||||
// createResponse is to shorten the case-switch in the JukeboxController
|
||||
func createResponse(status playback.DeviceStatus, err error) (*responses.Subsonic, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return statusResponse(status), nil
|
||||
}
|
||||
|
||||
func statusResponse(status playback.DeviceStatus) *responses.Subsonic {
|
||||
response := newResponse()
|
||||
response.JukeboxStatus = deviceStatusToJukeboxStatus(status)
|
||||
return response
|
||||
}
|
||||
|
||||
func deviceStatusToJukeboxStatus(status playback.DeviceStatus) *responses.JukeboxStatus {
|
||||
return &responses.JukeboxStatus{
|
||||
CurrentIndex: int32(status.CurrentIndex),
|
||||
Playing: status.Playing,
|
||||
Gain: status.Gain,
|
||||
Position: int32(status.Position),
|
||||
}
|
||||
}
|
||||
103
server/subsonic/library_scanning.go
Normal file
103
server/subsonic/library_scanning.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
status, err := api.scanner.Status(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Scanner status", err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
response := newResponse()
|
||||
response.ScanStatus = &responses.ScanStatus{
|
||||
Scanning: status.Scanning,
|
||||
Count: int64(status.Count),
|
||||
FolderCount: int64(status.FolderCount),
|
||||
LastScan: &status.LastScan,
|
||||
Error: status.LastError,
|
||||
ScanType: status.ScanType,
|
||||
ElapsedTime: int64(status.ElapsedTime),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
loggedUser, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
|
||||
if !loggedUser.IsAdmin {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
|
||||
p := req.Params(r)
|
||||
fullScan := p.BoolOr("fullScan", false)
|
||||
|
||||
// Parse optional target parameters for selective scanning
|
||||
var targets []model.ScanTarget
|
||||
if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
|
||||
targets, err = model.ParseTargets(targetParams)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))
|
||||
}
|
||||
|
||||
// Validate all libraries in targets exist and user has access to them
|
||||
userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
|
||||
// Check each target library
|
||||
for _, target := range targets {
|
||||
if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) {
|
||||
return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID))
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: if single library with empty path and it's the only library in DB, call ScanAll
|
||||
if len(targets) == 1 && targets[0].FolderPath == "" {
|
||||
allLibs, err := api.ds.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
if len(allLibs) == 1 {
|
||||
targets = nil // This will trigger ScanAll below
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
var err error
|
||||
|
||||
if len(targets) > 0 {
|
||||
log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName)
|
||||
_, err = api.scanner.ScanFolders(ctx, fullScan, targets)
|
||||
} else {
|
||||
log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName)
|
||||
_, err = api.scanner.ScanAll(ctx, fullScan)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error scanning", err)
|
||||
return
|
||||
}
|
||||
log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
|
||||
}()
|
||||
|
||||
return api.GetScanStatus(r)
|
||||
}
|
||||
396
server/subsonic/library_scanning_test.go
Normal file
396
server/subsonic/library_scanning_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LibraryScanning", func() {
|
||||
var api *Router
|
||||
var ms *tests.MockScanner
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = tests.NewMockScanner()
|
||||
api = &Router{scanner: ms}
|
||||
})
|
||||
|
||||
Describe("StartScan", func() {
|
||||
It("requires admin authentication", func() {
|
||||
// Create non-admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "user-id",
|
||||
IsAdmin: false,
|
||||
})
|
||||
|
||||
// Create request
|
||||
r := httptest.NewRequest("GET", "/rest/startScan", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should return authorization error
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
|
||||
})
|
||||
|
||||
It("triggers a full scan with no parameters", func() {
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with no parameters
|
||||
r := httptest.NewRequest("GET", "/rest/startScan", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanAll was called (eventually, since it's in a goroutine)
|
||||
Eventually(func() int {
|
||||
return ms.GetScanAllCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanAllCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].FullScan).To(BeFalse())
|
||||
})
|
||||
|
||||
It("triggers a full scan with fullScan=true", func() {
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with fullScan parameter
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanAll was called with fullScan=true
|
||||
Eventually(func() int {
|
||||
return ms.GetScanAllCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanAllCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].FullScan).To(BeTrue())
|
||||
})
|
||||
|
||||
It("triggers a selective scan with single target parameter", func() {
|
||||
// Setup mocks
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
|
||||
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with single target parameter
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanFolders was called with correct targets
|
||||
Eventually(func() int {
|
||||
return ms.GetScanFoldersCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
targets := calls[0].Targets
|
||||
Expect(targets).To(HaveLen(1))
|
||||
Expect(targets[0].LibraryID).To(Equal(1))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
})
|
||||
|
||||
It("triggers a selective scan with multiple target parameters", func() {
|
||||
// Setup mocks
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
|
||||
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with multiple target parameters
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanFolders was called with correct targets
|
||||
Eventually(func() int {
|
||||
return ms.GetScanFoldersCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
targets := calls[0].Targets
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].LibraryID).To(Equal(1))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Reggae"))
|
||||
Expect(targets[1].LibraryID).To(Equal(2))
|
||||
Expect(targets[1].FolderPath).To(Equal("Classical/Bach"))
|
||||
})
|
||||
|
||||
It("triggers a selective full scan with target and fullScan parameters", func() {
|
||||
// Setup mocks
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
|
||||
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with target and fullScan parameters
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanFolders was called with fullScan=true
|
||||
Eventually(func() int {
|
||||
return ms.GetScanFoldersCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].FullScan).To(BeTrue())
|
||||
targets := calls[0].Targets
|
||||
Expect(targets).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns error for invalid target format", func() {
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with invalid target format (missing colon)
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should return error
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||
})
|
||||
|
||||
It("returns error for invalid library ID in target", func() {
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with invalid library ID
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should return error
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||
})
|
||||
|
||||
It("returns error when library does not exist", func() {
|
||||
// Setup mocks - user has access to library 1 and 2 only
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
|
||||
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with library ID that doesn't exist
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should return ErrorDataNotFound
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
|
||||
It("calls ScanAll when single library with empty path and only one library exists", func() {
|
||||
// Setup mocks - single library in DB
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
|
||||
mockLibraryRepo := &tests.MockLibraryRepo{}
|
||||
mockLibraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Music Library", Path: "/music"},
|
||||
})
|
||||
mockDS := &tests.MockDataStore{
|
||||
MockedUser: mockUserRepo,
|
||||
MockedLibrary: mockLibraryRepo,
|
||||
}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with single library and empty path
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanAll was called instead of ScanFolders
|
||||
Eventually(func() int {
|
||||
return ms.GetScanAllCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
Expect(ms.GetScanFoldersCallCount()).To(Equal(0))
|
||||
})
|
||||
|
||||
It("calls ScanFolders when single library with empty path but multiple libraries exist", func() {
|
||||
// Setup mocks - multiple libraries in DB
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
|
||||
mockLibraryRepo := &tests.MockLibraryRepo{}
|
||||
mockLibraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Music Library", Path: "/music"},
|
||||
{ID: 2, Name: "Audiobooks", Path: "/audiobooks"},
|
||||
})
|
||||
mockDS := &tests.MockDataStore{
|
||||
MockedUser: mockUserRepo,
|
||||
MockedLibrary: mockLibraryRepo,
|
||||
}
|
||||
api.ds = mockDS
|
||||
|
||||
// Create admin user
|
||||
ctx := request.WithUser(context.Background(), model.User{
|
||||
ID: "admin-id",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create request with single library and empty path
|
||||
r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.StartScan(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
|
||||
// Verify ScanFolders was called (not ScanAll)
|
||||
Eventually(func() int {
|
||||
return ms.GetScanFoldersCallCount()
|
||||
}).Should(BeNumerically(">", 0))
|
||||
calls := ms.GetScanFoldersCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
targets := calls[0].Targets
|
||||
Expect(targets).To(HaveLen(1))
|
||||
Expect(targets[0].LibraryID).To(Equal(1))
|
||||
Expect(targets[0].FolderPath).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetScanStatus", func() {
|
||||
It("returns scan status", func() {
|
||||
// Setup mock scanner status
|
||||
ms.SetStatusResponse(&model.ScannerStatus{
|
||||
Scanning: false,
|
||||
Count: 100,
|
||||
FolderCount: 10,
|
||||
})
|
||||
|
||||
// Create request
|
||||
ctx := context.Background()
|
||||
r := httptest.NewRequest("GET", "/rest/getScanStatus", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Call endpoint
|
||||
response, err := api.GetScanStatus(r)
|
||||
|
||||
// Should succeed
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.ScanStatus).ToNot(BeNil())
|
||||
Expect(response.ScanStatus.Scanning).To(BeFalse())
|
||||
Expect(response.ScanStatus.Count).To(Equal(int64(100)))
|
||||
Expect(response.ScanStatus.FolderCount).To(Equal(int64(10)))
|
||||
})
|
||||
})
|
||||
})
|
||||
222
server/subsonic/media_annotation.go
Normal file
222
server/subsonic/media_annotation.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rating, err := p.Int("rating")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
||||
err = api.setRating(r.Context(), id, rating)
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) setRating(ctx context.Context, id string, rating int) error {
|
||||
var repo model.AnnotatedRepository
|
||||
var resource string
|
||||
|
||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch entity.(type) {
|
||||
case *model.Artist:
|
||||
repo = api.ds.Artist(ctx)
|
||||
resource = "artist"
|
||||
case *model.Album:
|
||||
repo = api.ds.Album(ctx)
|
||||
resource = "album"
|
||||
default:
|
||||
repo = api.ds.MediaFile(ctx)
|
||||
resource = "song"
|
||||
}
|
||||
err = repo.SetRating(rating, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := &events.RefreshResource{}
|
||||
api.broker.SendMessage(ctx, event.With(resource, id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
albumIds, _ := p.Strings("albumId")
|
||||
artistIds, _ := p.Strings("artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
ids = append(ids, albumIds...)
|
||||
ids = append(ids, artistIds...)
|
||||
|
||||
err := api.setStar(r.Context(), true, ids...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
albumIds, _ := p.Strings("albumId")
|
||||
artistIds, _ := p.Strings("artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
ids = append(ids, albumIds...)
|
||||
ids = append(ids, artistIds...)
|
||||
|
||||
err := api.setStar(r.Context(), false, ids...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Debug(ctx, "Changing starred", "ids", ids, "starred", star)
|
||||
if len(ids) == 0 {
|
||||
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
|
||||
return nil
|
||||
}
|
||||
event := &events.RefreshResource{}
|
||||
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
for _, id := range ids {
|
||||
exist, err := tx.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Album(ctx).SetStar(star, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event = event.With("album", id)
|
||||
continue
|
||||
}
|
||||
exist, err = tx.Artist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Artist(ctx).SetStar(star, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event = event.With("artist", id)
|
||||
continue
|
||||
}
|
||||
err = tx.MediaFile(ctx).SetStar(star, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event = event.With("song", id)
|
||||
}
|
||||
api.broker.SendMessage(ctx, event)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, err := p.Strings("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times, _ := p.Times("time")
|
||||
if len(times) > 0 && len(times) != len(ids) {
|
||||
return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
|
||||
}
|
||||
submission := p.BoolOr("submission", true)
|
||||
position := p.IntOr("position", 0)
|
||||
ctx := r.Context()
|
||||
|
||||
if submission {
|
||||
err := api.scrobblerSubmit(ctx, ids, times)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err)
|
||||
}
|
||||
} else {
|
||||
err := api.scrobblerNowPlaying(ctx, ids[0], position)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []time.Time) error {
|
||||
var submissions []scrobbler.Submission
|
||||
log.Debug(ctx, "Scrobbling tracks", "ids", ids, "times", times)
|
||||
for i, id := range ids {
|
||||
var t time.Time
|
||||
if len(times) > 0 {
|
||||
t = times[i]
|
||||
} else {
|
||||
t = time.Now()
|
||||
}
|
||||
submissions = append(submissions, scrobbler.Submission{TrackID: id, Timestamp: t})
|
||||
}
|
||||
|
||||
return api.scrobbler.Submit(ctx, submissions)
|
||||
}
|
||||
|
||||
func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error {
|
||||
mf, err := api.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mf == nil {
|
||||
return fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
client, _ := request.ClientFrom(ctx)
|
||||
clientId, ok := request.ClientUniqueIdFrom(ctx)
|
||||
if !ok {
|
||||
clientId = player.ID
|
||||
}
|
||||
|
||||
log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position)
|
||||
err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position)
|
||||
return err
|
||||
}
|
||||
145
server/subsonic/media_annotation_test.go
Normal file
145
server/subsonic/media_annotation_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaAnnotationController", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
var playTracker *fakePlayTracker
|
||||
var eventBroker *fakeEventBroker
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("submit all scrobbles with only the id", func() {
|
||||
submissionTime := time.Now()
|
||||
r := newGetRequest("id=12", "id=34")
|
||||
|
||||
_, err := router.Scrobble(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playTracker.Submissions).To(HaveLen(2))
|
||||
Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally(">", submissionTime))
|
||||
Expect(playTracker.Submissions[0].TrackID).To(Equal("12"))
|
||||
Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally(">", submissionTime))
|
||||
Expect(playTracker.Submissions[1].TrackID).To(Equal("34"))
|
||||
})
|
||||
|
||||
It("submit all scrobbles with respective times", func() {
|
||||
time1 := time.Now().Add(-20 * time.Minute)
|
||||
t1 := time1.UnixMilli()
|
||||
time2 := time.Now().Add(-10 * time.Minute)
|
||||
t2 := time2.UnixMilli()
|
||||
r := newGetRequest("id=12", "id=34", fmt.Sprintf("time=%d", t1), fmt.Sprintf("time=%d", t2))
|
||||
|
||||
_, err := router.Scrobble(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playTracker.Submissions).To(HaveLen(2))
|
||||
Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally("~", time1))
|
||||
Expect(playTracker.Submissions[0].TrackID).To(Equal("12"))
|
||||
Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally("~", time2))
|
||||
Expect(playTracker.Submissions[1].TrackID).To(Equal("34"))
|
||||
})
|
||||
|
||||
It("checks if number of ids match number of times", func() {
|
||||
r := newGetRequest("id=12", "id=34", "time=1111")
|
||||
|
||||
_, err := router.Scrobble(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(playTracker.Submissions).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("submission=false", func() {
|
||||
var req *http.Request
|
||||
BeforeEach(func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "12"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ID: "player-1"})
|
||||
req = newGetRequest("id=12", "submission=false")
|
||||
req = req.WithContext(ctx)
|
||||
})
|
||||
|
||||
It("does not scrobble", func() {
|
||||
_, err := router.Scrobble(req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playTracker.Submissions).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("registers a NowPlaying", func() {
|
||||
_, err := router.Scrobble(req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playTracker.Playing).To(HaveLen(1))
|
||||
Expect(playTracker.Playing).To(HaveKey("player-1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakePlayTracker struct {
|
||||
Submissions []scrobbler.Submission
|
||||
Playing map[string]string
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error {
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
if f.Playing == nil {
|
||||
f.Playing = make(map[string]string)
|
||||
}
|
||||
f.Playing[playerId] = trackId
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
|
||||
return nil, f.Error
|
||||
}
|
||||
|
||||
func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Submission) error {
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
f.Submissions = append(f.Submissions, submissions...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
|
||||
|
||||
type fakeEventBroker struct {
|
||||
http.Handler
|
||||
Events []events.Event
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
|
||||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
|
||||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
153
server/subsonic/media_retrieval.go
Normal file
153
server/subsonic/media_retrieval.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
if !conf.Server.EnableGravatar {
|
||||
return api.getPlaceHolderAvatar(w, r)
|
||||
}
|
||||
p := req.Params(r)
|
||||
username, err := p.String("username")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, err := api.ds.User(ctx).FindByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Email == "" {
|
||||
log.Warn(ctx, "User needs an email for gravatar to work", "username", username)
|
||||
return api.getPlaceHolderAvatar(w, r)
|
||||
}
|
||||
http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (api *Router) getPlaceHolderAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
f, err := resources.FS().Open(consts.PlaceholderAvatar)
|
||||
if err != nil {
|
||||
log.Error(r, "Image not found", err)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = io.Copy(w, f)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
// If context is already canceled, discard request without further processing
|
||||
if r.Context().Err() != nil {
|
||||
return nil, nil //nolint:nilerr
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
p := req.Params(r)
|
||||
id, _ := p.String("id")
|
||||
size := p.IntOr("size", 0)
|
||||
square := p.BoolOr("square", false)
|
||||
|
||||
imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square)
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return nil, nil
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
log.Warn(r, "Couldn't find coverArt", "id", id, err)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Artwork not found")
|
||||
case err != nil:
|
||||
log.Error(r, "Error retrieving coverArt", "id", id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer imgReader.Close()
|
||||
w.Header().Set("cache-control", "public, max-age=315360000")
|
||||
w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123))
|
||||
|
||||
cnt, err := io.Copy(w, imgReader)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error sending image", "count", cnt, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
artist, _ := p.String("artist")
|
||||
title, _ := p.String("title")
|
||||
response := newResponse()
|
||||
lyricsResponse := responses.Lyrics{}
|
||||
response.Lyrics = &lyricsResponse
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mediaFiles) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(structuredLyrics) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
lyricsResponse.Artist = artist
|
||||
lyricsResponse.Title = title
|
||||
|
||||
lyricsText := ""
|
||||
for _, line := range structuredLyrics[0].Line {
|
||||
lyricsText += line.Value + "\n"
|
||||
}
|
||||
|
||||
lyricsResponse.Value = lyricsText
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := req.Params(r).String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mediaFile, err := api.ds.MediaFile(r.Context()).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
379
server/subsonic/media_retrieval_test.go
Normal file
379
server/subsonic/media_retrieval_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaRetrievalController", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
|
||||
var artwork *fakeArtwork
|
||||
var w *httptest.ResponseRecorder
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
})
|
||||
|
||||
Describe("GetCoverArt", func() {
|
||||
It("should return data for that id", func() {
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artwork.recvSize).To(Equal(128))
|
||||
Expect(artwork.recvSquare).To(BeTrue())
|
||||
Expect(w.Body.String()).To(Equal(artwork.data))
|
||||
})
|
||||
|
||||
It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() {
|
||||
r := newGetRequest() // No id parameter
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artwork.recvId).To(BeEmpty())
|
||||
Expect(w.Body.String()).To(Equal(artwork.data))
|
||||
})
|
||||
|
||||
It("should fail when the file is not found", func() {
|
||||
artwork.err = model.ErrNotFound
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
Expect(err).To(MatchError("Artwork not found"))
|
||||
})
|
||||
|
||||
It("should fail when there is an unknown error", func() {
|
||||
artwork.err = errors.New("weird error")
|
||||
r := newGetRequest("id=34", "size=128")
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
Expect(err).To(MatchError("weird error"))
|
||||
})
|
||||
|
||||
When("client disconnects (context is cancelled)", func() {
|
||||
It("should not call the service if cancelled before the call", func() {
|
||||
// Create a request
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
r = r.WithContext(ctx)
|
||||
cancel() // Cancel the context before the call
|
||||
|
||||
// Call the GetCoverArt method
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
// Expect no error and no call to the artwork service
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artwork.recvId).To(Equal(""))
|
||||
Expect(artwork.recvSize).To(Equal(0))
|
||||
Expect(artwork.recvSquare).To(BeFalse())
|
||||
Expect(w.Body.String()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not return data if cancelled during the call", func() {
|
||||
// Create a request with a context that will be cancelled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel() // Ensure the context is cancelled after the test (best practices)
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
r = r.WithContext(ctx)
|
||||
artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service
|
||||
|
||||
// Call the GetCoverArt method
|
||||
_, err := router.GetCoverArt(w, r)
|
||||
|
||||
// Expect no error and the service to have been called
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artwork.recvId).To(Equal("34"))
|
||||
Expect(artwork.recvSize).To(Equal(128))
|
||||
Expect(artwork.recvSquare).To(BeTrue())
|
||||
Expect(w.Body.String()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLyrics", func() {
|
||||
It("should return data for given artist & title", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*lyrics,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "2",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
|
||||
},
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
|
||||
},
|
||||
{
|
||||
ID: "3",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
||||
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
||||
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
||||
})
|
||||
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
|
||||
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
|
||||
mockRepo.SetData(model.MediaFiles{})
|
||||
response, err := router.GetLyrics(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Lyrics.Artist).To(Equal(""))
|
||||
Expect(response.Lyrics.Title).To(Equal(""))
|
||||
Expect(response.Lyrics.Value).To(Equal(""))
|
||||
})
|
||||
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
},
|
||||
{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
ID: "2",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
||||
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
||||
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLyricsBySongId", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
|
||||
var times = []int64{18800, 22801}
|
||||
|
||||
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
|
||||
Expect(actual).ToNot(BeNil())
|
||||
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
|
||||
for i, realLyric := range actual.StructuredLyrics {
|
||||
expectedLyric := expected.StructuredLyrics[i]
|
||||
|
||||
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
||||
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
||||
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
||||
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
||||
|
||||
if expectedLyric.Offset == nil {
|
||||
Expect(realLyric.Offset).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
|
||||
}
|
||||
|
||||
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
|
||||
for j, realLine := range realLyric.Line {
|
||||
expectedLine := expectedLyric.Line[j]
|
||||
Expect(realLine.Value).To(Equal(expectedLine.Value))
|
||||
|
||||
if expectedLine.Start == nil {
|
||||
Expect(realLine.Start).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It("should return mixed lyrics", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced, *unsynced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
Lang: "eng",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Lang: "xxx",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: false,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
offset := int64(-100)
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: &offset,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeArtwork struct {
|
||||
artwork.Artwork
|
||||
data string
|
||||
err error
|
||||
ctxCancelFunc func()
|
||||
recvId string
|
||||
recvSize int
|
||||
recvSquare bool
|
||||
}
|
||||
|
||||
func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if c.err != nil {
|
||||
return nil, time.Time{}, c.err
|
||||
}
|
||||
c.recvId = id
|
||||
c.recvSize = size
|
||||
c.recvSquare = square
|
||||
if c.ctxCancelFunc != nil {
|
||||
c.ctxCancelFunc() // Simulate context cancellation
|
||||
return nil, time.Time{}, context.Canceled
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
|
||||
}
|
||||
|
||||
type mockedMediaFile struct {
|
||||
tests.MockMediaFileRepo
|
||||
}
|
||||
|
||||
func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
data, err := m.MockMediaFileRepo.GetAll(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Hardcoded support for lyrics sorting
|
||||
result := slices.Clone(data)
|
||||
// Sort by presence of lyrics, then by updated_at. Respect the order specified in opts.
|
||||
slices.SortFunc(result, func(a, b model.MediaFile) int {
|
||||
diff := cmp.Or(
|
||||
cmp.Compare(a.Lyrics, b.Lyrics),
|
||||
cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()),
|
||||
)
|
||||
if opts[0].Order == "desc" {
|
||||
return -diff
|
||||
}
|
||||
return diff
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
272
server/subsonic/middlewares.go
Normal file
272
server/subsonic/middlewares.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
ua "github.com/mileusna/useragent"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
|
||||
}
|
||||
var parts []string
|
||||
for key, values := range r.Form {
|
||||
for _, v := range values {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = strings.Join(parts, "&")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
|
||||
username := server.InternalAuth(r)
|
||||
|
||||
// If the username comes from internal auth, do not also do reverse proxy auth, as
|
||||
// the request will have no reverse proxy IP
|
||||
if username != "" {
|
||||
return username, true
|
||||
}
|
||||
|
||||
return server.UsernameFromExtAuthHeader(r), false
|
||||
}
|
||||
|
||||
func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var requiredParameters []string
|
||||
|
||||
username, _ := fromInternalOrProxyAuth(r)
|
||||
if username != "" {
|
||||
requiredParameters = []string{"v", "c"}
|
||||
} else {
|
||||
requiredParameters = []string{"u", "v", "c"}
|
||||
}
|
||||
|
||||
p := req.Params(r)
|
||||
for _, param := range requiredParameters {
|
||||
if _, err := p.String(param); err != nil {
|
||||
log.Warn(r, err)
|
||||
sendError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
username, _ = p.String("u")
|
||||
}
|
||||
client, _ := p.String("c")
|
||||
version, _ := p.String("v")
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = request.WithUsername(ctx, username)
|
||||
ctx = request.WithClient(ctx, client)
|
||||
ctx = request.WithVersion(ctx, version)
|
||||
log.Debug(ctx, "API: New request "+r.URL.Path, "username", username, "client", client, "version", version)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var usr *model.User
|
||||
var err error
|
||||
|
||||
username, isInternalAuth := fromInternalOrProxyAuth(r)
|
||||
if username != "" {
|
||||
authType := If(isInternalAuth, "internal", "reverse-proxy")
|
||||
usr, err = ds.User(ctx).FindByUsername(username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
} else if err != nil {
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
} else {
|
||||
p := req.Params(r)
|
||||
username, _ := p.String("u")
|
||||
pass, _ := p.String("p")
|
||||
token, _ := p.String("t")
|
||||
salt, _ := p.String("s")
|
||||
jwt, _ := p.String("jwt")
|
||||
|
||||
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
case err != nil:
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
default:
|
||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
sendError(w, r, newError(responses.ErrorAuthenticationFail))
|
||||
return
|
||||
}
|
||||
|
||||
ctx = request.WithUser(ctx, *usr)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == user.UserName
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
valid = pass == user.Password
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
|
||||
valid = t == token
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPlayer(players core.Players) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userName, _ := request.UsernameFrom(ctx)
|
||||
client, _ := request.ClientFrom(ctx)
|
||||
playerId := playerIDFromCookie(r, userName)
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
userAgent := canonicalUserAgent(r)
|
||||
player, trc, err := players.Register(ctx, playerId, client, userAgent, ip)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not register player", "username", userName, "client", client, err)
|
||||
} else {
|
||||
ctx = request.WithPlayer(ctx, *player)
|
||||
if trc != nil {
|
||||
ctx = request.WithTranscoding(ctx, *trc)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName(userName),
|
||||
Value: player.ID,
|
||||
MaxAge: consts.CookieExpiry,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: cmp.Or(conf.Server.BasePath, "/"),
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalUserAgent(r *http.Request) string {
|
||||
u := ua.Parse(r.Header.Get("user-agent"))
|
||||
userAgent := u.Name
|
||||
if u.OS != "" {
|
||||
userAgent = userAgent + "/" + u.OS
|
||||
}
|
||||
return userAgent
|
||||
}
|
||||
|
||||
func playerIDFromCookie(r *http.Request, userName string) string {
|
||||
cookieName := playerIDCookieName(userName)
|
||||
var playerId string
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
playerId = c.Value
|
||||
log.Trace(r, "playerId found in cookies", "playerId", playerId)
|
||||
}
|
||||
return playerId
|
||||
}
|
||||
|
||||
func playerIDCookieName(userName string) string {
|
||||
cookieName := fmt.Sprintf("nd-player-%x", userName)
|
||||
return cookieName
|
||||
}
|
||||
|
||||
const subsonicErrorPointer = "subsonicErrorPointer"
|
||||
|
||||
func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
status := int32(-1)
|
||||
contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status)
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
|
||||
// We want to get the client name (even if not present for certain endpoints)
|
||||
p := req.Params(r)
|
||||
client, _ := p.String("c")
|
||||
|
||||
// If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP
|
||||
if status == -1 {
|
||||
status = int32(ww.Status())
|
||||
}
|
||||
|
||||
shortPath := strings.Replace(r.URL.Path, ".view", "", 1)
|
||||
|
||||
metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r.WithContext(contextWithStatus))
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
501
server/subsonic/middlewares_test.go
Normal file
501
server/subsonic/middlewares_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func newGetRequest(queryParams ...string) *http.Request {
|
||||
r := httptest.NewRequest("GET", "/ping?"+strings.Join(queryParams, "&"), nil)
|
||||
ctx := r.Context()
|
||||
return r.WithContext(log.NewContext(ctx))
|
||||
}
|
||||
|
||||
func newPostRequest(queryParam string, formFields ...string) *http.Request {
|
||||
r, err := http.NewRequest("POST", "/ping?"+queryParam, strings.NewReader(strings.Join(formFields, "&")))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
|
||||
ctx := r.Context()
|
||||
return r.WithContext(log.NewContext(ctx))
|
||||
}
|
||||
|
||||
var _ = Describe("Middlewares", func() {
|
||||
var next *mockHandler
|
||||
var w *httptest.ResponseRecorder
|
||||
var ds model.DataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
next = &mockHandler{}
|
||||
w = httptest.NewRecorder()
|
||||
ds = &tests.MockDataStore{}
|
||||
})
|
||||
|
||||
Describe("ParsePostForm", func() {
|
||||
It("converts any filed in a x-www-form-urlencoded POST into query params", func() {
|
||||
r := newPostRequest("a=abc", "u=user", "v=1.15", "c=test")
|
||||
cp := postFormToQueryParams(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
|
||||
Expect(next.req.URL.Query().Get("u")).To(Equal("user"))
|
||||
Expect(next.req.URL.Query().Get("v")).To(Equal("1.15"))
|
||||
Expect(next.req.URL.Query().Get("c")).To(Equal("test"))
|
||||
})
|
||||
It("adds repeated params", func() {
|
||||
r := newPostRequest("a=abc", "id=1", "id=2")
|
||||
cp := postFormToQueryParams(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
|
||||
Expect(next.req.URL.Query()["id"]).To(ConsistOf("1", "2"))
|
||||
})
|
||||
It("overrides query params with same key", func() {
|
||||
r := newPostRequest("a=query", "a=body")
|
||||
cp := postFormToQueryParams(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.req.URL.Query().Get("a")).To(Equal("body"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CheckParams", func() {
|
||||
It("passes when all required params are available (subsonicauth case)", func() {
|
||||
r := newGetRequest("u=user", "v=1.15", "c=test")
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
username, _ := request.UsernameFrom(next.req.Context())
|
||||
Expect(username).To(Equal("user"))
|
||||
version, _ := request.VersionFrom(next.req.Context())
|
||||
Expect(version).To(Equal("1.15"))
|
||||
client, _ := request.ClientFrom(next.req.Context())
|
||||
Expect(client).To(Equal("test"))
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("passes when all required params are available (reverse-proxy case)", func() {
|
||||
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
|
||||
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||
|
||||
r := newGetRequest("v=1.15", "c=test")
|
||||
r.Header.Add("Remote-User", "user")
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "127.0.0.234"))
|
||||
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
username, _ := request.UsernameFrom(next.req.Context())
|
||||
Expect(username).To(Equal("user"))
|
||||
version, _ := request.VersionFrom(next.req.Context())
|
||||
Expect(version).To(Equal("1.15"))
|
||||
client, _ := request.ClientFrom(next.req.Context())
|
||||
Expect(client).To(Equal("test"))
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails when user is missing", func() {
|
||||
r := newGetRequest("v=1.15", "c=test")
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fails when version is missing", func() {
|
||||
r := newGetRequest("u=user", "c=test")
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fails when client is missing", func() {
|
||||
r := newGetRequest("u=user", "v=1.15")
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Authenticate", func() {
|
||||
BeforeEach(func() {
|
||||
ur := ds.User(context.TODO())
|
||||
_ = ur.Put(&model.User{
|
||||
UserName: "admin",
|
||||
NewPassword: "wordpass",
|
||||
})
|
||||
})
|
||||
|
||||
When("using password authentication", func() {
|
||||
It("passes authentication with correct credentials", func() {
|
||||
r := newGetRequest("u=admin", "p=wordpass")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with invalid user", func() {
|
||||
r := newGetRequest("u=invalid", "p=wordpass")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fails authentication with invalid password", func() {
|
||||
r := newGetRequest("u=admin", "p=INVALID")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
When("using token authentication", func() {
|
||||
var salt = "12345"
|
||||
|
||||
It("passes authentication with correct token", func() {
|
||||
token := fmt.Sprintf("%x", md5.Sum([]byte("wordpass"+salt)))
|
||||
r := newGetRequest("u=admin", "t="+token, "s="+salt)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with invalid token", func() {
|
||||
r := newGetRequest("u=admin", "t=INVALID", "s="+salt)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fails authentication with empty password", func() {
|
||||
// Token generated with random Salt, empty password
|
||||
token := fmt.Sprintf("%x", md5.Sum([]byte(""+salt)))
|
||||
r := newGetRequest("u=NON_EXISTENT_USER", "t="+token, "s="+salt)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
When("using JWT authentication", func() {
|
||||
var validToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
auth.Init(ds)
|
||||
})
|
||||
|
||||
It("passes authentication with correct token", func() {
|
||||
usr := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(usr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
r := newGetRequest("u=admin", "jwt="+validToken)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with invalid token", func() {
|
||||
r := newGetRequest("u=admin", "jwt=INVALID_TOKEN")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
When("using reverse proxy authentication", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
|
||||
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||
})
|
||||
|
||||
It("passes authentication with correct IP and header", func() {
|
||||
r := newGetRequest("u=admin")
|
||||
r.Header.Add("Remote-User", "admin")
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1"))
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong IP", func() {
|
||||
r := newGetRequest("u=admin")
|
||||
r.Header.Add("Remote-User", "admin")
|
||||
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.2.1"))
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
When("using internal authentication", func() {
|
||||
It("passes authentication with correct internal credentials", func() {
|
||||
// Simulate internal authentication by setting the context with WithInternalAuth
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(request.WithInternalAuth(r.Context(), "admin"))
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with missing internal context", func() {
|
||||
r := newGetRequest("u=admin")
|
||||
// Do not set the internal auth context
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
// Internal auth requires the context, so this should fail
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPlayer", func() {
|
||||
var mockedPlayers *mockPlayers
|
||||
var r *http.Request
|
||||
BeforeEach(func() {
|
||||
mockedPlayers = &mockPlayers{}
|
||||
r = newGetRequest()
|
||||
ctx := request.WithUsername(r.Context(), "someone")
|
||||
ctx = request.WithClient(ctx, "client")
|
||||
r = r.WithContext(ctx)
|
||||
})
|
||||
|
||||
It("returns a new player in the cookies when none is specified", func() {
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
|
||||
})
|
||||
|
||||
It("does not add the cookie if there was an error", func() {
|
||||
ctx := request.WithClient(r.Context(), "error")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("PlayerId specified in Cookies", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName("someone"),
|
||||
Value: "123",
|
||||
MaxAge: consts.CookieExpiry,
|
||||
}
|
||||
r.AddCookie(cookie)
|
||||
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
It("stores the player in the context", func() {
|
||||
Expect(next.called).To(BeTrue())
|
||||
player, _ := request.PlayerFrom(next.req.Context())
|
||||
Expect(player.ID).To(Equal("123"))
|
||||
_, ok := request.TranscodingFrom(next.req.Context())
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns the playerId in the cookie", func() {
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Player has transcoding configured", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName("someone"),
|
||||
Value: "123",
|
||||
MaxAge: consts.CookieExpiry,
|
||||
}
|
||||
r.AddCookie(cookie)
|
||||
mockedPlayers.transcoding = &model.Transcoding{ID: "12"}
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
It("stores the player in the context", func() {
|
||||
player, _ := request.PlayerFrom(next.req.Context())
|
||||
Expect(player.ID).To(Equal("123"))
|
||||
transcoding, _ := request.TranscodingFrom(next.req.Context())
|
||||
Expect(transcoding.ID).To(Equal("12"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateCredentials", func() {
|
||||
var usr *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
ur := ds.User(context.TODO())
|
||||
_ = ur.Put(&model.User{
|
||||
UserName: "admin",
|
||||
NewPassword: "wordpass",
|
||||
})
|
||||
|
||||
var err error
|
||||
usr, err = ur.FindByUsernameWithPassword("admin")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
err := validateCredentials(usr, "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
err := validateCredentials(usr, "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
err := validateCredentials(usr, "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var usr *model.User
|
||||
var validToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
auth.Init(ds)
|
||||
|
||||
usr = &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(usr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
err := validateCredentials(usr, "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
err := validateCredentials(usr, "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
u := &model.User{UserName: "hacker"}
|
||||
validToken, _ = auth.CreateToken(u)
|
||||
err := validateCredentials(usr, "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockHandler struct {
|
||||
req *http.Request
|
||||
called bool
|
||||
}
|
||||
|
||||
func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
mh.req = r
|
||||
mh.called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
type mockPlayers struct {
|
||||
core.Players
|
||||
transcoding *model.Transcoding
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return &model.Player{ID: playerId}, nil
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
if client == "error" {
|
||||
return nil, nil, errors.New(client)
|
||||
}
|
||||
return &model.Player{ID: id}, mp.transcoding, nil
|
||||
}
|
||||
18
server/subsonic/opensubsonic.go
Normal file
18
server/subsonic/opensubsonic.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
)
|
||||
|
||||
func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
45
server/subsonic/opensubsonic_test.go
Normal file
45
server/subsonic/opensubsonic_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package subsonic_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
var (
|
||||
router *subsonic.Router
|
||||
w *httptest.ResponseRecorder
|
||||
r *http.Request
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
|
||||
It("should return the correct OpenSubsonicExtensions", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
172
server/subsonic/playlists.go
Normal file
172
server/subsonic/playlists.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
response := newResponse()
|
||||
response.Playlists = &responses.Playlists{
|
||||
Playlist: slice.Map(allPls, api.buildPlaylist),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.getPlaylist(ctx, id)
|
||||
}
|
||||
|
||||
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
|
||||
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, err.Error(), "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Playlist = &responses.PlaylistWithSongs{
|
||||
Playlist: api.buildPlaylist(*pls),
|
||||
}
|
||||
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
|
||||
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
owner := getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner.ID != pls.OwnerID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = owner.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
songIds, _ := p.Strings("songId")
|
||||
playlistId, _ := p.String("playlistId")
|
||||
name, _ := p.String("name")
|
||||
if playlistId == "" && name == "" {
|
||||
return nil, errors.New("required parameter name is missing")
|
||||
}
|
||||
id, err := api.create(ctx, playlistId, name, songIds)
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
return api.getPlaylist(ctx, id)
|
||||
}
|
||||
|
||||
func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = api.ds.Playlist(r.Context()).Delete(id)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
playlistId, err := p.String("playlistId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songsToAdd, _ := p.Strings("songIdToAdd")
|
||||
songIndexesToRemove, _ := p.Ints("songIndexToRemove")
|
||||
var plsName *string
|
||||
if s, err := p.String("name"); err == nil {
|
||||
plsName = &s
|
||||
}
|
||||
comment := p.StringPtr("comment")
|
||||
public := p.BoolPtr("public")
|
||||
|
||||
log.Debug(r, "Updating playlist", "id", playlistId)
|
||||
if plsName != nil {
|
||||
log.Trace(r, fmt.Sprintf("-- New Name: '%s'", *plsName))
|
||||
}
|
||||
log.Trace(r, fmt.Sprintf("-- Adding: '%v'", songsToAdd))
|
||||
log.Trace(r, fmt.Sprintf("-- Removing: '%v'", songIndexesToRemove))
|
||||
|
||||
err = api.playlists.Update(r.Context(), playlistId, plsName, comment, public, songsToAdd, songIndexesToRemove)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r, "Error updating playlist", "id", playlistId, err)
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist {
|
||||
pls := responses.Playlist{}
|
||||
pls.Id = p.ID
|
||||
pls.Name = p.Name
|
||||
pls.Comment = p.Comment
|
||||
pls.SongCount = int32(p.SongCount)
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Duration = int32(p.Duration)
|
||||
pls.Public = p.Public
|
||||
pls.Created = p.CreatedAt
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
if p.IsSmartPlaylist() {
|
||||
pls.Changed = time.Now()
|
||||
} else {
|
||||
pls.Changed = p.UpdatedAt
|
||||
}
|
||||
return pls
|
||||
}
|
||||
88
server/subsonic/playlists_test.go
Normal file
88
server/subsonic/playlists_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ core.Playlists = (*fakePlaylists)(nil)
|
||||
|
||||
var _ = Describe("UpdatePlaylist", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
var playlists *fakePlaylists
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
r := newGetRequest("playlistId=123", "comment=")
|
||||
_, err := router.UpdatePlaylist(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
||||
Expect(playlists.lastComment).ToNot(BeNil())
|
||||
Expect(*playlists.lastComment).To(Equal(""))
|
||||
})
|
||||
|
||||
It("leaves comment unchanged when parameter is missing", func() {
|
||||
r := newGetRequest("playlistId=123")
|
||||
_, err := router.UpdatePlaylist(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
||||
Expect(playlists.lastComment).To(BeNil())
|
||||
})
|
||||
|
||||
It("sets public to true when parameter is 'true'", func() {
|
||||
r := newGetRequest("playlistId=123", "public=true")
|
||||
_, err := router.UpdatePlaylist(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
||||
Expect(playlists.lastPublic).ToNot(BeNil())
|
||||
Expect(*playlists.lastPublic).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sets public to false when parameter is 'false'", func() {
|
||||
r := newGetRequest("playlistId=123", "public=false")
|
||||
_, err := router.UpdatePlaylist(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
||||
Expect(playlists.lastPublic).ToNot(BeNil())
|
||||
Expect(*playlists.lastPublic).To(BeFalse())
|
||||
})
|
||||
|
||||
It("leaves public unchanged when parameter is missing", func() {
|
||||
r := newGetRequest("playlistId=123")
|
||||
_, err := router.UpdatePlaylist(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playlists.lastPlaylistID).To(Equal("123"))
|
||||
Expect(playlists.lastPublic).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
type fakePlaylists struct {
|
||||
core.Playlists
|
||||
lastPlaylistID string
|
||||
lastName *string
|
||||
lastComment *string
|
||||
lastPublic *bool
|
||||
lastAdd []string
|
||||
lastRemove []int
|
||||
}
|
||||
|
||||
func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error {
|
||||
f.lastPlaylistID = playlistID
|
||||
f.lastName = name
|
||||
f.lastComment = comment
|
||||
f.lastPublic = public
|
||||
f.lastAdd = idsToAdd
|
||||
f.lastRemove = idxToRemove
|
||||
return nil
|
||||
}
|
||||
111
server/subsonic/radio.go
Normal file
111
server/subsonic/radio.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
streamUrl, err := p.String("streamUrl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name, err := p.String("name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
homepageUrl, _ := p.String("homepageUrl")
|
||||
ctx := r.Context()
|
||||
|
||||
radio := &model.Radio{
|
||||
StreamUrl: streamUrl,
|
||||
HomePageUrl: homepageUrl,
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err = api.ds.Radio(ctx).Put(radio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = api.ds.Radio(r.Context()).Delete(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make([]responses.Radio, len(radios))
|
||||
for i, g := range radios {
|
||||
res[i] = responses.Radio{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
StreamUrl: g.StreamUrl,
|
||||
HomepageUrl: g.HomePageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.InternetRadioStations = &responses.InternetRadioStations{
|
||||
Radios: res,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamUrl, err := p.String("streamUrl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name, err := p.String("name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
homepageUrl, _ := p.String("homepageUrl")
|
||||
ctx := r.Context()
|
||||
|
||||
radio := &model.Radio{
|
||||
ID: id,
|
||||
StreamUrl: streamUrl,
|
||||
HomePageUrl: homepageUrl,
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err = api.ds.Radio(ctx).Put(radio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumInfo": {
|
||||
"notes": "Believe is the twenty-third studio album by American singer-actress Cher...",
|
||||
"musicBrainzId": "03c91c40-49a6-44a7-90e7-a700edf97a62",
|
||||
"lastFmUrl": "https://www.last.fm/music/Cher/Believe",
|
||||
"smallImageUrl": "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png",
|
||||
"mediumImageUrl": "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png",
|
||||
"largeImageUrl": "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumInfo>
|
||||
<notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes>
|
||||
<musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId>
|
||||
<lastFmUrl>https://www.last.fm/music/Cher/Believe</lastFmUrl>
|
||||
<smallImageUrl>https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png</smallImageUrl>
|
||||
<mediumImageUrl>https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png</mediumImageUrl>
|
||||
<largeImageUrl>https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png</largeImageUrl>
|
||||
</albumInfo>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumInfo": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumInfo></albumInfo>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumList": {
|
||||
"album": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "sort name",
|
||||
"mediaType": "album",
|
||||
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
|
||||
"isrc": [],
|
||||
"genres": [
|
||||
{
|
||||
"name": "Genre 1"
|
||||
},
|
||||
{
|
||||
"name": "Genre 2"
|
||||
}
|
||||
],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0,
|
||||
"bitDepth": 0,
|
||||
"moods": [
|
||||
"mood1",
|
||||
"mood2"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "artist-1",
|
||||
"name": "Artist 1"
|
||||
},
|
||||
{
|
||||
"id": "artist-2",
|
||||
"name": "Artist 2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "Display artist",
|
||||
"albumArtists": [
|
||||
{
|
||||
"id": "album-artist-1",
|
||||
"name": "Artist 1"
|
||||
},
|
||||
{
|
||||
"id": "album-artist-2",
|
||||
"name": "Artist 2"
|
||||
}
|
||||
],
|
||||
"displayAlbumArtist": "Display album artist",
|
||||
"contributors": [],
|
||||
"displayComposer": "",
|
||||
"explicitStatus": "explicit"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<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">
|
||||
<genres name="Genre 1"></genres>
|
||||
<genres name="Genre 2"></genres>
|
||||
<moods>mood1</moods>
|
||||
<moods>mood2</moods>
|
||||
<artists id="artist-1" name="Artist 1"></artists>
|
||||
<artists id="artist-2" name="Artist 2"></artists>
|
||||
<albumArtists id="album-artist-1" name="Artist 1"></albumArtists>
|
||||
<albumArtists id="album-artist-2" name="Artist 2"></albumArtists>
|
||||
</album>
|
||||
</albumList>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumList": {
|
||||
"album": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" title="title" isVideo="false"></album>
|
||||
</albumList>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumList": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList></albumList>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "1",
|
||||
"name": "album",
|
||||
"artist": "artist",
|
||||
"genre": "rock",
|
||||
"userRating": 4,
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
},
|
||||
{
|
||||
"name": "progressive"
|
||||
}
|
||||
],
|
||||
"musicBrainzId": "1234",
|
||||
"isCompilation": true,
|
||||
"sortName": "sorted album",
|
||||
"discTitles": [
|
||||
{
|
||||
"disc": 1,
|
||||
"title": "disc 1"
|
||||
},
|
||||
{
|
||||
"disc": 2,
|
||||
"title": "disc 2"
|
||||
},
|
||||
{
|
||||
"disc": 3,
|
||||
"title": ""
|
||||
}
|
||||
],
|
||||
"originalReleaseDate": {
|
||||
"year": 1994,
|
||||
"month": 2,
|
||||
"day": 4
|
||||
},
|
||||
"releaseDate": {
|
||||
"year": 2000,
|
||||
"month": 5,
|
||||
"day": 10
|
||||
},
|
||||
"releaseTypes": [
|
||||
"album",
|
||||
"live"
|
||||
],
|
||||
"recordLabels": [
|
||||
{
|
||||
"name": "label1"
|
||||
},
|
||||
{
|
||||
"name": "label2"
|
||||
}
|
||||
],
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist1 \u0026 artist2",
|
||||
"explicitStatus": "clean",
|
||||
"version": "Deluxe Edition",
|
||||
"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",
|
||||
"isrc": [
|
||||
"ISRC-1"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
},
|
||||
{
|
||||
"name": "progressive"
|
||||
}
|
||||
],
|
||||
"replayGain": {
|
||||
"trackGain": 1,
|
||||
"albumGain": 2,
|
||||
"trackPeak": 3,
|
||||
"albumPeak": 4,
|
||||
"baseGain": 5,
|
||||
"fallbackGain": 6
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplingRate": 44100,
|
||||
"bitDepth": 16,
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist1 \u0026 artist2",
|
||||
"albumArtists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "album artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "album artist2"
|
||||
}
|
||||
],
|
||||
"displayAlbumArtist": "album artist1 \u0026 album artist2",
|
||||
"contributors": [
|
||||
{
|
||||
"role": "role1",
|
||||
"artist": {
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "role2",
|
||||
"subRole": "subrole4",
|
||||
"artist": {
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"displayComposer": "composer 1 \u0026 composer 2",
|
||||
"explicitStatus": "clean"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {
|
||||
"trackGain": 0,
|
||||
"albumGain": 0,
|
||||
"trackPeak": 0,
|
||||
"albumPeak": 0,
|
||||
"baseGain": 0,
|
||||
"fallbackGain": 0
|
||||
},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0,
|
||||
"bitDepth": 0,
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"albumArtists": [],
|
||||
"displayAlbumArtist": "",
|
||||
"contributors": [],
|
||||
"displayComposer": "",
|
||||
"explicitStatus": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition">
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<discTitles disc="1" title="disc 1"></discTitles>
|
||||
<discTitles disc="2" title="disc 2"></discTitles>
|
||||
<discTitles disc="3" title=""></discTitles>
|
||||
<originalReleaseDate year="1994" month="2" day="4"></originalReleaseDate>
|
||||
<releaseDate year="2000" month="5" day="10"></releaseDate>
|
||||
<releaseTypes>album</releaseTypes>
|
||||
<releaseTypes>live</releaseTypes>
|
||||
<recordLabels name="label1"></recordLabels>
|
||||
<recordLabels name="label2"></recordLabels>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></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 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<albumArtists id="1" name="album artist1"></albumArtists>
|
||||
<albumArtists id="2" name="album artist2"></albumArtists>
|
||||
<contributors role="role1">
|
||||
<artist id="1" name="artist1"></artist>
|
||||
</contributors>
|
||||
<contributors role="role2" subRole="subrole4">
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
</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">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</song>
|
||||
</album>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<album id="" name=""></album>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"userRating": 0,
|
||||
"genres": [],
|
||||
"musicBrainzId": "",
|
||||
"isCompilation": false,
|
||||
"sortName": "",
|
||||
"discTitles": [],
|
||||
"originalReleaseDate": {},
|
||||
"releaseDate": {},
|
||||
"releaseTypes": [],
|
||||
"recordLabels": [],
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"explicitStatus": "",
|
||||
"version": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<album id="" name=""></album>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "1234",
|
||||
"sortName": "sort name",
|
||||
"roles": [
|
||||
"role1",
|
||||
"role2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
|
||||
<roles>role1</roles>
|
||||
<roles>role2</roles>
|
||||
</artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "1234",
|
||||
"sortName": "sort name",
|
||||
"roles": [
|
||||
"role1",
|
||||
"role2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
|
||||
<roles>role1</roles>
|
||||
<roles>role2</roles>
|
||||
</artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A"></artists>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artistInfo": {
|
||||
"biography": "Black Sabbath is an English \u003ca target='_blank' href=\"https://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band",
|
||||
"musicBrainzId": "5182c1d9-c7d2-4dad-afa0-ccfeada921a8",
|
||||
"lastFmUrl": "https://www.last.fm/music/Black+Sabbath",
|
||||
"smallImageUrl": "https://userserve-ak.last.fm/serve/64/27904353.jpg",
|
||||
"mediumImageUrl": "https://userserve-ak.last.fm/serve/126/27904353.jpg",
|
||||
"largeImageUrl": "https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg",
|
||||
"similarArtist": [
|
||||
{
|
||||
"id": "22",
|
||||
"name": "Accept"
|
||||
},
|
||||
{
|
||||
"id": "101",
|
||||
"name": "Bruce Dickinson"
|
||||
},
|
||||
{
|
||||
"id": "26",
|
||||
"name": "Aerosmith"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<artistInfo>
|
||||
<biography>Black Sabbath is an English <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography>
|
||||
<musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId>
|
||||
<lastFmUrl>https://www.last.fm/music/Black+Sabbath</lastFmUrl>
|
||||
<smallImageUrl>https://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl>
|
||||
<mediumImageUrl>https://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl>
|
||||
<largeImageUrl>https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl>
|
||||
<similarArtist id="22" name="Accept"></similarArtist>
|
||||
<similarArtist id="101" name="Bruce Dickinson"></similarArtist>
|
||||
<similarArtist id="26" name="Aerosmith"></similarArtist>
|
||||
</artistInfo>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artistInfo": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<artistInfo></artistInfo>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"bookmarks": {
|
||||
"bookmark": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
},
|
||||
"position": 123,
|
||||
"username": "user2",
|
||||
"comment": "a comment",
|
||||
"created": "0001-01-01T00:00:00Z",
|
||||
"changed": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<bookmarks>
|
||||
<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>
|
||||
</bookmark>
|
||||
</bookmarks>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"bookmarks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<bookmarks></bookmarks>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"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",
|
||||
"isrc": [
|
||||
"ISRC-1",
|
||||
"ISRC-2"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
},
|
||||
{
|
||||
"name": "progressive"
|
||||
}
|
||||
],
|
||||
"replayGain": {
|
||||
"trackGain": 1,
|
||||
"albumGain": 2,
|
||||
"trackPeak": 3,
|
||||
"albumPeak": 4,
|
||||
"baseGain": 5,
|
||||
"fallbackGain": 6
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplingRate": 44100,
|
||||
"bitDepth": 16,
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist 1 \u0026 artist 2",
|
||||
"albumArtists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "album artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "album artist2"
|
||||
}
|
||||
],
|
||||
"displayAlbumArtist": "album artist 1 \u0026 album artist 2",
|
||||
"contributors": [
|
||||
{
|
||||
"role": "role1",
|
||||
"subRole": "subrole3",
|
||||
"artist": {
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "role2",
|
||||
"artist": {
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "composer",
|
||||
"artist": {
|
||||
"id": "3",
|
||||
"name": "composer1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "composer",
|
||||
"artist": {
|
||||
"id": "4",
|
||||
"name": "composer2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"displayComposer": "composer 1 \u0026 composer 2",
|
||||
"explicitStatus": "clean"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {
|
||||
"trackGain": 0,
|
||||
"albumGain": 0,
|
||||
"trackPeak": 0,
|
||||
"albumPeak": 0,
|
||||
"baseGain": 0,
|
||||
"fallbackGain": 0
|
||||
},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0,
|
||||
"bitDepth": 0,
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"albumArtists": [],
|
||||
"displayAlbumArtist": "",
|
||||
"contributors": [],
|
||||
"displayComposer": "",
|
||||
"explicitStatus": ""
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
"name": "N"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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">
|
||||
<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 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<isrc>ISRC-2</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<albumArtists id="1" name="album artist1"></albumArtists>
|
||||
<albumArtists id="2" name="album artist2"></albumArtists>
|
||||
<contributors role="role1" subRole="subrole3">
|
||||
<artist id="1" name="artist1"></artist>
|
||||
</contributors>
|
||||
<contributors role="role2">
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
<contributors role="composer">
|
||||
<artist id="3" name="composer1"></artist>
|
||||
</contributors>
|
||||
<contributors role="composer">
|
||||
<artist id="4" name="composer2"></artist>
|
||||
</contributors>
|
||||
</child>
|
||||
<child id="" isDir="false" isVideo="false">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0,
|
||||
"bitDepth": 0,
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"albumArtists": [],
|
||||
"displayAlbumArtist": "",
|
||||
"contributors": [],
|
||||
"displayComposer": "",
|
||||
"explicitStatus": ""
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
"name": "N"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<child id="1" isDir="false" title="title" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"id": "1",
|
||||
"name": "N"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"genres": {
|
||||
"genre": [
|
||||
{
|
||||
"value": "Rock",
|
||||
"songCount": 1000,
|
||||
"albumCount": 100
|
||||
},
|
||||
{
|
||||
"value": "Reggae",
|
||||
"songCount": 500,
|
||||
"albumCount": 50
|
||||
},
|
||||
{
|
||||
"value": "Pop",
|
||||
"songCount": 0,
|
||||
"albumCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<genres>
|
||||
<genre songCount="1000" albumCount="100">Rock</genre>
|
||||
<genre songCount="500" albumCount="50">Reggae</genre>
|
||||
<genre songCount="0" albumCount="0">Pop</genre>
|
||||
</genres>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"genres": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<genres></genres>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"indexes": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<indexes lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist>
|
||||
</index>
|
||||
</indexes>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"indexes": {
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<indexes lastModified="1" ignoredArticles="A"></indexes>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"internetRadioStations": {
|
||||
"internetRadioStation": [
|
||||
{
|
||||
"id": "12345678",
|
||||
"name": "Example Stream",
|
||||
"streamUrl": "https://example.com/stream",
|
||||
"homePageUrl": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<internetRadioStations>
|
||||
<internetRadioStation id="12345678" name="Example Stream" streamUrl="https://example.com/stream" homePageUrl="https://example.com"></internetRadioStation>
|
||||
</internetRadioStations>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"internetRadioStations": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<internetRadioStations></internetRadioStations>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"license": {
|
||||
"valid": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<license valid="true"></license>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"lyrics": {
|
||||
"artist": "Rick Astley",
|
||||
"title": "Never Gonna Give You Up",
|
||||
"value": "Never gonna give you up\n\t\t\t\tNever gonna let you down\n\t\t\t\tNever gonna run around and desert you\n\t\t\t\tNever gonna say goodbye"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<lyrics artist="Rick Astley" title="Never Gonna Give You Up">Never gonna give you up
				Never gonna let you down
				Never gonna run around and desert you
				Never gonna say goodbye</lyrics>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"lyrics": {
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<lyrics></lyrics>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"lyricsList": {
|
||||
"structuredLyrics": [
|
||||
{
|
||||
"displayArtist": "Rick Astley",
|
||||
"displayTitle": "Never Gonna Give You Up",
|
||||
"lang": "eng",
|
||||
"line": [
|
||||
{
|
||||
"start": 18800,
|
||||
"value": "We're no strangers to love"
|
||||
},
|
||||
{
|
||||
"start": 22801,
|
||||
"value": "You know the rules and so do I"
|
||||
}
|
||||
],
|
||||
"offset": 100,
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"displayArtist": "Rick Astley",
|
||||
"displayTitle": "Never Gonna Give You Up",
|
||||
"lang": "xxx",
|
||||
"line": [
|
||||
{
|
||||
"value": "We're no strangers to love"
|
||||
},
|
||||
{
|
||||
"value": "You know the rules and so do I"
|
||||
}
|
||||
],
|
||||
"offset": 100,
|
||||
"synced": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<lyricsList>
|
||||
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="eng" offset="100" synced="true">
|
||||
<line start="18800">We're no strangers to love</line>
|
||||
<line start="22801">You know the rules and so do I</line>
|
||||
</structuredLyrics>
|
||||
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="xxx" offset="100" synced="false">
|
||||
<line>We're no strangers to love</line>
|
||||
<line>You know the rules and so do I</line>
|
||||
</structuredLyrics>
|
||||
</lyricsList>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"lyricsList": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<lyricsList></lyricsList>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"musicFolders": {
|
||||
"musicFolder": [
|
||||
{
|
||||
"id": 111,
|
||||
"name": "aaa"
|
||||
},
|
||||
{
|
||||
"id": 222,
|
||||
"name": "bbb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<musicFolders>
|
||||
<musicFolder id="111" name="aaa"></musicFolder>
|
||||
<musicFolder id="222" name="bbb"></musicFolder>
|
||||
</musicFolders>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"musicFolders": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<musicFolders></musicFolders>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"openSubsonicExtensions": [
|
||||
{
|
||||
"name": "template",
|
||||
"versions": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<openSubsonicExtensions name="template">
|
||||
<versions>1</versions>
|
||||
<versions>2</versions>
|
||||
</openSubsonicExtensions>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"openSubsonicExtensions": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueue": {
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"current": "111",
|
||||
"position": 243,
|
||||
"username": "user1",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": "a_client"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user