diff --git a/db/migrations/20250730104020_add_api_key_to_player_table.go b/db/migrations/20250730104020_add_api_key_to_player_table.go
new file mode 100644
index 000000000..34b887d54
--- /dev/null
+++ b/db/migrations/20250730104020_add_api_key_to_player_table.go
@@ -0,0 +1,34 @@
+package migrations
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/pressly/goose/v3"
+)
+
+func init() {
+ goose.AddMigrationContext(upAddAPIKeyToPlayer, downDropAPIKeyFromPlayer)
+}
+
+func upAddAPIKeyToPlayer(_ context.Context, tx *sql.Tx) error {
+ _, err := tx.Exec(`
+-- Add nullable api_key column to player table
+ALTER TABLE player ADD COLUMN api_key VARCHAR(255);
+
+-- Add index on api_key for faster lookups
+CREATE INDEX IF NOT EXISTS player_api_key ON player(api_key);
+`)
+ return err
+}
+
+func downDropAPIKeyFromPlayer(_ context.Context, tx *sql.Tx) error {
+ _, err := tx.Exec(`
+-- Drop the index first
+DROP INDEX IF EXISTS player_api_key;
+
+-- Then drop the column
+ALTER TABLE player DROP COLUMN api_key;
+`)
+ return err
+}
diff --git a/model/player.go b/model/player.go
index 39ea99d1a..f896ebd25 100644
--- a/model/player.go
+++ b/model/player.go
@@ -18,6 +18,7 @@ type Player struct {
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
+ APIKey string `structs:"api_key" json:"apiKey" db:"api_key"`
}
type Players []Player
@@ -28,4 +29,6 @@ type PlayerRepository interface {
Put(p *Player) error
CountAll(...QueryOptions) (int64, error)
CountByClient(...QueryOptions) (map[string]int64, error)
+ FindByAPIKey(key string) (*Player, error)
+ GenerateAPIKey(playerId string) (string, error)
}
diff --git a/persistence/player_repository.go b/persistence/player_repository.go
index 73c820753..00c5d1fee 100644
--- a/persistence/player_repository.go
+++ b/persistence/player_repository.go
@@ -7,6 +7,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
@@ -57,6 +58,19 @@ func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.P
return &res, err
}
+func (r *playerRepository) FindByAPIKey(key string) (*model.Player, error) {
+ if key == "" {
+ return nil, model.ErrNotFound
+ }
+ sel := r.selectPlayer().Where(Eq{"api_key": key})
+ var res model.Player
+ err := r.queryOne(sel, &res)
+ if err != nil {
+ return nil, err
+ }
+ return &res, nil
+}
+
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.selectPlayer(options...)
return s.Where(r.addRestriction())
@@ -130,25 +144,37 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
return u.IsAdmin || p.UserId == u.ID
}
+func (r *playerRepository) preparePlayerForSave(p *model.Player) *model.Player {
+ playerCopy := *p
+ if playerCopy.UserId == "" {
+ user := loggedUser(r.ctx)
+ playerCopy.UserId = user.ID
+ }
+ playerCopy.APIKey = ""
+ return &playerCopy
+}
+
func (r *playerRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Player)
- if !r.isPermitted(t) {
+ playerToSave := r.preparePlayerForSave(t)
+ if !r.isPermitted(playerToSave) {
return "", rest.ErrPermissionDenied
}
- id, err := r.put(t.ID, t)
+ playerId, err := r.put(playerToSave.ID, playerToSave)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
- return id, err
+ return playerId, err
}
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
- if !r.isPermitted(t) {
+ playerToUpdate := r.preparePlayerForSave(t)
+ if !r.isPermitted(playerToUpdate) {
return rest.ErrPermissionDenied
}
- _, err := r.put(id, t, cols...)
+ _, err := r.put(id, playerToUpdate, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@@ -164,6 +190,33 @@ func (r *playerRepository) Delete(id string) error {
return err
}
+// GenerateAPIKey generates a new API key for the specified player
+func (r *playerRepository) GenerateAPIKey(playerID string) (string, error) {
+ player, err := r.Get(playerID)
+ if errors.Is(err, model.ErrNotFound) {
+ return "", rest.ErrNotFound
+ }
+ if err != nil {
+ return "", err
+ }
+
+ if !r.isPermitted(player) {
+ return "", rest.ErrPermissionDenied
+ }
+
+ player.APIKey = generateAPIKey()
+ err = r.Put(player)
+ if err != nil {
+ return "", err
+ }
+ return player.APIKey, nil
+}
+
+// generateAPIKey creates a new random API key
+func generateAPIKey() string {
+ return "nav_" + id.NewRandom()
+}
+
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository = (*playerRepository)(nil)
var _ rest.Persistable = (*playerRepository)(nil)
diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go
index f6c669493..f7e71f6b4 100644
--- a/persistence/player_repository_test.go
+++ b/persistence/player_repository_test.go
@@ -13,8 +13,12 @@ import (
)
var _ = Describe("PlayerRepository", func() {
- var adminRepo *playerRepository
- var database *dbx.DB
+ var (
+ adminRepo *playerRepository
+ database *dbx.DB
+ adminCtx context.Context
+ regularCtx context.Context
+ )
var (
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
@@ -25,11 +29,14 @@ var _ = Describe("PlayerRepository", func() {
)
BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
- ctx = request.WithUser(ctx, adminUser)
+ adminCtx = log.NewContext(context.TODO())
+ adminCtx = request.WithUser(adminCtx, adminUser)
+
+ regularCtx = log.NewContext(context.TODO())
+ regularCtx = request.WithUser(regularCtx, regularUser)
database = GetDBXBuilder()
- adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
+ adminRepo = NewPlayerRepository(adminCtx, database).(*playerRepository)
for idx := range players {
err := adminRepo.Put(&players[idx])
@@ -86,6 +93,28 @@ var _ = Describe("PlayerRepository", func() {
})
})
+ Describe("Save", func() {
+ var repo *playerRepository
+ BeforeEach(func() {
+ ctx := log.NewContext(context.TODO())
+ ctx = request.WithUser(ctx, regularUser)
+ repo = NewPlayerRepository(ctx, database).(*playerRepository)
+ })
+ It("fails if different user id is set", func() {
+ newPlayer := model.Player{
+ Name: "Test Player",
+ Client: "TestClient",
+ UserAgent: "TestAgent",
+ UserId: adminUser.ID,
+ }
+ id, err := repo.Save(&newPlayer)
+ Expect(err).To(Equal(rest.ErrPermissionDenied))
+
+ _, err = repo.Get(id)
+ Expect(err).To(Equal(rest.ErrNotFound))
+ })
+ })
+
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
var repo *playerRepository
@@ -171,41 +200,37 @@ var _ = Describe("PlayerRepository", func() {
})
Describe("Save", func() {
- DescribeTable("item type", func(player model.Player) {
- clone := player
- clone.ID = ""
- clone.IP = "192.168.1.1"
- id, err := repo.Save(&clone)
-
- if clone.UserId == "" {
- Expect(err).To(HaveOccurred())
- } else if !admin && player.Username == adminPlayer1.Username {
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- clone.UserId = ""
- } else {
- Expect(err).To(BeNil())
- Expect(id).ToNot(BeEmpty())
+ It("excludes API key from being saved", func() {
+ newPlayer := model.Player{
+ Name: "Test Player",
+ Client: "TestClient",
+ UserAgent: "TestAgent",
+ APIKey: "should-not-be-saved",
}
- count, err := repo.Count()
+ id, err := repo.Save(&newPlayer)
Expect(err).To(BeNil())
- clone.ID = id
- newItem, err := repo.Get(id)
+ savedPlayer, err := repo.Get(id)
+ Expect(err).To(BeNil())
+ Expect(savedPlayer.APIKey).To(BeEmpty())
+ })
- if clone.UserId == "" {
- Expect(count).To(Equal(baseCount))
- Expect(err).To(Equal(model.ErrNotFound))
- } else {
- Expect(count).To(Equal(baseCount + 1))
- Expect(err).To(BeNil())
- Expect(*newItem).To(Equal(clone))
+ It("sets user ID from context", func() {
+ repo := NewPlayerRepository(regularCtx, database).(*playerRepository)
+ newPlayer := model.Player{
+ Name: "Test Player",
+ Client: "TestClient",
+ UserAgent: "TestAgent",
}
- },
- Entry("same user", userPlayer),
- Entry("other item", otherPlayer),
- Entry("fake item", model.Player{}),
- )
+
+ id, err := repo.Save(&newPlayer)
+ Expect(err).To(BeNil())
+
+ savedPlayer, err := repo.Get(id)
+ Expect(err).To(BeNil())
+ Expect(savedPlayer.UserId).To(Equal(regularUser.ID))
+ })
})
Describe("Update", func() {
@@ -215,9 +240,7 @@ var _ = Describe("PlayerRepository", func() {
clone.MaxBitRate = 10000
err := repo.Update(clone.ID, &clone, "ip")
- if clone.UserId == "" {
- Expect(err).To(HaveOccurred())
- } else if !admin && player.Username == adminPlayer1.Username {
+ if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.IP = player.IP
} else {
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 370bdbd1e..97509657e 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -3,6 +3,7 @@ package nativeapi
import (
"context"
"encoding/json"
+ "errors"
"html"
"net/http"
"strconv"
@@ -51,7 +52,7 @@ func (n *Router) routes() http.Handler {
n.R(r, "/album", model.Album{}, false)
n.R(r, "/artist", model.Artist{}, false)
n.R(r, "/genre", model.Genre{}, false)
- n.R(r, "/player", model.Player{}, true)
+ n.addPlayerRoute(r)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.R(r, "/radio", model.Radio{}, true)
n.R(r, "/tag", model.Tag{}, true)
@@ -102,6 +103,25 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
+func (n *Router) addPlayerRoute(r chi.Router) {
+ constructor := func(ctx context.Context) rest.Repository {
+ return n.ds.Resource(ctx, model.Player{})
+ }
+
+ r.Route("/player", func(r chi.Router) {
+ r.Get("/", rest.GetAll(constructor))
+ r.Post("/", rest.Post(constructor))
+
+ 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.Post("/apiKey", generatePlayerApiKey(n.ds))
+ })
+ })
+}
+
func (n *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Playlist{})
@@ -198,6 +218,28 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
+func generatePlayerApiKey(ds model.DataStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ playerId := chi.URLParam(r, "id")
+ err := ds.WithTxImmediate(func(tx model.DataStore) error {
+ repo := tx.Player(ctx)
+ apiKey, err := repo.GenerateAPIKey(playerId)
+ if err == nil {
+ resp := []byte(`{"apiKey":"` + html.EscapeString(apiKey) + `"}`)
+ _, err = w.Write(resp)
+ }
+ return err
+ })
+ if errors.Is(err, model.ErrNotFound) {
+ http.Error(w, "not found", http.StatusNotFound)
+ } else if err != nil {
+ log.Error(ctx, "Error retrieving player from DB", "id", playerId, err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
func (n *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index af1ba448f..3ea68cd9c 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -63,14 +63,16 @@ func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
+ p := req.Params(r)
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
+ } else if apiKey, _ := p.String("apiKey"); apiKey != "" {
+ requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
- p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
@@ -123,21 +125,69 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
+ apiKey, _ := p.String("apiKey")
- usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
- if errors.Is(err, context.Canceled) {
- log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ // When an API key is provided, username should not be provided
+ if apiKey != "" && username != "" {
+ log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
+ sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
- switch {
- case errors.Is(err, model.ErrNotFound):
- log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
- case err != nil:
- log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
- default:
- err = validateCredentials(usr, pass, token, salt, jwt)
- if err != nil {
+
+ // Check for conflicting authentication mechanisms
+ authMechanismsCount := 0
+ if apiKey != "" {
+ authMechanismsCount++
+ }
+ if pass != "" {
+ authMechanismsCount++
+ }
+ if token != "" && salt != "" {
+ authMechanismsCount++
+ }
+ if jwt != "" {
+ authMechanismsCount++
+ }
+ if authMechanismsCount > 1 {
+ log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
+ sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
+ return
+ }
+
+ if apiKey != "" {
+ var player *model.Player
+ player, err = ds.Player(ctx).FindByAPIKey(apiKey)
+ if errors.Is(err, context.Canceled) {
+ log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err)
+ return
+ }
+ switch {
+ case errors.Is(err, model.ErrNotFound):
+ log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
+ case err != nil:
+ log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
+ default:
+ usr, err = ds.User(ctx).Get(player.UserId)
+ if err != nil {
+ log.Error(ctx, "API: Error retrieving user from API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
+ }
+ }
+ } else {
+ usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
+ if errors.Is(err, context.Canceled) {
+ log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ return
+ }
+ switch {
+ case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ case err != nil:
+ log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ default:
+ err = validateCredentials(usr, pass, token, salt, jwt)
+ if err != nil {
+ log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
+ }
}
}
}
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index a30d5b3af..33c38404d 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -306,6 +306,74 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeFalse())
})
})
+
+ When("using api key authentication", func() {
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ ur := ds.User(context.TODO())
+ user := &model.User{
+ UserName: "user-api",
+ NewPassword: "wordpass-api",
+ }
+ _ = ur.Put(user)
+
+ pr := ds.Player(context.TODO())
+ player := &model.Player{
+ ID: "player1",
+ Name: "Test Player",
+ UserAgent: "Test/1.0",
+ UserId: user.ID,
+ Client: "test-client",
+ IP: "127.0.0.1",
+ LastSeen: time.Now(),
+ TranscodingId: "",
+ MaxBitRate: 320,
+ ReportRealPath: false,
+ ScrobbleEnabled: true,
+ APIKey: "nav_12345",
+ }
+ _ = pr.Put(player)
+ })
+
+ It("passes authentication with correct api key", func() {
+ r := newGetRequest("apiKey=nav_12345")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(next.called).To(BeTrue())
+ user, _ := request.UserFrom(next.req.Context())
+ Expect(user.UserName).To(Equal("user-api"))
+ })
+
+ It("fails authentication with invalid api key", func() {
+ r := newGetRequest("apiKey=invalid-api-key")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ Expect(next.called).To(BeFalse())
+ })
+
+ It("fails authentication with empty api key", func() {
+ r := newGetRequest("apiKey=")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ Expect(next.called).To(BeFalse())
+ })
+
+ It("fails authentication if both api key and username are provided", func() {
+ r := newGetRequest("apiKey=api-key", "u=user-api")
+ cp := authenticate(ds)(next)
+ cp.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
+ Expect(next.called).To(BeFalse())
+ })
+ })
})
Describe("GetPlayer", func() {
diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go
index 42e5427b3..b90f9260b 100644
--- a/server/subsonic/responses/errors.go
+++ b/server/subsonic/responses/errors.go
@@ -1,25 +1,31 @@
package responses
const (
- ErrorGeneric int32 = 0
- ErrorMissingParameter int32 = 10
- ErrorClientTooOld int32 = 20
- ErrorServerTooOld int32 = 30
- ErrorAuthenticationFail int32 = 40
- ErrorAuthorizationFail int32 = 50
- ErrorTrialExpired int32 = 60
- ErrorDataNotFound int32 = 70
+ ErrorGeneric int32 = 0
+ ErrorMissingParameter int32 = 10
+ ErrorClientTooOld int32 = 20
+ ErrorServerTooOld int32 = 30
+ ErrorAuthenticationFail int32 = 40
+ ErrorTokenAuthNotSupported int32 = 41
+ ErrorAuthMechanismNotSupported int32 = 42
+ ErrorMultipleAuthMechanismsProvided int32 = 43
+ ErrorAuthorizationFail int32 = 50
+ ErrorTrialExpired int32 = 60
+ ErrorDataNotFound int32 = 70
)
var errors = map[int32]string{
- ErrorGeneric: "A generic error",
- ErrorMissingParameter: "Required parameter is missing",
- ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
- ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
- ErrorAuthenticationFail: "Wrong username or password",
- ErrorAuthorizationFail: "User is not authorized for the given operation",
- ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
- ErrorDataNotFound: "The requested data was not found",
+ ErrorGeneric: "A generic error",
+ ErrorMissingParameter: "Required parameter is missing",
+ ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
+ ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
+ ErrorAuthenticationFail: "Invalid username, password, or API key",
+ ErrorTokenAuthNotSupported: "Token authentication not supported",
+ ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported",
+ ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
+ ErrorAuthorizationFail: "User is not authorized for the given operation",
+ ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
+ ErrorDataNotFound: "The requested data was not found",
}
func ErrorMsg(code int32) string {
diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go
index 56f68a74b..d05795d70 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -191,7 +191,7 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository {
if db.RealDS != nil {
db.MockedPlayer = db.RealDS.Player(ctx)
} else {
- db.MockedPlayer = struct{ model.PlayerRepository }{}
+ db.MockedPlayer = CreateMockPlayerRepo()
}
}
return db.MockedPlayer
diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go
new file mode 100644
index 000000000..7d5c6dd1e
--- /dev/null
+++ b/tests/mock_player_repo.go
@@ -0,0 +1,88 @@
+package tests
+
+import (
+ "encoding/base64"
+
+ "github.com/navidrome/navidrome/model"
+)
+
+func CreateMockPlayerRepo() *MockedPlayerRepo {
+ return &MockedPlayerRepo{
+ Data: make(map[string]*model.Player),
+ }
+}
+
+type MockedPlayerRepo struct {
+ model.PlayerRepository
+ Error error
+ Data map[string]*model.Player
+}
+
+func (m *MockedPlayerRepo) Get(id string) (*model.Player, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+
+ player, exists := m.Data[id]
+ if !exists {
+ return nil, model.ErrNotFound
+ }
+ return player, nil
+}
+
+func (m *MockedPlayerRepo) FindMatch(userId, client, userAgent string) (*model.Player, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+
+ for _, player := range m.Data {
+ if player.UserId == userId && player.Client == client && player.UserAgent == userAgent {
+ return player, nil
+ }
+ }
+ return nil, model.ErrNotFound
+}
+
+func (m *MockedPlayerRepo) Put(p *model.Player) error {
+ if m.Error != nil {
+ return m.Error
+ }
+
+ if p.ID == "" {
+ p.ID = base64.StdEncoding.EncodeToString([]byte(p.Name + "_" + p.UserId + "_" + p.Client))
+ }
+ m.Data[p.ID] = p
+ return nil
+}
+
+func (m *MockedPlayerRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
+ if m.Error != nil {
+ return 0, m.Error
+ }
+ return int64(len(m.Data)), nil
+}
+
+func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]int64, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+
+ result := make(map[string]int64)
+ for _, player := range m.Data {
+ result[player.Client]++
+ }
+ return result, nil
+}
+
+func (m *MockedPlayerRepo) FindByAPIKey(key string) (*model.Player, error) {
+ if m.Error != nil {
+ return nil, m.Error
+ }
+
+ for _, player := range m.Data {
+ if player.APIKey == key {
+ return player, nil
+ }
+ }
+ return nil, model.ErrNotFound
+}
diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go
index 9f3dd672e..ef4ad8508 100644
--- a/tests/mock_user_repo.go
+++ b/tests/mock_user_repo.go
@@ -24,7 +24,7 @@ type MockedUserRepo struct {
UserLibraries map[string][]int // userID -> libraryIDs
}
-func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
+func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if u.Error != nil {
return 0, u.Error
}
diff --git a/ui/src/apikey/ApiKeyCreate.jsx b/ui/src/apikey/ApiKeyCreate.jsx
new file mode 100644
index 000000000..575246082
--- /dev/null
+++ b/ui/src/apikey/ApiKeyCreate.jsx
@@ -0,0 +1,57 @@
+import React, { useCallback } from 'react'
+import {
+ Create,
+ SimpleForm,
+ TextInput,
+ required,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRedirect,
+} from 'react-admin'
+import { Title } from '../common'
+
+const ApiKeyCreate = (props) => {
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+ const resourceName = translate('resources.apikey.name', { smart_count: 1 })
+ const title = translate('ra.page.create', {
+ name: `${resourceName}`,
+ })
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'create',
+ resource: 'apikey',
+ payload: { data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.apikey.notifications.created', 'info', {
+ smart_count: 1,
+ })
+ redirect('/apikey')
+ } catch (error) {
+ if (error.body.errors) {
+ return error.body.errors
+ }
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+