mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-27 02:37:21 +01:00
Merge upstream/master into feat-podcast-ux-improvements/5420
Resolve conflicts with the stateless-repositories refactor (#6149): - port podcast repositories, model interfaces, mocks, service and handlers to per-call ctx - podcast channel repo now implements generic rest.Repository/Persistable - register podcast repos in SQLStore and MockDataStore - merge OpenSubsonic extensions (apiKeyAuthentication + podcast extensions) - renumber podcast migrations after upstream's latest (20260930...)
This commit is contained in:
commit
19385dc28b
508 changed files with 13229 additions and 8764 deletions
|
|
@ -15,14 +15,12 @@ import (
|
|||
)
|
||||
|
||||
func (api *Router) addArtistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.Artist{})
|
||||
}
|
||||
repo := api.ds.Artist()
|
||||
r.Route("/artist", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Post("/image", api.uploadArtistImage())
|
||||
r.Delete("/image", api.deleteArtistImage())
|
||||
})
|
||||
|
|
@ -32,7 +30,7 @@ func (api *Router) addArtistRoute(r chi.Router) {
|
|||
func (api *Router) uploadArtistImage() http.HandlerFunc {
|
||||
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||
artistID := chi.URLParamFromCtx(ctx, "id")
|
||||
ar, err := api.ds.Artist(ctx).Get(artistID)
|
||||
ar, err := api.ds.Artist().Get(ctx, artistID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
|
|
@ -46,7 +44,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc {
|
|||
}
|
||||
ar.UploadedImage = filename
|
||||
ar.UpdatedAt = new(time.Now())
|
||||
if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil {
|
||||
if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID)
|
||||
|
|
@ -57,7 +55,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc {
|
|||
func (api *Router) deleteArtistImage() http.HandlerFunc {
|
||||
return handleImageDelete(func(ctx context.Context) error {
|
||||
artistID := chi.URLParamFromCtx(ctx, "id")
|
||||
ar, err := api.ds.Artist(ctx).Get(artistID)
|
||||
ar, err := api.ds.Artist().Get(ctx, artistID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
|
|
@ -69,7 +67,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc {
|
|||
}
|
||||
ar.UploadedImage = ""
|
||||
ar.UpdatedAt = new(time.Now())
|
||||
if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil {
|
||||
if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -19,17 +20,19 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("Config API", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
@ -49,8 +52,8 @@ var _ = Describe("Config API", func() {
|
|||
}
|
||||
|
||||
// Store in mock datastore
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed())
|
||||
})
|
||||
|
||||
Describe("GET /api/config", func() {
|
||||
|
|
|
|||
49
server/nativeapi/delete_many_response_test.go
Normal file
49
server/nativeapi/delete_many_response_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writeDeleteManyResponse", func() {
|
||||
var w *httptest.ResponseRecorder
|
||||
|
||||
write := func(ids ...string) map[string]any {
|
||||
w = httptest.NewRecorder()
|
||||
writeDeleteManyResponse(w, httptest.NewRequest("DELETE", "/missing", nil), ids)
|
||||
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed(), "response body must be valid JSON: %s", w.Body.String())
|
||||
return body
|
||||
}
|
||||
|
||||
It("returns a single id as an object", func() {
|
||||
Expect(write("abc123")).To(HaveKeyWithValue("id", "abc123"))
|
||||
})
|
||||
|
||||
It("returns multiple ids as a list", func() {
|
||||
Expect(write("a", "b")).To(HaveKeyWithValue("ids", ConsistOf("a", "b")))
|
||||
})
|
||||
|
||||
It("stays valid JSON when the id contains a backslash", func() {
|
||||
Expect(write(`a\`)).To(HaveKeyWithValue("id", `a\`))
|
||||
})
|
||||
|
||||
It("stays valid JSON when the id contains a quote", func() {
|
||||
Expect(write(`a"b`)).To(HaveKeyWithValue("id", `a"b`))
|
||||
})
|
||||
|
||||
It("does not HTML-escape the id into entities", func() {
|
||||
Expect(write("a&b")).To(HaveKeyWithValue("id", "a&b"))
|
||||
})
|
||||
|
||||
It("responds 200 with a JSON content type", func() {
|
||||
write("abc123")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
})
|
||||
})
|
||||
|
|
@ -13,7 +13,7 @@ import (
|
|||
)
|
||||
|
||||
func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) {
|
||||
file, err := ds.MediaFile(ctx).Get(id)
|
||||
file, err := ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -21,17 +22,19 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("Library API", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
var library1, library2 model.Library
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
@ -63,10 +66,10 @@ var _ = Describe("Library API", func() {
|
|||
}
|
||||
|
||||
// Store in mock datastore
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
|
||||
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed())
|
||||
Expect(ds.Library().Put(ctx, &library1)).To(Succeed())
|
||||
Expect(ds.Library().Put(ctx, &library2)).To(Succeed())
|
||||
})
|
||||
|
||||
Describe("Library CRUD Operations", func() {
|
||||
|
|
@ -293,7 +296,7 @@ var _ = Describe("Library API", func() {
|
|||
Describe("GET /api/user/{id}/library", func() {
|
||||
It("returns user's libraries", func() {
|
||||
// Set up user libraries
|
||||
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
|
||||
err := ds.User().SetUserLibraries(ctx, regularUser.ID, []int{1, 2})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -38,6 +39,7 @@ func (f *fakeProvider) calls() []string {
|
|||
}
|
||||
|
||||
var _ = Describe("Metadata API", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
var queueRepo *tests.MockArtworkQueueRepo
|
||||
|
|
@ -47,6 +49,7 @@ var _ = Describe("Metadata API", func() {
|
|||
var adminToken, userToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
|
|
@ -54,9 +57,9 @@ var _ = Describe("Metadata API", func() {
|
|||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
playlistRepo := tests.CreateMockPlaylistRepo()
|
||||
Expect(albumRepo.Put(&model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed())
|
||||
Expect(artistRepo.Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||
Expect(playlistRepo.Put(&model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed())
|
||||
Expect(albumRepo.Put(ctx, &model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed())
|
||||
Expect(artistRepo.Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||
Expect(playlistRepo.Put(ctx, &model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed())
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
|
|
@ -66,13 +69,13 @@ var _ = Describe("Metadata API", func() {
|
|||
}
|
||||
auth.Init(ds)
|
||||
provider = &fakeProvider{}
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
|
||||
regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"}
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed())
|
||||
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
|
|
@ -83,7 +86,7 @@ var _ = Describe("Metadata API", func() {
|
|||
|
||||
Describe("POST /api/metadata/{kind}/{id}/refresh", func() {
|
||||
It("clears state and enqueues a Bump for admins", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -93,10 +96,10 @@ var _ = Describe("Metadata API", func() {
|
|||
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
queued, err := queueRepo.DequeueBatch(1000)
|
||||
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued).To(ContainElement(SatisfyAll(
|
||||
HaveField("ItemKind", "al"),
|
||||
|
|
|
|||
|
|
@ -14,24 +14,21 @@ import (
|
|||
)
|
||||
|
||||
type missingRepository struct {
|
||||
model.ResourceRepository
|
||||
rest.Repository[model.MediaFile]
|
||||
mfRepo model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor {
|
||||
return func(ctx context.Context) rest.Repository {
|
||||
return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})}
|
||||
}
|
||||
func newMissingRepository(ds model.DataStore) rest.Repository[model.MediaFile] {
|
||||
mf := ds.MediaFile()
|
||||
return &missingRepository{Repository: mf, mfRepo: mf}
|
||||
}
|
||||
|
||||
func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.Count(opt)
|
||||
func (r *missingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
|
||||
return r.Repository.Count(ctx, r.parseOptions(options))
|
||||
}
|
||||
|
||||
func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.ReadAll(opt)
|
||||
func (r *missingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) {
|
||||
return r.Repository.ReadAll(ctx, r.parseOptions(options))
|
||||
}
|
||||
|
||||
func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions {
|
||||
|
|
@ -44,8 +41,8 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query
|
|||
return opt
|
||||
}
|
||||
|
||||
func (r *missingRepository) Read(id string) (any, error) {
|
||||
mf, err := r.mfRepo.Get(id)
|
||||
func (r *missingRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) {
|
||||
mf, err := r.mfRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -55,10 +52,6 @@ func (r *missingRepository) Read(id string) (any, error) {
|
|||
return mf, nil
|
||||
}
|
||||
|
||||
func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
|
@ -86,5 +79,3 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
|||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -35,12 +36,12 @@ var _ = Describe("Missing Files Endpoint", func() {
|
|||
auth.Init(ds)
|
||||
|
||||
user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"}
|
||||
Expect(userRepo.Put(&user)).To(Succeed())
|
||||
Expect(userRepo.Put(GinkgoT().Context(), &user)).To(Succeed())
|
||||
var err error
|
||||
token, err = auth.CreateToken(&user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil))
|
||||
router = server.JWTVerifier(New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil))
|
||||
})
|
||||
|
||||
DescribeTable("GET /missing/{id}",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package nativeapi
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -62,26 +60,26 @@ func (api *Router) routes() http.Handler {
|
|||
r := chi.NewRouter()
|
||||
|
||||
// Public
|
||||
api.RX(r, "/translation", newTranslationRepository, false)
|
||||
rx(r, "/translation", newTranslationRepository(), false)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(api.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.RX(r, "/user", api.users.NewRepository, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
rx(r, "/user", api.users.Repository(), true)
|
||||
rx(r, "/song", api.ds.MediaFile(), false)
|
||||
rx(r, "/album", api.ds.Album(), false)
|
||||
api.addArtistRoute(r)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
rx(r, "/genre", api.ds.Genre(), false)
|
||||
rx(r, "/player", api.ds.Player(), true)
|
||||
rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig)
|
||||
api.addRadioRoute(r)
|
||||
api.addPodcastRoute(r)
|
||||
api.R(r, "/tag", model.Tag{}, false)
|
||||
api.R(r, "/scrobble", model.Scrobble{}, false)
|
||||
rx(r, "/tag", api.ds.Tag(), false)
|
||||
rx(r, "/scrobble", api.ds.Scrobble(), false)
|
||||
if conf.Server.EnableSharing {
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
rx(r, "/share", api.share.Repository(), true)
|
||||
}
|
||||
|
||||
api.addPlaylistRoute(r)
|
||||
|
|
@ -99,47 +97,38 @@ func (api *Router) routes() http.Handler {
|
|||
api.addUserLibraryRoute(r)
|
||||
api.addPluginRoute(r)
|
||||
api.addMetadataRoute(r)
|
||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||
rx(r, "/library", api.libs.Repository(), true)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model)
|
||||
}
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
func rx[T any](r chi.Router, pathPrefix string, repo rest.Repository[T], persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
if persistable {
|
||||
r.Post("/", rest.Post(constructor))
|
||||
r.Post("/", rest.Post(repo))
|
||||
}
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
if persistable {
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
r.Put("/", rest.Put(repo))
|
||||
r.Delete("/", rest.Delete(repo))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.playlists.NewRepository(ctx)
|
||||
}
|
||||
repo := api.playlists.Repository()
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-type") == "application/json" {
|
||||
rest.Post(constructor)(w, r)
|
||||
rest.Post(repo)(w, r)
|
||||
return
|
||||
}
|
||||
createPlaylistFromM3U(api.playlists)(w, r)
|
||||
|
|
@ -147,9 +136,9 @@ func (api *Router) addPlaylistRoute(r chi.Router) {
|
|||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Put("/", rest.Put(repo))
|
||||
r.Delete("/", rest.Delete(repo))
|
||||
r.Post("/image", uploadPlaylistImage(api.playlists))
|
||||
r.Delete("/image", deletePlaylistImage(api.playlists))
|
||||
})
|
||||
|
|
@ -201,28 +190,24 @@ func (api *Router) addQueueRoute(r chi.Router) {
|
|||
|
||||
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||
rx(r, "/", newMissingRepository(api.ds), false)
|
||||
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||
})
|
||||
}
|
||||
|
||||
func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) {
|
||||
var resp []byte
|
||||
var err error
|
||||
var payload any
|
||||
if len(ids) == 1 {
|
||||
resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`)
|
||||
payload = struct {
|
||||
ID string `json:"id"`
|
||||
}{ID: ids[0]}
|
||||
} else {
|
||||
resp, err = json.Marshal(&struct {
|
||||
payload = struct {
|
||||
Ids []string `json:"ids"`
|
||||
}{Ids: ids})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshaling response", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}{Ids: ids}
|
||||
}
|
||||
_, err = w.Write(resp) //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if err := rest.RespondWithJSON(w, http.StatusOK, payload); err != nil {
|
||||
log.Error(r.Context(), "Error writing response", "ids", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package nativeapi
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -21,6 +23,7 @@ import (
|
|||
|
||||
var _ = Describe("Song Endpoints", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
router http.Handler
|
||||
ds *tests.MockDataStore
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
|
|
@ -31,6 +34,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
|
@ -56,7 +60,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
err := userRepo.Put(ctx, &testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create test songs
|
||||
|
|
@ -95,7 +99,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
|
@ -369,7 +373,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
IsAdmin: true,
|
||||
NewPassword: "adminpass",
|
||||
}
|
||||
err := userRepo.Put(&adminUser)
|
||||
err := userRepo.Put(ctx, &adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for admin user
|
||||
|
|
@ -392,7 +396,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
IsAdmin: false,
|
||||
NewPassword: "userpass",
|
||||
}
|
||||
err := userRepo.Put(®ularUser)
|
||||
err := userRepo.Put(ctx, ®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for regular user
|
||||
|
|
|
|||
|
|
@ -16,10 +16,9 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown.
|
||||
func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) {
|
||||
switch {
|
||||
|
|
@ -34,7 +33,7 @@ func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) {
|
|||
}
|
||||
}
|
||||
|
||||
func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
func playlistTracksHandler(pls playlists.Playlists, handler func(rest.Repository[model.PlaylistTrack]) http.HandlerFunc, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r))
|
||||
|
|
@ -42,12 +41,12 @@ func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refresh
|
|||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
|
||||
handler(tracks).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool {
|
||||
handler := playlistTracksHandler(pls, rest.GetAll[model.PlaylistTrack], func(r *http.Request) bool {
|
||||
return req.Params(r).Int64Or("_start", 0) == 0
|
||||
})
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -60,7 +59,7 @@ func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|||
}
|
||||
|
||||
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
|
||||
return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true })
|
||||
return playlistTracksHandler(pls, rest.Get[model.PlaylistTrack], func(*http.Request) bool { return true })
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
|
|
@ -101,8 +100,7 @@ func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
|||
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u"))
|
||||
|
||||
_, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -96,7 +97,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
err := userRepo.Put(GinkgoT().Context(), &testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
|
|
@ -183,6 +184,37 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||
})
|
||||
})
|
||||
|
||||
var _ = Describe("handleExportPlaylist", func() {
|
||||
export := func(name string) *httptest.ResponseRecorder {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/playlist/{playlistId}", handleExportPlaylist(&mockPlaylistsService{
|
||||
playlist: &model.Playlist{ID: "pls-1", Name: name},
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/playlist/pls-1", nil))
|
||||
return w
|
||||
}
|
||||
|
||||
It("names the download after the playlist", func() {
|
||||
w := export("Road Trip")
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Road Trip.m3u"`))
|
||||
})
|
||||
|
||||
It("does not let the playlist name inject a second filename parameter", func() {
|
||||
w := export(`party"; filename="evil.html`)
|
||||
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`))
|
||||
})
|
||||
|
||||
It("keeps non-ASCII names in filename*", func() {
|
||||
w := export("Кино")
|
||||
|
||||
Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="download.m3u"; filename*=utf-8''%D0%9A%D0%B8%D0%BD%D0%BE.m3u`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("writePlaylistError", func() {
|
||||
DescribeTable("maps a service error to an HTTP status",
|
||||
func(err error, expected int) {
|
||||
|
|
@ -203,23 +235,15 @@ type mockPlaylistTrackRepo struct {
|
|||
tracks model.PlaylistTracks
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
|
||||
func (m *mockPlaylistTrackRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) {
|
||||
return int64(len(m.tracks)), nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
|
||||
func (m *mockPlaylistTrackRepo) ReadAll(context.Context, ...rest.QueryOptions) ([]model.PlaylistTrack, error) {
|
||||
return m.tracks, nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) EntityName() string {
|
||||
return "playlist_track"
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
||||
func (m *mockPlaylistTrackRepo) Read(_ context.Context, id string) (*model.PlaylistTrack, error) {
|
||||
for _, t := range m.tracks {
|
||||
if t.ID == id {
|
||||
return &t, nil
|
||||
|
|
@ -230,7 +254,9 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
|||
|
||||
type mockPlaylistsService struct {
|
||||
playlists.Playlists
|
||||
tracksRepo rest.Repository
|
||||
repo rest.Repository[model.Playlist]
|
||||
tracksRepo rest.Repository[model.PlaylistTrack]
|
||||
playlist *model.Playlist
|
||||
removeImageFn func(ctx context.Context, id string) error
|
||||
setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error
|
||||
}
|
||||
|
|
@ -249,6 +275,17 @@ func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader i
|
|||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
|
||||
func (m *mockPlaylistsService) Repository() rest.Repository[model.Playlist] {
|
||||
return m.repo
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) {
|
||||
if m.playlist == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return m.playlist, nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository[model.PlaylistTrack] {
|
||||
return m.tracksRepo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,17 +15,15 @@ import (
|
|||
)
|
||||
|
||||
func (api *Router) addPluginRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Plugin(ctx)
|
||||
}
|
||||
repo := api.ds.Plugin()
|
||||
|
||||
r.Route("/plugin", func(r chi.Router) {
|
||||
r.Use(pluginsEnabledMiddleware)
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.Post("/rescan", api.rescanPlugins)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Put("/", api.updatePlugin)
|
||||
})
|
||||
})
|
||||
|
|
@ -68,10 +66,10 @@ type PluginUpdateRequest struct {
|
|||
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
ctx := r.Context()
|
||||
repo := api.ds.Plugin(ctx)
|
||||
repo := api.ds.Plugin()
|
||||
|
||||
// Get existing plugin to verify it exists
|
||||
if _, err := repo.Get(id); err != nil {
|
||||
if _, err := repo.Get(ctx, id); err != nil {
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
||||
return
|
||||
|
|
@ -123,7 +121,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
|||
if enableErr := api.pluginManager.EnablePlugin(ctx, id); enableErr != nil {
|
||||
log.Error(ctx, "Error enabling plugin", "id", id, enableErr)
|
||||
// Refresh plugin from DB to get the error
|
||||
plugin, err := repo.Get(id)
|
||||
plugin, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting updated plugin after enable failure", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
|
@ -153,7 +151,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Refresh and return updated plugin
|
||||
plugin, err := repo.Get(id)
|
||||
plugin, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting updated plugin", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
|
@ -204,7 +202,7 @@ func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJS
|
|||
// Returns an error if validation or update fails (error response already written).
|
||||
func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error {
|
||||
// Get current values if not provided in request
|
||||
plugin, err := repo.Get(id)
|
||||
plugin, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting plugin for users update", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
|
@ -237,7 +235,7 @@ func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.Pl
|
|||
// Returns an error if validation or update fails (error response already written).
|
||||
func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error {
|
||||
// Get current values if not provided in request
|
||||
plugin, err := repo.Get(id)
|
||||
plugin, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting plugin for libraries update", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
|
|
@ -21,6 +22,7 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("Plugin API", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var mockManager *tests.MockPluginManager
|
||||
var router http.Handler
|
||||
|
|
@ -28,13 +30,14 @@ var _ = Describe("Plugin API", func() {
|
|||
var testPlugin1, testPlugin2 model.Plugin
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.Plugins.Enabled = true
|
||||
ds = &tests.MockDataStore{}
|
||||
mockManager = &tests.MockPluginManager{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
@ -71,8 +74,8 @@ var _ = Describe("Plugin API", func() {
|
|||
}
|
||||
|
||||
// Store users in mock datastore
|
||||
Expect(ds.User(GinkgoT().Context()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(GinkgoT().Context()).Put(®ularUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed())
|
||||
})
|
||||
|
||||
Context("when plugins are disabled", func() {
|
||||
|
|
@ -104,10 +107,9 @@ var _ = Describe("Plugin API", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Store test plugins as admin
|
||||
ctx := GinkgoT().Context()
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
Expect(ds.Plugin(adminCtx).Put(&testPlugin1)).To(Succeed())
|
||||
Expect(ds.Plugin(adminCtx).Put(&testPlugin2)).To(Succeed())
|
||||
Expect(ds.Plugin().Put(adminCtx, &testPlugin1)).To(Succeed())
|
||||
Expect(ds.Plugin().Put(adminCtx, &testPlugin2)).To(Succeed())
|
||||
})
|
||||
|
||||
Describe("GET /api/plugin", func() {
|
||||
|
|
@ -160,9 +162,9 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when EnablePlugin is called
|
||||
mockManager.EnablePluginFn = func(ctx context.Context, id string) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Enabled = true
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"enabled":true}`)
|
||||
|
|
@ -186,9 +188,9 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginConfig is called
|
||||
mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Config = configJSON
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`)
|
||||
|
|
@ -226,9 +228,9 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginConfig is called
|
||||
mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Config = configJSON
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"config":""}`)
|
||||
|
|
@ -251,10 +253,10 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginUsers is called
|
||||
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Users = usersJSON
|
||||
p.AllUsers = allUsers
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`)
|
||||
|
|
@ -279,10 +281,10 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginUsers is called
|
||||
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Users = usersJSON
|
||||
p.AllUsers = allUsers
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"allUsers":true}`)
|
||||
|
|
@ -307,10 +309,10 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginUsers is called
|
||||
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Users = usersJSON
|
||||
p.AllUsers = allUsers
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`)
|
||||
|
|
@ -348,10 +350,10 @@ var _ = Describe("Plugin API", func() {
|
|||
// Configure mock to update the repo when UpdatePluginUsers is called
|
||||
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
|
||||
adminCtx := request.WithUser(ctx, adminUser)
|
||||
p, _ := ds.Plugin(adminCtx).Get(id)
|
||||
p, _ := ds.Plugin().Get(adminCtx, id)
|
||||
p.Users = usersJSON
|
||||
p.AllUsers = allUsers
|
||||
return ds.Plugin(adminCtx).Put(p)
|
||||
return ds.Plugin().Put(adminCtx, p)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"users":""}`)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
func (api *Router) addPodcastRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.PodcastChannel{})
|
||||
}
|
||||
repo := api.ds.PodcastChannel()
|
||||
r.Route("/podcast", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.With(adminOnlyMiddleware).Get("/preview", api.podcastPreview)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Delete("/", rest.Delete(repo))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -38,7 +34,7 @@ func (api *Router) podcastPreview(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
exists, _ := api.ds.PodcastChannel(r.Context()).ExistsByURL(feedURL)
|
||||
exists, _ := api.ds.PodcastChannel().ExistsByURL(r.Context(), feedURL)
|
||||
feed.AlreadyExists = exists
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(feed)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) b
|
|||
// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling.
|
||||
// Returns the queue (nil if not found) and false if an error occurred and response was sent.
|
||||
func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) {
|
||||
existing, err := ds.PlayQueue(ctx).Retrieve(userID)
|
||||
existing, err := ds.PlayQueue().Retrieve(ctx, userID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
@ -70,8 +70,8 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
|
|||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
repo := ds.PlayQueue(ctx)
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
repo := ds.PlayQueue()
|
||||
pq, err := repo.RetrieveWithMediaFiles(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
@ -112,7 +112,7 @@ func saveQueue(ds model.DataStore) http.HandlerFunc {
|
|||
ChangedBy: client,
|
||||
Items: items,
|
||||
}
|
||||
if err := ds.PlayQueue(ctx).Store(pq); err != nil {
|
||||
if err := ds.PlayQueue().Store(ctx, pq); err != nil {
|
||||
log.Error(ctx, "Error saving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -191,7 +191,7 @@ func updateQueue(ds model.DataStore) http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Perform partial update of the specified columns only
|
||||
if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil {
|
||||
if err := ds.PlayQueue().Store(ctx, pq, cols...); err != nil {
|
||||
log.Error(ctx, "Error updating queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -204,7 +204,7 @@ func clearQueue(ds model.DataStore) http.HandlerFunc {
|
|||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil {
|
||||
if err := ds.PlayQueue().Clear(ctx, user.ID); err != nil {
|
||||
log.Error(ctx, "Error clearing queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ var _ = Describe("Queue Endpoints", func() {
|
|||
repo = &tests.MockPlayQueueRepo{}
|
||||
user = model.User{ID: "u1", UserName: "user"}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&user)
|
||||
_ = userRepo.Put(GinkgoT().Context(), &user)
|
||||
ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,15 @@ import (
|
|||
)
|
||||
|
||||
func (api *Router) addRadioRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.Radio{})
|
||||
}
|
||||
repo := api.ds.Radio()
|
||||
r.Route("/radio", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Post("/", rest.Post(constructor))
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.Post("/", rest.Post(repo))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Put("/", rest.Put(repo))
|
||||
r.Delete("/", rest.Delete(repo))
|
||||
r.Post("/image", api.uploadRadioImage())
|
||||
r.Delete("/image", api.deleteRadioImage())
|
||||
})
|
||||
|
|
@ -34,7 +32,7 @@ func (api *Router) addRadioRoute(r chi.Router) {
|
|||
func (api *Router) uploadRadioImage() http.HandlerFunc {
|
||||
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||
radio, err := api.ds.Radio().Get(ctx, radioID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
|
|
@ -47,7 +45,7 @@ func (api *Router) uploadRadioImage() http.HandlerFunc {
|
|||
return err
|
||||
}
|
||||
radio.UploadedImage = filename
|
||||
if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil {
|
||||
if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil {
|
||||
return err
|
||||
}
|
||||
api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID)
|
||||
|
|
@ -58,7 +56,7 @@ func (api *Router) uploadRadioImage() http.HandlerFunc {
|
|||
func (api *Router) deleteRadioImage() http.HandlerFunc {
|
||||
return handleImageDelete(func(ctx context.Context) error {
|
||||
radioID := chi.URLParamFromCtx(ctx, "id")
|
||||
radio, err := api.ds.Radio(ctx).Get(radioID)
|
||||
radio, err := api.ds.Radio().Get(ctx, radioID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
|
|
@ -69,7 +67,7 @@ func (api *Router) deleteRadioImage() http.HandlerFunc {
|
|||
return err
|
||||
}
|
||||
radio.UploadedImage = ""
|
||||
if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil {
|
||||
if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil {
|
||||
return err
|
||||
}
|
||||
api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID)
|
||||
|
|
|
|||
|
|
@ -23,28 +23,28 @@ type translation struct {
|
|||
TermCount int `json:"termCount"`
|
||||
}
|
||||
|
||||
func newTranslationRepository(context.Context) rest.Repository {
|
||||
func newTranslationRepository() rest.Repository[translation] {
|
||||
return &translationRepository{}
|
||||
}
|
||||
|
||||
type translationRepository struct{}
|
||||
|
||||
func (r *translationRepository) Read(id string) (any, error) {
|
||||
func (r *translationRepository) Read(_ context.Context, id string) (*translation, error) {
|
||||
translations, _ := loadTranslations()
|
||||
if t, ok := translations[id]; ok {
|
||||
return t, nil
|
||||
return &t, nil
|
||||
}
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
// Count simple implementation, does not support any `options`
|
||||
func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) {
|
||||
func (r *translationRepository) Count(context.Context, ...rest.QueryOptions) (int64, error) {
|
||||
_, count := loadTranslations()
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ReadAll simple implementation, only returns IDs. Does not support any `options`
|
||||
func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) {
|
||||
func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ([]translation, error) {
|
||||
translations, _ := loadTranslations()
|
||||
var result []translation
|
||||
for _, t := range translations {
|
||||
|
|
@ -54,14 +54,6 @@ func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (r *translationRepository) EntityName() string {
|
||||
return "translation"
|
||||
}
|
||||
|
||||
func (r *translationRepository) NewInstance() any {
|
||||
return &translation{}
|
||||
}
|
||||
|
||||
var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) {
|
||||
translations := make(map[string]translation)
|
||||
fsys := resources.FS()
|
||||
|
|
@ -140,4 +132,4 @@ func countTranslatedTerms(obj map[string]any) int {
|
|||
return count
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*translationRepository)(nil)
|
||||
var _ rest.Repository[translation] = (*translationRepository)(nil)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
|
|
@ -29,10 +30,12 @@ func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {}
|
|||
|
||||
// Pins that the token-epoch handoff survives a real request through the real middleware chain.
|
||||
var _ = Describe("PUT /user/{id}: token refresh on self password change", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
// db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package.
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableUserEditing = true
|
||||
|
|
@ -45,13 +48,13 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
|
|||
auth.Init(ds)
|
||||
|
||||
userService := core.NewUser(ds, noopPluginUnloader{})
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
})
|
||||
|
||||
It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() {
|
||||
usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"}
|
||||
Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
|
||||
Expect(ds.User().Put(ctx, &usr)).To(Succeed())
|
||||
|
||||
token, err := auth.CreateToken(&usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -72,7 +75,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
|
|||
claims, err := auth.Validate(refreshed)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID)
|
||||
reloaded, err := ds.User().Get(ctx, usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reloaded.TokenEpoch).To(Equal(1))
|
||||
Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue