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:
Jiho Andrew Lee 2026-09-30 15:23:00 +09:00
commit 19385dc28b
508 changed files with 13229 additions and 8764 deletions

View file

@ -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)

View file

@ -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(&regularUser)).To(Succeed())
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
Expect(ds.User().Put(ctx, &regularUser)).To(Succeed())
})
Describe("GET /api/config", func() {

View 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"))
})
})

View file

@ -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
}

View file

@ -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(&regularUser)).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, &regularUser)).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)

View file

@ -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(&regularUser)).To(Succeed())
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
Expect(ds.User().Put(ctx, &regularUser)).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"),

View file

@ -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{}

View file

@ -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}",

View file

@ -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)
}
}

View file

@ -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(&regularUser)
err := userRepo.Put(ctx, &regularUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for regular user

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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(&regularUser)).To(Succeed())
Expect(ds.User().Put(ctx, &adminUser)).To(Succeed())
Expect(ds.User().Put(ctx, &regularUser)).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":""}`)

View file

@ -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)

View file

@ -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

View file

@ -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{}}
})

View file

@ -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)

View file

@ -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)

View file

@ -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))